Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8465514782 | ||
|
|
08e8501d7b | ||
|
|
95eb175457 | ||
|
|
1cd471487b | ||
|
|
473bcdd204 | ||
|
|
48a1312237 |
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
})[];
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { LockedPackage } from "./dependency-security-facts.mjs";
|
||||
export function lockedPackages(value: unknown): LockedPackage[];
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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,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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]) {
|
||||
|
||||
Reference in new issue
Block a user