diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f73d63c1..487ec115 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -243,7 +243,9 @@ jobs: node-version: '20.x' - name: Run npm audit - run: npm audit --audit-level=high + run: | + npm audit signatures + npm audit --audit-level=high continue-on-error: true # Allows PR to proceed, but marks job as failed if vulnerabilities found lint: diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index 1a4eb8bd..f2818c0b 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -34,6 +34,7 @@ jobs: run: | if [ -f package-lock.json ]; then npm ci --ignore-scripts + npm audit signatures npm audit --audit-level=high else echo "No package-lock.json found; skipping npm audit" diff --git a/scripts/ci/validate-workflow-security.js b/scripts/ci/validate-workflow-security.js index 45238d97..5b1e2280 100644 --- a/scripts/ci/validate-workflow-security.js +++ b/scripts/ci/validate-workflow-security.js @@ -26,6 +26,8 @@ const RULES = [ const WRITE_PERMISSION_PATTERN = /^\s*(?:contents|issues|pull-requests|actions|checks|deployments|discussions|id-token|packages|pages|repository-projects|security-events|statuses):\s*write\b/m; const NPM_CI_PATTERN = /\bnpm\s+ci\b(?![^\n]*--ignore-scripts)/g; +const NPM_AUDIT_PATTERN = /\bnpm\s+audit\b(?!\s+signatures\b)/; +const NPM_AUDIT_SIGNATURES_PATTERN = /\bnpm\s+audit\s+signatures\b/; const ACTIONS_CACHE_PATTERN = /uses:\s*['"]?actions\/cache@/m; const ID_TOKEN_WRITE_PATTERN = /^\s*id-token:\s*write\b/m; @@ -127,6 +129,16 @@ function findViolations(filePath, source) { }); } + if (NPM_AUDIT_PATTERN.test(source) && !NPM_AUDIT_SIGNATURES_PATTERN.test(source)) { + violations.push({ + filePath, + event: 'npm audit signatures', + description: 'workflows that run npm audit must also verify registry signatures', + expression: 'npm audit without npm audit signatures', + line: getLineNumber(source, source.search(NPM_AUDIT_PATTERN)), + }); + } + return violations; } diff --git a/tests/ci/validate-workflow-security.test.js b/tests/ci/validate-workflow-security.test.js index e9a3d567..a3bad9cc 100644 --- a/tests/ci/validate-workflow-security.test.js +++ b/tests/ci/validate-workflow-security.test.js @@ -122,6 +122,21 @@ function run() { assert.match(result.stderr, /id-token: write must not restore or save shared dependency caches/); })) passed++; else failed++; + if (test('rejects npm audit without registry signature verification', () => { + const result = runValidator({ + 'unsafe-audit.yml': `name: Unsafe\non:\n push:\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: npm audit --audit-level=high\n`, + }); + assert.notStrictEqual(result.status, 0, 'Expected validator to fail when npm audit signatures is missing'); + assert.match(result.stderr, /npm audit must also verify registry signatures/); + })) passed++; else failed++; + + if (test('allows npm audit when registry signatures are verified', () => { + const result = runValidator({ + 'safe-audit.yml': `name: Safe\non:\n push:\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: |\n npm audit signatures\n npm audit --audit-level=high\n`, + }); + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + })) passed++; else failed++; + console.log(`\nPassed: ${passed}`); console.log(`Failed: ${failed}`); diff --git a/tests/docs/copilot-support.test.js b/tests/docs/copilot-support.test.js index 8b3eae4f..1e7ec718 100644 --- a/tests/docs/copilot-support.test.js +++ b/tests/docs/copilot-support.test.js @@ -27,7 +27,8 @@ function read(relativePath) { } function parseSimpleFrontmatter(source, relativePath) { - const match = source.match(/^---\n([\s\S]*?)\n---\n/); + const normalizedSource = source.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'); + const match = normalizedSource.match(/^---\n([\s\S]*?)\n---\n/); assert.ok(match, `${relativePath} must start with YAML frontmatter`); const fields = {}; diff --git a/tests/scripts/repair.test.js b/tests/scripts/repair.test.js index fc65ea76..98a22573 100644 --- a/tests/scripts/repair.test.js +++ b/tests/scripts/repair.test.js @@ -64,6 +64,16 @@ function runNode(scriptPath, args = [], options = {}) { } } +function normalizeComparablePath(filePath) { + const normalized = path.normalize(filePath); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function pathListIncludes(paths, expectedPath) { + const normalizedExpected = normalizeComparablePath(expectedPath); + return paths.some(filePath => normalizeComparablePath(filePath) === normalizedExpected); +} + function test(name, fn) { try { fn(); @@ -117,7 +127,7 @@ function runTests() { const parsed = JSON.parse(repairResult.stdout); assert.strictEqual(parsed.results[0].status, 'repaired'); - assert.ok(parsed.results[0].repairedPaths.includes(managedPath)); + assert.ok(pathListIncludes(parsed.results[0].repairedPaths, managedPath)); assert.strictEqual(fs.readFileSync(managedPath, 'utf8'), expectedContent); assert.ok(fs.existsSync(statePath)); } finally {