diff --git a/docs/contributing/dependency-policy.md b/docs/contributing/dependency-policy.md index 6d8562a..6061587 100644 --- a/docs/contributing/dependency-policy.md +++ b/docs/contributing/dependency-policy.md @@ -30,10 +30,29 @@ Before adding a dependency, consider: `.github/workflows/dependency-review.yml` reviews dependency changes on pull requests using GitHub's Dependency Review Action. +The workflow contract is: + +| Setting | Required behavior | +| --- | --- | +| Trigger | `pull_request`, using the default opened, synchronize, and reopened events; no branch or path filters | +| Permissions | Top-level `contents: read`; no job-level override, write permission, or secrets required | +| Job | `dependency-review`, displayed as `Dependency Review`, on `ubuntu-latest` with a 10-minute timeout | +| Action | `actions/dependency-review-action`, pinned to a reviewed full 40-character commit SHA | +| Failure threshold | `fail-on-severity: high`, covering newly introduced high and critical vulnerabilities | +| Execution | No conditional skips, `continue-on-error`, checkout, install, or arbitrary command steps | + +This is an unprivileged PR check, not `pull_request_target`. It compares dependency changes between the PR's base and head; it is not a full audit of every dependency already present in the base branch. There are no severity exceptions or package allowlists configured in this workflow. + The baseline policy fails a pull request when it introduces a dependency with a known vulnerability rated `high` or `critical`. Lower-severity findings remain review signals and may still block a change when the affected dependency is security-sensitive, unnecessary, directly exploitable in the CLI threat model, or otherwise conflicts with maintainer judgment. Dependency Review is a complement to normal code review. A green dependency-review result does not make a new package automatically acceptable. +Contributors must resolve high/critical findings by removing or replacing the dependency or selecting a reviewed fixed version. Maintainers review the dependency's purpose, ownership, license, transitive footprint, and install scripts as well as the automated result. Lowering the threshold, adding exceptions, skipping a failing job, or granting write permissions is not a routine way to unblock a PR. Any proposed policy/workflow change requires explicit supply-chain/security maintainer review and corresponding documentation and conformance-test updates. + +If the check cannot run because of a service, repository configuration, or fork-approval problem, maintainers must resolve that blocker and rerun the check; an absent or skipped result is not a pass. Mandatory merge enforcement is configured separately through the [repository ruleset](repository-ruleset.md), not by this document or the workflow alone. Dependency Review introduces no auto-merge behavior: dependency PRs still require the normal checks and maintainer review. + +`tests/dependency-review-policy.test.ts` checks this deliberately small workflow's complete executable shape and the documented settings. It includes negative cases for filters, privileged triggers/permissions, weakened severity, conditional skips, error suppression, extra inputs, and added steps. Run it with `npm test -- tests/dependency-review-policy.test.ts` after the standard clean install. The check is static and does not call GitHub or execute an action. + ## Dependabot Updates `.github/dependabot.yml` monitors both: diff --git a/tests/dependency-review-policy.test.ts b/tests/dependency-review-policy.test.ts new file mode 100644 index 0000000..45ab143 --- /dev/null +++ b/tests/dependency-review-policy.test.ts @@ -0,0 +1,151 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const root = join(import.meta.dirname, '..'); +const workflow = readFileSync( + join(root, '.github/workflows/dependency-review.yml'), + 'utf8', +).replace(/\r\n/g, '\n'); +const policy = readFileSync( + join(root, 'docs/contributing/dependency-policy.md'), + 'utf8', +); + +// A strict, intentionally small YAML subset avoids a new parser dependency. +// Compare the whole executable shape: isolated regex checks could miss an +// override, second trigger, skip condition, or extra step elsewhere in the file. +// Changes to this contract require a corresponding policy/security review. +const expected = `name: Dependency Review +on: + pull_request: +permissions: + contents: read +jobs: + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Review dependency changes + uses: actions/dependency-review-action@REVIEWED_SHA + with: + fail-on-severity: high`; + +function conforms(source: string): boolean { + if ( + !/^ {8}uses: actions\/dependency-review-action@[a-f0-9]{40}(?:\s+#.*)?\r?$/m.test( + source, + ) + ) { + return false; + } + const normalized = source + .split(/\r?\n/) + .map((line) => line.replace(/\s+#.*$/, '').trimEnd()) + .filter((line) => line.trim() !== '' && !line.trimStart().startsWith('#')) + .join('\n') + .replace( + /actions\/dependency-review-action@[a-f0-9]{40}(?=\n)/g, + 'actions/dependency-review-action@REVIEWED_SHA', + ); + return normalized === expected; +} + +describe('Dependency Review policy conformance', () => { + it('keeps the complete workflow unprivileged, mandatory and high-severity gated', () => { + expect(conforms(workflow)).toBe(true); + }); + + it('accepts comments and line endings without changing executable settings', () => { + expect(conforms(`# Review-only workflow\n${workflow}`)).toBe(true); + expect(conforms(workflow.replace(/\r?\n/g, '\r\n'))).toBe(true); + }); + + it.each([ + ['privileged trigger', ' pull_request:', ' pull_request_target:'], + [ + 'path filter', + ' pull_request:', + ' pull_request:\n paths: [package.json]', + ], + [ + 'branch filter', + ' pull_request:', + ' pull_request:\n branches: [main]', + ], + [ + 'restricted events', + ' pull_request:', + ' pull_request:\n types: [opened]', + ], + ['write permission', ' contents: read', ' contents: write'], + [ + 'blanket write permission', + 'permissions:\n contents: read', + 'permissions: write-all', + ], + [ + 'job override', + ' steps:', + ' permissions:\n contents: write\n steps:', + ], + [ + 'critical-only threshold', + 'fail-on-severity: high', + 'fail-on-severity: critical', + ], + ['removed gate', ' fail-on-severity: high', ''], + ['skipped job', ' steps:', ' if: false\n steps:'], + [ + 'ignored failure', + ' steps:', + ' continue-on-error: true\n steps:', + ], + [ + 'extra action input', + ' with:', + ' with:\n allow-ghsas: GHSA-example', + ], + [ + 'added execution step', + ' steps:', + ' steps:\n - run: gh pr merge --auto', + ], + ])('rejects %s', (_name, from, to) => { + expect(workflow).toContain(from); + expect(conforms(workflow.replace(from, to))).toBe(false); + }); + + it.each(['v5', 'REVIEWED_SHA'])( + 'rejects unpinned action reference %s', + (ref) => { + const unpinned = workflow.replace( + /actions\/dependency-review-action@[a-f0-9]{40}/, + `actions/dependency-review-action@${ref}`, + ); + expect(unpinned).not.toBe(workflow); + expect(conforms(unpinned)).toBe(false); + }, + ); + + it('documents the enforced settings and maintainer responsibilities', () => { + for (const setting of [ + '`pull_request`', + '`contents: read`', + '`dependency-review`', + '`ubuntu-latest`', + '10-minute timeout', + '`actions/dependency-review-action`', + '40-character commit SHA', + '`fail-on-severity: high`', + ]) { + expect(policy).toContain(setting); + } + expect(policy).toContain('high and critical'); + expect(policy).toContain('no branch or path filters'); + expect(policy).toContain('absent or skipped result is not a pass'); + expect(policy).toContain('supply-chain/security maintainer review'); + expect(policy).toContain('introduces no auto-merge behavior'); + }); +});