Compare commits

...
33 changed files with 2079 additions and 81 deletions

No files matched your search

+41
View File
@@ -57,6 +57,7 @@ jobs:
contents: read
actions: read
outputs:
dependency_security: ${{ steps.plan.outputs.dependency_security }}
tier: ${{ steps.plan.outputs.tier }}
qualification_run_id: ${{ steps.qualification.outputs.run_id }}
code: ${{ steps.plan.outputs.code }}
@@ -1091,6 +1092,45 @@ jobs:
if: needs.changes.outputs.website == 'true'
uses: ./.github/workflows/website-check.yml
dependency-security:
name: Dependency security
needs: changes
if: needs.changes.outputs.dependency_security == 'true' || needs.changes.outputs.tier == 'reused'
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
env:
NODE_OPTIONS: --max-old-space-size=1024
UV_THREADPOOL_SIZE: "1"
TURBO_TOKEN: ""
TURBO_CACHE: local:rw
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: ${{ inputs.source_sha || github.sha }}
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Validate registry identities before installing dependencies
run: node scripts/ci/dependency-security.mjs --mode=preflight
- run: npm ci --ignore-scripts --no-audit --no-fund --maxsockets=2
- name: Check source lock advisories, installed signatures and identities
run: node scripts/ci/dependency-security.mjs --mode=source
- name: Check a fresh published consumer daily
if: github.event_name == 'schedule' && github.event.schedule == '23 3 * * *'
run: node scripts/ci/dependency-security.mjs --mode=published
- name: Retain dependency security evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dependency-security-${{ github.run_attempt }}
path: .cache/dependency-security/
if-no-files-found: warn
retention-days: 14
required:
name: CI required
# Report failed dependencies, but release superseded PR concurrency slots.
@@ -1098,6 +1138,7 @@ jobs:
needs:
[
changes,
dependency-security,
static,
static-deep,
build,
+13 -2
View File
@@ -77,15 +77,26 @@ jobs:
- name: Rehearse the original qualified tarball
env:
RECOVERY_VERSION: ${{ steps.release.outputs.version }}
run: node scripts/verify-release-artifact.mjs .cache/verified-package/package.tgz "$RECOVERY_VERSION"
RECOVERY_SOURCE_SHA: ${{ steps.release.outputs.source_sha }}
run: node scripts/verify-release-artifact.mjs .cache/verified-package/package.tgz "$RECOVERY_VERSION" --dependency-security=.cache/dependency-security --dependency-source=.cache/release-source --dependency-source-sha="$RECOVERY_SOURCE_SHA"
- name: Verify published CLI, MCP and update
env:
RECOVERY_VERSION: ${{ steps.release.outputs.version }}
run: node scripts/verify-published-package.mjs "$RECOVERY_VERSION"
RECOVERY_SOURCE_SHA: ${{ steps.release.outputs.source_sha }}
run: node scripts/verify-published-package.mjs "$RECOVERY_VERSION" --dependency-security=.cache/dependency-security --dependency-source=.cache/release-source --dependency-source-sha="$RECOVERY_SOURCE_SHA"
- name: Validate released registry metadata
working-directory: .cache/release-source
run: npm run mcp-registry:check
- name: Retain recovery consumer dependency security evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: recovery-consumer-security-${{ github.run_attempt }}
path: .cache/dependency-security/
if-no-files-found: warn
retention-days: 14
publish-mcp:
name: Finish MCP Registry publication
needs: verify
+20 -2
View File
@@ -342,7 +342,16 @@ jobs:
env:
VERIFIED_PACKAGE_TARBALL: ${{ steps.package.outputs.tarball }}
VERIFIED_PACKAGE_VERSION: ${{ steps.package.outputs.version }}
run: node scripts/verify-release-artifact.mjs "$VERIFIED_PACKAGE_TARBALL" "$VERIFIED_PACKAGE_VERSION"
run: node scripts/verify-release-artifact.mjs "$VERIFIED_PACKAGE_TARBALL" "$VERIFIED_PACKAGE_VERSION" --dependency-security=.cache/dependency-security
- name: Retain consumer dependency security evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: qualified-consumer-security-${{ github.run_attempt }}
path: .cache/dependency-security/
if-no-files-found: warn
retention-days: 14
publish:
name: Publish to npm
@@ -415,7 +424,16 @@ jobs:
- name: Verify published CLI from npm
env:
VERIFIED_PACKAGE_VERSION: ${{ steps.package.outputs.version }}
run: node scripts/verify-published-package.mjs "$VERIFIED_PACKAGE_VERSION"
run: node scripts/verify-published-package.mjs "$VERIFIED_PACKAGE_VERSION" --dependency-security=.cache/dependency-security
- name: Retain consumer dependency security evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: published-consumer-security-${{ github.run_attempt }}
path: .cache/dependency-security/
if-no-files-found: warn
retention-days: 14
publish-mcp:
name: Publish to MCP Registry
+32
View File
@@ -28,6 +28,38 @@ job rebuilds or repacks the archive.
This rehearsal checks package consumption on the Linux release runner;
qualification retains its broader native-host and real-provider checks.
Dependency policy: the source lock pins the build, while an npm consumer resolves
the published package's semver ranges. Consumer installations are not pinned by
the source lock. Qualification checks live advisories for the source lock,
signatures for npm-supported installed dependencies, and installed identities.
Hourly qualification reuse still requires fresh dependency evidence; cached
provider qualification is not a current registry security result. The daily
scheduled run also checks a fresh installation of the current npm release.
Both the qualified-archive rehearsal and published-package verification run the
same checks on their actual installed consumer tree before executing REA.
Installations disable lifecycle scripts and retain the generated consumer lock.
Evidence artifacts contain command output, npm-resolved package identities and
relationships, a content fingerprint, and version/integrity differences against
the source lock. Recovery supplies the original released source directory and
SHA independently of the corrected controller checkout.
New semver-compatible versions are recorded; changed integrity
for a source-locked identity fails. Unsupported registries, malformed output,
unavailable registry evidence and exceeded output budgets fail the check.
Optional omissions remain explicit. npm does not expose a verified-signature
count here, so it remains unknown; these checks do not establish that every
platform package or transitive lifecycle download is safe, or detect all malware.
To reproduce the read-only checks locally, use
`node scripts/ci/dependency-security.mjs --mode=source` after installing the
locked dependencies, or `--mode=published` for a fresh current-release consumer.
`--evidence=PATH` selects the retained evidence directory. The CI job limits
registry concurrency to two connections and runs scans sequentially; each
command has a three-minute deadline and a 16 MiB output budget. No prior pass is
used in place of a live advisory or signature check.
Over-budget or interrupted command captures retain failure metadata rather than
an unsafe truncated prefix; that evidence is unavailable and cannot pass.
Before publishing, npm lookup failures and malformed metadata stop the job.
An existing version must match the qualified tarball's SHA-512 and intended
`latest`/`next` dist-tag before it can be reused. Publication never overwrites
+85
View File
@@ -0,0 +1,85 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { checkDependencySecurity } from "../lib/dependency-security.mjs";
import { requireRegistryLock } from "../lib/dependency-security-facts.mjs";
import { lockedPackages } from "../lib/npm-locked-packages.mjs";
import { installCanaryPackage } from "../lib/release-package-install.mjs";
const { values } = parseArgs({
options: {
mode: { type: "string", default: "source" },
evidence: { type: "string", default: ".cache/dependency-security" },
},
});
const referenceLockfile = resolve("package-lock.json");
// This pre-install check uses only Node built-ins and executes no dependency code.
requireRegistryLock(
lockedPackages(JSON.parse(await readFile(referenceLockfile, "utf8"))),
);
if (values.mode === "preflight") {
console.log(
"Source lock uses the supported npm registry and SHA-512 identities",
);
} else if (values.mode === "source") {
const report = await checkDependencySecurity({
directory: process.cwd(),
referenceLockfile,
evidenceDirectory: values.evidence,
sourceSha: process.env.GITHUB_SHA ?? null,
});
console.log(JSON.stringify(report));
if (!report.checks_passed) process.exitCode = 1;
} else if (values.mode === "published") {
const response = await fetch("https://registry.npmjs.org/rea-agents/latest", {
signal: AbortSignal.timeout(30_000),
headers: { accept: "application/json" },
});
if (!response.ok)
throw new Error(`Published REA metadata unavailable: ${response.status}`);
const product = await response.json();
if (
product.name !== "rea-agents" ||
typeof product.version !== "string" ||
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(
product.version,
)
)
throw new Error("Invalid published REA identity");
const workspace = await mkdtemp(join(tmpdir(), "rea-dependency-consumer-"));
let report;
try {
const userconfig = join(workspace, "user.npmrc"),
globalconfig = join(workspace, "global.npmrc");
await writeFile(userconfig, "", { mode: 0o600 });
await writeFile(globalconfig, "", { mode: 0o600 });
const environment = {
...process.env,
npm_config_registry: "https://registry.npmjs.org",
npm_config_userconfig: userconfig,
npm_config_globalconfig: globalconfig,
npm_config_maxsockets: "2",
npm_config_prefer_online: "true",
};
const installation = await installCanaryPackage(
`rea-agents@${product.version}`,
workspace,
environment,
);
report = await checkDependencySecurity({
directory: installation.directory,
referenceLockfile,
evidenceDirectory: values.evidence,
mode: "consumer",
expectedVersion: product.version,
sourceSha: process.env.GITHUB_SHA ?? null,
environment,
});
} finally {
// A cleanup failure propagates; never report a successful completed run first.
await rm(workspace, { recursive: true, force: true });
}
console.log(JSON.stringify(report));
if (!report.checks_passed) process.exitCode = 1;
} else throw new Error("Expected --mode=preflight, source, or published");
+5
View File
@@ -204,6 +204,10 @@ if (tier === "reused") {
.split("\0")
.filter(Boolean);
for (const file of files) {
selected.dependency_security ||= matchesAny(
file,
SCOPES.dependency_security,
);
if (matchesAny(file, SCOPES.reverse_tools)) selected.reverse_tools = true;
if (
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test(
@@ -245,6 +249,7 @@ if (tier === "reused") {
selected.code = true;
selected.runtime = true;
const owners = Object.entries(SCOPES)
.filter(([owner]) => owner !== "dependency_security")
.filter(([, patterns]) =>
patterns.some((pattern) => matchesGlob(file, pattern)),
)
+4 -1
View File
@@ -28,9 +28,12 @@ if (
);
if (outputs.tier === "full" && selectedScopes.length !== CI_FLAGS.length)
throw new Error("Full qualification requires every canonical CI scope");
const required = reused ? ["changes"] : ["changes", "static", "test"];
const required = reused
? ["changes", "dependency-security"]
: ["changes", "static", "test"];
if (outputs.tier === "full") required.push("static-deep");
const lanes = {
dependency_security: ["dependency-security"],
code: [
"build",
"test-shard",
+21
View File
@@ -2,6 +2,25 @@
// Shared build inputs are classified once in plan.mjs rather than repeated in
// every workflow.
export const SCOPES = {
// Cross-cutting live registry evidence; does not replace provider ownership.
dependency_security: [
"package.json",
"package-lock.json",
".nvmrc",
".npmrc",
".github/workflows/ci.yml",
".github/workflows/release.yml",
".github/workflows/release-recovery.yml",
"scripts/ci/dependency-security.mjs",
"scripts/lib/dependency-security*.mjs",
"scripts/lib/npm-locked-packages.mjs",
"scripts/lib/npm-command.mjs",
"scripts/lib/transport-authentication.mjs",
"scripts/lib/release-package-install.mjs",
"scripts/verify-release-artifact.mjs",
"scripts/verify-published-package.mjs",
"tests/boundary/filesystem/dependencySecurity*.test.ts",
],
reverse_tools: [
".github/workflows/mutation.yml",
"src/application/reverse/**",
@@ -115,6 +134,8 @@ export const SCOPES = {
"scripts/ci/npm-release-state.*",
"scripts/verify-release-artifact.mjs",
"scripts/lib/release-package-canary.mjs",
"scripts/lib/release-package-install.mjs",
"scripts/lib/npm-command.mjs",
"scripts/verify-published-package.mjs",
"tests/boundary/filesystem/releaseRecovery.test.ts",
"tests/boundary/filesystem/mcpRegistryPublication.test.ts",
+6 -19
View File
@@ -3,6 +3,8 @@ import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { lockedPackages } from "./lib/npm-locked-packages.mjs";
import { redactTransportAuthentication } from "./lib/transport-authentication.mjs";
import { ensureGeneratedFile } from "./lib/generated-file.mjs";
const arguments_ = new Set(process.argv.slice(2));
@@ -13,25 +15,10 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const lock = JSON.parse(
await readFile(join(root, "package-lock.json"), "utf8"),
);
const entries = Object.entries(lock.packages)
.filter(([path]) => path !== "")
.map(([path, record]) => ({
path,
name: record.name ?? path.slice(path.lastIndexOf("node_modules/") + 13),
version: record.version,
license: record.license ?? "UNDECLARED",
resolved: record.resolved,
integrity: record.integrity,
dev: record.dev === true,
optional: record.optional === true,
}))
.sort((left, right) =>
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
);
for (const entry of entries)
for (const field of ["name", "version", "resolved", "integrity"])
if (typeof entry[field] !== "string" || entry[field].length === 0)
throw new Error(`Missing locked ${field}: ${entry.path}`);
const entries = lockedPackages(lock).map((entry) => ({
...entry,
resolved: redactTransportAuthentication(entry.resolved),
}));
const runtime = entries.filter(({ dev }) => !dev);
const runtimeFingerprint = createHash("sha256")
.update(JSON.stringify(runtime))
@@ -0,0 +1,56 @@
export interface InstalledPackage {
id: string;
name: string;
version: string;
integrity: string | null;
source_url: string | null;
}
export interface LockedPackage {
path: string;
name: string;
version: string;
resolved: string;
integrity: string;
dev: boolean;
optional: boolean;
license: unknown;
}
export function registryIdentity(
resolved: unknown,
integrity: unknown,
): boolean;
export function requireRegistryLock(packages: LockedPackage[]): void;
export function advisoryFacts(
value: unknown,
exitCode: number | null,
): {
status: "unavailable" | "failed" | "passed";
affected_packages: number | null;
};
export function signatureFacts(
value: unknown,
exitCode: number | null,
): {
status: "unavailable" | "failed" | "passed";
invalid: number | null;
missing: number | null;
verified: null;
};
export function dependencyGraph(
value: unknown,
directory: string,
installedPackages: LockedPackage[],
): {
nodes: InstalledPackage[];
edges: { from: string; name: string; to: string }[];
omissions: { from: string; name: string; reason: string }[];
unknowns: { reason: string }[];
fingerprint: string | null;
};
export function sourceIdentityDifferences(
nodes: InstalledPackage[],
reference: LockedPackage[],
runtimeOnly: boolean,
): (InstalledPackage & {
reason: "integrity_differs" | "integrity_unknown" | "version_not_locked";
})[];
+246
View File
@@ -0,0 +1,246 @@
import { createHash } from "node:crypto";
import { isAbsolute, relative, sep } from "node:path";
const record = (value) =>
typeof value === "object" && value !== null && !Array.isArray(value);
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const count = (value) => Number.isSafeInteger(value) && value >= 0;
export function registryIdentity(resolved, integrity) {
try {
const url = new URL(resolved);
return (
url.protocol === "https:" &&
url.hostname === "registry.npmjs.org" &&
url.port === "" &&
url.username === "" &&
url.password === "" &&
/^sha512-[A-Za-z0-9+/]{86}==$/u.test(integrity)
);
} catch {
return false;
}
}
export function requireRegistryLock(packages) {
for (const entry of packages)
if (!registryIdentity(entry.resolved, entry.integrity))
throw new Error(`Unsupported registry or integrity for ${entry.path}`);
}
export function advisoryFacts(value, exitCode) {
const counts = value?.metadata?.vulnerabilities;
const keys = ["info", "low", "moderate", "high", "critical", "total"];
if (
!record(counts) ||
!keys.every((key) => count(counts[key])) ||
keys.slice(0, -1).reduce((sum, key) => sum + counts[key], 0) !==
counts.total ||
value.error !== undefined
)
return {
status: "unavailable",
reason: "invalid_advisory_report",
affected_packages: null,
};
const status =
counts.total > 0
? "failed"
: exitCode === 0 && value.error === undefined
? "passed"
: "unavailable";
return {
status,
reason: status === "unavailable" ? "advisory_command_failed" : null,
affected_packages: counts.total,
severity: Object.fromEntries(keys.map((key) => [key, counts[key]])),
};
}
export function signatureFacts(value, exitCode) {
if (
!record(value) ||
!Array.isArray(value.invalid) ||
!Array.isArray(value.missing)
)
return {
status: "unavailable",
reason: "invalid_signature_report",
invalid: null,
missing: null,
verified: null,
};
const status =
value.invalid.length || value.missing.length
? "failed"
: exitCode === 0 && value.error === undefined
? "passed"
: "unavailable";
return {
status,
reason: status === "unavailable" ? "signature_command_failed" : null,
invalid: value.invalid.length,
missing: value.missing.length,
verified: null,
};
}
/** Use npm's resolved relationships, never implement a second dependency resolver. */
export function dependencyGraph(value, directory, installedPackages) {
if (!record(value) || value.path !== directory)
throw new Error("Installed tree root does not match the selected project");
const nodes = new Map(),
edges = new Map(),
omissions = new Map(),
unknowns = [];
const locked = new Map(installedPackages.map((entry) => [entry.path, entry]));
const pending = [{ value, from: null, dependency: null }];
while (pending.length) {
const item = pending.pop(),
node = item.value;
if (
!record(node) ||
typeof node.path !== "string" ||
!isAbsolute(node.path)
) {
unknowns.push({
from: item.from,
dependency: item.dependency,
reason: "unresolved_identity",
});
continue;
}
const path = relative(directory, node.path);
if (
path === ".." ||
path.startsWith(".." + sep) ||
isAbsolute(path) ||
typeof node.name !== "string" ||
node.name.length === 0 ||
typeof node.version !== "string" ||
node.version.length === 0
) {
unknowns.push({
from: item.from,
dependency: item.dependency,
reason: "invalid_package_identity",
});
continue;
}
const id = path.split(sep).join("/") || ".";
const selected = locked.get(id);
// npm ls may read package.json without integrity when its hidden cache is
// stale. Join npm's actual identities/edges to the selected project's lock,
// which owns declared integrity, using the exact installation path.
const matched =
selected?.name === node.name &&
selected.version === node.version &&
(node.resolved === undefined || selected.resolved === node.resolved);
if (
item.from !== null &&
(!matched ||
(node.integrity !== undefined && node.integrity !== selected.integrity))
)
unknowns.push({ id, reason: "installed_identity_not_bound_to_lock" });
if (
node.error !== undefined ||
(Array.isArray(node.problems) && node.problems.length)
)
unknowns.push({ id, reason: "npm_reported_problem" });
const fact = {
id,
name: node.name,
version: node.version,
integrity: matched ? selected.integrity : null,
source_url: matched
? selected.resolved
: typeof node.resolved === "string"
? node.resolved
: null,
};
if (nodes.has(id) && JSON.stringify(nodes.get(id)) !== JSON.stringify(fact))
unknowns.push({ id, reason: "conflicting_package_identity" });
else nodes.set(id, fact);
if (item.from !== null) {
const edge = { from: item.from, name: item.dependency, to: id };
edges.set(JSON.stringify(edge), edge);
}
if (node.dependencies === undefined) continue;
if (!record(node.dependencies)) {
unknowns.push({ id, reason: "invalid_dependency_relationships" });
continue;
}
for (const [name, child] of Object.entries(node.dependencies)) {
if (
record(child) &&
Object.keys(child).length === 0 &&
(Object.hasOwn(node.optionalDependencies ?? {}, name) ||
node.peerDependenciesMeta?.[name]?.optional === true)
) {
const omission = { from: id, name, reason: "optional_not_reported" };
omissions.set(JSON.stringify(omission), omission);
} else pending.push({ value: child, from: id, dependency: name });
}
}
const facts = {
nodes: [...nodes.values()].sort((a, b) => compare(a.id, b.id)),
edges: [...edges.values()].sort((a, b) =>
compare(JSON.stringify(a), JSON.stringify(b)),
),
omissions: [...omissions.values()].sort((a, b) =>
compare(JSON.stringify(a), JSON.stringify(b)),
),
};
return {
...facts,
unknowns,
fingerprint: unknowns.length
? null
: createHash("sha256")
.update(
JSON.stringify({
...facts,
// Preserve URLs as evidence, but exclude temporary archive locations
// from the content identity of otherwise identical installed trees.
nodes: facts.nodes.map(
({ source_url: _source, ...identity }) => identity,
),
}),
)
.digest("hex"),
};
}
/** Version drift is allowed for consumers; changed bytes of a locked identity are not. */
export function sourceIdentityDifferences(nodes, reference, runtimeOnly) {
const packages = new Map();
for (const entry of reference.filter((entry) => !runtimeOnly || !entry.dev)) {
if (!packages.has(entry.name)) packages.set(entry.name, []);
packages
.get(entry.name)
.push({ version: entry.version, integrity: entry.integrity });
}
return nodes
.filter(
(node) =>
node.id !== "." &&
(!runtimeOnly || node.id !== "node_modules/rea-agents"),
)
.flatMap((node) => {
const expected = packages.get(node.name) ?? [];
const sameVersion = expected.filter(
(entry) => entry.version === node.version,
);
if (sameVersion.some((entry) => entry.integrity === node.integrity))
return [];
return [
{
...node,
source_identities: expected,
reason: sameVersion.length
? node.integrity === null
? "integrity_unknown"
: "integrity_differs"
: "version_not_locked",
},
];
});
}
+36
View File
@@ -0,0 +1,36 @@
export const SECURITY_OUTPUT_BYTES: number;
export interface SecurityReport {
checks_passed: boolean;
evidence_directory: string;
precondition?: { status: string; reason: string };
advisory: { status: string; affected_packages?: number | null };
signatures: { status: string; verified: null };
tree: {
status: string;
content_graph_sha256?: string | null;
versions_outside_source_lock?: number;
};
}
export interface NpmObservation {
stdout: string;
stderr: string;
exitCode: number | null;
elapsedMs: number;
failure: string | null;
}
export function checkDependencySecurity(
options: {
directory: string;
evidenceDirectory: string;
referenceLockfile: string;
mode?: "source" | "consumer";
sourceSha?: string | null;
expectedVersion?: string | null;
qualifiedTarball?: string | null;
environment?: NodeJS.ProcessEnv;
},
run?: (
args: string[],
options: { cwd: string; env: NodeJS.ProcessEnv },
) => Promise<NpmObservation>,
): Promise<SecurityReport>;
+352
View File
@@ -0,0 +1,352 @@
import { redactTransportAuthentication } from "./transport-authentication.mjs";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, writeFile, stat } from "node:fs/promises";
import { join, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { verifyPublishedPackageIntegrity } from "../ci/qualified-package-envelope.mjs";
import { lockedPackages } from "./npm-locked-packages.mjs";
import {
advisoryFacts,
signatureFacts,
dependencyGraph,
sourceIdentityDifferences,
registryIdentity,
requireRegistryLock,
} from "./dependency-security-facts.mjs";
import { execNpm } from "./npm-command.mjs";
// CI evidence budget, not a package-format limit. Overflow is unavailable evidence.
export const SECURITY_OUTPUT_BYTES = 16 * 1024 * 1024;
const digest = (text) => createHash("sha256").update(text).digest("hex");
const unavailable = (reason) => ({ status: "unavailable", reason });
async function executeNpm(args, options) {
const started = performance.now();
try {
const result = await execNpm(args, {
...options,
encoding: "utf8",
maxBuffer: SECURITY_OUTPUT_BYTES,
timeout: 180_000,
});
return {
...result,
exitCode: 0,
elapsedMs: Math.round(performance.now() - started),
failure: null,
};
} catch (error) {
return {
stdout: error.stdout ?? "",
stderr: error.stderr ?? error.message,
exitCode: typeof error.code === "number" ? error.code : null,
elapsedMs: Math.round(performance.now() - started),
failure: String(error.code ?? error.signal ?? "command_failed"),
};
}
}
/** One scan implementation for CI source locks and the actual installed release consumer. */
export async function checkDependencySecurity(
{
directory,
evidenceDirectory,
referenceLockfile,
mode = "source",
sourceSha = null,
expectedVersion = null,
qualifiedTarball = null,
environment = process.env,
},
run = executeNpm,
) {
directory = resolve(directory);
await mkdir(evidenceDirectory, { recursive: true, mode: 0o700 });
const evidence = await mkdtemp(join(resolve(evidenceDirectory), "run-"));
// Keep parsed facts intact. Every retained representation crosses this one
// authentication-redaction boundary, including partial/failed graph facts.
const retain = async (name, source) => {
const rendered = redactTransportAuthentication(source);
await writeFile(join(evidence, name), rendered);
return { sha256: digest(rendered), text: rendered };
};
const report = {
schema_version: 1,
checks_passed: false,
mode,
started_at: new Date().toISOString(),
source_sha: sourceSha,
node_version: process.version,
npm_version: null,
platform: process.platform,
architecture: process.arch,
evidence_directory: evidence,
reference_lock_sha256: null,
advisory: unavailable("not_observed"),
signatures: { ...unavailable("not_observed"), verified: null },
tree: unavailable("not_observed"),
coverage: {
advisory: "selected project lock",
signatures:
"npm-supported installed registry dependencies on this platform; verified count not exposed",
graph:
"npm-reported installed identities/relationships bound to selected-lock declared origins/integrities; optional omissions retained; installed files not rehashed",
consumer_policy:
mode === "consumer"
? "semver resolution; source lock does not pin consumers"
: null,
},
commands: [],
};
const finish = async () => {
report.completed_at = new Date().toISOString();
const retained = await retain(
"report.json",
JSON.stringify(report, null, 2) + "\n",
);
// CLI/programmatic summaries expose the same redacted view as the artifact.
return JSON.parse(retained.text);
};
let reference;
let selectedLockSha;
let installedPackages;
try {
const lockStat = await stat(referenceLockfile);
if (!lockStat.isFile() || lockStat.size > SECURITY_OUTPUT_BYTES)
throw new Error(
"Reference lock exceeds CI evidence budget or is not a file",
);
const bytes = await readFile(referenceLockfile);
report.reference_lock_sha256 = digest(bytes);
reference = lockedPackages(JSON.parse(bytes));
requireRegistryLock(reference);
if (!["source", "consumer"].includes(mode))
throw new Error("Unknown dependency security mode");
const localLock = join(directory, "package-lock.json");
const localStat = await stat(localLock);
if (!localStat.isFile() || localStat.size > SECURITY_OUTPUT_BYTES)
throw new Error("Selected project has no consumer lock");
const selectedBytes = await readFile(localLock);
selectedLockSha = digest(selectedBytes);
installedPackages = lockedPackages(JSON.parse(selectedBytes));
report.selected_lock_sha256 = selectedLockSha;
report.retained_lock_sha256 = (
await retain("selected-package-lock.json", selectedBytes.toString("utf8"))
).sha256;
const product = JSON.parse(
await readFile(
join(
directory,
...(mode === "consumer" ? ["node_modules", "rea-agents"] : []),
"package.json",
),
"utf8",
),
);
if (
product.name !== "rea-agents" ||
typeof product.version !== "string" ||
(expectedVersion !== null && product.version !== expectedVersion)
)
throw new Error(
"Installed REA identity differs from the selected release",
);
report.product = { name: product.name, version: product.version };
} catch (error) {
report.precondition = unavailable(error.message);
return finish();
}
const userconfig = join(evidence, ".empty-user.npmrc"),
globalconfig = join(evidence, ".empty-global.npmrc");
await writeFile(userconfig, "", { mode: 0o600 });
await writeFile(globalconfig, "", { mode: 0o600 });
const common = [
"--ignore-scripts",
"--registry=https://registry.npmjs.org",
"--maxsockets=2",
"--prefer-online",
`--userconfig=${userconfig}`,
`--globalconfig=${globalconfig}`,
];
const collect = async (name, args, json = true) => {
const result = await run([...args, ...common], {
cwd: directory,
env: environment,
});
const oversized =
Buffer.byteLength(result.stdout) > SECURITY_OUTPUT_BYTES ||
Buffer.byteLength(result.stderr) > SECURITY_OUTPUT_BYTES;
const incomplete =
oversized ||
result.exitCode === null ||
result.failure === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
if (oversized || result.failure === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
result.exitCode = null;
result.failure = "output_limit";
}
// execFile may already have cut a URL before its host/@ delimiter. An
// incomplete capture cannot be safely rendered as a retained text prefix.
const omitted =
"Output not retained: capture incomplete or exceeded the evidence budget.\n";
const stdout = incomplete ? omitted : result.stdout,
stderr = incomplete ? omitted : result.stderr;
const stdoutHash = await retain(`${name}.${json ? "json" : "txt"}`, stdout);
const stderrHash = await retain(`${name}.stderr.txt`, stderr);
report.commands.push({
name,
exit_code: result.exitCode,
elapsed_ms: result.elapsedMs,
failure: result.failure,
output_retained: !incomplete,
stdout_sha256: stdoutHash.sha256,
stderr_sha256: stderrHash.sha256,
});
let value = null;
try {
value = incomplete ? null : json ? JSON.parse(stdout) : stdout.trim();
} catch {
/* Raw malformed evidence is retained; never interpret it as zero. */
}
return { value, exitCode: result.exitCode };
};
const version = await collect("npm-version", ["--version"], false);
if (
version.exitCode !== 0 ||
typeof version.value !== "string" ||
!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/u.test(version.value)
) {
report.precondition = unavailable("npm_toolchain_unavailable");
return finish();
}
report.npm_version = version.value;
const audit = await collect("audit", [
"audit",
"--package-lock-only",
"--include=dev",
"--include=optional",
"--json",
]);
report.advisory = advisoryFacts(audit.value, audit.exitCode);
const signatures = await collect("signatures", [
"audit",
"signatures",
"--include=dev",
"--include=optional",
"--json",
]);
report.signatures = signatureFacts(signatures.value, signatures.exitCode);
const tree = await collect("installed-tree", [
"ls",
"--all",
"--include=dev",
"--include=optional",
"--json",
"--long",
]);
try {
const graph = dependencyGraph(tree.value, directory, installedPackages);
if (
graph.nodes.length < 2 ||
(mode === "source" &&
!graph.nodes.some(
(node) =>
node.id === "." &&
node.name === report.product.name &&
node.version === report.product.version,
)) ||
(mode === "consumer" &&
!graph.nodes.some(
(node) =>
node.id === "node_modules/rea-agents" &&
node.name === "rea-agents" &&
node.version === report.product.version,
))
)
throw new Error("Installed REA dependency tree is incomplete");
if (tree.exitCode !== 0) graph.fingerprint = null;
for (const node of graph.nodes.filter((node) => node.id !== ".")) {
if (
mode === "consumer" &&
node.id === "node_modules/rea-agents" &&
qualifiedTarball !== null
) {
if (
node.name !== "rea-agents" ||
node.version !== report.product.version ||
typeof node.source_url !== "string" ||
fileURLToPath(
new URL(node.source_url, pathToFileURL(directory + sep)),
) !== resolve(qualifiedTarball)
)
throw new Error(
"Consumer archive identity differs from the selected qualified tarball",
);
await verifyPublishedPackageIntegrity(qualifiedTarball, node.integrity);
report.qualified_archive = {
path: resolve(qualifiedTarball),
integrity: node.integrity,
};
} else if (!registryIdentity(node.source_url, node.integrity)) {
graph.unknowns.push({
id: node.id,
reason: "unsupported_registry_or_integrity",
});
}
}
if (graph.unknowns.length) graph.fingerprint = null;
const differences = sourceIdentityDifferences(
graph.nodes,
reference,
mode === "consumer",
);
await retain("tree-facts.json", JSON.stringify(graph, null, 2) + "\n");
await retain(
"source-identity-differences.json",
JSON.stringify(differences, null, 2) + "\n",
);
const byteDifferences = differences.filter(
(entry) => entry.reason === "integrity_differs",
);
const unknownIntegrities = differences.filter(
(entry) => entry.reason === "integrity_unknown",
);
const status =
graph.unknowns.length || unknownIntegrities.length || tree.exitCode !== 0
? "unavailable"
: byteDifferences.length || (mode === "source" && differences.length)
? "failed"
: "passed";
report.tree = {
status,
nodes: graph.nodes.length,
edges: graph.edges.length,
optional_not_reported: graph.omissions.length,
unknowns: graph.unknowns.length,
content_graph_sha256: graph.fingerprint,
versions_outside_source_lock: differences.filter(
(entry) => entry.reason === "version_not_locked",
).length,
changed_locked_integrities: byteDifferences.length,
unknown_locked_integrities: unknownIntegrities.length,
};
} catch (error) {
report.tree = unavailable(error.message);
}
report.checks_passed = [
report.advisory,
report.signatures,
report.tree,
].every((value) => value.status === "passed");
try {
const lock = join(directory, "package-lock.json");
if (
(await stat(lock)).size > SECURITY_OUTPUT_BYTES ||
digest(await readFile(lock)) !== selectedLockSha
)
throw new Error("selected_lock_changed_during_scan");
} catch (error) {
report.checks_passed = false;
report.precondition = unavailable(error.message);
}
return finish();
}
+24
View File
@@ -0,0 +1,24 @@
import { execFile } from "node:child_process";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
const exec = promisify(execFile);
/** Run npm through its JavaScript entry point so Windows command scripts are not required. */
export const execNpm = (arguments_, options = {}) => {
const environment = options.env ?? process.env;
const selectedEntry = environment.npm_execpath;
if (
selectedEntry !== undefined &&
(typeof selectedEntry !== "string" || selectedEntry.length === 0)
)
throw new Error("npm_execpath must name the selected npm CLI entry point");
const entry =
selectedEntry ??
(process.platform === "win32"
? join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js")
: undefined);
return entry === undefined
? exec("npm", arguments_, options)
: exec(process.execPath, [entry, ...arguments_], options);
};
+2
View File
@@ -0,0 +1,2 @@
import type { LockedPackage } from "./dependency-security-facts.mjs";
export function lockedPackages(value: unknown): LockedPackage[];
+41
View File
@@ -0,0 +1,41 @@
/** Parse the root npm lock inventory once for notices and security evidence. */
export function lockedPackages(lock) {
if (
!lock ||
typeof lock !== "object" ||
![2, 3].includes(lock.lockfileVersion) ||
!lock.packages ||
typeof lock.packages !== "object" ||
Array.isArray(lock.packages)
)
throw new Error("Expected an npm v2/v3 package lock with package records");
return Object.entries(lock.packages)
.filter(([path]) => path !== "")
.map(([path, record]) => {
if (
!path.startsWith("node_modules/") ||
path
.split("/")
.some((part) => part === ".." || part === "." || part === "") ||
path.includes("\\")
)
throw new Error(`Unsupported locked package path: ${path}`);
if (!record || typeof record !== "object" || Array.isArray(record))
throw new Error(`Invalid locked package: ${path}`);
const entry = {
path,
name: record.name ?? path.slice(path.lastIndexOf("node_modules/") + 13),
version: record.version,
license: record.license ?? "UNDECLARED",
resolved: record.resolved,
integrity: record.integrity,
dev: record.dev === true,
optional: record.optional === true,
};
for (const field of ["name", "version", "resolved", "integrity"])
if (typeof entry[field] !== "string" || entry[field].length === 0)
throw new Error(`Missing locked ${field}: ${path}`);
return entry;
})
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
}
-21
View File
@@ -14,27 +14,6 @@ import {
const execute = promisify(execFile);
/** Install the selected archive or registry specifier before any protocol deadline. */
export async function installCanaryPackage(specifier, workspace, environment) {
const prefix = join(workspace, "installed");
await execute(
"npm",
[
"install",
"--ignore-scripts",
"--no-save",
"--no-package-lock",
"--no-audit",
"--no-fund",
"--prefix",
prefix,
specifier,
],
{ cwd: workspace, env: environment, maxBuffer: 16 * 1024 * 1024 },
);
return join(prefix, "node_modules", "rea-agents");
}
/** Exercise the same installed CLI/MCP contract before and after publication. */
export async function verifyInstalledCanaryPackage(
packageRoot,
+26
View File
@@ -0,0 +1,26 @@
export interface ConsumerSecurityContext {
evidenceDirectory: string;
referenceLockfile: string;
sourceSha: string | null;
}
export const consumerSecurityOptions: Record<
"dependency-security" | "dependency-source" | "dependency-source-sha",
{ type: "string" }
>;
export function consumerSecurityContext(
values: Record<string, string | undefined>,
directory?: string,
checkoutSha?: string,
): ConsumerSecurityContext | null;
export function installCanaryPackage(
specifier: string,
workspace: string,
environment: NodeJS.ProcessEnv,
): Promise<{ directory: string; packageRoot: string }>;
export function verifyCanaryDependencies(
installation: { directory: string },
context: ConsumerSecurityContext | null,
version: string,
environment: NodeJS.ProcessEnv,
qualifiedTarball?: string | null,
): Promise<void>;
+95
View File
@@ -0,0 +1,95 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { execNpm } from "./npm-command.mjs";
/** Install the selected archive or registry specifier before any protocol deadline. */
export async function installCanaryPackage(specifier, workspace, environment) {
const prefix = join(workspace, "installed");
await mkdir(prefix, { recursive: true });
await writeFile(
join(prefix, "package.json"),
JSON.stringify({
name: "rea-release-consumer-check",
version: "0.0.0",
private: true,
}) + "\n",
{ flag: "wx", mode: 0o600 },
);
await execNpm(
[
"install",
"--ignore-scripts",
"--save-exact",
"--package-lock=true",
"--no-audit",
"--no-fund",
"--prefix",
prefix,
specifier,
],
{ cwd: workspace, env: environment, maxBuffer: 16 * 1024 * 1024 },
);
return {
directory: prefix,
packageRoot: join(prefix, "node_modules", "rea-agents"),
};
}
export const consumerSecurityOptions = {
"dependency-security": { type: "string" },
"dependency-source": { type: "string" },
"dependency-source-sha": { type: "string" },
};
/** The source that owns a release may differ from the controller's checkout. */
export function consumerSecurityContext(
values,
directory = process.cwd(),
checkoutSha = process.env.GITHUB_SHA,
) {
const evidenceDirectory = values["dependency-security"];
const selectedSource = values["dependency-source"],
selectedSha = values["dependency-source-sha"];
if (
(selectedSource === undefined) !== (selectedSha === undefined) ||
(selectedSource !== undefined && evidenceDirectory === undefined)
)
throw new Error(
"Dependency source overrides require both source directory and SHA with security evidence enabled",
);
if (evidenceDirectory === undefined) return null;
const sourceSha = selectedSha ?? checkoutSha ?? null;
if (sourceSha !== null && !/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("Dependency source SHA must be a full commit identity");
return {
evidenceDirectory,
referenceLockfile: join(
resolve(directory, selectedSource ?? "."),
"package-lock.json",
),
sourceSha,
};
}
export async function verifyCanaryDependencies(
installation,
context,
version,
environment,
qualifiedTarball = null,
) {
if (context === null) return;
const { checkDependencySecurity } = await import("./dependency-security.mjs");
const report = await checkDependencySecurity({
...context,
directory: installation.directory,
mode: "consumer",
expectedVersion: version,
environment,
qualifiedTarball,
});
if (!report.checks_passed)
throw new Error(
`Consumer dependency checks failed: ${report.evidence_directory}`,
);
}
@@ -0,0 +1 @@
export function redactTransportAuthentication(text: string): string;
+20
View File
@@ -0,0 +1,20 @@
/** Redact only HTTP URL authentication; keep paths, names, queries and other text. */
export function redactTransportAuthentication(text) {
return text.replace(/https?:\/\/[^\s"<>\\]+/giu, (value) => {
try {
const parsed = new URL(value);
if (!parsed.username && !parsed.password) return value;
const start = value.indexOf("://") + 3;
const tail = value.slice(start),
end = tail.search(/[/?#]/u);
const authority = end < 0 ? tail : tail.slice(0, end);
return (
value.slice(0, start) +
authority.slice(authority.lastIndexOf("@") + 1) +
(end < 0 ? "" : tail.slice(end))
);
} catch {
return value;
}
});
}
+1 -19
View File
@@ -1,6 +1,5 @@
import { execFile } from "node:child_process";
import { lstat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { Ajv2020 } from "ajv/dist/2020.js";
@@ -18,24 +17,7 @@ const loadMcpVerifierResults = () => import("./mcp-verifier-results.mjs");
/** Promisified child_process execution helper. */
export const exec = promisify(execFile);
/** Run npm through its JavaScript entry point so Windows command scripts are not required. */
export const execNpm = (arguments_, options = {}) => {
const environment = options.env ?? process.env;
const selectedEntry = environment.npm_execpath;
if (
selectedEntry !== undefined &&
(typeof selectedEntry !== "string" || selectedEntry.length === 0)
)
throw new Error("npm_execpath must name the selected npm CLI entry point");
const entry =
selectedEntry ??
(process.platform === "win32"
? join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js")
: undefined);
return entry === undefined
? exec("npm", arguments_, options)
: exec(process.execPath, [entry, ...arguments_], options);
};
export { execNpm } from "./npm-command.mjs";
/** Run a command and return its stdout. */
export const run = async (command, args, env) =>
+26 -9
View File
@@ -1,28 +1,38 @@
import {
installCanaryPackage,
consumerSecurityOptions,
consumerSecurityContext,
verifyCanaryDependencies,
} from "./lib/release-package-install.mjs";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { promisify } from "node:util";
import { parseArgs, promisify } from "node:util";
import { pathToFileURL } from "node:url";
import { lt, prerelease, rcompare, valid } from "semver";
import { readNpmRelease, stableUpdateVersion } from "./ci/npm-release.mjs";
import {
installCanaryPackage,
verifyInstalledCanaryPackage,
} from "./lib/release-package-canary.mjs";
import { verifyInstalledCanaryPackage } from "./lib/release-package-canary.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
const verifierRun = createVerifierRun();
const execFileAsync = promisify(execFile);
const version = process.argv[2];
const { values, positionals } = parseArgs({
allowPositionals: true,
options: consumerSecurityOptions,
});
const [version] = positionals;
const security = consumerSecurityContext(values);
if (
typeof version !== "string" ||
valid(version) !== version ||
process.argv.length !== 3
positionals.length !== 1
)
throw new Error("Usage: node scripts/verify-published-package.mjs <version>");
throw new Error(
"Usage: node scripts/verify-published-package.mjs <version> [--dependency-security=directory]",
);
const canaryRoot = await mkdtemp(join(tmpdir(), "rea-published-canary-"));
const serverEnvironment = {
@@ -33,11 +43,18 @@ delete serverEnvironment.HOPPER_TARGET_PATH;
let surface;
let update;
try {
const publishedRoot = await installCanaryPackage(
const installation = await installCanaryPackage(
`rea-agents@${version}`,
canaryRoot,
serverEnvironment,
);
const { packageRoot: publishedRoot } = installation;
await verifyCanaryDependencies(
installation,
security,
version,
serverEnvironment,
);
surface = await verifyInstalledCanaryPackage(
publishedRoot,
version,
+24 -7
View File
@@ -1,14 +1,23 @@
import {
installCanaryPackage,
consumerSecurityOptions,
consumerSecurityContext,
verifyCanaryDependencies,
} from "./lib/release-package-install.mjs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { valid } from "semver";
import {
installCanaryPackage,
verifyInstalledCanaryPackage,
} from "./lib/release-package-canary.mjs";
import { verifyInstalledCanaryPackage } from "./lib/release-package-canary.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
const [tarball, version, ...extra] = process.argv.slice(2);
const { values, positionals } = parseArgs({
allowPositionals: true,
options: consumerSecurityOptions,
});
const [tarball, version, ...extra] = positionals;
const security = consumerSecurityContext(values);
if (
tarball === undefined ||
typeof version !== "string" ||
@@ -16,7 +25,7 @@ if (
extra.length
)
throw new Error(
"Usage: node scripts/verify-release-artifact.mjs <qualified-tarball> <version>",
"Usage: node scripts/verify-release-artifact.mjs <qualified-tarball> <version> [--dependency-security=directory]",
);
const source = resolve(tarball);
const verifierRun = createVerifierRun();
@@ -28,11 +37,19 @@ const environment = {
delete environment.HOPPER_TARGET_PATH;
let result;
try {
const packageRoot = await installCanaryPackage(
const installation = await installCanaryPackage(
source,
workspace,
environment,
);
const { packageRoot: packageRoot } = installation;
await verifyCanaryDependencies(
installation,
security,
version,
environment,
source,
);
result = await verifyInstalledCanaryPackage(
packageRoot,
version,
@@ -1607,3 +1607,37 @@ it.each([
});
},
);
it.each([
"package-lock.json",
".nvmrc",
"scripts/lib/dependency-security.mjs",
"scripts/lib/npm-command.mjs",
"scripts/ci/dependency-security.mjs",
"scripts/verify-release-artifact.mjs",
"tests/boundary/filesystem/dependencySecurity.test.ts",
])(
"selects live dependency security without losing existing owners for %s",
async (path) => {
const { directory, base } = await fixture();
const head = await change(directory, [path]);
const plan = await classify(directory, base, head);
expect(plan.dependency_security).toBe(true);
if (
[
"package-lock.json",
".nvmrc",
"scripts/lib/npm-command.mjs",
"scripts/verify-release-artifact.mjs",
].includes(path)
)
expect(plan.package).toBe(true);
},
);
it("keeps unrelated provider and documentation changes outside the selective security lane", async () => {
const { directory, base } = await fixture();
const head = await change(directory, ["src/provider.ts", "README.md"]);
expect((await classify(directory, base, head)).dependency_security).toBe(
false,
);
});
@@ -254,7 +254,7 @@ async function requiredRun(
result:
name === overrides.failed
? "failure"
: changes.tier === "reused"
: changes.tier === "reused" && name !== "dependency-security"
? "skipped"
: "success",
};
@@ -373,3 +373,19 @@ it.each([
expect(await readFile(outcome.output, "utf8")).not.toContain("receipt_");
},
);
it("requires fresh security evidence even when exact-source provider qualification is reused", async () => {
const { CI_FLAGS } = await import(
new URL("../../../scripts/ci/scopes.mjs", import.meta.url).href
);
const changes = {
...Object.fromEntries(CI_FLAGS.map((flag: string) => [flag, "false"])),
tier: "reused",
qualification_run_id: "7",
};
const outcome = await requiredRun(changes, { failed: "dependency-security" });
await expect(outcome.result).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("dependency-security"),
});
});
@@ -155,6 +155,7 @@ it("holds native and provider fan-out behind static validation while portable fe
const { jobs } = await workflow();
const earlyJobs = new Set([
"changes",
"dependency-security",
"static",
"static-deep",
"build",
@@ -514,6 +515,7 @@ it.each([
async (selected, result, passes) => {
const workflow = await requiredNeeds();
for (const [scope, lane] of [
["dependency_security", "dependency-security"],
["sqlite", "sqlite-runtime"],
["workflows", "static"],
["ghidra_bridge", "ghidra_bridge"],
@@ -0,0 +1,593 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import {
advisoryFacts,
dependencyGraph as resolvedGraph,
registryIdentity,
signatureFacts,
} from "../../../scripts/lib/dependency-security-facts.mjs";
import {
checkDependencySecurity,
SECURITY_OUTPUT_BYTES,
} from "../../../scripts/lib/dependency-security.mjs";
import type { NpmObservation } from "../../../scripts/lib/dependency-security.mjs";
import { lockedPackages } from "../../../scripts/lib/npm-locked-packages.mjs";
import { redactTransportAuthentication } from "../../../scripts/lib/transport-authentication.mjs";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const sri = `sha512-${Buffer.alloc(64, 1).toString("base64")}`;
const registry = "https://registry.npmjs.org";
const clean = {
metadata: {
vulnerabilities: {
info: 0,
low: 0,
moderate: 0,
high: 0,
critical: 0,
total: 0,
},
},
};
const observation = (value: unknown, exitCode = 0): NpmObservation => ({
stdout: JSON.stringify(value),
stderr: "",
exitCode,
elapsedMs: 1,
failure: null,
});
it.each([
null,
{},
{
metadata: {
vulnerabilities: { ...clean.metadata.vulnerabilities, total: 1 },
},
},
{
metadata: {
vulnerabilities: { ...clean.metadata.vulnerabilities, info: -1 },
},
},
{ ...clean, error: {} },
])(
"keeps missing or contradictory advisory evidence unavailable: %j",
(value) => {
expect(advisoryFacts(value, 0)).toMatchObject({
status: "unavailable",
affected_packages: null,
});
},
);
it("distinguishes known vulnerabilities from command failure and never invents signature counts", () => {
expect(advisoryFacts(clean, 0)).toMatchObject({
status: "passed",
affected_packages: 0,
});
expect(advisoryFacts(clean, 1)).toMatchObject({ status: "unavailable" });
expect(
advisoryFacts(
{
metadata: {
vulnerabilities: {
...clean.metadata.vulnerabilities,
high: 1,
total: 1,
},
},
},
1,
),
).toMatchObject({ status: "failed", affected_packages: 1 });
expect(signatureFacts({ invalid: [], missing: [] }, 0)).toEqual({
status: "passed",
reason: null,
invalid: 0,
missing: 0,
verified: null,
});
expect(signatureFacts({ invalid: [], missing: [] }, 1).status).toBe(
"unavailable",
);
expect(signatureFacts({}, 0).status).toBe("unavailable");
expect(signatureFacts({ invalid: [{}], missing: [] }, 1).status).toBe(
"failed",
);
expect(signatureFacts({ invalid: [], missing: [{}] }, 1).status).toBe(
"failed",
);
});
it("accepts only supported registry identities and redacts only transport authentication", () => {
expect(registryIdentity(`${registry}/a/-/a-1.0.0.tgz`, sri)).toBe(true);
for (const url of [
"http://registry.npmjs.org/a",
"https://other.example/a",
"https://user:pass@registry.npmjs.org/a",
"file:///a",
])
expect(registryIdentity(url, sri)).toBe(false);
expect(registryIdentity(`${registry}/a`, "sha1-legacy")).toBe(false);
const text =
"name=secret token=caller-value /tmp/secret https://user:p%40ss@EXAMPLE.com/a?q=token#keep";
expect(redactTransportAuthentication(text)).toBe(
"name=secret token=caller-value /tmp/secret https://EXAMPLE.com/a?q=token#keep",
);
for (const scheme of ["HTTPS", "hTtPs", "HTTP"]) {
expect(
redactTransportAuthentication(
`${scheme}://user:fixture-secret@EXAMPLE.com/a?q=token#keep`,
),
).toBe(`${scheme}://EXAMPLE.com/a?q=token#keep`);
}
expect(() =>
lockedPackages({
lockfileVersion: 3,
packages: { "node_modules/../a": {} },
}),
).toThrow("path");
});
const packageNode = (directory: string, name: string, version = "1.0.0") => ({
name,
version,
path: join(directory, "node_modules", name),
integrity: sri,
resolved: `${registry}/${name}/-/${name}-${version}.tgz`,
});
const dependencyGraph = (value: unknown, directory: string) =>
resolvedGraph(
value,
directory,
lockedPackages({
lockfileVersion: 3,
packages: Object.fromEntries(
["a", "b"].map((name) => [
`node_modules/${name}`,
packageNode(directory, name),
]),
),
}),
);
it("keeps npm deduplication edges, optional omissions and stable content identities across workspaces", () => {
const make = (directory: string) => {
const a = packageNode(directory, "a");
return dependencyGraph(
{
name: "rea-agents",
version: "6.7.0",
path: directory,
dependencies: {
a,
b: {
...packageNode(directory, "b"),
peerDependenciesMeta: { optional: { optional: true } },
dependencies: { a, optional: {} },
},
},
},
directory,
);
};
const first = make("/tmp/first"),
second = make("/tmp/second");
expect(first.nodes).toHaveLength(3);
expect(first.edges).toContainEqual({
from: "node_modules/b",
name: "a",
to: "node_modules/a",
});
expect(first.omissions).toEqual([
{
from: "node_modules/b",
name: "optional",
reason: "optional_not_reported",
},
]);
expect(first.fingerprint).toBe(second.fingerprint);
expect(first.fingerprint).toMatch(/^[a-f0-9]{64}$/u);
});
it.each([
{},
{ name: "a", version: "1.0.0", path: "/outside", integrity: sri },
])("preserves unresolved installed identities as unknown: %j", (child) => {
const graph = dependencyGraph(
{
name: "rea-agents",
version: "6.7.0",
path: "/tmp/project",
dependencies: { a: child },
},
"/tmp/project",
);
expect(graph.unknowns).toHaveLength(1);
expect(graph.fingerprint).toBeNull();
});
it("rejects conflicting identities rather than choosing the last deduplicated record", () => {
const directory = "/tmp/project",
a = packageNode(directory, "a");
const graph = dependencyGraph(
{
name: "rea-agents",
version: "6.7.0",
path: directory,
dependencies: {
a,
b: {
...packageNode(directory, "b"),
dependencies: { a: { ...a, version: "2.0.0" } },
},
},
},
directory,
);
expect(graph.unknowns).toContainEqual({
id: "node_modules/a",
reason: "conflicting_package_identity",
});
expect(graph.fingerprint).toBeNull();
});
async function scanFixture(
options: {
consumer?: boolean;
changedVersion?: boolean;
changedIntegrity?: boolean;
mirror?: boolean;
authenticated?: boolean;
tarball?: boolean;
} = {},
) {
const root = await createTestTempDirectory("rea-security-evidence-");
const directory = join(root, "project");
await mkdir(directory);
const reference = {
lockfileVersion: 3,
packages: {
"": {},
"node_modules/a": {
name: "a",
version: "1.0.0",
resolved: `${registry}/a/-/a-1.0.0.tgz`,
integrity: sri,
},
},
};
const referenceLockfile = join(root, "reference.json");
await writeFile(referenceLockfile, JSON.stringify(reference));
await writeFile(
join(directory, "package-lock.json"),
JSON.stringify(reference),
);
await writeFile(
join(directory, "package.json"),
JSON.stringify({ name: "rea-agents", version: "6.7.0" }),
);
const a = packageNode(
directory,
"a",
options.changedVersion ? "2.0.0" : "1.0.0",
);
if (options.changedIntegrity)
a.integrity = `sha512-${Buffer.alloc(64, 2).toString("base64")}`;
if (options.mirror) a.resolved = "https://mirror.example/a.tgz";
if (options.authenticated)
a.resolved = `HTTPS://user:fixture-secret@registry.npmjs.org/a/-/a-${a.version}.tgz`;
const dependencies: Record<string, unknown> = { a };
let qualifiedTarball: string | undefined;
if (options.consumer) {
const product = packageNode(directory, "rea-agents", "6.7.0");
await mkdir(product.path, { recursive: true });
await writeFile(
join(product.path, "package.json"),
JSON.stringify({ name: "rea-agents", version: "6.7.0" }),
);
if (options.tarball) {
qualifiedTarball = join(root, "package.tgz");
const bytes = Buffer.from("qualified archive bytes");
await writeFile(qualifiedTarball, bytes);
product.integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
// npm retains a file specifier relative to the consumer lock directory.
product.resolved = "file:../package.tgz";
}
dependencies["rea-agents"] = product;
}
await writeFile(
join(directory, "package-lock.json"),
JSON.stringify({
lockfileVersion: 3,
packages: Object.fromEntries(
Object.entries(dependencies).map(([name, record]) => [
`node_modules/${name}`,
record,
]),
),
}),
);
const reported = structuredClone(dependencies);
if (options.authenticated) {
const selected = reported.a as { resolved?: string; integrity?: string };
delete selected.resolved;
delete selected.integrity;
}
const results = new Map([
[
"npm-version",
{
stdout: "11.16.0",
stderr: "",
exitCode: 0,
elapsedMs: 1,
failure: null,
} satisfies NpmObservation,
],
["audit", observation(clean)],
["signatures", observation({ invalid: [], missing: [] })],
[
"tree",
observation({
name: "rea-agents",
version: "6.7.0",
path: directory,
dependencies: reported,
}),
],
]);
const calls: string[][] = [];
const run = async (args: string[]) => {
calls.push(args);
const name =
args[0] === "--version"
? "npm-version"
: args[0] === "ls"
? "tree"
: args[1] === "signatures"
? "signatures"
: "audit";
const value = results.get(name);
if (!value) throw new Error(`Unexpected command ${name}`);
return { ...value };
};
const scan = () =>
checkDependencySecurity(
{
directory,
referenceLockfile,
evidenceDirectory: join(root, "evidence"),
mode: options.consumer ? "consumer" : "source",
expectedVersion: "6.7.0",
...(qualifiedTarball === undefined ? {} : { qualifiedTarball }),
environment: { REA_TEST_AMBIENT_MARKER: "do-not-persist" },
},
run,
);
return { scan, results, calls, qualifiedTarball };
}
it("retains full evidence with compact facts, fresh unique runs, hashes and no ambient environment", async () => {
const fixture = await scanFixture();
const first = await fixture.scan(),
second = await fixture.scan();
expect(first.checks_passed).toBe(true);
expect(first.signatures.verified).toBeNull();
expect(first.evidence_directory).not.toBe(second.evidence_directory);
expect(fixture.calls).toHaveLength(8);
for (const args of fixture.calls) {
expect(args).toContain("--ignore-scripts");
expect(args).toContain("--registry=https://registry.npmjs.org");
expect(args).toContain("--maxsockets=2");
}
const report = await readFile(
join(first.evidence_directory, "report.json"),
"utf8",
);
expect(report).not.toContain("do-not-persist");
expect(report).toContain("stdout_sha256");
expect(
await readFile(join(first.evidence_directory, "audit.json"), "utf8"),
).toBe(JSON.stringify(clean));
});
it.each(["audit", "signatures", "tree"])(
"fails closed for missing %s evidence without overwriting an earlier pass",
async (name) => {
const fixture = await scanFixture();
const prior = await fixture.scan();
fixture.results.set(name, {
stdout: "not JSON",
stderr: "network unavailable",
exitCode: 1,
elapsedMs: 1,
failure: "1",
});
const report = await fixture.scan();
expect(prior.checks_passed).toBe(true);
expect(report.checks_passed).toBe(false);
expect(report.evidence_directory).not.toBe(prior.evidence_directory);
},
);
it("does not expose a complete tree fingerprint after a failed npm tree command", async () => {
const fixture = await scanFixture(),
tree = fixture.results.get("tree");
if (!tree) throw new Error("Missing fixture tree");
tree.exitCode = 1;
const report = await fixture.scan();
expect(report.tree).toMatchObject({
status: "unavailable",
content_graph_sha256: null,
});
expect(report.checks_passed).toBe(false);
});
it("keeps unbound integrity unknown instead of claiming changed bytes or a new version", async () => {
const fixture = await scanFixture();
const original = fixture.results.get("tree");
if (!original) throw new Error("Missing fixture tree");
const tree = JSON.parse(original.stdout) as {
dependencies: { a: { resolved: string } };
};
tree.dependencies.a.resolved =
"https://registry.npmjs.org/a/-/another-source.tgz";
fixture.results.set("tree", observation(tree));
const report = await fixture.scan();
expect(report.checks_passed).toBe(false);
expect(report.tree).toMatchObject({
status: "unavailable",
changed_locked_integrities: 0,
unknown_locked_integrities: 1,
versions_outside_source_lock: 0,
content_graph_sha256: null,
});
expect(
await readFile(
join(report.evidence_directory, "source-identity-differences.json"),
"utf8",
),
).toContain('"reason": "integrity_unknown"');
});
it("bounds retained command output and marks overflow unavailable", async () => {
const fixture = await scanFixture();
fixture.results.set("audit", {
stdout: "x".repeat(SECURITY_OUTPUT_BYTES + 1),
stderr: "",
exitCode: 0,
elapsedMs: 1,
failure: null,
});
const report = await fixture.scan();
expect(report.checks_passed).toBe(false);
expect(report.advisory.status).toBe("unavailable");
expect(
await readFile(join(report.evidence_directory, "audit.json"), "utf8"),
).toContain("Output not retained");
});
it.each(["stdout", "stderr"] as const)(
"does not retain credentials crossing the %s capture budget",
async (stream) => {
const fixture = await scanFixture();
const output = observation(clean);
const partialUrl = "HTTPS://user:fixture-secret";
output[stream] =
"x".repeat(SECURITY_OUTPUT_BYTES - partialUrl.length) +
partialUrl +
"@registry.npmjs.org/a";
fixture.results.set("audit", output);
const report = await fixture.scan();
expect(report.checks_passed).toBe(false);
for (const name of ["audit.json", "audit.stderr.txt"]) {
const retained = await readFile(
join(report.evidence_directory, name),
"utf8",
);
expect(retained).not.toContain("fixture-secret");
expect(retained).toContain("Output not retained");
expect(Buffer.byteLength(retained)).toBeLessThan(128);
}
},
);
it.each(["ERR_CHILD_PROCESS_STDIO_MAXBUFFER", "SIGTERM"])(
"does not retain a prefix already cut by %s",
async (failure) => {
const fixture = await scanFixture();
fixture.results.set("audit", {
stdout: "HTTPS://user:fixture-secret",
stderr: "HTTPS://user:fixture-secret",
exitCode: null,
elapsedMs: 1,
failure,
});
const report = await fixture.scan();
expect(report.advisory.status).toBe("unavailable");
expect(report.checks_passed).toBe(false);
const evidence = await readFile(
join(report.evidence_directory, "audit.json"),
"utf8",
);
expect(evidence).toContain("Output not retained");
expect(evidence).not.toContain("fixture-secret");
expect(
await readFile(join(report.evidence_directory, "report.json"), "utf8"),
).toContain('"output_retained": false');
},
);
it("records floating consumer versions while rejecting changed bytes and unsupported registries", async () => {
const floating = await (
await scanFixture({ consumer: true, changedVersion: true })
).scan();
expect(floating.checks_passed).toBe(true);
expect(floating.tree.versions_outside_source_lock).toBe(1);
expect(
(await (await scanFixture({ changedVersion: true })).scan()).checks_passed,
).toBe(false);
expect(
(
await (
await scanFixture({ consumer: true, changedIntegrity: true })
).scan()
).checks_passed,
).toBe(false);
expect((await (await scanFixture({ mirror: true })).scan()).tree.status).toBe(
"unavailable",
);
});
it("binds the local consumer exception to the selected archive's SHA-512 bytes", async () => {
const fixture = await scanFixture({ consumer: true, tarball: true });
expect((await fixture.scan()).checks_passed).toBe(true);
if (!fixture.qualifiedTarball) throw new Error("Missing fixture tarball");
await writeFile(fixture.qualifiedTarball, "changed bytes");
expect((await fixture.scan()).checks_passed).toBe(false);
});
it("redacts raw lock credentials from every retained partial consumer representation", async () => {
const fixture = await scanFixture({
consumer: true,
authenticated: true,
changedVersion: true,
});
const report = await fixture.scan();
expect(report.checks_passed).toBe(false);
expect(report.tree.status).toBe("unavailable");
for (const name of [
"selected-package-lock.json",
"tree-facts.json",
"source-identity-differences.json",
"report.json",
"installed-tree.json",
]) {
const retained = await readFile(
join(report.evidence_directory, name),
"utf8",
);
expect(retained).not.toContain("fixture-secret");
if (
[
"selected-package-lock.json",
"tree-facts.json",
"source-identity-differences.json",
].includes(name)
)
expect(retained).toContain("HTTPS://registry.npmjs.org/a/-/a-2.0.0.tgz");
}
});
it("uses the selected lock for declared integrity when npm reads uncached package metadata", () => {
const directory = "/tmp/project";
const complete = packageNode(directory, "a");
const { integrity: _reported, resolved: _origin, ...uncached } = complete;
const root = (child: unknown) => ({
name: "rea-agents",
version: "6.7.0",
path: directory,
dependencies: { a: child },
});
const fromCache = dependencyGraph(root(complete), directory);
const fromPackages = dependencyGraph(root(uncached), directory);
expect(fromPackages.unknowns).toEqual([]);
expect(fromPackages.fingerprint).toBe(fromCache.fingerprint);
expect(fromPackages.nodes.find((node) => node.name === "a")?.integrity).toBe(
sri,
);
expect(
dependencyGraph(root({ ...uncached, version: "9.0.0" }), directory)
.fingerprint,
).toBeNull();
});
@@ -0,0 +1,216 @@
import { execFile } from "node:child_process";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { parse } from "yaml";
import { z } from "zod";
import { consumerSecurityContext } from "../../../scripts/lib/release-package-install.mjs";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const step = z
.object({
run: z.string().optional(),
if: z.string().optional(),
uses: z.string().optional(),
with: z.record(z.string(), z.unknown()).optional(),
})
.passthrough();
const readWorkflow = async (name: string) =>
z
.object({
jobs: z.record(
z.string(),
z
.object({
needs: z.union([z.string(), z.array(z.string())]).optional(),
if: z.string().optional(),
permissions: z.record(z.string(), z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
steps: z.array(step).optional(),
})
.passthrough(),
),
})
.parse(parse(await readFile(`.github/workflows/${name}.yml`, "utf8")));
it("requires fresh read-only registry checks with bounded installation and retained evidence", async () => {
const { jobs } = await readWorkflow("ci");
const job = jobs["dependency-security"];
expect(job?.needs).toBe("changes");
expect(job?.if).toBe(
"needs.changes.outputs.dependency_security == 'true' || needs.changes.outputs.tier == 'reused'",
);
expect(job?.permissions).toEqual({ contents: "read" });
expect(job?.env).toMatchObject({
TURBO_TOKEN: "",
TURBO_CACHE: "local:rw",
NODE_OPTIONS: "--max-old-space-size=1024",
UV_THREADPOOL_SIZE: "1",
});
expect(job?.["timeout-minutes"]).toBe(15);
const steps = job?.steps ?? [];
const preflight = steps.findIndex((entry) =>
entry.run?.includes("--mode=preflight"),
);
const install = steps.findIndex((entry) => entry.run?.startsWith("npm ci "));
const source = steps.findIndex((entry) =>
entry.run?.includes("--mode=source"),
);
expect(preflight).toBeGreaterThanOrEqual(0);
expect(install).toBeGreaterThan(preflight);
expect(steps[install]?.run).toContain("--ignore-scripts");
expect(steps[install]?.run).toContain("--maxsockets=2");
expect(source).toBeGreaterThan(install);
expect(
steps.find((entry) => entry.run?.includes("--mode=published"))?.if,
).toBe(
"github.event_name == 'schedule' && github.event.schedule == '23 3 * * *'",
);
const evidence = steps.find((entry) =>
entry.uses?.startsWith("actions/upload-artifact@"),
);
expect(evidence?.if).toBe("always()");
expect(evidence?.with?.path).toBe(".cache/dependency-security/");
expect(evidence?.with?.name).toBe(
"dependency-security-${{ github.run_attempt }}",
);
});
it.each([
["release", "rehearsal"],
["release", "verify-npm"],
["release-recovery", "verify"],
])(
"retains live consumer checks and failure evidence in %s/%s",
async (workflow, name) => {
const job = (await readWorkflow(workflow)).jobs[name];
const canaries =
job?.steps?.filter((entry) =>
/verify-(?:release-artifact|published-package)\.mjs/u.test(
entry.run ?? "",
),
) ?? [];
expect(canaries.length).toBeGreaterThan(0);
expect(
job?.steps?.find(
(entry) => entry.with?.path === ".cache/dependency-security/",
)?.with?.name,
).toEqual(expect.stringContaining("${{ github.run_attempt }}"));
for (const canary of canaries) {
expect(canary.run).toContain(
"--dependency-security=.cache/dependency-security",
);
expect(canary.if).toBeUndefined();
expect(canary["continue-on-error"]).toBeUndefined();
if (workflow === "release-recovery") {
expect(canary.run).toContain(
"--dependency-source=.cache/release-source",
);
expect(canary.run).toContain(
'--dependency-source-sha="$RECOVERY_SOURCE_SHA"',
);
expect(canary.env).toEqual({
RECOVERY_VERSION: "${{ steps.release.outputs.version }}",
RECOVERY_SOURCE_SHA: "${{ steps.release.outputs.source_sha }}",
});
}
}
expect(
job?.steps?.find(
(entry) => entry.with?.path === ".cache/dependency-security/",
)?.if,
).toBe("always()");
},
);
it("validates lock identities before npm installation without importing installed dependencies", async () => {
const directory = await createTestTempDirectory(
"rea-security-no-dependencies-",
);
for (const file of [
"scripts/ci/dependency-security.mjs",
"scripts/ci/qualified-package-envelope.mjs",
"scripts/lib/dependency-security.mjs",
"scripts/lib/dependency-security-facts.mjs",
"scripts/lib/npm-command.mjs",
"scripts/lib/npm-locked-packages.mjs",
"scripts/lib/transport-authentication.mjs",
"scripts/lib/release-package-install.mjs",
]) {
await mkdir(dirname(join(directory, file)), { recursive: true });
await copyFile(file, join(directory, file));
}
const lock = {
lockfileVersion: 3,
packages: {
"node_modules/a": {
version: "1.0.0",
resolved: "https://registry.npmjs.org/a/-/a-1.0.0.tgz",
integrity: `sha512-${Buffer.alloc(64).toString("base64")}`,
},
},
};
const lockfile = join(directory, "package-lock.json");
await writeFile(lockfile, JSON.stringify(lock));
const run = () =>
execute(
process.execPath,
["scripts/ci/dependency-security.mjs", "--mode=preflight"],
{ cwd: directory },
);
expect((await run()).stdout).toContain("supported npm registry");
lock.packages["node_modules/a"].resolved =
"https://user:fixture-secret@registry.npmjs.org/a.tgz";
await writeFile(lockfile, JSON.stringify(lock));
await expect(run()).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("Unsupported registry"),
});
lock.packages["node_modules/a"].resolved = "https://other.example/a.tgz";
await writeFile(lockfile, JSON.stringify(lock));
await expect(run()).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("Unsupported registry"),
});
});
it("binds recovery evidence to the original release source rather than the controller checkout", () => {
const controllerSha = "a".repeat(40),
releasedSha = "b".repeat(40);
const controller = resolve("controller-fixture"),
releasedSource = resolve("released-source-fixture");
const values = { "dependency-security": "evidence" };
expect(consumerSecurityContext(values, controller, controllerSha)).toEqual({
evidenceDirectory: "evidence",
referenceLockfile: join(controller, "package-lock.json"),
sourceSha: controllerSha,
});
expect(
consumerSecurityContext(
{
...values,
"dependency-source": releasedSource,
"dependency-source-sha": releasedSha,
},
controller,
controllerSha,
),
).toEqual({
evidenceDirectory: "evidence",
referenceLockfile: join(releasedSource, "package-lock.json"),
sourceSha: releasedSha,
});
for (const incomplete of [
{ ...values, "dependency-source": releasedSource },
{ ...values, "dependency-source-sha": releasedSha },
{
"dependency-source": releasedSource,
"dependency-source-sha": releasedSha,
},
])
expect(() => consumerSecurityContext(incomplete)).toThrow("require both");
expect(
consumerSecurityContext({}, controller, "legacy-unused-sha"),
).toBeNull();
});
@@ -238,6 +238,10 @@ it("limits recovery to verification and dependent MCP publication from the relea
expect(digest).toBeGreaterThanOrEqual(0);
expect(rehearsal).toBeGreaterThan(digest);
expect(canary).toBeGreaterThan(rehearsal);
for (const index of [rehearsal, canary])
expect(steps[index]?.run).toContain(
"--dependency-security=.cache/dependency-security",
);
const publisher = workflow.jobs["publish-mcp"];
expect(publisher.needs).toBe("verify");
expect(
@@ -696,6 +696,9 @@ it("rehearses the qualified archive before npm authority and retains registry ve
VERIFIED_PACKAGE_TARBALL: "${{ steps.package.outputs.tarball }}",
VERIFIED_PACKAGE_VERSION: "${{ steps.package.outputs.version }}",
});
expect(exercise?.run).toContain(
"--dependency-security=.cache/dependency-security",
);
expect(exercise?.if).toBeUndefined();
expect(exercise?.["continue-on-error"]).toBeUndefined();
const publish = workflow.jobs.publish;
@@ -785,6 +788,9 @@ it("rehearses the qualified archive before npm authority and retains registry ve
);
expect(bytes).toBeGreaterThanOrEqual(0);
expect(canary).toBeGreaterThan(bytes);
expect(commands[canary]).toContain(
"--dependency-security=.cache/dependency-security",
);
for (const index of [bytes, canary]) {
expect(verification.steps[index]?.if).toBeUndefined();
expect(verification.steps[index]?.["continue-on-error"]).toBeUndefined();
@@ -16,6 +16,8 @@ test("generates locked inventory and rejects stale notice metadata or preserved
for (const file of [
"scripts/generate-third-party-inventory.mjs",
"scripts/lib/generated-file.mjs",
"scripts/lib/npm-locked-packages.mjs",
"scripts/lib/transport-authentication.mjs",
])
await copyFile(resolve(file), resolve(root, file));
const script = resolve(root, "scripts/generate-third-party-inventory.mjs");
@@ -30,6 +32,7 @@ test("generates locked inventory and rejects stale notice metadata or preserved
await writeFile(
resolve(root, "package-lock.json"),
JSON.stringify({
lockfileVersion: 3,
packages: {
"": { name: "fixture", version: "1.0.0" },
"node_modules/fixture-runtime": runtime,
@@ -114,4 +117,21 @@ test("generates locked inventory and rejects stale notice metadata or preserved
expect(
await readFile(resolve(root, "licenses/npm-dependencies.txt"), "utf8"),
).toBe(inventory);
const locked = JSON.parse(
await readFile(resolve(root, "package-lock.json"), "utf8"),
) as {
packages: Record<string, { resolved?: string }>;
};
const selected = locked.packages["node_modules/fixture-runtime"];
if (!selected) throw new Error("Missing fixture package");
selected.resolved = runtime.resolved.replace(
"https://",
"https://user:fixture-secret@",
);
await writeFile(resolve(root, "package-lock.json"), JSON.stringify(locked));
const redacted = await processes.run(process.execPath, [script]);
expect(redacted.exitCode, redacted.stderr).toBe(0);
expect(
await readFile(resolve(root, "licenses/npm-dependencies.txt"), "utf8"),
).toBe(inventory);
});
@@ -40,6 +40,14 @@ it.skipIf(process.platform === "win32")(
} finally {
await harness.close();
}
// The replay calls only binary_session. Preserve its real result contract
// and every tool name/input contract, without compiling unused result
// schemas again in each replay client. Real-package canaries keep all schemas.
snapshot.tools.tools = snapshot.tools.tools.map((tool) => {
if (tool.name === "binary_session") return tool;
const { outputSchema: _unused, ...replayed } = tool;
return replayed;
});
await mkdir(join(candidate, "scripts"), { recursive: true });
await writeFile(
join(candidate, "package.json"),
@@ -84,6 +92,8 @@ if (process.argv[2] === "mcp") {
for (const file of [
"scripts/verify-release-artifact.mjs",
"scripts/lib/release-package-canary.mjs",
"scripts/lib/release-package-install.mjs",
"scripts/lib/npm-command.mjs",
"scripts/lib/mcp-verifier-results.mjs",
"scripts/lib/verifier-run.mjs",
]) {