mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
ci: check dependency vulnerabilities on PRs and stable releases (#31858)
* ci: audit dependency security without gating releases * ci: audit stable release tags instead of running daily * ci: isolate release audit data and fix integration checks * ci: require exact audit inputs and ignore candidate archive attributes
This commit is contained in:
@@ -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
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
@@ -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' &&
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[graph]
|
||||
all-features = true
|
||||
|
||||
[advisories]
|
||||
ignore = []
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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`]: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user