diff --git a/actions/dependency-audit/README.md b/actions/dependency-audit/README.md index 468bfbf..a01a2d6 100644 --- a/actions/dependency-audit/README.md +++ b/actions/dependency-audit/README.md @@ -27,7 +27,12 @@ The workflow checks out the repository and runs the action on `ubuntu-slim`. | Event | Scope | Fails when | Tracking issue | | --- | --- | --- | --- | | Pull request that changes a lockfile | Every lockfile, compared with the same lockfiles on the base commit | The pull request adds a known vulnerability | Not changed | -| Push to `main` that changes a lockfile, daily schedule, manual run | Every lockfile on `main` | Any known vulnerability is present | Opened or updated while vulnerabilities remain; closed with a comment when none remain | +| Push to `main` that changes a lockfile, daily schedule, manual run on `main` | Every lockfile on `main` | Never because of findings | Opened or updated while vulnerabilities remain; closed with a comment when none remain | +| Manual run on another branch, or any run in a repository with issues turned off | Every lockfile on that branch | Any known vulnerability is present | None | + +Existing vulnerabilities live in the issue rather than in a failing run on `main`, so +they do not turn every commit red or send a failure email each day. A scanner or API +error fails any run. The run summary lists the scanned lockfiles and one row per vulnerable package version and advisory: severity, the advisory link on osv.dev, fixed versions above diff --git a/actions/dependency-audit/index.mjs b/actions/dependency-audit/index.mjs index df37d80..4b9fcea 100644 --- a/actions/dependency-audit/index.mjs +++ b/actions/dependency-audit/index.mjs @@ -349,7 +349,8 @@ export async function fetchBaseLockfiles({ github, repository, ref, paths, direc export async function syncIssue({ github, repository, findings, report }) { const vulnerable = findings.filter(finding => !finding.informational); - const open = await github(`/repos/${repository}/issues?state=open&labels=${issueLabel}&per_page=100`); + const open = await github(`/repos/${repository}/issues?state=open&labels=${issueLabel}&per_page=100`, { allow: [410] }); + if (open.status === 410) return { action: 'disabled', number: null }; const existing = list(open.data).filter(issue => !issue.pull_request && issue.title.startsWith('Dependency audit:')); const [current, ...duplicates] = existing.sort((left, right) => left.number - right.number); for (const duplicate of duplicates) { @@ -423,16 +424,19 @@ export async function runAction(environment = process.env, dependencies = {}) { const vulnerable = findings.filter(finding => !finding.informational); const added = introduced === null ? [] : introduced.filter(finding => !finding.informational); const defaultBranch = event.repository?.default_branch; - let issue = { action: 'none', number: null }; + const untracked = { action: 'none', number: null }; + let issue = untracked; if (introduced === null && defaultBranch && environment.GITHUB_REF === `refs/heads/${defaultBranch}` && ['schedule', 'workflow_dispatch', 'push'].includes(environment.GITHUB_EVENT_NAME)) { issue = await syncIssue({ github, repository, findings, report }); if (issue.number !== null) log(`Tracking issue #${issue.number} ${issue.action}`); + if (issue.action === 'disabled') log('Issues are disabled in this repository, so the run fails while vulnerabilities remain.'); } if (environment.GITHUB_OUTPUT) { await appendFile(environment.GITHUB_OUTPUT, `vulnerabilities=${rows(vulnerable).length}\nintroduced=${rows(added).length}\n`, 'utf8'); } - const failed = introduced === null ? vulnerable.length > 0 : added.length > 0; + const issueKept = issue !== untracked && issue.action !== 'disabled'; + const failed = introduced === null ? vulnerable.length > 0 && !issueKept : added.length > 0; log(introduced === null ? (vulnerable.length === 0 ? 'No known vulnerabilities.' : `${countLine(vulnerable)}.`) : (added.length === 0 ? 'This pull request adds no known vulnerabilities.' : `This pull request adds ${countLine(added)}.`)); diff --git a/actions/dependency-audit/index.test.mjs b/actions/dependency-audit/index.test.mjs index a7f582f..3146a71 100644 --- a/actions/dependency-audit/index.test.mjs +++ b/actions/dependency-audit/index.test.mjs @@ -273,7 +273,7 @@ test('a pull request audit scans head and base and fails only on what it adds', assert.equal(result.issue.action, 'none'); }); -test('a scheduled audit on the default branch fails on any vulnerability and updates the tracking issue', async t => { +test('a scheduled audit on the default branch records vulnerabilities in the tracking issue and stays green', async t => { const { environment, summary } = await actionFixture(t, { repository: { default_branch: 'main' } }, 'schedule'); const calls = []; const fetchImpl = async (url, options = {}) => { @@ -284,8 +284,9 @@ test('a scheduled audit on the default branch fails on any vulnerability and upd return Response.json({ number: 11 }, { status: 201 }); }; const pin = { ...scanner, sha256: createHash('sha256').update('binary').digest('hex') }; - const result = await runAction(environment, { fetchImpl, platform: 'linux', arch: 'x64', scan: async () => sample, pin, lockfiles: () => ['bun.lock'], log: () => {} }); - assert.equal(result.failed, true); + const dependencies = { fetchImpl, platform: 'linux', arch: 'x64', scan: async () => sample, pin, lockfiles: () => ['bun.lock'], log: () => {} }; + const result = await runAction(environment, dependencies); + assert.equal(result.failed, false, 'the issue, not a red run on main, carries existing vulnerabilities'); assert.deepEqual(result.issue, { action: 'created', number: 11 }); assert.deepEqual(calls.slice(1), [ ['GET', 'https://api.github.com/repos/hraness/example/issues'], @@ -293,9 +294,22 @@ test('a scheduled audit on the default branch fails on any vulnerability and upd ['POST', 'https://api.github.com/repos/hraness/example/issues'], ]); assert.match(await readFile(summary, 'utf8'), /Run: https:\/\/github\.com\/hraness\/example\/actions\/runs\/42/u); + const branch = await actionFixture(t, { repository: { default_branch: 'main' } }, 'workflow_dispatch', 'refs/heads/feature'); - const other = await runAction(branch.environment, { fetchImpl, platform: 'linux', arch: 'x64', scan: async () => sample, pin, lockfiles: () => [], log: () => {} }); - assert.equal(other.issue.action, 'none', 'only the default branch owns the tracking issue'); + const manual = await runAction(branch.environment, dependencies); + assert.equal(manual.issue.action, 'none', 'only the default branch owns the tracking issue'); + assert.equal(manual.failed, true, 'without a tracking issue, the run itself reports existing vulnerabilities'); + + const disabled = await actionFixture(t, { repository: { default_branch: 'main' } }, 'schedule'); + const noIssues = await runAction(disabled.environment, { ...dependencies, fetchImpl: async (url, options) => ( + String(url).includes('/issues?') ? new Response('Issues are disabled', { status: 410 }) : fetchImpl(url, options)) }); + assert.deepEqual(noIssues.issue, { action: 'disabled', number: null }); + assert.equal(noIssues.failed, true, 'a repository with issues disabled falls back to a failing run'); + + const clean = await actionFixture(t, { repository: { default_branch: 'main' } }, 'schedule'); + const advisoriesOnly = { results: [sample.results[2]].map(result => ({ ...result, packages: result.packages.slice(0, 1) })) }; + const quiet = await runAction(clean.environment, { ...dependencies, scan: async () => advisoriesOnly }); + assert.equal(quiet.failed, false); }); test('the action entrypoint fails visibly outside a Git checkout', async t => { diff --git a/scripts/sync-dependency-audit.py b/scripts/sync-dependency-audit.py index 3a4c07d..299b52d 100644 --- a/scripts/sync-dependency-audit.py +++ b/scripts/sync-dependency-audit.py @@ -155,7 +155,7 @@ def inspect(repo: dict, expected: str, globs: list[str]) -> dict: - Adds or updates `.github/workflows/dependency-audit.yml` from `templates/dependency-audit.yml` in hraness/.github, pinned to action commit `{sha}`. - The audit scans every tracked lockfile with a pinned OSV-Scanner on pull requests that change a lockfile, on pushes to `main`, daily, and on demand. -- A pull request fails the audit only when it adds a known vulnerability; the scheduled run on `main` fails while any is present and keeps one `dependency-audit` issue current. +- A pull request fails the audit only when it adds a known vulnerability. The run on `main` keeps one `dependency-audit` issue current with every known vulnerability and closes it when none remain. - The check is advisory: do not add it to required checks. #### Test plan