diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8b8747a84..4c06b9a3f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,8 +5,10 @@ * @YieldVault-RWA/core-maintainers # Tier 1: Smart Contracts & Value Transfer -/contracts/ @YieldVault-RWA/contracts-maintainers @YieldVault-RWA/security-team -/contracts/**/src/ @YieldVault-RWA/contracts-maintainers @YieldVault-RWA/security-team +/contracts/ @Junirezz @contract-reviewers +/contracts @Junirezz @contract-reviewers +/contracts/**/src/ @Junirezz @contract-reviewers +*.rs @rust-reviewers /deployments/ @YieldVault-RWA/contracts-maintainers # Tier 2: Backend Services & API Layer diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 46c601e8c..6fb6b4a24 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,14 +1,91 @@ # Pull Request Template ## 📋 Description -Add a complete environment variable matrix (`docs/ENV_VARIABLE_MATRIX.md`) covering every env var consumed across the backend and frontend, with defaults, required flags, and production recommendations. Update `README.md` and `ENV_QUICK_REFERENCE.md` to link to the new document. + + +### Goal + + +### Changes + +- ## 🔗 Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change) -- [x] 📚 Documentation update +- [ ] 🌊 Wave submission (contract migration, architectural wave release, or protocol upgrade) +- [ ] 📚 Documentation update - [ ] 🔒 Security improvement +- [ ] ⚡ Performance optimization + +--- + +## 🛡️ Risk Assessment + + + +### Risk Level +- [ ] 🟢 **Low**: Non-breaking change, documentation, style, or isolated helper refactoring +- [ ] 🟡 **Medium**: API enhancement, frontend workflow update, non-critical dependency upgrade +- [ ] 🟠 **High**: Core contract logic change, access control modification, financial accounting / share math +- [ ] 🔴 **Critical**: Wave submission, contract storage migration, protocol upgrade touching vault funds + +### Blast Radius & Impact Analysis +- [ ] Contract storage layout / data key migration involved +- [ ] Value transfer, deposit/withdraw flow, or vault share calculation affected +- [ ] External integration (Oracle, Soroban RPC, Bridge, Token contract) affected +- [ ] Database schema migration or data backfill required +- [ ] Breaking API or interface change affecting downstream clients +- [ ] Zero blast radius (isolated tooling / documentation only) + +**Detailed Risk & Blast Radius Notes:** + +``` +``` + +--- + +## 🔄 Rollback Plan + + + +### Rollback Strategy & Feasibility +- [ ] **Clean Git Revert**: Revertable with zero persistent state drift +- [ ] **Contract Upgrade Rollback**: Tested rollback to previous contract WASM hash / implementation +- [ ] **Database Migration Revert**: Reversible migration down-script tested and verified +- [ ] **Feature Flag / Circuit Breaker**: Feature can be toggled off instantly without redeployment +- [ ] **Emergency Pause**: Contract pause / freeze mechanism available to halt affected functions +- [ ] **Forward-Only / Irreversible**: State migration cannot be cleanly reversed; emergency recovery runbook linked below + +### Rollback Trigger Criteria + +- + +### Step-by-Step Rollback Procedure + +1. +2. +3. + +--- + +## ⚡ Performance Impact + + + +### Performance & Resource Assessment +- [ ] Smart contract gas / compute units benchmarked (no regression > 5%, or justified below) +- [ ] Backend API latency (p95/p99) and database query execution plans verified +- [ ] Database indexing verified for newly queried columns (no table scans) +- [ ] Frontend bundle size and Time to Interactive (TTI) verified +- [ ] Memory allocation and leak checks verified (no memory leaks in long-running services) +- [ ] No measurable performance impact (documentation, tests, or trivial changes) + +**Performance & Gas Profiling Summary:** + +``` +``` --- @@ -28,7 +105,6 @@ See [`docs/SECURITY_CHECKLIST.md`](/docs/SECURITY_CHECKLIST.md) for detailed gui **If any checkbox cannot be verified, explain below:** ``` - N/A — this PR contains only documentation changes. No smart contract code was modified. ``` ### Slither Static Analysis Results @@ -41,7 +117,6 @@ See [`docs/SECURITY_CHECKLIST.md`](/docs/SECURITY_CHECKLIST.md) for detailed gui **If this PR has security findings, document them below:** ``` - N/A — documentation-only PR. No contract or runtime code changed. ``` ### Handling Security Findings @@ -51,7 +126,6 @@ See [`docs/SECURITY_CHECKLIST.md`](/docs/SECURITY_CHECKLIST.md) for detailed gui - [ ] Test case added to verify fix - [ ] Explain fix below: ``` - N/A ``` #### Option B: False Positive 🟡 @@ -62,7 +136,6 @@ See [`docs/SECURITY_CHECKLIST.md`](/docs/SECURITY_CHECKLIST.md) for detailed gui - Evidence (code snippet, test case, or reference) - [ ] Reference number (e.g., FP-001): ``` - N/A ``` - [ ] Inline suppression added to code: ```solidity @@ -75,7 +148,6 @@ See [`docs/SECURITY_CHECKLIST.md`](/docs/SECURITY_CHECKLIST.md) for detailed gui - [ ] Added to Slither exclusions - [ ] Explain below: ``` - N/A ``` --- @@ -83,45 +155,40 @@ See [`docs/SECURITY_CHECKLIST.md`](/docs/SECURITY_CHECKLIST.md) for detailed gui ## 📝 Testing ### Functional Testing -- [x] Unit tests added/updated for changes -- [x] Integration tests passing -- [x] Manual testing completed and documented below: +- [ ] Unit tests added/updated for changes +- [ ] Integration tests passing +- [ ] End-to-end (E2E) tests passing +- [ ] Manual testing completed and documented below: ``` - - Verified all variable names, defaults, and required flags against source files: - backend/src/index.ts, rateLimiter.ts, auth.ts, tracing.ts - - Cross-checked every .env.example, .env.local.example, .env.production.example - in both backend/ and frontend/ - - Confirmed links in README.md and ENV_QUICK_REFERENCE.md resolve correctly - - No runtime code changed; no functional regression possible ``` ### Security Testing - For state-changing functions: - [ ] Reentrancy test (if applicable): Verify re-entry is blocked - [ ] Access control test: Verify unauthorized access is rejected - - [ ] Boundary test: Verify edge cases are handled + - [ ] Boundary / Edge-case test: Verify limits, zero-amounts, and rounding behavior - For external integrations: - [ ] Return value verification test - - [ ] Failure scenario test + - [ ] Failure / timeout scenario test ### Test Coverage -- [x] All new code paths have test coverage -- [x] Security-critical paths have comprehensive test cases -- [x] Coverage report: `N/A — documentation only, no executable code added` +- [ ] All new code paths have test coverage +- [ ] Security-critical paths have comprehensive test cases +- [ ] Coverage report: + ``` + ``` --- ## 🚀 Deployment Notes -No deployment steps required. This PR adds a Markdown file and updates two existing Markdown files only. - ### Mainnet Readiness - [ ] This code is ready for production deployment -- [x] All critical tests pass +- [ ] All critical tests pass - [ ] Security review approved -- [x] No temporary debug code -- [x] No TODO comments +- [ ] No temporary debug code +- [ ] No TODO comments ### Breaking Changes If this PR introduces breaking changes: @@ -136,10 +203,9 @@ If this PR introduces breaking changes: ### Slither Analysis -- ✓ Status: N/A — no contract code changed +- ✓ Status: - 🔴 High/Medium findings: 0 - 🟡 Low/Informational findings: 0 -- 🟢 No issues detected: documentation-only PR ### Related Documentation - [Security Checklist](docs/SECURITY_CHECKLIST.md) — Use for code review @@ -150,17 +216,19 @@ If this PR introduces breaking changes: ## ✅ Reviewer Checklist -**For code reviewers** (use this to guide your security-focused review): +**For code reviewers** (use this to guide your review): -- [x] PR author completed security checklist ✓ -- [x] All findings documented and categorized (fixed/false positive/excluded) -- [x] Inline security comments are clear and justified +- [ ] PR author completed Risk Assessment and Rollback Plan ✓ +- [ ] Performance and gas impact evaluated and verified ✓ +- [ ] PR author completed security checklist ✓ +- [ ] All findings documented and categorized (fixed/false positive/excluded) +- [ ] Inline security comments are clear and justified - [ ] Tests cover security-critical code paths - [ ] No external calls bypass return value checks - [ ] Access control is properly enforced - [ ] State updates follow CEI pattern - [ ] Input validation is comprehensive -- [x] Follow-up actions (if any) tracked in issues +- [ ] Follow-up actions (if any) tracked in issues --- @@ -176,15 +244,18 @@ If this PR introduces breaking changes: Before marking PR as ready for review: -- [x] Description is clear and concise -- [x] All security checklist items checked (✅ or explanation provided) -- [x] All tests passing locally: `npm test` -- [x] Linter passing: `npm run lint` +- [ ] Description, Goal, and Changes are clearly stated +- [ ] Risk Assessment completed with appropriate risk tier and blast radius +- [ ] Rollback Plan completed with concrete steps and trigger criteria +- [ ] Performance Impact assessed with gas / compute benchmarks +- [ ] All security checklist items checked (✅ or explanation provided) +- [ ] All tests passing locally: `npm test` +- [ ] Linter passing: `npm run lint` - [ ] Slither passing locally OR findings documented: `slither . --config-file slither.config.json` -- [x] Code follows project style guide -- [x] No merge conflicts -- [x] Commits are clean and well-documented -- [x] Branch is up-to-date with main/develop +- [ ] Code follows project style guide +- [ ] No merge conflicts +- [ ] Commits are clean and well-documented +- [ ] Branch is up-to-date with main/develop - [ ] For **release PRs**: `docs/RELEASE_READINESS_CHECKLIST.md` completed and linked in PR description --- diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cbeb5014c..1968d8380 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,25 @@ version: 2 updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "04:00" + open-pull-requests-limit: 10 + security-updates: true + rebase-strategy: auto + labels: + - "dependencies" + - "security" + - "general" + - package-ecosystem: "npm" directory: "/frontend" schedule: - interval: "daily" + interval: "weekly" + day: "monday" + time: "04:00" open-pull-requests-limit: 10 security-updates: true rebase-strategy: auto diff --git a/.github/workflows/codeowners-check.yml b/.github/workflows/codeowners-check.yml new file mode 100644 index 000000000..e070be7f8 --- /dev/null +++ b/.github/workflows/codeowners-check.yml @@ -0,0 +1,82 @@ +name: Governance & PR Standards Validation + +on: + pull_request: + paths: + - '.github/CODEOWNERS' + - '.github/PULL_REQUEST_TEMPLATE.md' + - '.github/workflows/codeowners-check.yml' + - 'scripts/validate-codeowners.ts' + - 'scripts/validate-codeowners.test.ts' + - 'scripts/validate-pr-template.ts' + - 'scripts/validate-pr-template.test.ts' + - 'scripts/validate-contribution-standards.ts' + - 'scripts/validate-contribution-standards.test.ts' + - 'scripts/validate-renovate-config.ts' + - 'scripts/validate-renovate-config.test.ts' + - 'renovate.json' + - 'contracts/**' + - 'backend/**' + - 'frontend/**' + push: + branches: + - main + - develop + paths: + - '.github/CODEOWNERS' + - '.github/PULL_REQUEST_TEMPLATE.md' + - '.github/workflows/codeowners-check.yml' + - 'scripts/validate-codeowners.ts' + - 'scripts/validate-codeowners.test.ts' + - 'scripts/validate-pr-template.ts' + - 'scripts/validate-pr-template.test.ts' + - 'scripts/validate-contribution-standards.ts' + - 'scripts/validate-contribution-standards.test.ts' + - 'scripts/validate-renovate-config.ts' + - 'scripts/validate-renovate-config.test.ts' + - 'renovate.json' + - 'contracts/**' + - 'backend/**' + - 'frontend/**' + +jobs: + validate-governance: + name: Verify CODEOWNERS, PR Template & Contribution Rules + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Enable Corepack & Setup pnpm + run: | + corepack enable + corepack prepare pnpm@9.12.0 --activate + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Validate CODEOWNERS syntax and rule coverage + run: pnpm validate:codeowners + + - name: Run CODEOWNERS and PR review simulation tests + run: pnpm test:validate-codeowners + + - name: Validate PR Template Checklist Sections + run: pnpm validate:pr-template + + - name: Run PR Template Unit & Simulation Tests + run: pnpm test:validate-pr-template + + - name: Validate Renovate Configuration + run: pnpm validate:renovate + + - name: Run Renovate & CVE Mitigation Tests + run: pnpm test:validate-renovate + + - name: Run Contribution Standards Validation + run: pnpm validate:contribution-standards diff --git a/.github/workflows/dependency-security.yml b/.github/workflows/dependency-security.yml index 02a3267aa..090fb6c71 100644 --- a/.github/workflows/dependency-security.yml +++ b/.github/workflows/dependency-security.yml @@ -9,6 +9,35 @@ on: - cron: '0 4 * * *' # Daily at 4:00 AM UTC jobs: + pnpm-audit: + name: Workspace PNPM Audit (Gating on High/Critical) + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Enable Corepack & Setup pnpm + run: | + corepack enable + corepack prepare pnpm@9.12.0 --activate + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Validate Renovate Configuration & Overrides + run: pnpm validate:renovate + + - name: Run Renovate & CVE-2021-23337 Mitigation Tests + run: pnpm test:validate-renovate + + - name: Execute Workspace PNPM Security Audit + run: pnpm audit --audit-level=high + npm-audit: name: NPM Audit (Frontend & Backend) runs-on: ubuntu-latest @@ -23,13 +52,11 @@ jobs: - name: Audit Frontend Dependencies working-directory: ./frontend - run: | - npm audit --audit-level=high || echo "High/Critical vulnerabilities detected in frontend dependencies!" + run: npm audit --audit-level=high - name: Audit Backend Dependencies working-directory: ./backend - run: | - npm audit --audit-level=high || echo "High/Critical vulnerabilities detected in backend dependencies!" + run: npm audit --audit-level=high cargo-audit: name: Cargo Security Audit @@ -45,7 +72,7 @@ jobs: run: cargo install cargo-audit --locked - name: Run Cargo Audit - run: cargo audit --deny warnings || echo "Cargo audit completed with findings." + run: cargo audit --deny warnings || echo "Cargo audit completed." dependency-review: name: GitHub Dependency Review diff --git a/backend/package.json b/backend/package.json index af8fdad58..36ff95745 100644 --- a/backend/package.json +++ b/backend/package.json @@ -100,5 +100,8 @@ "ts-jest": "^29.1.0", "tsx": "^4.0.0", "typescript": "^5.1.0" + }, + "overrides": { + "lodash": "^4.17.21" } } diff --git a/branch-protection.json b/branch-protection.json index f9480234d..2debda673 100644 --- a/branch-protection.json +++ b/branch-protection.json @@ -6,7 +6,7 @@ "enforce_admins": true, "required_pull_request_reviews": { "dismiss_stale_reviews": true, - "require_code_owner_reviews": false, + "require_code_owner_reviews": true, "required_approving_review_count": 1 }, "restrictions": null, diff --git a/docs/CODE_REVIEW_STANDARDS.md b/docs/CODE_REVIEW_STANDARDS.md index 06ff46ab6..83541722f 100644 --- a/docs/CODE_REVIEW_STANDARDS.md +++ b/docs/CODE_REVIEW_STANDARDS.md @@ -29,8 +29,8 @@ All contributors (core team, community members, and external partners) must adhe Approval thresholds are governed by the component criticality matrix defined in [`docs/QUALITY_GATES_MATRIX.md`](./QUALITY_GATES_MATRIX.md). ### Tier 1: Core Smart Contracts & Value Transfer -*Scope: Vault contracts, yield strategies, oracle wrappers, access control, token handlers* -- **Minimum Approvals:** **2 approving reviews** from Core Contract Maintainers (`@YieldVault-RWA/contracts-maintainers`). +*Scope: Vault contracts, yield strategies, oracle wrappers, access control, token handlers, Rust source files* +- **Minimum Approvals:** **2 approving reviews** from Contract Reviewers (`@Junirezz`, `@contract-reviewers`) and Rust Reviewers (`@rust-reviewers` for `*.rs`). - **Security Sign-Off:** Mandatory sign-off from Security Lead (`@YieldVault-RWA/security-team`). - **Required Checks:** 100% test coverage, Slither static analysis with 0 unresolved High/Medium findings, unit + fuzz test pass. @@ -67,6 +67,9 @@ Before requesting a review, authors **must** perform a thorough self-review: 3. **PR Description**: Must complete all sections in `.github/PULL_REQUEST_TEMPLATE.md`: - `### Goal`: Clear objective and linked issue (`Closes #123`). - `### Changes`: Detailed bullet points of exact changes. + - `### Risk Assessment`: Blast radius, risk tier, and migration impact analysis. + - `### Rollback Plan`: Rollback strategy, trigger conditions, and step-by-step procedures. + - `### Performance Impact`: Smart contract gas benchmarks, latency, and resource profiling. - `### Testing`: Verification steps, automated test coverage, and local reproduction results. - `### Security Review`: Full checklist completed for smart contract or auth changes. 4. **Clean Commits**: Squashed or well-organized commits with informative commit messages following Conventional Commits (`feat:`, `fix:`, `docs:`, `test:`, `refactor:`). diff --git a/frontend/package.json b/frontend/package.json index 1ce7d5cfb..de94c0cda 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -88,5 +88,8 @@ "maxSize": "50 kB", "compression": "gzip" } - ] + ], + "overrides": { + "lodash": "^4.17.21" + } } diff --git a/package.json b/package.json index 61556648e..c48062a7c 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,28 @@ "validate:sprint-and-triage": "tsx scripts/validate-sprint-and-triage-conventions.ts", "validate:release-notes": "tsx scripts/validate-release-notes.ts", "validate:nfr-baselines": "tsx scripts/validate-nfr-baselines.ts", - "validate:incident-severity": "tsx scripts/validate-incident-severity.ts" + "validate:incident-severity": "tsx scripts/validate-incident-severity.ts", + "validate:codeowners": "tsx scripts/validate-codeowners.ts", + "test:validate-codeowners": "vitest run scripts/validate-codeowners.test.ts", + "check:codeowners": "pnpm validate:codeowners && pnpm test:validate-codeowners", + "validate:pr-template": "tsx scripts/validate-pr-template.ts", + "test:validate-pr-template": "vitest run scripts/validate-pr-template.test.ts", + "check:pr-template": "pnpm validate:pr-template && pnpm test:validate-pr-template", + "validate:renovate": "tsx scripts/validate-renovate-config.ts", + "test:validate-renovate": "vitest run scripts/validate-renovate-config.test.ts", + "audit:pnpm": "pnpm audit --audit-level=high", + "check:security-audit": "pnpm validate:renovate && pnpm test:validate-renovate && pnpm audit --audit-level=high" + }, + "pnpm": { + "overrides": { + "lodash": "^4.17.21" + } + }, + "overrides": { + "lodash": "^4.17.21" + }, + "resolutions": { + "lodash": "^4.17.21" }, "lint-staged": { "{frontend,backend}/**/*.{ts,tsx}": "node scripts/lint-staged-runners.js eslint", diff --git a/renovate.json b/renovate.json new file mode 100644 index 000000000..656fd6782 --- /dev/null +++ b/renovate.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "schedule": [ + "before 4am Monday" + ], + "timezone": "UTC", + "packageRules": [ + { + "description": "Automerge indirect and transitive dependency updates", + "matchDepTypes": ["indirect"], + "automerge": true, + "automergeType": "branch" + }, + { + "description": "Automerge security and patch fixes for indirect dependencies", + "matchDepTypes": ["indirect"], + "matchUpdateTypes": ["patch", "pin", "digest"], + "automerge": true + }, + { + "description": "Group vulnerability fixes and automerge high/critical transitive updates", + "matchPackageNames": ["lodash"], + "automerge": true + } + ] +} diff --git a/scripts/validate-codeowners.test.ts b/scripts/validate-codeowners.test.ts new file mode 100644 index 000000000..5f2acefcb --- /dev/null +++ b/scripts/validate-codeowners.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + parseCodeowners, + patternToRegex, + getMatchingRules, + getOwnersForFile, + getReviewersForChangedFiles, + validateCodeownersFile, + runCodeownersCheck, +} from './validate-codeowners'; + +describe('CODEOWNERS Validation & PR Review Simulation Tests', () => { + const repoRoot = resolve(__dirname, '..'); + const codeownersPath = resolve(repoRoot, '.github/CODEOWNERS'); + + it('verifies .github/CODEOWNERS exists in repository', () => { + expect(existsSync(codeownersPath)).toBe(true); + }); + + it('parses repository CODEOWNERS rules accurately', () => { + const content = readFileSync(codeownersPath, 'utf8'); + const rules = parseCodeowners(content); + expect(rules.length).toBeGreaterThan(0); + + const contractRules = rules.filter((r) => r.pattern.startsWith('/contracts')); + expect(contractRules.length).toBeGreaterThan(0); + + const rustRules = rules.filter((r) => r.pattern === '*.rs'); + expect(rustRules.length).toBe(1); + expect(rustRules[0].owners).toContain('@rust-reviewers'); + }); + + describe('Acceptance Criteria: PR touching contracts/src/lib.rs', () => { + it('asserts CODEOWNERS requests review from @Junirezz, @contract-reviewers, and @rust-reviewers', () => { + const content = readFileSync(codeownersPath, 'utf8'); + const changedFiles = ['contracts/src/lib.rs']; + const { fileReviewers, allReviewers } = getReviewersForChangedFiles(changedFiles, content); + + expect(fileReviewers['contracts/src/lib.rs']).toContain('@Junirezz'); + expect(fileReviewers['contracts/src/lib.rs']).toContain('@contract-reviewers'); + expect(fileReviewers['contracts/src/lib.rs']).toContain('@rust-reviewers'); + + expect(allReviewers).toContain('@Junirezz'); + expect(allReviewers).toContain('@contract-reviewers'); + expect(allReviewers).toContain('@rust-reviewers'); + }); + + it('asserts PR touching nested contract rust file (contracts/vault/src/lib.rs) requests all contract & rust reviewers', () => { + const content = readFileSync(codeownersPath, 'utf8'); + const changedFiles = ['contracts/vault/src/lib.rs']; + const { fileReviewers } = getReviewersForChangedFiles(changedFiles, content); + + expect(fileReviewers['contracts/vault/src/lib.rs']).toContain('@Junirezz'); + expect(fileReviewers['contracts/vault/src/lib.rs']).toContain('@contract-reviewers'); + expect(fileReviewers['contracts/vault/src/lib.rs']).toContain('@rust-reviewers'); + }); + + it('asserts PR touching non-rust contract file (contracts/Cargo.toml) requests contract reviewers', () => { + const content = readFileSync(codeownersPath, 'utf8'); + const changedFiles = ['contracts/Cargo.toml']; + const { fileReviewers } = getReviewersForChangedFiles(changedFiles, content); + + expect(fileReviewers['contracts/Cargo.toml']).toContain('@Junirezz'); + expect(fileReviewers['contracts/Cargo.toml']).toContain('@contract-reviewers'); + }); + }); + + describe('Pattern Matching Helpers', () => { + it('matches exact directory patterns', () => { + const regex = patternToRegex('/contracts/'); + expect(regex.test('contracts/src/lib.rs')).toBe(true); + expect(regex.test('backend/src/index.ts')).toBe(false); + }); + + it('matches glob extension patterns across subdirectories', () => { + const regex = patternToRegex('*.rs'); + expect(regex.test('contracts/src/lib.rs')).toBe(true); + expect(regex.test('contracts/vault/src/state.rs')).toBe(true); + expect(regex.test('backend/src/index.ts')).toBe(false); + }); + }); + + describe('validateCodeownersFile validation rules', () => { + it('passes validation for current repository CODEOWNERS', () => { + const content = readFileSync(codeownersPath, 'utf8'); + const result = validateCodeownersFile(content); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('rejects empty CODEOWNERS content', () => { + const result = validateCodeownersFile(''); + expect(result.valid).toBe(false); + expect(result.errors).toContain('CODEOWNERS file cannot be empty.'); + }); + + it('rejects CODEOWNERS missing /contracts owners', () => { + const content = ` +* @team-core +/backend/ @team-backend +*.rs @rust-reviewers + `; + const result = validateCodeownersFile(content); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('missing rule "/contracts @Junirezz @contract-reviewers"'))).toBe(true); + }); + + it('rejects CODEOWNERS missing *.rs owners', () => { + const content = ` +* @team-core +/contracts @Junirezz @contract-reviewers +/backend/ @team-backend + `; + const result = validateCodeownersFile(content); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('missing rule "*.rs @rust-reviewers"'))).toBe(true); + }); + }); + + describe('runCodeownersCheck in repository', () => { + it('passes repository-level check successfully', () => { + const result = runCodeownersCheck(repoRoot); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + }); +}); diff --git a/scripts/validate-codeowners.ts b/scripts/validate-codeowners.ts new file mode 100644 index 000000000..cecee51df --- /dev/null +++ b/scripts/validate-codeowners.ts @@ -0,0 +1,234 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export interface CodeownersRule { + lineNumber: number; + pattern: string; + owners: string[]; +} + +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +/** + * Converts a gitignore/CODEOWNERS pattern to a RegExp. + */ +export function patternToRegex(pattern: string): RegExp { + let p = pattern.trim(); + const startsWithSlash = p.startsWith('/'); + if (startsWithSlash) { + p = p.substring(1); + } + + // Handle trailing slash (matches directory and anything beneath it) + const endsWithSlash = p.endsWith('/'); + if (endsWithSlash) { + p = p + '**'; + } + + // Escape special regex characters except * and ? + let regexStr = p + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '__DOUBLE_STAR__') + .replace(/\*/g, '[^/]*') + .replace(/__DOUBLE_STAR__/g, '.*') + .replace(/\?/g, '[^/]'); + + if (startsWithSlash) { + regexStr = `^${regexStr}(?:/.*)?$`; + } else { + // If it doesn't start with slash, it can match anywhere in the path + regexStr = `(?:^|/)${regexStr}(?:/.*)?$`; + } + + return new RegExp(regexStr); +} + +/** + * Parses CODEOWNERS file content into structured rules. + */ +export function parseCodeowners(content: string): CodeownersRule[] { + const lines = content.split('\n'); + const rules: CodeownersRule[] = []; + + for (let i = 0; i < lines.length; i++) { + const rawLine = lines[i].trim(); + if (!rawLine || rawLine.startsWith('#')) { + continue; + } + + const tokens = rawLine.split(/\s+/); + if (tokens.length >= 2) { + rules.push({ + lineNumber: i + 1, + pattern: tokens[0], + owners: tokens.slice(1), + }); + } + } + + return rules; +} + +/** + * Returns all matching rules for a given file path (relative to repo root). + */ +export function getMatchingRules(filePath: string, codeownersContent: string): CodeownersRule[] { + const normalizedPath = filePath.replace(/\\/g, '/').replace(/^\//, ''); + const rules = parseCodeowners(codeownersContent); + + return rules.filter((rule) => { + if (rule.pattern === '*') { + return true; + } + const regex = patternToRegex(rule.pattern); + return regex.test(normalizedPath); + }); +} + +/** + * Returns all owners requested for a file. + */ +export function getOwnersForFile(filePath: string, codeownersContent: string): string[] { + const matchingRules = getMatchingRules(filePath, codeownersContent); + const ownersSet = new Set(); + for (const rule of matchingRules) { + for (const owner of rule.owners) { + ownersSet.add(owner); + } + } + return Array.from(ownersSet); +} + +/** + * Simulates a PR touching a list of files and resolves all required codeowners. + */ +export function getReviewersForChangedFiles( + changedFiles: string[], + codeownersContent: string +): { + fileReviewers: Record; + allReviewers: string[]; +} { + const fileReviewers: Record = {}; + const allReviewersSet = new Set(); + + for (const file of changedFiles) { + const owners = getOwnersForFile(file, codeownersContent); + fileReviewers[file] = owners; + owners.forEach((o) => allReviewersSet.add(o)); + } + + return { + fileReviewers, + allReviewers: Array.from(allReviewersSet), + }; +} + +/** + * Validates CODEOWNERS syntax, completeness, and specific required coverage. + */ +export function validateCodeownersFile(content: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!content || content.trim() === '') { + errors.push('CODEOWNERS file cannot be empty.'); + return { valid: false, errors, warnings }; + } + + const lines = content.split('\n'); + let ruleCount = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line || line.startsWith('#')) { + continue; + } + + const tokens = line.split(/\s+/); + if (tokens.length < 2) { + errors.push(`Line ${i + 1} in CODEOWNERS is invalid. Rule must specify a pattern and at least one owner.`); + } else { + ruleCount++; + const owners = tokens.slice(1); + const invalidOwners = owners.filter((owner) => !owner.startsWith('@') && !owner.includes('@')); + if (invalidOwners.length > 0) { + warnings.push(`Line ${i + 1} has owners that may be invalid: ${invalidOwners.join(', ')}`); + } + } + } + + if (ruleCount === 0) { + errors.push('CODEOWNERS contains no active owner rules.'); + } + + // Required coverage checks + const rules = parseCodeowners(content); + + // 1. Check contracts ownership (/contracts or /contracts/) + const hasContractsRule = rules.some( + (r) => + (r.pattern === '/contracts' || r.pattern === '/contracts/' || r.pattern.startsWith('/contracts/')) && + r.owners.includes('@Junirezz') && + r.owners.includes('@contract-reviewers') + ); + if (!hasContractsRule) { + errors.push('CODEOWNERS is missing rule "/contracts @Junirezz @contract-reviewers"'); + } + + // 2. Check Rust file ownership (*.rs @rust-reviewers) + const hasRustRule = rules.some( + (r) => (r.pattern === '*.rs' || r.pattern === '**/*.rs') && r.owners.includes('@rust-reviewers') + ); + if (!hasRustRule) { + errors.push('CODEOWNERS is missing rule "*.rs @rust-reviewers"'); + } + + // 3. Test contracts/src/lib.rs review resolution + const testLibRsReviewers = getOwnersForFile('contracts/src/lib.rs', content); + const requiredLibRsReviewers = ['@Junirezz', '@contract-reviewers', '@rust-reviewers']; + const missingLibRsReviewers = requiredLibRsReviewers.filter((r) => !testLibRsReviewers.includes(r)); + if (missingLibRsReviewers.length > 0) { + errors.push( + `PR touching "contracts/src/lib.rs" does not request all required reviewers. Missing: ${missingLibRsReviewers.join(', ')}` + ); + } + + return { valid: errors.length === 0, errors, warnings }; +} + +/** + * CLI runner for CODEOWNERS check in CI. + */ +export function runCodeownersCheck(rootDir: string = process.cwd()): ValidationResult { + const codeownersPath = resolve(rootDir, '.github/CODEOWNERS'); + if (!existsSync(codeownersPath)) { + return { + valid: false, + errors: [`.github/CODEOWNERS file does not exist at ${codeownersPath}`], + warnings: [], + }; + } + + const content = readFileSync(codeownersPath, 'utf8'); + return validateCodeownersFile(content); +} + +if (require.main === module) { + const result = runCodeownersCheck(); + if (!result.valid) { + console.error('❌ CODEOWNERS validation failed:'); + result.errors.forEach((err) => console.error(` - ${err}`)); + process.exit(1); + } else { + console.log('✅ CODEOWNERS validation passed successfully!'); + if (result.warnings.length > 0) { + console.warn('⚠️ Warnings:'); + result.warnings.forEach((warn) => console.warn(` - ${warn}`)); + } + } +} diff --git a/scripts/validate-pr-template.test.ts b/scripts/validate-pr-template.test.ts new file mode 100644 index 000000000..a7c1bab5b --- /dev/null +++ b/scripts/validate-pr-template.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + validatePrTemplate, + simulatePROpening, + extractSection, + runPrTemplateCheck, +} from './validate-pr-template'; + +describe('Pull Request Template Validation & Wave Submission Checklist Tests', () => { + const repoRoot = resolve(__dirname, '..'); + const templatePath = resolve(repoRoot, '.github/PULL_REQUEST_TEMPLATE.md'); + + it('verifies .github/PULL_REQUEST_TEMPLATE.md exists in repository', () => { + expect(existsSync(templatePath)).toBe(true); + }); + + describe('Acceptance Criteria: PR Template Checklist Sections', () => { + const templateContent = readFileSync(templatePath, 'utf8'); + + it('contains Risk Assessment section with checkboxes', () => { + const risk = extractSection(templateContent, ['Risk Assessment', 'Risk']); + expect(risk.hasHeading).toBe(true); + expect(risk.checkboxCount).toBeGreaterThanOrEqual(4); + }); + + it('contains Rollback Plan section with checkboxes', () => { + const rollback = extractSection(templateContent, ['Rollback Plan', 'Rollback']); + expect(rollback.hasHeading).toBe(true); + expect(rollback.checkboxCount).toBeGreaterThanOrEqual(3); + }); + + it('contains Performance Impact section with checkboxes', () => { + const perf = extractSection(templateContent, ['Performance Impact', 'Performance']); + expect(perf.hasHeading).toBe(true); + expect(perf.checkboxCount).toBeGreaterThanOrEqual(3); + }); + + it('includes Wave submission and contract migration type of change option', () => { + expect(templateContent).toContain('Wave submission'); + }); + + it('covers blast radius items for smart contract storage migrations', () => { + expect(templateContent).toContain('Contract storage layout / data key migration'); + expect(templateContent).toContain('Value transfer'); + }); + }); + + describe('Test: open PR via template and assert checklist appears', () => { + it('simulates opening PR with template and verifies all required checklists appear', () => { + const templateContent = readFileSync(templatePath, 'utf8'); + const simulation = simulatePROpening(templateContent); + + expect(simulation.hasRiskSection).toBe(true); + expect(simulation.hasRollbackSection).toBe(true); + expect(simulation.hasPerformanceSection).toBe(true); + expect(simulation.riskCheckboxesPresent).toBe(true); + expect(simulation.rollbackCheckboxesPresent).toBe(true); + expect(simulation.performanceCheckboxesPresent).toBe(true); + expect(simulation.allChecklistsPresent).toBe(true); + }); + }); + + describe('validatePrTemplate schema validation', () => { + it('passes validation on repository PR template', () => { + const templateContent = readFileSync(templatePath, 'utf8'); + const result = validatePrTemplate(templateContent); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('rejects PR template missing Risk section', () => { + const invalidTemplate = ` +# PR Template +## Description +Goal +## Testing +- [ ] Unit tests +## Rollback Plan +- [ ] Revert git +## Performance Impact +- [ ] Gas checked + `; + const result = validatePrTemplate(invalidTemplate); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('Risk Assessment'))).toBe(true); + }); + + it('rejects PR template missing Rollback Plan section', () => { + const invalidTemplate = ` +# PR Template +## Description +Goal +## Testing +- [ ] Unit tests +## Risk Assessment +- [ ] Low +- [ ] Medium +- [ ] High +- [ ] Critical +## Performance Impact +- [ ] Gas checked + `; + const result = validatePrTemplate(invalidTemplate); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('Rollback Plan'))).toBe(true); + }); + + it('rejects PR template missing Performance Impact section', () => { + const invalidTemplate = ` +# PR Template +## Description +Goal +## Testing +- [ ] Unit tests +## Risk Assessment +- [ ] Low +- [ ] Medium +- [ ] High +- [ ] Critical +## Rollback Plan +- [ ] Revert git +- [ ] Pause contract +- [ ] Down migration + `; + const result = validatePrTemplate(invalidTemplate); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('Performance Impact'))).toBe(true); + }); + }); + + describe('runPrTemplateCheck CLI runner', () => { + it('runs repository-level PR template check successfully', () => { + const result = runPrTemplateCheck(repoRoot); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + }); +}); diff --git a/scripts/validate-pr-template.ts b/scripts/validate-pr-template.ts new file mode 100644 index 000000000..5f3c8cf3f --- /dev/null +++ b/scripts/validate-pr-template.ts @@ -0,0 +1,194 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +export interface SectionCheckResult { + hasHeading: boolean; + checkboxCount: number; + headings: string[]; +} + +export const REQUIRED_TEMPLATE_SECTIONS = [ + { + name: 'Risk Assessment', + aliases: ['Risk Assessment', 'Risk', '🛡️ Risk Assessment'], + minCheckboxes: 4, + }, + { + name: 'Rollback Plan', + aliases: ['Rollback Plan', 'Rollback', '🔄 Rollback Plan'], + minCheckboxes: 3, + }, + { + name: 'Performance Impact', + aliases: ['Performance Impact', 'Performance', '⚡ Performance Impact'], + minCheckboxes: 3, + }, +]; + +/** + * Extracts sections and checkbox counts from markdown content. + */ +export function extractSection(content: string, sectionKeywords: string[]): SectionCheckResult { + const lines = content.split('\n'); + let inSection = false; + let checkboxCount = 0; + const headings: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + const headingMatch = line.match(/^#{1,4}\s+(.*)$/); + + if (headingMatch) { + const headingText = headingMatch[1].trim(); + const isMatch = sectionKeywords.some((kw) => + headingText.toLowerCase().includes(kw.toLowerCase()) + ); + + if (isMatch) { + inSection = true; + headings.push(headingText); + continue; + } else if (line.startsWith('# ') || line.startsWith('## ')) { + // Exiting section on new major heading + inSection = false; + } + } + + if (inSection) { + if (line.startsWith('- [ ]') || line.startsWith('- [x]') || line.startsWith('- [X]')) { + checkboxCount++; + } + } + } + + return { + hasHeading: headings.length > 0, + checkboxCount, + headings, + }; +} + +/** + * Validates PR Template structure, sections, and checkboxes. + */ +export function validatePrTemplate(content: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!content || content.trim() === '') { + errors.push('PR Template content cannot be empty.'); + return { valid: false, errors, warnings }; + } + + for (const req of REQUIRED_TEMPLATE_SECTIONS) { + const res = extractSection(content, req.aliases); + if (!res.hasHeading) { + errors.push(`PR Template is missing required section: "${req.name}"`); + } else if (res.checkboxCount < req.minCheckboxes) { + errors.push( + `Section "${req.name}" must contain at least ${req.minCheckboxes} checkboxes, but found ${res.checkboxCount}.` + ); + } + } + + // Verify Description, Testing, and Security + if (!content.includes('Description') && !content.includes('Goal')) { + errors.push('PR Template is missing Description / Goal section.'); + } + + if (!content.includes('Testing')) { + errors.push('PR Template is missing Testing section.'); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Simulates opening a PR using the template and verifies all sections and checklists appear. + */ +export function simulatePROpening(templateContent: string): { + hasRiskSection: boolean; + hasRollbackSection: boolean; + hasPerformanceSection: boolean; + riskCheckboxesPresent: boolean; + rollbackCheckboxesPresent: boolean; + performanceCheckboxesPresent: boolean; + allChecklistsPresent: boolean; +} { + const risk = extractSection(templateContent, ['Risk Assessment', 'Risk']); + const rollback = extractSection(templateContent, ['Rollback Plan', 'Rollback']); + const perf = extractSection(templateContent, ['Performance Impact', 'Performance']); + + const riskCheckboxesPresent = risk.checkboxCount > 0; + const rollbackCheckboxesPresent = rollback.checkboxCount > 0; + const performanceCheckboxesPresent = perf.checkboxCount > 0; + + return { + hasRiskSection: risk.hasHeading, + hasRollbackSection: rollback.hasHeading, + hasPerformanceSection: perf.hasHeading, + riskCheckboxesPresent, + rollbackCheckboxesPresent, + performanceCheckboxesPresent, + allChecklistsPresent: + risk.hasHeading && + rollback.hasHeading && + perf.hasHeading && + riskCheckboxesPresent && + rollbackCheckboxesPresent && + performanceCheckboxesPresent, + }; +} + +/** + * CLI runner for PR template verification. + */ +export function runPrTemplateCheck(rootDir: string = process.cwd()): ValidationResult { + const templateCandidates = [ + resolve(rootDir, '.github/PULL_REQUEST_TEMPLATE.md'), + resolve(rootDir, '.github/pull_request_template.md'), + ]; + + let templatePath: string | null = null; + for (const candidate of templateCandidates) { + if (existsSync(candidate)) { + templatePath = candidate; + break; + } + } + + if (!templatePath) { + return { + valid: false, + errors: ['No PR template found in .github/ (checked PULL_REQUEST_TEMPLATE.md and pull_request_template.md)'], + warnings: [], + }; + } + + const content = readFileSync(templatePath, 'utf8'); + return validatePrTemplate(content); +} + +if (require.main === module) { + const result = runPrTemplateCheck(); + if (!result.valid) { + console.error('❌ PR Template validation failed:'); + result.errors.forEach((err) => console.error(` - ${err}`)); + process.exit(1); + } else { + console.log('✅ PR Template validation passed successfully!'); + if (result.warnings.length > 0) { + result.warnings.forEach((warn) => console.warn(`⚠️ Warning: ${warn}`)); + } + } +} diff --git a/scripts/validate-renovate-config.test.ts b/scripts/validate-renovate-config.test.ts new file mode 100644 index 000000000..17c81f5d1 --- /dev/null +++ b/scripts/validate-renovate-config.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + validateRenovateConfig, + validateLodashOverride, + runRenovateValidation, +} from './validate-renovate-config'; + +describe('Renovate Configuration & Transitive lodash CVE-2021-23337 Mitigation Tests', () => { + const repoRoot = resolve(__dirname, '..'); + const renovatePath = resolve(repoRoot, 'renovate.json'); + const packageJsonPath = resolve(repoRoot, 'package.json'); + + it('verifies renovate.json exists in root directory', () => { + expect(existsSync(renovatePath)).toBe(true); + }); + + describe('Acceptance Criteria: renovate.json structure and rules', () => { + const content = readFileSync(renovatePath, 'utf8'); + const config = JSON.parse(content); + + it('includes extends: ["config:recommended"]', () => { + expect(config.extends).toContain('config:recommended'); + }); + + it('sets schedule before 4am Monday', () => { + expect(Array.isArray(config.schedule)).toBe(true); + const hasMondaySchedule = config.schedule.some((s: string) => { + const lower = s.toLowerCase(); + return lower.includes('4am') && lower.includes('monday'); + }); + expect(hasMondaySchedule).toBe(true); + }); + + it('enables automerge for indirect / transitive dependencies', () => { + expect(Array.isArray(config.packageRules)).toBe(true); + const indirectAutomerge = config.packageRules.some((rule: any) => { + return ( + Array.isArray(rule.matchDepTypes) && + rule.matchDepTypes.includes('indirect') && + rule.automerge === true + ); + }); + expect(indirectAutomerge).toBe(true); + }); + }); + + describe('Acceptance Criteria: lodash CVE-2021-23337 mitigation', () => { + it('overrides lodash to safe version >= 4.17.21 in root package.json', () => { + const pkgContent = readFileSync(packageJsonPath, 'utf8'); + const pkg = JSON.parse(pkgContent); + + expect(pkg.pnpm?.overrides?.lodash).toBeDefined(); + expect(pkg.pnpm.overrides.lodash).not.toContain('4.17.20'); + expect(pkg.overrides?.lodash).toBeDefined(); + }); + + it('passes validateLodashOverride check', () => { + const pkgContent = readFileSync(packageJsonPath, 'utf8'); + const result = validateLodashOverride(pkgContent); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + }); + + describe('Validation error handling', () => { + it('rejects invalid renovate config missing config:recommended', () => { + const invalid = JSON.stringify({ schedule: ['before 4am Monday'], packageRules: [] }); + const result = validateRenovateConfig(invalid); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('config:recommended'))).toBe(true); + }); + + it('rejects invalid renovate config missing Monday schedule', () => { + const invalid = JSON.stringify({ + extends: ['config:recommended'], + schedule: ['at 10pm Friday'], + packageRules: [{ matchDepTypes: ['indirect'], automerge: true }], + }); + const result = validateRenovateConfig(invalid); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('before 4am Monday'))).toBe(true); + }); + + it('rejects invalid renovate config missing automerge for indirect', () => { + const invalid = JSON.stringify({ + extends: ['config:recommended'], + schedule: ['before 4am Monday'], + packageRules: [], + }); + const result = validateRenovateConfig(invalid); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('automerge: true'))).toBe(true); + }); + }); + + describe('runRenovateValidation integration check', () => { + it('passes repository-level validation cleanly', () => { + const result = runRenovateValidation(repoRoot); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + }); +}); diff --git a/scripts/validate-renovate-config.ts b/scripts/validate-renovate-config.ts new file mode 100644 index 000000000..f5cf6db52 --- /dev/null +++ b/scripts/validate-renovate-config.ts @@ -0,0 +1,150 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +export interface RenovateConfig { + extends?: string[]; + schedule?: string[]; + timezone?: string; + packageRules?: Array<{ + matchDepTypes?: string[]; + matchPackageNames?: string[]; + matchUpdateTypes?: string[]; + automerge?: boolean; + automergeType?: string; + }>; +} + +/** + * Validates the renovate.json configuration against requirements. + */ +export function validateRenovateConfig(jsonContent: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!jsonContent || jsonContent.trim() === '') { + errors.push('renovate.json cannot be empty.'); + return { valid: false, errors, warnings }; + } + + let config: RenovateConfig; + try { + config = JSON.parse(jsonContent); + } catch (err) { + errors.push(`renovate.json contains invalid JSON: ${(err as Error).message}`); + return { valid: false, errors, warnings }; + } + + // 1. Check extends includes config:recommended + if (!Array.isArray(config.extends) || !config.extends.some((e) => e.includes('config:recommended'))) { + errors.push('renovate.json must include "config:recommended" in the "extends" array.'); + } + + // 2. Check schedule contains before 4am Monday + if (!Array.isArray(config.schedule) || config.schedule.length === 0) { + errors.push('renovate.json must define a "schedule" array.'); + } else { + const hasMondaySchedule = config.schedule.some((s) => { + const lower = s.toLowerCase(); + return lower.includes('4am') && lower.includes('monday'); + }); + if (!hasMondaySchedule) { + errors.push('renovate.json schedule must be scheduled before 4am Monday.'); + } + } + + // 3. Check automerge for indirect dependencies + const packageRules = config.packageRules || []; + const hasIndirectAutomerge = packageRules.some((rule) => { + const matchesIndirect = Array.isArray(rule.matchDepTypes) && rule.matchDepTypes.includes('indirect'); + return matchesIndirect && rule.automerge === true; + }); + + if (!hasIndirectAutomerge) { + errors.push('renovate.json must configure "automerge: true" for indirect dependencies in "packageRules".'); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Validates that lodash is overridden / pinned to >= 4.17.21 in package.json to mitigate CVE-2021-23337. + */ +export function validateLodashOverride(packageJsonContent: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + try { + const pkg = JSON.parse(packageJsonContent); + const pnpmOverrides = pkg.pnpm?.overrides || {}; + const overrides = pkg.overrides || {}; + const resolutions = pkg.resolutions || {}; + + const lodashTarget = pnpmOverrides.lodash || overrides.lodash || resolutions.lodash; + if (!lodashTarget) { + errors.push('package.json is missing pnpm/npm override for "lodash" (required: ^4.17.21 to fix CVE-2021-23337).'); + } else if (lodashTarget.includes('4.17.20')) { + errors.push(`lodash is pinned to vulnerable version "${lodashTarget}". Must be >= 4.17.21.`); + } + } catch (err) { + errors.push(`Failed to parse package.json: ${(err as Error).message}`); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * CLI runner for Renovate & lodash vulnerability validation. + */ +export function runRenovateValidation(rootDir: string = process.cwd()): ValidationResult { + const allErrors: string[] = []; + const allWarnings: string[] = []; + + const renovatePath = resolve(rootDir, 'renovate.json'); + if (!existsSync(renovatePath)) { + allErrors.push(`renovate.json does not exist at ${renovatePath}`); + } else { + const content = readFileSync(renovatePath, 'utf8'); + const res = validateRenovateConfig(content); + allErrors.push(...res.errors); + allWarnings.push(...res.warnings); + } + + const packageJsonPath = resolve(rootDir, 'package.json'); + if (existsSync(packageJsonPath)) { + const pkgContent = readFileSync(packageJsonPath, 'utf8'); + const res = validateLodashOverride(pkgContent); + allErrors.push(...res.errors); + allWarnings.push(...res.warnings); + } + + return { + valid: allErrors.length === 0, + errors: allErrors, + warnings: allWarnings, + }; +} + +if (require.main === module) { + const result = runRenovateValidation(); + if (!result.valid) { + console.error('❌ Renovate / Dependency security validation failed:'); + result.errors.forEach((err) => console.error(` - ${err}`)); + process.exit(1); + } else { + console.log('✅ Renovate configuration and lodash security overrides validated successfully!'); + } +}