diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 818c72f25..9fa5d7693 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,35 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # ---------------------------------------------------------- + # -1. WORKFLOW POLICY GATE: runs before any job invokes the governed + # ./.github/actions/setup composite (first used by "security" below), so a + # PR that tampers with that composite โ€” or any other workflow/action file โ€” + # is rejected before its own tampered code ever executes. Uses only + # external, SHA-pinned actions directly, never the composite it gates. + # ---------------------------------------------------------- + workflow-policy: + name: ๐Ÿ“œ Workflow Policy Gate + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + version: 11.22.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + cache: pnpm + # QNBS-v3: no lifecycle/.pnpmfile.cjs hooks โ€” install must not run repo code before the gate. + - name: Install dependencies (no lifecycle scripts) + run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile + # QNBS-v3: node directly โ€” pnpm run's own pre-run check rejects an --ignore-pnpmfile install. + - name: Workflow-policy structural gate (permissions, needs graph, action pins) + run: node scripts/workflow-policy-check.mjs + # ---------------------------------------------------------- # 0. SECURITY: pnpm audit + gitleaks + dependency review # ---------------------------------------------------------- @@ -34,6 +63,7 @@ jobs: name: ๐Ÿ”’ Security Audit runs-on: ubuntu-latest timeout-minutes: 10 + needs: [workflow-policy] # QNBS-v3: dependency-review-action needs readable PR metadata โ€” otherwise the job fails unnecessarily on external PRs/scopes. permissions: contents: read @@ -138,7 +168,7 @@ jobs: contents: read pull-requests: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false @@ -442,12 +472,26 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 # QNBS-v3: Every unconditional job is either required here or explicitly advisory at job level. - needs: [security, signatures, quality, changes, rust-tauri, core-rust, build, e2e, lighthouse, vrt] + needs: + [ + workflow-policy, + security, + signatures, + quality, + changes, + rust-tauri, + core-rust, + build, + e2e, + lighthouse, + vrt, + ] if: always() steps: - name: Verify all required jobs succeeded run: | FAIL=0 + [ "${{ needs.workflow-policy.result }}" = "success" ] || FAIL=1 [ "${{ needs.security.result }}" = "success" ] || FAIL=1 [ "${{ needs.signatures.result }}" = "success" ] || FAIL=1 [ "${{ needs.quality.result }}" = "success" ] || FAIL=1 @@ -464,6 +508,7 @@ jobs: [ "${{ needs.vrt.result }}" = "success" ] || FAIL=1 if [ "$FAIL" = "1" ]; then echo "One or more required jobs did not succeed:" + echo " workflow-policy: ${{ needs.workflow-policy.result }}" echo " security: ${{ needs.security.result }}" echo " signatures: ${{ needs.signatures.result }}" echo " quality: ${{ needs.quality.result }}" diff --git a/AGENTS.md b/AGENTS.md index cf06c01ec..4cfea1742 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -373,11 +373,11 @@ procedure. ### Pipeline Graph ``` -security โ”€โ”€โ–บ quality โ”€โ”€โ”ฌโ”€โ”€โ–บ build โ”€โ”€โ–บ lighthouse - โ”œโ”€โ”€โ–บ e2e - โ”œโ”€โ”€โ–บ e2e-deep (non-blocking) - โ”œโ”€โ”€โ–บ storybook - โ””โ”€โ”€โ–บ vrt +workflow-policy โ”€โ”€โ–บ security โ”€โ”€โ–บ quality โ”€โ”€โ”ฌโ”€โ”€โ–บ build โ”€โ”€โ–บ lighthouse + โ”œโ”€โ”€โ–บ e2e + โ”œโ”€โ”€โ–บ e2e-deep (non-blocking) + โ”œโ”€โ”€โ–บ storybook + โ””โ”€โ”€โ–บ vrt build (main, non-PR) โ”€โ”€โ–บ upload-pages-artifact deploy (main, non-PR) needs: ci-success โ”€โ”€โ–บ GitHub Pages ``` @@ -386,6 +386,7 @@ deploy (main, non-PR) needs: ci-success โ”€โ”€โ–บ GitHub Pages | Job | Purpose | |-----|---------| +| `workflow-policy` | Structural gate on `.github/workflows/*.yml` + `.github/actions/**/action.yml` (`scripts/workflow-policy-check.mjs`) โ€” permissions, needs graph, SHA-pinned action references, publishing boundary. Runs first, before any job invokes the governed `./.github/actions/setup` composite; sets up pnpm/Node with external SHA-pinned actions directly instead of that composite. | | `security` | `pnpm audit --audit-level=high`, OSV scanner (pnpm + `src-tauri/` + `crates/` Cargo lockfiles), gitleaks secrets scan, dependency review on PRs | | `quality` | Node 22 + 24 matrix โ†’ Biome lint, suppression-debt ratchet, `i18n:check`, `docs:check`, `csp:verify`, `parity:check`, `tsgo --noEmit`, Storybook build, Vitest + coverage, Codecov upload | | `rust-tauri` | `fmt`/`check`/`clippy`/`test` for `src-tauri/`; path-scoped (skips on PRs that don't touch it), needs GTK/WebKit apt-get steps | diff --git a/README.md b/README.md index 7c6ff2642..c7935547b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales โ€” 2925 keys - 7005+ tests / 578 files + 7076+ tests / 579 files Codecov Coverage License MIT CI Status @@ -512,7 +512,7 @@ The Settings โ†’ AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys ร— 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7005+ tests / 578 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7076+ tests / 579 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -550,7 +550,7 @@ WorldScript-Studio/ โ”‚ โ”œโ”€โ”€ sw.js # PWA Service Worker โ”‚ โ””โ”€โ”€ manifest.json # PWA Web App Manifest v3 โ”œโ”€โ”€ tests/ -โ”‚ โ”œโ”€โ”€ unit/ # Vitest unit tests (7005+ tests, 578 files) โ€” count spans tests/, components/, packages/*/tests/, not just this folder +โ”‚ โ”œโ”€โ”€ unit/ # Vitest unit tests (7076+ tests, 579 files) โ€” count spans tests/, components/, packages/*/tests/, not just this folder โ”‚ โ”‚ โ”œโ”€โ”€ ai/ # aiSmallModules, aiCoreFallbackPaths โ”‚ โ”‚ โ””โ”€โ”€ settings/ # WebLlmPanel, AiSections โ”‚ โ””โ”€โ”€ e2e/ # Playwright specs + helpers.ts @@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard โ€” SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):** -- **7005+ unit tests** across **578 test files** โ€” CI is authoritative for pass/fail +- **7076+ unit tests** across **579 test files** โ€” CI is authoritative for pass/fail - Coverage thresholds: lines โ‰ฅ 80 ยท branches โ‰ฅ 66 ยท functions โ‰ฅ 72 ยท statements โ‰ฅ 78 โ€” enforced in CI (see Codecov badge for live metrics) - i18n: **2925 keys ร— 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/docs/CI.md b/docs/CI.md index 25d5dbc4d..a17a7604b 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -37,8 +37,8 @@ CI runs for the affected test path before removing a temporary quarantine. ### Gate authority -`โœ… CI Success` is the required branch-protection status and aggregates `security`, `signatures`, -`quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, and `vrt`. `e2e-deep` and +`โœ… CI Success` is the required branch-protection status and aggregates `workflow-policy`, `security`, +`signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, and `vrt`. `e2e-deep` and `storybook` are explicitly advisory at job level while their stability criteria are measured. The `deploy` job depends only on that aggregate and remains main-push-only. @@ -83,6 +83,8 @@ pnpm/action-setup (explicit patched 11.22.0) โ†’ actions/setup-node (cache: pnpm Each job that uses the composite must call `actions/checkout@v6` first (local composite actions are resolved from the workspace, so the repo must be checked out before `uses: ./.github/actions/setup` can be used). The `quality` job additionally passes `node-version: ${{ matrix.node-version }}` to cover the LTS matrix. +**`workflow-policy` deliberately does not use this composite.** It sets up pnpm/Node inline with the same external SHA-pinned actions instead, so a PR that tampers with `.github/actions/setup/action.yml` itself can't have that tampered content execute before `workflow-policy` evaluates it โ€” see the job table above. + --- ## Workflow triggers @@ -98,21 +100,22 @@ Each job that uses the composite must call `actions/checkout@v6` first (local co ## Job graph ```text -security โ”€โ”€โ–บ quality โ”€โ”€โ”ฌโ”€โ”€โ–บ build โ”€โ”€โ”ฌโ”€โ”€โ–บ lighthouse - โ”œโ”€โ”€โ–บ e2e โ””โ”€โ”€โ–บ vrt - โ”œโ”€โ”€โ–บ e2e-deep (advisory) - โ””โ”€โ”€โ–บ storybook (advisory) - -security โ”€โ”ฌ -signatures โ”€โ”ค -quality โ”€โ”€โ”ผโ”€โ”€โ–บ ci-success (required-status aggregator) -changes โ”€โ”€โ”ค -rust โ”€โ”€โ”€โ”€โ”ค -core-rust โ”ค -build โ”€โ”€โ”€โ”€โ”ค -e2e โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -lighthouse โ”€โ”ค -vrt โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +workflow-policy โ”€โ”€โ–บ security โ”€โ”€โ–บ quality โ”€โ”€โ”ฌโ”€โ”€โ–บ build โ”€โ”€โ”ฌโ”€โ”€โ–บ lighthouse + โ”œโ”€โ”€โ–บ e2e โ””โ”€โ”€โ–บ vrt + โ”œโ”€โ”€โ–บ e2e-deep (advisory) + โ””โ”€โ”€โ–บ storybook (advisory) + +workflow-policy โ”€โ” +security โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +signatures โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +quality โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ–บ ci-success (required-status aggregator) +changes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +rust โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +core-rust โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +build โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +e2e โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +lighthouse โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +vrt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ build (main, non-PR) โ”€โ”€โ–บ upload-pages-artifact ci-success (main, non-PR) โ”€โ”€โ–บ deploy โ”€โ”€โ–บ GitHub Pages @@ -127,7 +130,8 @@ registry gzip-decoding failure mode, while OSV failures remain blocking. | Job | Needs | Purpose | |-----|--------|---------| -| `security` | โ€” | `pnpm audit --audit-level=high`; **OSV scanner** (`google/osv-scanner-action`) for npm + Rust lockfiles; `gitleaks` secrets scan; on PRs: `dependency-review-action` | +| `workflow-policy` | โ€” | Structural (real-parser, not regex) validation of every `.github/workflows/*.yml` and `.github/actions/**/action.yml` โ€” permissions, needs graph, SHA-pinned action references, publishing boundary (`scripts/workflow-policy-check.mjs`). Runs first, before any job invokes the governed `./.github/actions/setup` composite, using only external SHA-pinned actions directly (never that composite) and an `--ignore-scripts --ignore-pnpmfile` install, so a PR tampering with the composite (or its own install hooks) can't execute before the gate evaluates it. | +| `security` | `workflow-policy` | `pnpm audit --audit-level=high`; **OSV scanner** (`google/osv-scanner-action`) for npm + Rust lockfiles; `gitleaks` secrets scan; on PRs: `dependency-review-action` | | `scheduled-osv` | โ€” | Separate daily and manually triggerable (`workflow_dispatch`) `.github/workflows/security-scheduled.yml` scan of the same three lockfiles; `contents: read` only; fails closed and writes lockfile/package/advisory details to the step summary | | `quality` | `security` | Matrix **Node 22** and **24** โ†’ Biome lint, **`pnpm run i18n:check`**, **`pnpm run docs:check`**, **`pnpm run csp:verify`**, **`pnpm run parity:check`**, `pnpm run typecheck`, Vitest + coverage (+ non-blocking coverage-ratchet suggestion), Codecov (optional token), coverage artifact | | `rust-tauri` | `security` | Rust `cargo fmt --check`, `cargo check --locked`, `cargo clippy --locked --all-targets -- -D warnings`, and `cargo test --locked`; compile/lint signal for Tauri changes without building installers on every PR | @@ -137,7 +141,7 @@ registry gzip-decoding failure mode, while OSV failures remain blocking. | `storybook` | `quality` | Cloud-first โ€” Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--maxWorkers=2 --junit` (non-blocking, `continue-on-error: true` โ€” see [exit criteria](#non-blocking-gates--exit-criteria-f-13)); artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. | | `vrt` | `build` | Visual regression against production `dist`; `toHaveScreenshot()` with committed PNG baselines (4 views ร— Chromium); artifacts uploaded always | | `signatures` | `security` | Read-only GitHub API verification of every commit in the complete introduced range; pull-request commit pagination; and annotated release-tag plus target-commit verification. | -| `ci-success` | `security`, `signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, `vrt` | Required-status **aggregator** โ€” `if: always()`, fails if any required release-safety job does not resolve to `success`; signature verification is authoritative; Storybook and deep-E2E are explicitly advisory. Rust jobs are legitimately skipped when their paths are untouched. | +| `ci-success` | `workflow-policy`, `security`, `signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, `vrt` | Required-status **aggregator** โ€” `if: always()`, fails if any required release-safety job does not resolve to `success`; signature verification is authoritative; Storybook and deep-E2E are explicitly advisory. Rust jobs are legitimately skipped when their paths are untouched. | | `deploy` | `ci-success` | **Only** `main` push (not PR), and only after the aggregate gate succeeds; the Pages artifact is resolved from the same workflow run. | > **Desktop:** On-demand / tag-driven Tauri bundles live in [`tauri-build.yml`](../.github/workflows/tauri-build.yml); **`v*` tags** additionally publish installers on a **GitHub Release**. See [`docs/TAURI-CI.md`](TAURI-CI.md). Desktop CI does not block the web deploy graph above. diff --git a/package.json b/package.json index ddbc47b7b..2f84219d6 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "csp:check": "node scripts/check-csp-policy.mjs", "csp:verify": "node scripts/sync-csp.mjs && git diff --exit-code -- index.html nginx.conf public/_headers vercel.json src-tauri/tauri.conf.json && node scripts/check-csp-policy.mjs", "native-readiness:check": "node scripts/check-native-readiness.mjs", + "workflow-policy:check": "node scripts/workflow-policy-check.mjs", "suppressions:check": "node scripts/check-suppressions.mjs", "token:audit": "node scripts/audit-tokens.mjs", "guardrail:desktop-imports": "node scripts/check-tauri-import-boundary.mjs", @@ -202,7 +203,8 @@ "vitest": "^4.1.10", "wait-on": "^9.1.0", "wrangler": "^4.120.1", - "y-protocols": "^1.0.7" + "y-protocols": "^1.0.7", + "yaml": "^2.9.0" }, "simple-git-hooks": { "pre-commit": "node scripts/hooks/pre-commit.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5785b812b..383893928 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,6 +328,9 @@ importers: y-protocols: specifier: ^1.0.7 version: 1.0.7(yjs@13.6.31) + yaml: + specifier: ^2.9.0 + version: 2.9.0 packages/ai-core: optionalDependencies: @@ -15777,8 +15780,7 @@ snapshots: yallist@3.1.1: {} - yaml@2.9.0: - optional: true + yaml@2.9.0: {} yargs-parser@13.1.2: dependencies: diff --git a/scripts/ci-prepush-check-registry.mjs b/scripts/ci-prepush-check-registry.mjs index f74514dea..8d6bf76d2 100644 --- a/scripts/ci-prepush-check-registry.mjs +++ b/scripts/ci-prepush-check-registry.mjs @@ -35,6 +35,19 @@ const admissionCheckRegistry = Object.freeze([ 'scripts/ci-prepush-range-resolver.mjs', ]), }, + { + name: 'workflowPolicy', + // QNBS-v3: composite actions carry the same uses:-pin risk as workflows themselves. + matches: (file) => + file.startsWith('.github/workflows/') || file.startsWith('.github/actions/'), + implementationFiles: new Set([ + routingAuthority, + runnerAuthority, + 'scripts/ci-prepush-classifier.mjs', + 'scripts/ci-prepush-range-resolver.mjs', + 'scripts/workflow-policy-check.mjs', + ]), + }, ]); export function shouldRunAdmissionCheck(name, files) { diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs index 197ab5e16..a65fe344f 100644 --- a/scripts/ci-prepush-lowend.mjs +++ b/scripts/ci-prepush-lowend.mjs @@ -102,6 +102,9 @@ async function main() { if (shouldRunAdmissionCheck('contentGuard', classification.files) || full) await runCheck('Content guard', () => runNodeScript('scripts/content-guard.mjs')); + if (shouldRunAdmissionCheck('workflowPolicy', classification.files) || full) + await runCheck('Workflow policy', () => runNodeScript('scripts/workflow-policy-check.mjs')); + if (typecheckRequired) { await runCheck('TypeScript (single checker)', () => // QNBS-v3: one checker bounds memory use on constrained developer machines. diff --git a/scripts/workflow-policy-check.d.mts b/scripts/workflow-policy-check.d.mts new file mode 100644 index 000000000..eb4a06ecf --- /dev/null +++ b/scripts/workflow-policy-check.d.mts @@ -0,0 +1,121 @@ +import type { Document, LineCounter } from 'yaml'; + +export interface WorkflowPolicyFailure { + file: string; + message: string; +} + +export interface ListWorkflowFilesDependencies { + readdirSync?: (dir: string) => string[]; +} + +export function listWorkflowFiles( + root?: string, + dependencies?: ListWorkflowFilesDependencies, +): string[]; + +export interface DirEntryLike { + name: string; + isDirectory(): boolean; +} + +export interface ListActionFilesDependencies { + // QNBS-v3: recursive discovery needs Dirent-shaped entries, not listWorkflowFiles' flat string[]. + readdirSync?: (dir: string, options: { withFileTypes: true }) => DirEntryLike[]; +} + +export function listActionFiles( + root?: string, + dependencies?: ListActionFilesDependencies, +): string[]; + +export interface ParseWorkflowFileDependencies { + readFileSync?: (filePath: string, encoding: 'utf8') => string; +} + +export interface ParsedWorkflowFile { + filePath: string; + content: string; + doc: Document; + lineCounter: LineCounter; +} + +export function parseWorkflowFile( + filePath: string, + dependencies?: ParseWorkflowFileDependencies, +): ParsedWorkflowFile; + +export function checkTopLevelPermissions( + fileName: string, + doc: Document, + failures: WorkflowPolicyFailure[], +): void; + +export function checkJobWriteScopeAllowlist( + fileName: string, + doc: Document, + failures: WorkflowPolicyFailure[], +): void; + +export function checkNeedsGraph( + fileName: string, + doc: Document, + failures: WorkflowPolicyFailure[], +): void; + +export interface CheckActionPinsOptions { + fileKind?: 'workflow' | 'action'; + lineCounter?: LineCounter; +} + +export function checkActionPins( + fileName: string, + doc: Document, + failures: WorkflowPolicyFailure[], + options?: CheckActionPinsOptions, +): void; + +export function checkAggregatorNeeds( + fileName: string, + doc: Document, + failures: WorkflowPolicyFailure[], +): void; + +export function checkPublishingBoundary( + fileName: string, + doc: Document, + failures: WorkflowPolicyFailure[], +): void; + +export interface WorkflowTriggers { + workflowDispatch: boolean; + tagPush: boolean; +} + +export function getTriggers(doc: Document): WorkflowTriggers; + +export type CheckWorkflowFileDependencies = ParseWorkflowFileDependencies; + +export function checkWorkflowFile( + filePath: string, + dependencies?: CheckWorkflowFileDependencies, +): WorkflowPolicyFailure[]; + +export function checkActionFile( + filePath: string, + dependencies?: CheckWorkflowFileDependencies, +): WorkflowPolicyFailure[]; + +export type CheckAllWorkflowsDependencies = CheckWorkflowFileDependencies & + ListWorkflowFilesDependencies & + ListActionFilesDependencies & { + listWorkflowFiles?: (root: string) => string[]; + listActionFiles?: (root: string) => string[]; + }; + +export function checkAllWorkflows( + root?: string, + dependencies?: CheckAllWorkflowsDependencies, +): WorkflowPolicyFailure[]; + +export function main(): void; diff --git a/scripts/workflow-policy-check.mjs b/scripts/workflow-policy-check.mjs new file mode 100644 index 000000000..79dac3801 --- /dev/null +++ b/scripts/workflow-policy-check.mjs @@ -0,0 +1,515 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { LineCounter, isAlias, parseDocument } from 'yaml'; + +function resolveProjectRoot() { + try { + return resolve(fileURLToPath(new URL('..', import.meta.url))); + } catch { + return process.cwd(); + } +} +const projectRoot = resolveProjectRoot(); + +// QNBS-v3: exempts only these exact job/write-key pairs from the no-unallowlisted-write policy. +const WRITE_SCOPE_ALLOWLIST = { + 'ci.yml': { + build: new Set(['attestations', 'id-token']), + deploy: new Set(['pages', 'id-token']), + }, + 'docker.yml': { 'build-push': new Set(['packages']) }, + 'prune-deployments.yml': { prune: new Set(['deployments']) }, + 'tauri-build.yml': { release: new Set(['contents']) }, + 'codeql.yml': { analyze: new Set(['security-events']) }, + 'scorecard.yml': { analysis: new Set(['security-events', 'id-token']) }, +}; + +// QNBS-v3: contents:write is release/publish authority; only these jobs may hold it. +const PUBLISHING_ALLOWLIST = { 'tauri-build.yml': new Set(['release']) }; + +export function listWorkflowFiles(root = projectRoot, dependencies = {}) { + const dir = join(root, '.github/workflows'); + const listDir = dependencies.readdirSync ?? readdirSync; + if (!existsSync(dir)) return []; + return listDir(dir) + .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml')) + .sort() + .map((name) => join(dir, name)); +} + +// QNBS-v3: composite actions carry the same uses:-pin risk but have no jobs/permissions to check. +export function listActionFiles(root = projectRoot, dependencies = {}) { + const dir = join(root, '.github/actions'); + const listDir = dependencies.readdirSync ?? readdirSync; + if (!existsSync(dir)) return []; + const results = []; + // QNBS-v3: recurse any depth โ€” a composite action can nest below its group directory. + // QNBS-v3: a symlink here could redirect a governed action outside .github/actions unseen โ€” reject it. + const walk = (currentDir) => { + for (const entry of listDir(currentDir, { withFileTypes: true })) { + const entryPath = join(currentDir, entry.name); + if (entry.isSymbolicLink?.()) { + throw new Error(`symlink not allowed under .github/actions: ${entryPath}`); + } else if (entry.isDirectory()) { + walk(entryPath); + } else if (entry.name === 'action.yml' || entry.name === 'action.yaml') { + results.push(entryPath); + } + } + }; + walk(dir); + return results.sort(); +} + +export function parseWorkflowFile(filePath, dependencies = {}) { + const readFile = dependencies.readFileSync ?? readFileSync; + const content = readFile(filePath, 'utf8'); + const lineCounter = new LineCounter(); + const doc = parseDocument(content, { uniqueKeys: true, lineCounter }); + return { filePath, content, doc, lineCounter }; +} + +// QNBS-v3: single alias-resolution point โ€” every value read from a parsed tree must pass through here. +function resolveNode(node, doc) { + return node && isAlias(node) ? node.resolve(doc) : node; +} + +// QNBS-v3: resolves each job VALUE so an aliased whole job (ci-success: *base) isn't skipped downstream. +function jobMap(doc) { + const jobs = doc.get('jobs', true); + if (!jobs || typeof jobs.items === 'undefined') return new Map(); + const map = new Map(); + for (const pair of jobs.items) map.set(String(pair.key), resolveNode(pair.value, doc)); + return map; +} + +// QNBS-v3: resolves a steps: node (possibly aliased) and each item in it (also possibly aliased). +function resolveSteps(stepsNode, doc) { + const resolved = resolveNode(stepsNode, doc); + const items = resolved?.items ?? []; + return items.map((item) => resolveNode(item, doc)); +} + +// QNBS-v3: resolves a needs: node (possibly aliased, e.g. shared via &deps/*deps) to a string array. +function resolveNeedsList(needsNode, doc) { + const resolved = resolveNode(needsNode, doc); + if (!resolved) return []; + const value = resolved.toJSON(); + return Array.isArray(value) ? value : [value]; +} + +// QNBS-v3: an aliased *perms block resolves to Alias, not YAMLMap โ€” must dereference before use. +function permissionEntries(node, doc) { + const resolved = resolveNode(node, doc); + if (!resolved) return null; + if (typeof resolved.toJSON === 'function' && typeof resolved.items === 'undefined') { + return { scalar: resolved.toJSON() }; + } + const map = {}; + // QNBS-v3: a per-key value can itself be an alias (e.g. contents: *grant) โ€” resolve each one too. + for (const pair of resolved.items ?? []) { + const value = resolveNode(pair.value, doc); + map[String(pair.key)] = value !== undefined ? String(value) : String(pair.value); + } + return { map }; +} + +export function checkTopLevelPermissions(fileName, doc, failures) { + const permissions = permissionEntries(doc.get('permissions', true), doc); + if (!permissions) { + failures.push({ file: fileName, message: 'missing top-level `permissions:` block' }); + return; + } + if (permissions.scalar !== undefined) { + if (permissions.scalar !== 'read-all') { + failures.push({ + file: fileName, + message: `top-level permissions scalar must be "read-all", found "${permissions.scalar}"`, + }); + } + return; + } + const keys = Object.keys(permissions.map); + if (keys.length !== 1 || permissions.map.contents !== 'read') { + failures.push({ + file: fileName, + message: 'top-level permissions must be exactly {contents: read} or the scalar "read-all"', + }); + } +} + +export function checkJobWriteScopeAllowlist(fileName, doc, failures) { + const allowlist = WRITE_SCOPE_ALLOWLIST[fileName] ?? {}; + for (const [jobName, jobNode] of jobMap(doc)) { + const permissions = permissionEntries(jobNode?.get?.('permissions', true), doc); + if (!permissions) continue; + // QNBS-v3: scalar write-all grants every scope at once, which no job's allowlist ever lists. + if (permissions.scalar !== undefined) { + if (permissions.scalar !== 'read-all') { + failures.push({ + file: fileName, + message: `job "${jobName}" declares scalar permissions "${permissions.scalar}", which is never allowlisted (only "read-all" is a valid job-level scalar)`, + }); + } + continue; + } + const allowedWrites = allowlist[jobName] ?? new Set(); + for (const [key, value] of Object.entries(permissions.map)) { + if (value === 'write' && !allowedWrites.has(key)) { + failures.push({ + file: fileName, + message: `job "${jobName}" declares unallowlisted write permission "${key}"`, + }); + } + } + } +} + +export function checkNeedsGraph(fileName, doc, failures) { + const jobs = jobMap(doc); + const jobNames = new Set(jobs.keys()); + const needsOf = new Map(); + for (const [jobName, jobNode] of jobs) { + const needs = resolveNeedsList(jobNode?.get?.('needs', true), doc); + for (const dependency of needs) { + if (!jobNames.has(dependency)) { + failures.push({ + file: fileName, + message: `job "${jobName}" needs unknown job "${dependency}"`, + }); + } + } + needsOf.set(jobName, needs.filter((dependency) => jobNames.has(dependency))); + } + const visiting = new Set(); + const visited = new Set(); + const visit = (jobName, path) => { + if (visited.has(jobName)) return; + if (visiting.has(jobName)) { + failures.push({ + file: fileName, + message: `needs graph cycle detected: ${[...path, jobName].join(' -> ')}`, + }); + return; + } + visiting.add(jobName); + for (const dependency of needsOf.get(jobName) ?? []) visit(dependency, [...path, jobName]); + visiting.delete(jobName); + visited.add(jobName); + }; + for (const jobName of jobNames) visit(jobName, []); +} + +function collectWorkflowSteps(doc) { + const steps = []; + for (const [, jobNode] of jobMap(doc)) { + const stepsNode = jobNode?.get?.('steps', true); + steps.push(...resolveSteps(stepsNode, doc)); + } + return steps; +} + +function collectActionSteps(doc) { + const runsNode = resolveNode(doc.get('runs', true), doc); + const stepsNode = runsNode?.get?.('steps', true); + return resolveSteps(stepsNode, doc); +} + +const DOCKER_DIGEST_PATTERN = /@sha256:[0-9a-f]{64}$/; + +// QNBS-v3: checks one uses: value, resolving aliases first so *ref hides no bypass step 6/2 found. +function checkUsesRef({ usesNode, containerNode, doc, fileName, lineCounter, failures }) { + const resolvedNode = resolveNode(usesNode, doc); + if (!resolvedNode || typeof resolvedNode.value !== 'string') return; + const ref = resolvedNode.value; + const line = + lineCounter && Array.isArray(usesNode.range) + ? lineCounter.linePos(usesNode.range[0]).line + : undefined; + const loc = line ? `line ${line}: ` : ''; + // QNBS-v3: build-from-source has no registry pin concept โ€” already pinned by being in the commit. + if (ref === 'Dockerfile') return; + if (ref.startsWith('./')) { + // QNBS-v3: only .github/actions/** is scanned by listActionFiles โ€” any other local ref is unchecked. + if (ref.startsWith('./.github/actions/')) return; + failures.push({ + file: fileName, + message: `${loc}local action reference "${ref}" is outside the governed .github/actions/ directory and is never pin-checked`, + }); + return; + } + if (ref.startsWith('docker://')) { + // QNBS-v3: a mutable docker tag is as unpinned as a floating action tag โ€” require a digest. + if (!DOCKER_DIGEST_PATTERN.test(ref)) { + failures.push({ + file: fileName, + message: `${loc}docker reference "${ref}" must pin an immutable @sha256 digest, not a mutable tag`, + }); + } + return; + } + const atIndex = ref.indexOf('@'); + if (atIndex === -1) { + failures.push({ file: fileName, message: `${loc}action reference "${ref}" is missing an @ pin` }); + return; + } + const pin = ref.slice(atIndex + 1); + if (!/^[0-9a-f]{40}$/.test(pin)) { + failures.push({ + file: fileName, + message: `${loc}action reference "${ref}" must pin a 40-hex-char SHA`, + }); + return; + } + // QNBS-v3: an alias's comment can live at the anchor definition or the use site โ€” accept either. + const comment = + usesNode.comment ?? resolvedNode.comment ?? (containerNode?.flow ? containerNode.comment : undefined); + if (!comment || !/\S/.test(comment)) { + failures.push({ + file: fileName, + message: `${loc}SHA-pinned action "${ref}" is missing a trailing # comment`, + }); + } +} + +// QNBS-v3: walks the parsed tree (not raw text) so flow-mapping/alias steps can't bypass enforcement. +export function checkActionPins(fileName, doc, failures, options = {}) { + const { fileKind = 'workflow', lineCounter } = options; + const steps = fileKind === 'action' ? collectActionSteps(doc) : collectWorkflowSteps(doc); + for (const step of steps) { + const usesNode = step?.get?.('uses', true); + if (!usesNode) continue; + checkUsesRef({ usesNode, containerNode: step, doc, fileName, lineCounter, failures }); + } + // QNBS-v3: a job can itself call a reusable workflow via jobs..uses โ€” same pin risk as a step. + if (fileKind === 'workflow') { + for (const [, jobNode] of jobMap(doc)) { + const usesNode = jobNode?.get?.('uses', true); + if (!usesNode) continue; + checkUsesRef({ usesNode, containerNode: jobNode, doc, fileName, lineCounter, failures }); + } + } + // QNBS-v3: a Docker action (runs.using: docker) has no steps โ€” its own image: needs the same pin. + if (fileKind === 'action') { + const runsNode = resolveNode(doc.get('runs', true), doc); + const imageNode = runsNode?.get?.('image', true); + if (imageNode) { + checkUsesRef({ usesNode: imageNode, containerNode: runsNode, doc, fileName, lineCounter, failures }); + } + } +} + +// QNBS-v3: always()/failure()/cancelled() (incl. negated !cancelled()) all bypass default success-gating. +const NON_DEFAULT_GATING_PATTERN = /\b(?:always|failure|cancelled)\s*\(\)/; + +function jobHasNonDefaultGatingCondition(jobNode, doc) { + const ifNode = resolveNode(jobNode?.get?.('if', true), doc); + return typeof ifNode?.value === 'string' && NON_DEFAULT_GATING_PATTERN.test(ifNode.value); +} + +const NEEDS_RESULT_PATTERN = /needs\.([A-Za-z0-9_-]+)\.result/g; +const FAILURE_EXIT_PATTERN = /\bexit\s+(?:\$\S+|[1-9]\d*)/; + +// QNBS-v3: a whole-line shell comment can't affect control flow โ€” strip before pattern-matching. +function stripCommentLines(script) { + return script + .split('\n') + .filter((line) => !/^\s*#/.test(line)) + .join('\n'); +} + +// QNBS-v3: text heuristic, not a shell parser โ€” an exit in an unrelated branch can still false-pass. +function collectNeedsResultReferences(jobNode, doc) { + const stepsNode = jobNode?.get?.('steps', true); + const references = new Set(); + for (const step of resolveSteps(stepsNode, doc)) { + const runNode = step?.get?.('run', true); + if (typeof runNode?.value !== 'string') continue; + const script = stripCommentLines(runNode.value); + // QNBS-v3: a bare reference (e.g. echo) can't fail the job โ€” only count it alongside a real exit. + if (!FAILURE_EXIT_PATTERN.test(script)) continue; + for (const match of script.matchAll(NEEDS_RESULT_PATTERN)) references.add(match[1]); + } + return references; +} + +// QNBS-v3: excludes the full downstream closure (not just direct dependents) from ci-success.needs. +function computeAggregatorDescendants(jobs, doc) { + const dependents = new Map(); + for (const [jobName, jobNode] of jobs) { + const needs = resolveNeedsList(jobNode?.get?.('needs', true), doc); + for (const dependency of needs) { + if (!dependents.has(dependency)) dependents.set(dependency, []); + dependents.get(dependency).push(jobName); + } + } + const descendants = new Set(); + const queue = ['ci-success']; + while (queue.length > 0) { + const current = queue.pop(); + for (const dependent of dependents.get(current) ?? []) { + if (!descendants.has(dependent)) { + descendants.add(dependent); + queue.push(dependent); + } + } + } + return descendants; +} + +export function checkAggregatorNeeds(fileName, doc, failures) { + const jobs = jobMap(doc); + if (!jobs.has('ci-success')) return; + const aggregatorNode = jobs.get('ci-success'); + // QNBS-v3: needs: quality (bare string) must not decompose into per-character Set entries. + const declaredNeeds = new Set(resolveNeedsList(aggregatorNode?.get?.('needs', true), doc)); + const descendants = computeAggregatorDescendants(jobs, doc); + const expectedNeeds = new Set(); + for (const [jobName, jobNode] of jobs) { + if (jobName === 'ci-success') continue; + const continueOnError = jobNode?.get?.('continue-on-error', true); + if (continueOnError?.toJSON?.() === true) continue; // advisory job, not gating + if (descendants.has(jobName)) continue; // downstream of the aggregator, direct or transitive + expectedNeeds.add(jobName); + } + const missing = [...expectedNeeds].filter((name) => !declaredNeeds.has(name)); + const extra = [...declaredNeeds].filter((name) => !expectedNeeds.has(name)); + for (const name of missing) { + failures.push({ file: fileName, message: `ci-success.needs is missing gating job "${name}"` }); + } + for (const name of extra) { + failures.push({ + file: fileName, + message: `ci-success.needs lists "${name}", which is not a gating job (advisory or self-referential)`, + }); + } + if (jobHasNonDefaultGatingCondition(aggregatorNode, doc)) { + const checkedResults = collectNeedsResultReferences(aggregatorNode, doc); + for (const name of expectedNeeds) { + if (declaredNeeds.has(name) && !checkedResults.has(name)) { + failures.push({ + file: fileName, + message: `ci-success's if: condition overrides default success-gating but its run steps never check needs.${name}.result โ€” a failure of "${name}" would not fail the aggregator`, + }); + } + } + } +} + +// QNBS-v3: a tag-restricted ref check โ€” allowlisting by name alone can't survive that gate loosening. +const TAG_ONLY_CONDITION_PATTERN = /refs\/tags\/|ref_type\s*==\s*['"]tag['"]/; + +export function checkPublishingBoundary(fileName, doc, failures) { + const allowlist = PUBLISHING_ALLOWLIST[fileName] ?? new Set(); + for (const [jobName, jobNode] of jobMap(doc)) { + const permissions = permissionEntries(jobNode?.get?.('permissions', true), doc); + // QNBS-v3: scalar write-all implicitly grants contents:write too โ€” must not evade this check. + const hasContentsWrite = + permissions?.map?.contents === 'write' || + (permissions?.scalar !== undefined && permissions.scalar !== 'read-all'); + if (!hasContentsWrite) continue; + if (!allowlist.has(jobName)) { + failures.push({ + file: fileName, + message: `job "${jobName}" declares contents:write but is not on the publishing allowlist`, + }); + continue; + } + // QNBS-v3: allowlisting by name is only sound while the job itself stays tag-push-restricted. + const ifNode = resolveNode(jobNode?.get?.('if', true), doc); + if (typeof ifNode?.value !== 'string' || !TAG_ONLY_CONDITION_PATTERN.test(ifNode.value)) { + failures.push({ + file: fileName, + message: `publishing job "${jobName}" is on the allowlist but its if: condition no longer restricts it to a tag push โ€” loosening or removing that condition would expose contents:write outside a verified release`, + }); + } + } +} + +export function getTriggers(doc) { + const on = doc.get('on', true) ?? doc.get(true, true); + const triggers = { workflowDispatch: false, tagPush: false }; + if (!on) return triggers; + const onValue = on.toJSON ? on.toJSON() : on; + if (Array.isArray(onValue)) { + triggers.workflowDispatch = onValue.includes('workflow_dispatch'); + return triggers; + } + if (onValue && typeof onValue === 'object') { + triggers.workflowDispatch = 'workflow_dispatch' in onValue; + const push = onValue.push; + if (push && typeof push === 'object' && Array.isArray(push.tags) && push.tags.length > 0) { + triggers.tagPush = true; + } + } + return triggers; +} + +export function checkWorkflowFile(filePath, dependencies = {}) { + const fileName = basename(filePath); + const failures = []; + const { doc, lineCounter } = parseWorkflowFile(filePath, dependencies); + if (doc.errors.length > 0) { + for (const error of doc.errors) { + failures.push({ file: fileName, message: `YAML parse error: ${error.message}` }); + } + return failures; // QNBS-v3: structural checks below assume a parseable document. + } + checkTopLevelPermissions(fileName, doc, failures); + checkJobWriteScopeAllowlist(fileName, doc, failures); + checkNeedsGraph(fileName, doc, failures); + checkActionPins(fileName, doc, failures, { fileKind: 'workflow', lineCounter }); + checkAggregatorNeeds(fileName, doc, failures); + checkPublishingBoundary(fileName, doc, failures); + return failures; +} + +// QNBS-v3: composite actions have no jobs/permissions โ€” only the SHA-pin check applies to them. +export function checkActionFile(filePath, dependencies = {}) { + const fileName = basename(filePath); + const failures = []; + const { doc, lineCounter } = parseWorkflowFile(filePath, dependencies); + if (doc.errors.length > 0) { + for (const error of doc.errors) { + failures.push({ file: fileName, message: `YAML parse error: ${error.message}` }); + } + return failures; + } + checkActionPins(fileName, doc, failures, { fileKind: 'action', lineCounter }); + return failures; +} + +export function checkAllWorkflows(root = projectRoot, dependencies = {}) { + const workflowFiles = dependencies.listWorkflowFiles?.(root) ?? listWorkflowFiles(root, dependencies); + // QNBS-v3: fail-closed โ€” a rejected symlink must surface as a failure, never crash the whole check. + let actionFiles; + try { + actionFiles = dependencies.listActionFiles?.(root) ?? listActionFiles(root, dependencies); + } catch (error) { + return [ + ...workflowFiles.flatMap((filePath) => checkWorkflowFile(filePath, dependencies)), + { file: '.github/actions', message: error.message }, + ]; + } + return [ + ...workflowFiles.flatMap((filePath) => checkWorkflowFile(filePath, dependencies)), + ...actionFiles.flatMap((filePath) => checkActionFile(filePath, dependencies)), + ]; +} + +export function main() { + const failures = checkAllWorkflows(); + if (failures.length > 0) { + console.error('Workflow-policy check failed:'); + for (const failure of failures) console.error(`- [${failure.file}] ${failure.message}`); + process.exitCode = 1; + } else { + console.log( + 'Workflow-policy check passed: permissions, needs graph, action pins, aggregator sync, and publishing boundary are all structurally sound.', + ); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/tests/unit/tooling/workflowPolicyCheck.test.ts b/tests/unit/tooling/workflowPolicyCheck.test.ts new file mode 100644 index 000000000..8f8b0273f --- /dev/null +++ b/tests/unit/tooling/workflowPolicyCheck.test.ts @@ -0,0 +1,784 @@ +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parseDocument } from 'yaml'; +import { + checkActionFile, + checkActionPins, + checkAggregatorNeeds, + checkAllWorkflows, + checkJobWriteScopeAllowlist, + checkNeedsGraph, + checkPublishingBoundary, + checkTopLevelPermissions, + checkWorkflowFile, + getTriggers, + listActionFiles, +} from '../../../scripts/workflow-policy-check.mjs'; +import type { WorkflowPolicyFailure } from '../../../scripts/workflow-policy-check.d.mts'; + +const doc = (yaml: string) => parseDocument(yaml, { uniqueKeys: true }); +// QNBS-v3: split like strykerWorkflowPolicy.test.ts's helper so Biome doesn't misread this as a JS template. +const githubExpression = (expression: string) => '$' + '{{ ' + expression + ' }}'; + +// QNBS-v3: contents:read is the only safe top-level default โ€” every other form is a policy gap. +describe('checkTopLevelPermissions', () => { + it('passes for the canonical {contents: read} form', () => { + const failures: WorkflowPolicyFailure[] = []; + checkTopLevelPermissions('x.yml', doc('permissions:\n contents: read\njobs: {}\n'), failures); + expect(failures).toEqual([]); + }); + + it('passes for the scalar "read-all" form (OSSF Scorecard convention)', () => { + const failures: WorkflowPolicyFailure[] = []; + checkTopLevelPermissions('x.yml', doc('permissions: read-all\njobs: {}\n'), failures); + expect(failures).toEqual([]); + }); + + it('fails when a top-level write permission is declared', () => { + const failures: WorkflowPolicyFailure[] = []; + checkTopLevelPermissions( + 'x.yml', + doc('permissions:\n contents: write\njobs: {}\n'), + failures, + ); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/contents: read/); + }); + + it('fails when the permissions block is missing entirely', () => { + const failures: WorkflowPolicyFailure[] = []; + checkTopLevelPermissions('x.yml', doc('jobs: {}\n'), failures); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/missing/); + }); +}); + +// QNBS-v3: job-level scalar write-all must be caught here too, not only rejected at top level. +describe('checkJobWriteScopeAllowlist', () => { + it('allows an allowlisted job/permission pair', () => { + const failures: WorkflowPolicyFailure[] = []; + checkJobWriteScopeAllowlist( + 'ci.yml', + doc('jobs:\n build:\n permissions:\n attestations: write\n'), + failures, + ); + expect(failures).toEqual([]); + }); + + it('fails for a write permission not on the allowlist for that job', () => { + const failures: WorkflowPolicyFailure[] = []; + checkJobWriteScopeAllowlist( + 'ci.yml', + doc('jobs:\n quality:\n permissions:\n contents: write\n'), + failures, + ); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/unallowlisted write permission "contents"/); + }); + + it('fails for a write permission on an allowlisted job but not in its allowed set', () => { + const failures: WorkflowPolicyFailure[] = []; + checkJobWriteScopeAllowlist( + 'ci.yml', + doc('jobs:\n build:\n permissions:\n contents: write\n'), + failures, + ); + expect(failures).toHaveLength(1); + }); + + it('passes for the job-level scalar "read-all"', () => { + const failures: WorkflowPolicyFailure[] = []; + checkJobWriteScopeAllowlist('ci.yml', doc('jobs:\n build:\n permissions: read-all\n'), failures); + expect(failures).toEqual([]); + }); + + it('fails for the job-level scalar "write-all" (grants every scope, never allowlisted)', () => { + const failures: WorkflowPolicyFailure[] = []; + checkJobWriteScopeAllowlist( + 'ci.yml', + doc('jobs:\n build:\n permissions: write-all\n'), + failures, + ); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/scalar permissions "write-all"/); + }); + + // QNBS-v3: a per-key alias (contents: *grant) must resolve to "write", not the literal "*grant". + it('resolves a per-key alias inside a job permissions map', () => { + const failures: WorkflowPolicyFailure[] = []; + const content = ['x: &grant write', 'jobs:', ' build:', ' permissions:', ' contents: *grant', ''].join( + '\n', + ); + checkJobWriteScopeAllowlist('ci.yml', doc(content), failures); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/contents/); + }); + + // QNBS-v3: an aliased whole job (ci-success: *base) is an Alias node, not a map, until resolved. + it('resolves an aliased whole-job node before checking its permissions', () => { + const failures: WorkflowPolicyFailure[] = []; + const content = [ + 'jobs:', + ' base: &base', + ' permissions:', + ' contents: write', + ' copy: *base', + '', + ].join('\n'); + checkJobWriteScopeAllowlist('ci.yml', doc(content), failures); + expect(failures.some((f) => f.message.includes('"copy"'))).toBe(true); + }); +}); + +// QNBS-v3: needs-graph resolution/cycle-detection protects the aggregator sync check downstream. +describe('checkNeedsGraph', () => { + it('passes when every needs reference resolves to a real job', () => { + const failures: WorkflowPolicyFailure[] = []; + checkNeedsGraph('x.yml', doc('jobs:\n a: {}\n b:\n needs: [a]\n'), failures); + expect(failures).toEqual([]); + }); + + it('fails for an orphaned needs reference', () => { + const failures: WorkflowPolicyFailure[] = []; + checkNeedsGraph('x.yml', doc('jobs:\n a:\n needs: [ghost]\n'), failures); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/unknown job "ghost"/); + }); + + it('detects a two-job needs cycle', () => { + const failures: WorkflowPolicyFailure[] = []; + checkNeedsGraph( + 'x.yml', + doc('jobs:\n a:\n needs: [b]\n b:\n needs: [a]\n'), + failures, + ); + expect(failures.some((f) => f.message.includes('cycle detected'))).toBe(true); + }); + + // QNBS-v3: an aliased needs: list (c.needs: *deps) is an Alias node until resolved. + it('resolves an aliased needs: list shared between two jobs', () => { + const failures: WorkflowPolicyFailure[] = []; + checkNeedsGraph( + 'x.yml', + doc('jobs:\n a: {}\n b:\n needs: &deps [a]\n c:\n needs: *deps\n'), + failures, + ); + expect(failures).toEqual([]); + }); + + it('fails for an orphaned reference inside an aliased needs: list', () => { + const failures: WorkflowPolicyFailure[] = []; + checkNeedsGraph( + 'x.yml', + doc('jobs:\n a: {}\n b:\n needs: &deps [ghost]\n c:\n needs: *deps\n'), + failures, + ); + expect(failures.filter((f) => f.message.includes('unknown job "ghost"'))).toHaveLength(2); + }); +}); + +// QNBS-v3: walks the parsed step tree so block AND flow-mapping uses: forms both get pin-checked. +describe('checkActionPins', () => { + const wrap = (usesLine: string) => `jobs:\n a:\n steps:\n - ${usesLine}\n`; + const runsWrap = (usesLine: string) => `runs:\n using: composite\n steps:\n - ${usesLine}\n`; + + it('passes a 40-hex SHA pin with a trailing comment', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins( + 'x.yml', + doc(wrap('uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1')), + failures, + ); + expect(failures).toEqual([]); + }); + + it('passes a non-SemVer trailing comment (e.g. a channel name)', () => { + // QNBS-v3: dtolnay/rust-toolchain pins "# stable", not vX.Y.Z โ€” must not be a false positive. + const failures: WorkflowPolicyFailure[] = []; + checkActionPins( + 'x.yml', + doc(wrap('uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable')), + failures, + ); + expect(failures).toEqual([]); + }); + + it('skips a local composite action reference under the governed directory', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins('x.yml', doc(wrap('uses: ./.github/actions/setup')), failures); + expect(failures).toEqual([]); + }); + + // QNBS-v3: only .github/actions/** is scanned by listActionFiles โ€” anywhere else is unchecked. + it('fails for a local action reference outside .github/actions/', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins('x.yml', doc(wrap('uses: ./ci/setup')), failures); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/outside the governed \.github\/actions\//); + }); + + it('fails for a floating tag instead of a SHA', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins('x.yml', doc(wrap('uses: actions/checkout@v4 # v4')), failures); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/40-hex-char SHA/); + }); + + it('fails for a SHA pin missing its trailing comment', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins( + 'x.yml', + doc(wrap('uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1')), + failures, + ); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/missing a trailing # comment/); + }); + + it('fails for an unpinned action reference written as a flow-mapping step', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins('x.yml', doc(wrap('{ uses: actions/checkout@v4 }')), failures); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/40-hex-char SHA/); + }); + + it('passes a SHA-pinned flow-mapping step whose comment trails the flow map', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins( + 'x.yml', + doc(wrap('{ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 } # v7.0.1')), + failures, + ); + expect(failures).toEqual([]); + }); + + it('checks a composite action\'s runs.steps when fileKind is "action"', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins('action.yml', doc(runsWrap('uses: actions/checkout@v4 # v4')), failures, { + fileKind: 'action', + }); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/40-hex-char SHA/); + }); + + it('ignores jobs.*.steps when fileKind is "action" (composite actions have no jobs)', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins('action.yml', doc(wrap('uses: actions/checkout@v4 # v4')), failures, { + fileKind: 'action', + }); + expect(failures).toEqual([]); + }); + + // QNBS-v3: a Docker action has no steps: at all โ€” its own runs.image needs the same pin check. + it('fails for a mutable docker image on a Docker action (runs.using: docker)', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins('action.yml', doc('runs:\n using: docker\n image: docker://alpine:latest\n'), failures, { + fileKind: 'action', + }); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/@sha256/); + }); + + it('passes a Docker action image pinned to an immutable @sha256 digest', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins( + 'action.yml', + doc(`runs:\n using: docker\n image: docker://alpine@sha256:${'a'.repeat(64)}\n`), + failures, + { fileKind: 'action' }, + ); + expect(failures).toEqual([]); + }); + + it('passes a Docker action built from a local Dockerfile (no registry pin concept)', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins('action.yml', doc('runs:\n using: docker\n image: Dockerfile\n'), failures, { + fileKind: 'action', + }); + expect(failures).toEqual([]); + }); + + // QNBS-v3: a job calling a reusable workflow carries the same mutable-ref risk as a step's uses:. + it('fails for an unpinned job-level reusable-workflow reference', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins( + 'x.yml', + doc('jobs:\n call:\n uses: owner/repo/.github/workflows/file.yml@main\n'), + failures, + ); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/40-hex-char SHA/); + }); + + it('passes a SHA-pinned job-level reusable-workflow reference', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins( + 'x.yml', + doc( + 'jobs:\n call:\n uses: owner/repo/.github/workflows/file.yml@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1\n', + ), + failures, + ); + expect(failures).toEqual([]); + }); + + // QNBS-v3: a mutable docker tag is as unpinned as a floating action tag โ€” must require a digest. + it('fails for a mutable docker:// tag reference', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins('x.yml', doc(wrap('uses: docker://alpine:latest')), failures); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/@sha256/); + }); + + it('passes a docker:// reference pinned to an immutable @sha256 digest', () => { + const failures: WorkflowPolicyFailure[] = []; + checkActionPins('x.yml', doc(wrap(`uses: docker://alpine@sha256:${'a'.repeat(64)}`)), failures); + expect(failures).toEqual([]); + }); + + // QNBS-v3: GitHub resolves *action_ref before running the step โ€” the checker must do the same. + it('fails for an unpinned action reference hidden behind an alias', () => { + const failures: WorkflowPolicyFailure[] = []; + const content = ['x: &action_ref actions/checkout@v4', 'jobs:', ' a:', ' steps:', ' - uses: *action_ref', ''].join( + '\n', + ); + checkActionPins('x.yml', doc(content), failures); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/40-hex-char SHA/); + }); + + it('passes a SHA-pinned action reference behind an alias, comment at the anchor definition', () => { + const failures: WorkflowPolicyFailure[] = []; + const content = [ + 'x: &action_ref actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1', + 'jobs:', + ' a:', + ' steps:', + ' - uses: *action_ref', + '', + ].join('\n'); + checkActionPins('x.yml', doc(content), failures); + expect(failures).toEqual([]); + }); + + // QNBS-v3: an aliased whole steps: list (steps: *shared) is an Alias node, not a sequence, until resolved. + it('checks an aliased steps: list, not just an aliased individual step', () => { + const failures: WorkflowPolicyFailure[] = []; + const content = [ + 'x: &shared_steps', + ' - uses: actions/checkout@v4', + 'jobs:', + ' a:', + ' steps: *shared_steps', + '', + ].join('\n'); + checkActionPins('x.yml', doc(content), failures); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/40-hex-char SHA/); + }); +}); + +// QNBS-v3: needs: quality (bare string) must resolve as one dependency, not per-character Set entries. +describe('checkAggregatorNeeds', () => { + it('no-ops when the workflow has no ci-success aggregator', () => { + const failures: WorkflowPolicyFailure[] = []; + checkAggregatorNeeds('x.yml', doc('jobs:\n a: {}\n'), failures); + expect(failures).toEqual([]); + }); + + it('passes when needs exactly matches the gating job set', () => { + const failures: WorkflowPolicyFailure[] = []; + checkAggregatorNeeds( + 'ci.yml', + doc( + 'jobs:\n a: {}\n b:\n continue-on-error: true\n deploy:\n needs: [ci-success]\n ci-success:\n needs: [a]\n', + ), + failures, + ); + expect(failures).toEqual([]); + }); + + it('fails when a gating job is missing from needs', () => { + const failures: WorkflowPolicyFailure[] = []; + checkAggregatorNeeds('ci.yml', doc('jobs:\n a: {}\n ci-success:\n needs: []\n'), failures); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/missing gating job "a"/); + }); + + it('fails when needs lists an advisory (continue-on-error) job', () => { + const failures: WorkflowPolicyFailure[] = []; + checkAggregatorNeeds( + 'ci.yml', + doc( + 'jobs:\n a: {}\n storybook:\n continue-on-error: true\n ci-success:\n needs: [a, storybook]\n', + ), + failures, + ); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/not a gating job/); + }); + + it('treats a bare-string ci-success.needs as a single one-item dependency, not characters', () => { + const failures: WorkflowPolicyFailure[] = []; + checkAggregatorNeeds( + 'ci.yml', + doc('jobs:\n quality: {}\n ci-success:\n needs: quality\n'), + failures, + ); + expect(failures).toEqual([]); + }); + + it('fails when if: always() is set but the run script never checks a gating job\'s result', () => { + const failures: WorkflowPolicyFailure[] = []; + checkAggregatorNeeds( + 'ci.yml', + doc( + 'jobs:\n a: {}\n ci-success:\n if: always()\n needs: [a]\n steps:\n - run: echo ok\n', + ), + failures, + ); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/never check needs\.a\.result/); + }); + + it('passes when if: always() is set and the run script checks every gating job\'s result', () => { + const failures: WorkflowPolicyFailure[] = []; + const runLine = `[ \\"${githubExpression('needs.a.result')}\\" = success ] || exit 1`; + checkAggregatorNeeds( + 'ci.yml', + doc(`jobs:\n a: {}\n ci-success:\n if: always()\n needs: [a]\n steps:\n - run: "${runLine}"\n`), + failures, + ); + expect(failures).toEqual([]); + }); + + it('does not require a result check when if: always() is absent (default GH gating applies)', () => { + const failures: WorkflowPolicyFailure[] = []; + checkAggregatorNeeds( + 'ci.yml', + doc('jobs:\n a: {}\n ci-success:\n needs: [a]\n steps:\n - run: echo ok\n'), + failures, + ); + expect(failures).toEqual([]); + }); + + it('finds a result check split across multiple run steps', () => { + const failures: WorkflowPolicyFailure[] = []; + const stepA = `- run: "[ \\"${githubExpression('needs.a.result')}\\" = success ] || exit 1"`; + const stepB = `- run: "[ \\"${githubExpression('needs.b.result')}\\" = success ] || exit 1"`; + checkAggregatorNeeds( + 'ci.yml', + doc( + `jobs:\n a: {}\n b: {}\n ci-success:\n if: always()\n needs: [a, b]\n steps:\n ${stepA}\n ${stepB}\n`, + ), + failures, + ); + expect(failures).toEqual([]); + }); + + // QNBS-v3: a bare reference (e.g. echo) logs the result but can't fail the job โ€” must be rejected. + it('rejects a result reference that is only logged, never used to control failure', () => { + const failures: WorkflowPolicyFailure[] = []; + const stepA = `- run: "echo ${githubExpression('needs.a.result')}"`; + const stepB = `- run: "echo ${githubExpression('needs.b.result')}"`; + checkAggregatorNeeds( + 'ci.yml', + doc( + `jobs:\n a: {}\n b: {}\n ci-success:\n if: always()\n needs: [a, b]\n steps:\n ${stepA}\n ${stepB}\n`, + ), + failures, + ); + expect(failures).toHaveLength(2); + }); + + // QNBS-v3: a commented-out exit can't affect real control flow โ€” must not count as a check. + it('rejects a result reference whose only exit is inside a shell comment', () => { + const failures: WorkflowPolicyFailure[] = []; + const runLine = `echo ${githubExpression('needs.a.result')}\\n# exit 1`; + checkAggregatorNeeds( + 'ci.yml', + doc(`jobs:\n a: {}\n ci-success:\n if: always()\n needs: [a]\n steps:\n - run: "${runLine}"\n`), + failures, + ); + expect(failures).toHaveLength(1); + }); + + // QNBS-v3: smoke needs deploy needs ci-success โ€” smoke is a transitive, not just direct, descendant. + it('excludes a transitive descendant of ci-success, not only a direct one', () => { + const failures: WorkflowPolicyFailure[] = []; + checkAggregatorNeeds( + 'ci.yml', + doc('jobs:\n a: {}\n ci-success:\n needs: [a]\n deploy:\n needs: [ci-success]\n smoke:\n needs: [deploy]\n'), + failures, + ); + expect(failures).toEqual([]); + }); + + // QNBS-v3: failure()/cancelled() bypass default gating exactly like always() โ€” must be caught too. + it('fails when if: failure() is set but the run script never checks a gating job\'s result', () => { + const failures: WorkflowPolicyFailure[] = []; + checkAggregatorNeeds( + 'ci.yml', + doc('jobs:\n a: {}\n ci-success:\n if: failure()\n needs: [a]\n steps:\n - run: echo ok\n'), + failures, + ); + expect(failures).toHaveLength(1); + }); + + it('fails when if: !cancelled() is set but the run script never checks a gating job\'s result', () => { + const failures: WorkflowPolicyFailure[] = []; + checkAggregatorNeeds( + 'ci.yml', + doc('jobs:\n a: {}\n ci-success:\n if: "!cancelled()"\n needs: [a]\n steps:\n - run: echo ok\n'), + failures, + ); + expect(failures).toHaveLength(1); + }); + + // QNBS-v3: an aliased if: condition (if: *cond) is an Alias node until resolved. + it('fails when an aliased if: condition resolves to always()', () => { + const failures: WorkflowPolicyFailure[] = []; + const content = [ + 'x: &cond always()', + 'jobs:', + ' a: {}', + ' ci-success:', + ' if: *cond', + ' needs: [a]', + ' steps:', + ' - run: echo ok', + '', + ].join('\n'); + checkAggregatorNeeds('ci.yml', doc(content), failures); + expect(failures).toHaveLength(1); + }); +}); + +// QNBS-v3: scalar write-all implicitly grants contents:write and must not evade this boundary. +describe('checkPublishingBoundary', () => { + it('passes for contents:write on an allowlisted, tag-restricted publishing job', () => { + const failures: WorkflowPolicyFailure[] = []; + checkPublishingBoundary( + 'tauri-build.yml', + doc( + `jobs:\n release:\n if: ${githubExpression("github.ref_type == 'tag'")}\n permissions:\n contents: write\n`, + ), + failures, + ); + expect(failures).toEqual([]); + }); + + // QNBS-v3: allowlisting by (file, job) name alone survives the gate being loosened/removed. + it('fails for an allowlisted publishing job whose tag-only if: condition was removed', () => { + const failures: WorkflowPolicyFailure[] = []; + checkPublishingBoundary( + 'tauri-build.yml', + doc('jobs:\n release:\n permissions:\n contents: write\n'), + failures, + ); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/no longer restricts it to a tag push/); + }); + + it('fails for contents:write on a job not on the publishing allowlist', () => { + const failures: WorkflowPolicyFailure[] = []; + checkPublishingBoundary( + 'ci.yml', + doc('jobs:\n build:\n permissions:\n contents: write\n'), + failures, + ); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/not on the publishing allowlist/); + }); + + it('fails for the job-level scalar "write-all" on a job not on the publishing allowlist', () => { + const failures: WorkflowPolicyFailure[] = []; + checkPublishingBoundary('ci.yml', doc('jobs:\n build:\n permissions: write-all\n'), failures); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/not on the publishing allowlist/); + }); + + it('passes for the job-level scalar "read-all" (no implied contents:write)', () => { + const failures: WorkflowPolicyFailure[] = []; + checkPublishingBoundary('ci.yml', doc('jobs:\n build:\n permissions: read-all\n'), failures); + expect(failures).toEqual([]); + }); +}); + +// QNBS-v3: workflow_dispatch/tag-push recognition feeds the S5 qualification-lane boundary check. +describe('getTriggers', () => { + it('recognizes workflow_dispatch', () => { + expect(getTriggers(doc('on:\n workflow_dispatch: {}\njobs: {}\n')).workflowDispatch).toBe( + true, + ); + }); + + it('recognizes a tag-push trigger', () => { + expect(getTriggers(doc('on:\n push:\n tags: ["v*"]\njobs: {}\n')).tagPush).toBe(true); + }); + + it('reports both false when neither trigger is present', () => { + const triggers = getTriggers(doc('on:\n pull_request: {}\njobs: {}\n')); + expect(triggers).toEqual({ workflowDispatch: false, tagPush: false }); + }); +}); + +describe('checkWorkflowFile (duplicate keys / YAML-level errors)', () => { + it('reports a YAML parse error for duplicate top-level keys and skips structural checks', () => { + const content = 'permissions:\n contents: read\npermissions:\n contents: write\njobs: {}\n'; + const failures = checkWorkflowFile('duplicate.yml', { + readFileSync: () => content, + }); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/YAML parse error/); + }); + + it('resolves an anchor/alias pair and validates the resolved content', () => { + // QNBS-v3: proves anchors/aliases (core YAML) are resolved before structural checks run. + const content = [ + 'permissions: &perms', + ' contents: read', + 'jobs:', + ' a:', + ' permissions: *perms', + '', + ].join('\n'); + const failures = checkWorkflowFile('anchors.yml', { readFileSync: () => content }); + expect(failures).toEqual([]); + }); + + it('extracts the filename via basename(), not a hardcoded "/" split, given a nested path', () => { + // QNBS-v3: basename() is symmetric with join()'s platform separator; a bare split('/') was not. + const failures = checkWorkflowFile('/repo/.github/workflows/missing-perms.yml', { + readFileSync: () => 'jobs: {}\n', + }); + expect(failures).toHaveLength(1); + expect(failures[0]?.file).toBe('missing-perms.yml'); + }); +}); + +// QNBS-v3: composite actions are a distinct file kind โ€” only the pin check applies to them. +describe('checkActionFile', () => { + it('reports an unpinned action reference in a composite action', () => { + const content = 'runs:\n using: composite\n steps:\n - uses: actions/checkout@v4\n'; + const failures = checkActionFile('.github/actions/setup/action.yml', { + readFileSync: () => content, + }); + expect(failures).toHaveLength(1); + expect(failures[0]?.file).toBe('action.yml'); + }); + + it('passes a fully SHA-pinned composite action', () => { + const content = + 'runs:\n using: composite\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1\n'; + const failures = checkActionFile('.github/actions/setup/action.yml', { + readFileSync: () => content, + }); + expect(failures).toEqual([]); + }); + + it('reports a YAML parse error for a malformed composite action', () => { + const content = 'runs: [\n'; + const failures = checkActionFile('.github/actions/broken/action.yml', { + readFileSync: () => content, + }); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/YAML parse error/); + }); +}); + +describe('listActionFiles', () => { + it('discovers the repository\'s real .github/actions/setup/action.yml', () => { + // QNBS-v3: existsSync isn't DI'd (matches listWorkflowFiles), so this hits the real fs. + const files = listActionFiles(); + expect( + files.some((filePath) => filePath.endsWith(join('.github', 'actions', 'setup', 'action.yml'))), + ).toBe(true); + }); + + // QNBS-v3: existsSync isn't DI'd, so the real dir must exist for this fake readdirSync to fire. + // QNBS-v3: join(), not a literal "/", so the suffix matches join()'s separator on every platform. + it('discovers a composite action nested below its group directory (recursive)', () => { + const readdirSync = (dirPath: string) => { + if (dirPath.endsWith(join('.github', 'actions'))) { + return [{ name: 'release', isDirectory: () => true }]; + } + if (dirPath.endsWith(join('.github', 'actions', 'release'))) { + return [{ name: 'setup', isDirectory: () => true }]; + } + if (dirPath.endsWith(join('.github', 'actions', 'release', 'setup'))) { + return [{ name: 'action.yml', isDirectory: () => false }]; + } + return []; + }; + const files = listActionFiles(undefined, { readdirSync }); + expect( + files.some((f) => f.endsWith(join('.github', 'actions', 'release', 'setup', 'action.yml'))), + ).toBe(true); + }); + + // QNBS-v3: a symlinked directory could redirect a governed action outside .github/actions unseen. + it('throws when a symlink is found under .github/actions', () => { + const readdirSync = (dirPath: string) => { + if (dirPath.endsWith(join('.github', 'actions'))) { + return [{ name: 'setup', isDirectory: () => false, isSymbolicLink: () => true }]; + } + return []; + }; + expect(() => listActionFiles(undefined, { readdirSync })).toThrow(/symlink/i); + }); +}); + +describe('checkAllWorkflows', () => { + it('aggregates failures across multiple injected workflow files', () => { + const files = new Map([ + ['/repo/.github/workflows/a.yml', 'jobs: {}\n'], + ['/repo/.github/workflows/b.yml', 'permissions:\n contents: read\njobs: {}\n'], + ]); + const failures = checkAllWorkflows('/repo', { + listWorkflowFiles: () => [...files.keys()], + listActionFiles: () => [], + readFileSync: (filePath: string) => { + const content = files.get(filePath); + if (content === undefined) throw new Error(`unexpected path: ${filePath}`); + return content; + }, + }); + expect(failures).toHaveLength(1); + expect(failures[0]?.file).toBe('a.yml'); + }); + + it('also aggregates failures from injected composite action files', () => { + const files = new Map([ + ['/repo/.github/workflows/a.yml', 'permissions:\n contents: read\njobs: {}\n'], + [ + '/repo/.github/actions/setup/action.yml', + 'runs:\n using: composite\n steps:\n - uses: actions/checkout@v4\n', + ], + ]); + const failures = checkAllWorkflows('/repo', { + listWorkflowFiles: () => ['/repo/.github/workflows/a.yml'], + listActionFiles: () => ['/repo/.github/actions/setup/action.yml'], + readFileSync: (filePath: string) => { + const content = files.get(filePath); + if (content === undefined) throw new Error(`unexpected path: ${filePath}`); + return content; + }, + }); + expect(failures).toHaveLength(1); + expect(failures[0]?.file).toBe('action.yml'); + expect(failures[0]?.message).toMatch(/40-hex-char SHA/); + }); + + // QNBS-v3: a thrown symlink rejection must surface as a failure, never crash the whole check. + it('fail-closes a symlink rejection from listActionFiles into a failure, not a crash', () => { + const failures = checkAllWorkflows('/repo', { + listWorkflowFiles: () => [], + listActionFiles: () => { + throw new Error('symlink not allowed under .github/actions: /repo/.github/actions/setup'); + }, + }); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toMatch(/symlink/i); + }); +}); diff --git a/tests/unit/workflowPolicy.test.ts b/tests/unit/workflowPolicy.test.ts index 05928d2cd..6fcc1da41 100644 --- a/tests/unit/workflowPolicy.test.ts +++ b/tests/unit/workflowPolicy.test.ts @@ -134,6 +134,7 @@ describe('CI workflow policy', () => { 'Every unconditional job is either required here or explicitly advisory', ); expect(extractNeeds(workflowSource, 'ci-success')).toEqual([ + 'workflow-policy', 'security', 'signatures', 'quality',