diff --git a/.github/actions/prepare-dependency-audit/action.yml b/.github/actions/prepare-dependency-audit/action.yml new file mode 100644 index 00000000000..764409b4a0e --- /dev/null +++ b/.github/actions/prepare-dependency-audit/action.yml @@ -0,0 +1,28 @@ +name: Prepare release dependency audit data +description: Extract only manifests, lockfiles, and patches without candidate executable configuration +inputs: + ref: + required: true + description: Exact release ref to audit +runs: + using: composite + steps: + - shell: bash + env: + RELEASE_REF: ${{ inputs.ref }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git -c credential.helper= -c 'credential.helper=!gh auth git-credential' fetch --no-tags --depth=1 origin "$RELEASE_REF" + mapfile -t files < <(git ls-tree -r FETCH_HEAD -- \ + pnpm-lock.yaml pnpm-workspace.yaml mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml \ + native config/patches mobile/patches | \ + awk '$1 ~ /^100(644|755)$/ {sub(/^[^\t]*\t/, ""); print}' | \ + rg '(^|/)(pnpm-lock.yaml|pnpm-workspace.yaml)$|^native/[^/]+/Cargo\.(toml|lock)$|^(config|mobile)/patches/.*\.patch$') + test "${#files[@]}" -gt 0 + mkdir candidate + git archive --worktree-attributes FETCH_HEAD -- "${files[@]}" | tar -x -C candidate + for file in pnpm-lock.yaml pnpm-workspace.yaml mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml; do + test -f "candidate/$file" + test ! -L "candidate/$file" + done diff --git a/.github/workflows/ci-closed-pr-caches.yml b/.github/workflows/ci-closed-pr-caches.yml index f215a6d3226..b85e1fe4e7d 100644 --- a/.github/workflows/ci-closed-pr-caches.yml +++ b/.github/workflows/ci-closed-pr-caches.yml @@ -22,6 +22,7 @@ jobs: const superseded = context.payload.action === 'synchronize' const targets = [ ['pr-checks', 'pr.yml'], + ['dependency-security', 'dependency-security.yml'], ['node-server', 'node-server-tests.yml'], ['ssh-windows-hosts', 'ssh-windows-hosts.yml'], ['ssh-hostile-hosts', 'ssh-hostile-hosts.yml'], diff --git a/.github/workflows/dependency-security.yml b/.github/workflows/dependency-security.yml new file mode 100644 index 00000000000..a7efa4ef748 --- /dev/null +++ b/.github/workflows/dependency-security.yml @@ -0,0 +1,112 @@ +name: Dependency security + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] + paths: + - '**/package.json' + - '**/pnpm-lock.yaml' + - '**/pnpm-workspace.yaml' + - '**/.npmrc' + - 'native/**/Cargo.toml' + - 'native/**/Cargo.lock' + - 'config/dependency-security-exceptions.json' + - 'config/cargo-deny.toml' + - 'config/scripts/*dependency-security*' + - 'config/scripts/pnpm-cli-invocation.mjs' + - 'config/pnpmfile.cjs' + - '**/patches/**' + - '.github/workflows/dependency-security.yml' + - '.github/workflows/release-cut.yml' + - '.github/actions/install-node-dependencies/**' + - '.github/actions/prepare-dependency-audit/**' + - 'src/packages/process-host/**' + workflow_call: + inputs: + ref: + description: Exact release tag to audit using the workflow commit's tooling + required: true + type: string + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: dependency-security-${{ inputs.ref && github.run_id || github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + ORCA_BACKGROUND_LAUNCH: '1' + +jobs: + javascript: + if: github.event_name != 'pull_request' || github.event.pull_request.draft != true + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - uses: ./.github/actions/prepare-dependency-audit + if: inputs.ref != '' + with: + ref: ${{ inputs.ref }} + - name: Audit JavaScript lockfiles + shell: bash + env: + RELEASE_REF: ${{ inputs.ref }} + run: | + if [ -n "$RELEASE_REF" ]; then + pnpm check:dependencies candidate . mobile + else + pnpm check:dependencies + fi + - uses: actions/upload-artifact@v7 + if: always() + with: + name: dependency-security-javascript + path: tmp/dependency-security/*.json + if-no-files-found: error + + rust: + if: github.event_name != 'pull_request' || github.event.pull_request.draft != true + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + - uses: actions/cache@v5 + id: scanner + with: + path: ~/.cargo/bin/cargo-deny + key: cargo-deny-0.20.2-${{ runner.os }}-${{ runner.arch }} + - if: steps.scanner.outputs.cache-hit != 'true' + run: cargo install cargo-deny --locked --version 0.20.2 + - uses: ./.github/actions/prepare-dependency-audit + if: inputs.ref != '' + with: + ref: ${{ inputs.ref }} + - name: Audit native Rust lockfiles + shell: bash + working-directory: ${{ inputs.ref != '' && 'candidate' || '.' }} + run: | + set -euo pipefail + report_dir="$GITHUB_WORKSPACE/tmp/dependency-security" + mkdir -p "$report_dir" + status=0 + for manifest in native/*/Cargo.toml; do + name="$(basename "$(dirname "$manifest")")" + cargo deny --manifest-path "$manifest" --locked --config "$GITHUB_WORKSPACE/config/cargo-deny.toml" check advisories 2>&1 | tee "$report_dir/$name.log" || status=1 + done + exit "$status" + - uses: actions/upload-artifact@v7 + if: always() + with: + name: dependency-security-rust + path: tmp/dependency-security/*.log + if-no-files-found: error diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index ff462cc0e70..3a4d09c52df 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -852,6 +852,13 @@ jobs: TAG: ${{ needs.cut.outputs.tag }} run: node config/scripts/create-draft-release.mjs "$TAG" + dependency-security: + needs: cut + if: needs.cut.outputs.should_release == 'true' && !contains(needs.cut.outputs.tag, '-') + uses: ./.github/workflows/dependency-security.yml + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + terminal-rendering-golden: needs: cut if: needs.cut.outputs.should_release == 'true' @@ -2436,6 +2443,7 @@ jobs: publish-release: needs: - cut + - dependency-security - build - build-mac - verify-mac-build @@ -2446,6 +2454,7 @@ jobs: if: >- !cancelled() && needs.cut.result == 'success' && + (needs.dependency-security.result == 'success' || contains(needs.cut.outputs.tag, '-')) && needs.build.result == 'success' && needs.build-mac.result == 'success' && needs.verify-mac-build.result == 'success' && diff --git a/config/cargo-deny.toml b/config/cargo-deny.toml new file mode 100644 index 00000000000..b02d93c9cae --- /dev/null +++ b/config/cargo-deny.toml @@ -0,0 +1,5 @@ +[graph] +all-features = true + +[advisories] +ignore = [] diff --git a/config/dependency-security-exceptions.json b/config/dependency-security-exceptions.json new file mode 100644 index 00000000000..3ce690c757c --- /dev/null +++ b/config/dependency-security-exceptions.json @@ -0,0 +1,54 @@ +[ + { + "workspace": "mobile", + "package": "braces", + "version": "3.0.3", + "advisory": "GHSA-vfj7-8cjw-p6xm", + "owner": "Orca maintainers", + "reason": "Parser and AST walkers reject excessive nesting via the patch merged in #31681, covered by dependency-security-patches.test.mjs. Remove when a fixed upstream release is available.", + "expires": "2026-11-20", + "patch": { + "path": "patches/braces@3.0.3.patch", + "sha256": "c07bb14cec04bc891e1fe529720444cc9729a25117f9aaf24253cfe3132a34fd" + } + }, + { + "workspace": "docs/site", + "package": "braces", + "version": "3.0.3", + "advisory": "GHSA-vfj7-8cjw-p6xm", + "owner": "Orca maintainers", + "reason": "Parser and AST walkers reject excessive nesting via the patch merged in #31681, covered by dependency-security-patches.test.mjs. Remove when a fixed upstream release is available.", + "expires": "2026-11-20", + "patch": { + "path": "../../config/patches/braces@3.0.3.patch", + "sha256": "c07bb14cec04bc891e1fe529720444cc9729a25117f9aaf24253cfe3132a34fd" + } + }, + { + "workspace": "config/audit-tools", + "package": "braces", + "version": "3.0.3", + "advisory": "GHSA-vfj7-8cjw-p6xm", + "owner": "Orca maintainers", + "reason": "Parser and AST walkers reject excessive nesting via the patch merged in #31681, covered by dependency-security-patches.test.mjs. Remove when a fixed upstream release is available.", + "expires": "2026-11-20", + "patch": { + "path": "../patches/braces@3.0.3.patch", + "sha256": "c07bb14cec04bc891e1fe529720444cc9729a25117f9aaf24253cfe3132a34fd" + } + }, + { + "workspace": "mobile", + "package": "node-forge", + "version": "1.4.0", + "advisory": "GHSA-86w9-cpqp-85rv", + "owner": "Orca maintainers", + "reason": "RSA DigestAlgorithm rejects extra nested elements via the patch merged in #31681, covered by mobile/scripts/dependency-security-patches.test.ts. Remove when a fixed upstream release is available.", + "expires": "2026-11-20", + "patch": { + "path": "patches/node-forge@1.4.0.patch", + "sha256": "2798686e4488058ad81c3b8e32eb763c0feff21f4c717ce4d13b6ef2432cd5b8" + } + } +] diff --git a/config/dependency-security.md b/config/dependency-security.md new file mode 100644 index 00000000000..204d04d6aad --- /dev/null +++ b/config/dependency-security.md @@ -0,0 +1,42 @@ +# Dependency security checks + +`pnpm check:dependencies` audits the desktop, mobile, cloud, docs, and isolated +audit-tool lockfiles, including development and optional dependencies. It does not +install those workspaces or execute their pnpm hooks. High/critical advisories and +scanner failures fail the check; lower severities remain visible in JSON reports +under `tmp/dependency-security/`. + +The Linux workflow runs on dependency-change PRs, manually, and when cutting a +stable desktop release. There is no daily schedule. The release check scans the +exact release tag in parallel with builds and must pass before the release is +published; RC releases skip it. Stable releases audit the desktop and embedded +mobile-web lockfiles plus native Rust. Cloud, docs, and isolated audit tooling +remain covered by dependency PR checks and do not block desktop releases. +Scanner tooling and exception policy come from the workflow commit, while +lockfiles, patch registrations, and patch bytes come from the release tag. Only candidate Rust manifests, lockfiles, and patches are extracted; candidate +package manifests, pnpm/Cargo configuration, and toolchain overrides are excluded. The candidate is +not installed and its pnpm hooks do not run. Branch protection is unchanged. + +The Rust job runs pinned cargo-deny 0.20.2 directly against every native manifest +with `--locked`, all features, and no target restriction. The scanner binary is +cached; advisory data is fetched on every run. Reports upload even on failure. +The first CI run took 44 seconds for JavaScript and 2m04s for Rust, including a +cold scanner installation; the audit steps themselves took 2 and 1 seconds. + +The only JavaScript exceptions cover previously reviewed local patches. Each +names an exact workspace, package, version, advisory, owner, expiry, patch path, +and SHA-256. An exception is considered only for an actual high/critical finding. +The patch bytes, workspace registration, and locked patch hash/snapshots must +match before the exception applies. Expired exceptions stop exempting findings; +stale exceptions for removed/fixed packages do not fail clean scans. Remove an +exception and its patch when an upstream fixed version replaces them. + +This initial check covers JavaScript and native Rust advisories. It does not +establish reachability or scan licenses, container layers, downloaded binaries, +CocoaPods, Gradle, or Ruby dependencies. Registry/advisory outages fail the check +and block stable release publication until a successful rerun. After installing +cargo-deny, the Rust command can also be run locally: + +```sh +cargo deny --manifest-path native/windows-agent-hook/Cargo.toml --locked --config config/cargo-deny.toml check advisories +``` diff --git a/config/scripts/check-dependency-security.mjs b/config/scripts/check-dependency-security.mjs new file mode 100644 index 00000000000..57716d60a57 --- /dev/null +++ b/config/scripts/check-dependency-security.mjs @@ -0,0 +1,87 @@ +import { runProcessSync } from '@orca/process-host' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + AUDIT_WORKSPACES, + evaluateSecurityAudit, + securityExceptions +} from './dependency-security-audit.mjs' +import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' + +const ROOT = resolve(import.meta.dirname, '../..') + +export function readSecurityAudit(result) { + if ( + result.signal || + result.timedOut || + result.outputTruncated || + (result.code !== 0 && result.code !== 1) + ) { + throw new Error(`Dependency audit failed (${result.code}): ${result.stderr || result.stdout}`) + } + const report = JSON.parse(result.stdout) + if (result.code === 1 && Object.keys(report.advisories ?? {}).length === 0) { + throw new Error('Audit failed without a vulnerability report') + } + return report +} + +export function checkDependencySecurity(root = ROOT, workspaces = AUDIT_WORKSPACES) { + const exceptions = securityExceptions.parse( + JSON.parse(readFileSync(join(ROOT, 'config/dependency-security-exceptions.json'), 'utf8')) + ) + const reportDir = join(ROOT, 'tmp/dependency-security') + mkdirSync(reportDir, { recursive: true }) + const { command, prefixArgs } = resolvePnpmCliInvocation() + const today = new Date().toISOString().slice(0, 10) + let passed = true + for (const workspace of workspaces) { + const report = { workspace, checkedAt: new Date().toISOString() } + try { + const result = runProcessSync({ + program: command, + args: [ + ...prefixArgs, + 'audit', + '--json', + '--ignore-pnpmfile', + '--registry=https://registry.npmjs.org' + ], + cwd: join(root, workspace), + timeoutMs: 120_000, + maxOutputBytes: 32 * 1024 * 1024 + }) + report.audit = readSecurityAudit(result) + report.findings = evaluateSecurityAudit(report.audit, workspace, exceptions, { root, today }) + for (const item of report.findings) { + console.log( + `${workspace}: ${item.exception ? 'PATCHED' : item.blocking ? 'BLOCKING' : 'FINDING'} ${item.package}@${item.version} ${item.severity} ${item.advisory} ${item.url}` + ) + } + if (report.findings.some((item) => item.blocking)) { + passed = false + } + console.log(`${workspace}: ${report.findings.length} finding(s)`) + } catch (error) { + report.error = error.message + console.error(`${workspace}: ${error.message}`) + passed = false + } + const name = workspace === '.' ? 'desktop' : workspace.replaceAll('/', '-') + writeFileSync(join(reportDir, `${name}.json`), `${JSON.stringify(report, null, 2)}\n`) + } + console.log(`Dependency reports: ${reportDir}`) + return passed ? 0 : 1 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const workspaces = process.argv.slice(3) + if (workspaces.some((workspace) => !AUDIT_WORKSPACES.includes(workspace))) { + throw new Error('Unknown dependency audit workspace') + } + process.exitCode = checkDependencySecurity( + resolve(process.argv[2] ?? ROOT), + workspaces.length ? workspaces : AUDIT_WORKSPACES + ) +} diff --git a/config/scripts/ci-closed-pr-cancellation.test.mjs b/config/scripts/ci-closed-pr-cancellation.test.mjs index d6881ec348f..64546709e46 100644 --- a/config/scripts/ci-closed-pr-cancellation.test.mjs +++ b/config/scripts/ci-closed-pr-cancellation.test.mjs @@ -75,6 +75,7 @@ it('looks up exact PR groups without branch or head-SHA inference', async () => await result expect(request.mock.calls.map(([, args]) => args.concurrency_group_name)).toEqual([ 'pr-checks-123', + 'dependency-security-123', 'node-server-123', 'ssh-windows-hosts-123', 'ssh-hostile-hosts-123', diff --git a/config/scripts/dependency-security-audit.mjs b/config/scripts/dependency-security-audit.mjs new file mode 100644 index 00000000000..aa09c75251e --- /dev/null +++ b/config/scripts/dependency-security-audit.mjs @@ -0,0 +1,107 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { parse, parseAllDocuments } from 'yaml' +import { z } from 'zod' + +export const AUDIT_WORKSPACES = ['.', 'mobile', 'cloud', 'docs/site', 'config/audit-tools'] +const text = z.string().trim().min(1) +const severities = ['info', 'low', 'moderate', 'high', 'critical'] +export const securityExceptions = z.array( + z.strictObject({ + workspace: z.enum(AUDIT_WORKSPACES), + package: text, + version: text, + advisory: z.string().regex(/^GHSA-[-a-z0-9]+$/), + owner: text, + reason: text, + expires: z.iso.date(), + patch: z.strictObject({ path: text, sha256: z.string().regex(/^[a-f0-9]{64}$/) }) + }) +) +const auditSchema = z.object({ + advisories: z.record( + text, + z.object({ + module_name: text, + github_advisory_id: text, + severity: z.enum(severities), + url: z.url(), + findings: z.array(z.object({ version: text, paths: z.array(text).min(1) })).min(1) + }) + ), + metadata: z.object({ + vulnerabilities: z.object( + Object.fromEntries(severities.map((level) => [level, z.int().min(0)])) + ) + }) +}) + +function verifyPatch(root, exception) { + const cwd = join(root, exception.workspace) + const specifier = `${exception.package}@${exception.version}` + const policy = parse(readFileSync(join(cwd, 'pnpm-workspace.yaml'), 'utf8')) + const documents = parseAllDocuments(readFileSync(join(cwd, 'pnpm-lock.yaml'), 'utf8')) + for (const document of documents) { + if (document.errors.length) { + throw document.errors[0] + } + } + const lock = documents + .map((document) => document.toJS()) + .find((value) => value?.patchedDependencies?.[specifier]) + const snapshots = Object.keys(lock?.snapshots ?? {}).filter( + (key) => key === specifier || key.startsWith(`${specifier}(`) + ) + if ( + policy.patchedDependencies?.[specifier] !== exception.patch.path || + lock?.patchedDependencies?.[specifier] !== exception.patch.sha256 || + snapshots.length === 0 || + snapshots.some((key) => !key.includes(`(patch_hash=${exception.patch.sha256})`)) || + createHash('sha256') + .update(readFileSync(join(cwd, exception.patch.path))) + .digest('hex') !== exception.patch.sha256 + ) { + throw new Error(`Reviewed patch is not locked: ${exception.workspace} ${specifier}`) + } +} + +export function evaluateSecurityAudit( + value, + workspace, + exceptions, + { root, today = new Date().toISOString().slice(0, 10) } = {} +) { + const audit = auditSchema.parse(value) + const count = Object.values(audit.metadata.vulnerabilities).reduce((sum, n) => sum + n, 0) + if (count !== Object.keys(audit.advisories).length) { + throw new Error('Incomplete vulnerability report') + } + const findings = [] + for (const advisory of Object.values(audit.advisories)) { + for (const finding of advisory.findings) { + const item = { + package: advisory.module_name, + version: finding.version, + advisory: advisory.github_advisory_id, + severity: advisory.severity, + url: advisory.url, + blocking: ['high', 'critical'].includes(advisory.severity) + } + const exception = exceptions.find( + (entry) => + entry.workspace === workspace && + entry.package === item.package && + entry.version === item.version && + entry.advisory === item.advisory + ) + if (item.blocking && exception && exception.expires > today) { + verifyPatch(root, exception) + item.exception = exception + item.blocking = false + } + findings.push(item) + } + } + return findings +} diff --git a/config/scripts/dependency-security-audit.test.mjs b/config/scripts/dependency-security-audit.test.mjs new file mode 100644 index 00000000000..4be59ccea3d --- /dev/null +++ b/config/scripts/dependency-security-audit.test.mjs @@ -0,0 +1,149 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { stringify } from 'yaml' +import { evaluateSecurityAudit } from './dependency-security-audit.mjs' +import { readSecurityAudit } from './check-dependency-security.mjs' + +const roots = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) +const today = '2026-10-22' +const patch = 'reviewed security fix\n' +const sha256 = createHash('sha256').update(patch).digest('hex') +const exception = { + workspace: '.', + package: 'example', + version: '1.2.3', + advisory: 'GHSA-1234-5678-9abc', + owner: 'Maintainer', + reason: 'Patched while awaiting upstream', + expires: '2026-11-20', + patch: { path: 'example.patch', sha256 } +} +function audit(severity = 'high', versions = ['1.2.3']) { + return { + advisories: versions.length + ? { + 123: { + module_name: 'example', + github_advisory_id: exception.advisory, + severity, + url: 'https://github.com/advisories/GHSA-1234-5678-9abc', + findings: versions.map((version) => ({ version, paths: ['.>build-tool>example'] })) + } + } + : {}, + metadata: { + vulnerabilities: { + info: 0, + low: 0, + moderate: 0, + high: 0, + critical: 0, + [severity]: versions.length ? 1 : 0 + } + } + } +} +function patchedWorkspace() { + const root = mkdtempSync(join(tmpdir(), 'orca-dependency-security-')) + roots.push(root) + writeFileSync(join(root, 'example.patch'), patch) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + stringify({ + patchedDependencies: { 'example@1.2.3': 'example.patch' } + }) + ) + const lock = { + patchedDependencies: { 'example@1.2.3': sha256 }, + snapshots: { [`example@1.2.3(patch_hash=${sha256})`]: {} } + } + const writeLock = () => + writeFileSync( + join(root, 'pnpm-lock.yaml'), + `---\n${stringify({ lockfileVersion: '9.0', importers: {}, snapshots: {} })}---\n${stringify(lock)}` + ) + writeLock() + return { root, lock, writeLock } +} + +it.each(['high', 'critical'])('blocks %s advisories including build-time findings', (severity) => { + expect(evaluateSecurityAudit(audit(severity), '.', [], { today })).toEqual([ + expect.objectContaining({ severity, blocking: true }) + ]) +}) +it('retains lower severities without blocking', () => { + expect(evaluateSecurityAudit(audit('moderate'), '.', [], { today })).toEqual([ + expect.objectContaining({ severity: 'moderate', blocking: false }) + ]) +}) +it('applies an exception only to the exact patched finding', () => { + const { root } = patchedWorkspace() + const findings = evaluateSecurityAudit(audit('high', ['1.2.3', '2.0.0']), '.', [exception], { + root, + today + }) + expect(findings.map((item) => item.blocking)).toEqual([false, true]) + for (const change of [ + { workspace: 'mobile' }, + { package: 'other' }, + { advisory: 'GHSA-abcd-5678-9abc' } + ]) { + expect( + evaluateSecurityAudit(audit(), '.', [{ ...exception, ...change }], { root, today })[0] + .blocking + ).toBe(true) + } +}) +it('lets expired and removed patch exceptions coexist with a clean graph', () => { + expect( + evaluateSecurityAudit(audit('high', []), '.', [exception], { today: '2026-11-20' }) + ).toEqual([]) + expect( + evaluateSecurityAudit(audit(), '.', [exception], { today: '2026-11-20' })[0].blocking + ).toBe(true) +}) +it.each(['bytes', 'registration', 'lock hash', 'unpatched snapshot'])( + 'refuses a changed %s', + (change) => { + const { root, lock, writeLock } = patchedWorkspace() + if (change === 'bytes') { + writeFileSync(join(root, 'example.patch'), 'different fix') + } + if (change === 'registration') { + writeFileSync(join(root, 'pnpm-workspace.yaml'), '{}') + } + if (change === 'lock hash') { + lock.patchedDependencies['example@1.2.3'] = '0'.repeat(64) + } + if (change === 'unpatched snapshot') { + lock.snapshots['example@1.2.3'] = {} + } + writeLock() + expect(() => evaluateSecurityAudit(audit(), '.', [exception], { root, today })).toThrow( + 'Reviewed patch' + ) + } +) +it('rejects incomplete reports instead of treating them as clean', () => { + expect(() => evaluateSecurityAudit({}, '.', [], { today })).toThrow() + const incomplete = audit() + incomplete.advisories = {} + expect(() => evaluateSecurityAudit(incomplete, '.', [], { today })).toThrow('Incomplete') +}) +it.each([ + { code: 2 }, + { signal: 'SIGTERM' }, + { timedOut: true }, + { outputTruncated: true }, + { stdout: '{' }, + { code: 1, stdout: '{"error":"registry unavailable"}' } +])('fails closed on scanner errors: %j', (change) => { + expect(() => readSecurityAudit({ code: 0, stdout: JSON.stringify(audit()), ...change })).toThrow() +}) +it('reads nonzero finding reports for policy evaluation', () => { + expect(readSecurityAudit({ code: 1, stdout: JSON.stringify(audit()) })).toEqual(audit()) +}) diff --git a/config/scripts/dependency-security-release.test.mjs b/config/scripts/dependency-security-release.test.mjs new file mode 100644 index 00000000000..750376f0ea7 --- /dev/null +++ b/config/scripts/dependency-security-release.test.mjs @@ -0,0 +1,164 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { runProcess } from '@orca/process-host' +import { runInNewContext } from 'node:vm' +import { expect, it } from 'vitest' +import { parse } from 'yaml' + +const release = parse(readFileSync('.github/workflows/release-cut.yml', 'utf8')) +const security = parse(readFileSync('.github/workflows/dependency-security.yml', 'utf8')) +const gate = release.jobs['dependency-security'] +const publish = release.jobs['publish-release'] + +function evaluate(expression, tag, result) { + const needs = Object.fromEntries(publish.needs.map((job) => [job, { result: 'success' }])) + needs.cut.outputs = { tag, should_release: 'true' } + needs['dependency-security'].result = result + return runInNewContext(expression.replace(/needs\.([\w-]+)/g, 'needs["$1"]'), { + needs, + cancelled: () => false, + contains: (value, part) => value.includes(part) + }) +} + +it('blocks stable publication until its dependency scan succeeds while leaving RCs independent', () => { + for (const tag of ['v1.5.0', 'v2.0.1']) { + expect(evaluate(gate.if, tag, 'success')).toBe(true) + expect(evaluate(publish.if, tag, 'success')).toBe(true) + for (const result of ['failure', 'cancelled', 'skipped', '', undefined]) { + expect(evaluate(publish.if, tag, result)).toBe(false) + } + } + for (const tag of ['v1.5.0-rc.1', 'v1.5.0-rc.1.perf']) { + expect(evaluate(gate.if, tag, 'skipped')).toBe(false) + expect(evaluate(publish.if, tag, 'skipped')).toBe(true) + } +}) + +it('scans the release tag with read-only workflow tooling instead of installing candidate code', () => { + expect(gate.with.ref).toBe('refs/tags/${{ needs.cut.outputs.tag }}') + expect(gate.uses).toBe('./.github/workflows/dependency-security.yml') + expect(security.permissions).toEqual({ contents: 'read' }) + expect(security.on.schedule).toBeUndefined() + for (const job of Object.values(security.jobs)) { + const checkouts = job.steps.filter((step) => step.uses === 'actions/checkout@v6') + expect(checkouts).toHaveLength(1) + expect(checkouts[0].with.ref).toBe('${{ github.workflow_sha }}') + const preparation = job.steps.find( + (step) => step.uses === './.github/actions/prepare-dependency-audit' + ) + expect(preparation.with.ref).toBe('${{ inputs.ref }}') + expect(preparation.if).toBe("inputs.ref != ''") + for (const step of job.steps) { + if (step.uses?.startsWith('./')) { + expect(step['working-directory']).toBeUndefined() + expect(step.with?.['working-directory']).toBeUndefined() + } + } + } +}) + +it('extracts release data while excluding executable package and Cargo configuration', async () => { + const root = mkdtempSync(join(tmpdir(), 'release audit data ')) + const source = join(root, 'source') + const tooling = join(root, 'tooling') + mkdirSync(source) + mkdirSync(tooling) + const data = [ + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'mobile/pnpm-lock.yaml', + 'mobile/pnpm-workspace.yaml', + 'native/example/Cargo.toml', + 'native/example/Cargo.lock', + 'config/patches/example.patch', + 'mobile/patches/example.patch' + ] + const configuration = [ + 'package.json', + 'mobile/package.json', + '.gitattributes', + '.npmrc', + '.pnpmfile.cjs', + 'config/pnpmfile.cjs', + 'mobile/.npmrc', + 'mobile/.pnpmfile.cjs', + '.cargo/config.toml', + 'native/.cargo/config.toml', + 'native/example/.cargo/config.toml', + 'native/example/rust-toolchain.toml', + 'native/example/build.rs' + ] + async function run(program, args, cwd, env = process.env) { + const result = await runProcess({ program, args, cwd, env }) + expect(result.code, result.stderr).toBe(0) + } + try { + for (const file of [...data, ...configuration]) { + mkdirSync(dirname(join(source, file)), { recursive: true }) + writeFileSync( + join(source, file), + file === '.gitattributes' + ? '*.yaml export-ignore\n*.patch export-subst\n' + : `${file} contents` + ) + } + await run('git', ['init', '--quiet'], source) + await run('git', ['add', '.'], source) + await run( + 'git', + [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + 'commit', + '--quiet', + '-m', + 'fixture' + ], + source + ) + await run('git', ['init', '--quiet'], tooling) + await run('git', ['remote', 'add', 'origin', source], tooling) + const action = parse( + readFileSync('.github/actions/prepare-dependency-audit/action.yml', 'utf8') + ) + await run('bash', ['-c', action.runs.steps[0].run], tooling, { + ...process.env, + RELEASE_REF: 'HEAD' + }) + for (const file of data) { + expect(readFileSync(join(tooling, 'candidate', file), 'utf8')).toBe(`${file} contents`) + } + for (const file of configuration) { + expect(existsSync(join(tooling, 'candidate', file)), file).toBe(false) + } + rmSync(join(tooling, 'candidate'), { recursive: true }) + await run('git', ['rm', '--quiet', 'pnpm-workspace.yaml'], source) + await run( + 'git', + [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + 'commit', + '--quiet', + '-m', + 'missing workspace' + ], + source + ) + const missing = await runProcess({ + program: 'bash', + args: ['-c', action.runs.steps[0].run], + cwd: tooling, + env: { ...process.env, RELEASE_REF: 'HEAD' } + }) + expect(missing.code).not.toBe(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index c02dfa984b3..9c6139bf881 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -136,7 +136,8 @@ describe('Electron runtime package contract', () => { const evaluate = (source, needs, cancelled) => runInNewContext(source.replace(/needs\.([a-z][a-z0-9-]*)/g, 'needs["$1"]'), { needs, - cancelled: () => cancelled + cancelled: () => cancelled, + contains: (value, part) => value.includes(part) }) const results = ['success', 'failure', 'skipped', 'cancelled'] for (const matrix of results) { @@ -144,7 +145,8 @@ describe('Electron runtime package contract', () => { for (const verification of results) { for (const cancelled of [false, true]) { const needs = { - cut: { result: 'success', outputs: { should_release: 'true' } }, + cut: { result: 'success', outputs: { should_release: 'true', tag: 'v1.5.0' } }, + 'dependency-security': { result: 'success' }, build: { result: matrix }, 'build-mac': { result: dispatch }, 'verify-mac-build': { result: verification }, @@ -167,14 +169,16 @@ describe('Electron runtime package contract', () => { } for (const prerequisite of [ 'cut', + 'dependency-security', 'skill-sharing-linux-floor-release-gate', 'skill-sharing-release-gate', 'terminal-rendering-golden' ]) { const needs = Object.fromEntries([ - ['cut', { result: 'success', outputs: { should_release: 'true' } }], + ['cut', { result: 'success', outputs: { should_release: 'true', tag: 'v1.5.0' } }], ...[ 'build', + 'dependency-security', 'build-mac', 'verify-mac-build', 'skill-sharing-linux-floor-release-gate', diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index 4df557d73ea..25b890a03d7 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -51,6 +51,12 @@ const EXPECTED_MATRIX = { [`${RELEASE_WORKFLOW}#verify-mac-build`]: { actions: 'read', contents: 'read' }, [`${RELEASE_WORKFLOW}#create-release`]: { contents: 'write' }, [`${RELEASE_WORKFLOW}#cut`]: { contents: 'write' }, + [`${RELEASE_WORKFLOW}#dependency-security`]: { contents: 'read' }, + [`${RELEASE_WORKFLOW}#dependency-security -> .github/workflows/dependency-security.yml#javascript`]: + { contents: 'read' }, + [`${RELEASE_WORKFLOW}#dependency-security -> .github/workflows/dependency-security.yml#rust`]: { + contents: 'read' + }, [`${RELEASE_WORKFLOW}#docs-production-dispatch`]: { actions: 'write' }, [`${RELEASE_WORKFLOW}#homebrew-bump`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#homebrew-bump -> .github/workflows/homebrew-bump.yml#bump-cask`]: { diff --git a/package.json b/package.json index e5bf821656f..4843904071a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "main": "./out/main/index.js", "scripts": { + "check:dependencies": "pnpm run build:packages && node config/scripts/check-dependency-security.mjs", "install:release": "pnpm install --frozen-lockfile --cpu=current,x64,arm64", "audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src", "test:perf:contracts": "vitest run --config config/vitest.performance.config.ts",