Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion actions/dependency-audit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions actions/dependency-audit/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)}.`));
Expand Down
24 changes: 19 additions & 5 deletions actions/dependency-audit/index.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) => {
Expand All @@ -284,18 +284,32 @@ 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'],
['POST', 'https://api.github.com/repos/hraness/example/labels'],
['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 => {
Expand Down
2 changes: 1 addition & 1 deletion scripts/sync-dependency-audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading