From 1e791e66169b20c3bfee2ddd728a63682ad6e786 Mon Sep 17 00:00:00 2001 From: Bob Killen Date: Sun, 11 Oct 2026 09:33:16 -0500 Subject: [PATCH] ci(codeql): analyse the Actions workflows and scan on any workflow change codeql.yml initialised CodeQL with javascript-typescript only, so none of the ten workflow files were statically analysed, and its path filters named only codeql.yml itself, so a PR editing any other workflow never started the scan. Fan the analyze job out over a language matrix of javascript-typescript and actions (GA since 2025-04-22) with fail-fast: false, read the language into init and the analyze category from the matrix, and widen both paths: filters from .github/workflows/codeql.yml to .github/workflows/**. tests/codeql-contract.test.mjs pins the new shape: both languages must be in the matrix, init and analyze must read it, and the workflow glob must be in the filters. With the workflow change reverted the contract fails on three of its ten tests. Closes #1302 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Killen --- .github/workflows/codeql.yml | 27 +++++++++----- tests/codeql-contract.test.mjs | 65 +++++++++++++++++++++++++++------- 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1dc020d8..0cc34ff9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,7 +12,7 @@ on: - '**/*.tsx' - package.json - package-lock.json - - .github/workflows/codeql.yml + - '.github/workflows/**' pull_request: branches: [main] paths: @@ -24,7 +24,7 @@ on: - '**/*.tsx' - package.json - package-lock.json - - .github/workflows/codeql.yml + - '.github/workflows/**' schedule: - cron: '31 7 * * 1' @@ -32,23 +32,32 @@ permissions: contents: read # Not a required status check, so it is safe to skip entirely (rather than -# just cancel) when a PR touches no JavaScript/TypeScript source: an -# unmatched path means the workflow never starts, and nothing here gates the -# merge queue on it. The weekly schedule run still scans the full tree -# regardless of what changed most recently. +# just cancel) when a PR touches neither JavaScript/TypeScript source nor a +# workflow: an unmatched path means the workflow never starts, and nothing +# here gates the merge queue on it. The weekly schedule run still scans the +# full tree regardless of what changed most recently. concurrency: group: codeql-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: analyze: - name: Analyze JavaScript + name: Analyze ${{ matrix.language }} runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: read security-events: write actions: read + strategy: + # One language failing must not hide the other language's results. + fail-fast: false + matrix: + # `actions` analyses the workflow files themselves: script injection + # through ${{ github.event.* }} in `run:` blocks, over-broad + # GITHUB_TOKEN permissions, un-pinned third-party actions. Nothing else + # in this repository reads the workflows statically. + language: ['javascript-typescript', 'actions'] steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -57,9 +66,9 @@ jobs: - name: Initialize CodeQL uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: - languages: javascript-typescript + languages: ${{ matrix.language }} build-mode: none - name: Perform CodeQL analysis uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: - category: '/language:javascript-typescript' + category: '/language:${{ matrix.language }}' diff --git a/tests/codeql-contract.test.mjs b/tests/codeql-contract.test.mjs index 3ace740e..6bdc94e4 100644 --- a/tests/codeql-contract.test.mjs +++ b/tests/codeql-contract.test.mjs @@ -97,9 +97,7 @@ test('required returns a present value and names the fault in the rest', () => { }); test('the analyze job may upload its results to the security tab', () => { - const jobs = Object.entries(required(workflow.jobs, 'jobs')); - assert.equal(jobs.length, 1, 'codeql.yml declares more than one job'); - const [jobName, job] = jobs[0]; + const [jobName, job] = analyzeJob(); const permissions = required( job.permissions, `permissions for job ${jobName}`, @@ -125,18 +123,58 @@ test('init and analyze come from the same codeql-action release', () => { ); }); -test('the analysis declares a language set and the category that names it', () => { +// The languages CodeQL must analyse here. `javascript-typescript` is the site +// and its scripts; `actions` is the workflow files themselves, which nothing +// else in this repository reads statically. Dropping either from the matrix +// silently narrows the scan, so both are pinned by name. +const REQUIRED_LANGUAGES = ['actions', 'javascript-typescript']; + +function analyzeJob() { + const jobs = Object.entries(required(workflow.jobs, 'jobs')); + assert.equal(jobs.length, 1, 'codeql.yml declares more than one job'); + return jobs[0]; +} + +test('the analysis fans out over every required language', () => { + const [jobName, job] = analyzeJob(); + const strategy = required(job.strategy, `strategy for job ${jobName}`); + const languages = required( + required(strategy.matrix, `matrix for job ${jobName}`).language, + `matrix.language for job ${jobName}`, + ); + assert.ok( + Array.isArray(languages), + `codeql.yml matrix.language is not a list: ${JSON.stringify(languages)}`, + ); + const missing = REQUIRED_LANGUAGES.filter( + (language) => !languages.includes(language), + ); + assert.deepEqual( + missing, + [], + `codeql.yml matrix omits languages the scan must cover: ${missing.join(', ')}`, + ); + // A failure in one language's analysis must not cancel the other's upload. + assert.equal( + strategy['fail-fast'], + false, + 'codeql.yml matrix does not set fail-fast: false, so one language failing hides the other', + ); +}); + +test('init and analyze read the language from the matrix and name it in the category', () => { const languages = required( required(codeqlStep('init').with, '`with` block for codeql-action/init') .languages, 'languages for codeql-action/init', ); assert.equal( - typeof languages, - 'string', - 'codeql-action/init declares no languages', + languages, + '${{ matrix.language }}', + `codeql-action/init languages ${JSON.stringify( + languages, + )} does not read the matrix, so the fan-out above analyses nothing`, ); - assert.ok(languages.length > 0, 'codeql-action/init languages is empty'); const category = required( codeqlStep('analyze').with, '`with` block for codeql-action/analyze', @@ -146,7 +184,7 @@ test('the analysis declares a language set and the category that names it', () = `/language:${languages}`, `codeql-action/analyze category ${JSON.stringify( category, - )} does not name the analysed languages ${JSON.stringify(languages)}`, + )} does not name the analysed language ${JSON.stringify(languages)}`, ); }); @@ -179,11 +217,14 @@ test('every scanned source extension in the tree is matched by the path filters' ); }); -test('a change to the scanner configuration triggers a scan', () => { +test('a change to any workflow, including the scanner itself, triggers a scan', () => { + // `actions` analyses the workflow files, so the filter has to match all of + // them rather than only codeql.yml; the glob also covers codeql.yml, so + // narrowing the scan still runs it. const paths = pathFilters('push'); assert.ok( - paths.includes('.github/workflows/codeql.yml'), - 'codeql.yml path filters omit codeql.yml, so narrowing the scan does not run it', + paths.includes('.github/workflows/**'), + 'codeql.yml path filters omit .github/workflows/**, so a workflow edit is never analysed and narrowing the scan does not run it', ); });