Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions ci/reviewed-npm-audit.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schemaVersion": 3,
"schemaVersion": 2,
"nodeVersion": "22.23.1",
"registryOrigin": "https://registry.npmjs.org/",
"severityThreshold": "high",
Expand Down Expand Up @@ -70,10 +70,8 @@
"integrity": "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g==",
"tarballUrl": "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz",
"directory": "agents/openclaw/openclaw-runtime",
"reviewedLockSha256": [
"82489f62febb12da52833c0b1f7f6969f7e21a098c565ef1f91342b1e5e32d88",
"fdbee91a3f3a0222ec2e8e34864d09dfcff4711e86ac6bfa4d475af0593acd51"
]
"lockSha256": "82489f62febb12da52833c0b1f7f6969f7e21a098c565ef1f91342b1e5e32d88",
"replacementLockSha256": "759b31779f40867f35f15065b582eb1d3efb8fddb1fe43c207507c905fa2a421"
},
{
"id": "mcporter-runtime",
Expand All @@ -82,9 +80,8 @@
"integrity": "sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA==",
"tarballUrl": "https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz",
"directory": "agents/openclaw/mcporter-runtime",
"reviewedLockSha256": [
"c31959d7950903f7477ca2e143b3f1f4adfd10f1961fe97db40cd72f62b84830"
]
"lockSha256": "c31959d7950903f7477ca2e143b3f1f4adfd10f1961fe97db40cd72f62b84830",
"replacementLockSha256": "962dee34f6b0a493521d1619d1cf030e2630cbdfce8bf0598217202f57078793"
}
]
}
39 changes: 26 additions & 13 deletions scripts/audit-reviewed-npm-graph.mts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ type ReviewedPackage = Readonly<{
tarballUrl: string;
}>;
type LockedGraph = ReviewedPackage &
Readonly<{ directory: string; id: string; reviewedLockSha256: readonly string[] }>;
Readonly<{
directory: string;
id: string;
lockSha256: string;
replacementLockSha256?: string;
}>;
type AuditConfig = Readonly<{
archivePackages: readonly ReviewedPackage[];
archiveGraphId: string;
Expand All @@ -37,7 +42,7 @@ type AuditConfig = Readonly<{
lockedGraphs: readonly LockedGraph[];
nodeVersion: string;
registryOrigin: string;
schemaVersion: 3;
schemaVersion: 2;
severityThreshold: Severity;
}>;

Expand Down Expand Up @@ -116,10 +121,10 @@ function run(command: string, args: readonly string[], cwd: string) {
return result;
}

function readConfig(): AuditConfig {
const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as AuditConfig;
export function parseAuditConfig(contents: string): AuditConfig {
const parsed = JSON.parse(contents) as AuditConfig;
if (
parsed.schemaVersion !== 3 ||
parsed.schemaVersion !== 2 ||
!SEVERITIES.includes(parsed.severityThreshold) ||
typeof parsed.archiveGraphId !== "string" ||
!parsed.archiveGraphId ||
Expand All @@ -135,19 +140,23 @@ function readConfig(): AuditConfig {
!graph.id ||
typeof graph.directory !== "string" ||
!graph.directory ||
!Array.isArray(graph.reviewedLockSha256) ||
graph.reviewedLockSha256.length === 0 ||
graph.reviewedLockSha256.some(
(digest: unknown) => typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest),
) ||
new Set(graph.reviewedLockSha256).size !== graph.reviewedLockSha256.length,
typeof graph.lockSha256 !== "string" ||
!/^[0-9a-f]{64}$/.test(graph.lockSha256) ||
(graph.replacementLockSha256 !== undefined &&
(typeof graph.replacementLockSha256 !== "string" ||
!/^[0-9a-f]{64}$/.test(graph.replacementLockSha256) ||
graph.replacementLockSha256 === graph.lockSha256)),
)
) {
throw new Error("ci/reviewed-npm-audit.json is invalid");
}
return parsed;
}

function readConfig(): AuditConfig {
return parseAuditConfig(fs.readFileSync(CONFIG_PATH, "utf-8"));
}

function materializeArchiveGraph(packages: readonly ReviewedPackage[], tempRoot: string): string {
const graphDirectory = path.join(tempRoot, "reviewed-archive-graph");
fs.mkdirSync(graphDirectory);
Expand Down Expand Up @@ -199,7 +208,8 @@ function materializeLockedGraph(
);
const expectedLockSha256 = selectReviewedLockSha256(
sourceLock,
graph.reviewedLockSha256,
graph.lockSha256,
graph.replacementLockSha256,
graph.label,
);
verifyReviewedNpmLock({
Expand Down Expand Up @@ -227,10 +237,13 @@ function materializeLockedGraph(

export function selectReviewedLockSha256(
lockfilePath: string,
reviewedDigests: readonly string[],
lockSha256: string,
replacementLockSha256: string | undefined,
label: string,
): string {
const actual = createHash("sha256").update(fs.readFileSync(lockfilePath)).digest("hex");
const reviewedDigests =
replacementLockSha256 === undefined ? [lockSha256] : [lockSha256, replacementLockSha256];
if (!reviewedDigests.includes(actual)) {
throw new Error(
`${label} lock SHA-256 mismatch\nExpected one of: ${reviewedDigests.join(", ")}\nActual: ${actual}`,
Expand Down
25 changes: 24 additions & 1 deletion test/openclaw-locked-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ const INTEGRITY =
"sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g==";
const TARBALL = "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz";
const LOCK_SHA256 = "82489f62febb12da52833c0b1f7f6969f7e21a098c565ef1f91342b1e5e32d88";
const REPLACEMENT_LOCK_SHA256 = "759b31779f40867f35f15065b582eb1d3efb8fddb1fe43c207507c905fa2a421";
const MCPORTER_PACKAGE_SPEC = "mcporter@0.7.3";
const MCPORTER_LOCK_SHA256 = "c31959d7950903f7477ca2e143b3f1f4adfd10f1961fe97db40cd72f62b84830";
const MCPORTER_REPLACEMENT_LOCK_SHA256 =
"962dee34f6b0a493521d1619d1cf030e2630cbdfce8bf0598217202f57078793";
const roots: string[] = [];

function sha256(file: string): string {
Expand Down Expand Up @@ -362,18 +367,36 @@ describe("locked OpenClaw production installation (#5896)", () => {
const audit = JSON.parse(
fs.readFileSync(path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"), "utf-8"),
);
expect(audit.schemaVersion).toBe(2);
expect(audit.archivePackages).toEqual(
expect.arrayContaining([expect.objectContaining({ packageSpec: PACKAGE_SPEC })]),
);
expect(audit.lockedGraphs).toEqual(
expect.arrayContaining([
expect.objectContaining({
directory: "agents/openclaw/openclaw-runtime",
lockSha256: LOCK_SHA256,
packageSpec: PACKAGE_SPEC,
reviewedLockSha256: expect.arrayContaining([LOCK_SHA256]),
replacementLockSha256: REPLACEMENT_LOCK_SHA256,
}),
expect.objectContaining({
directory: "agents/openclaw/mcporter-runtime",
lockSha256: MCPORTER_LOCK_SHA256,
packageSpec: MCPORTER_PACKAGE_SPEC,
replacementLockSha256: MCPORTER_REPLACEMENT_LOCK_SHA256,
}),
]),
);
const openclawGraph = audit.lockedGraphs.find(
({ packageSpec }: { packageSpec?: string }) => packageSpec === PACKAGE_SPEC,
);
expect(openclawGraph).toBeDefined();
expect(openclawGraph).not.toHaveProperty("reviewedLockSha256");
const mcporterGraph = audit.lockedGraphs.find(
({ packageSpec }: { packageSpec?: string }) => packageSpec === MCPORTER_PACKAGE_SPEC,
);
expect(mcporterGraph).toBeDefined();
expect(mcporterGraph).not.toHaveProperty("reviewedLockSha256");

const baseWorkflow = fs.readFileSync(
path.join(REPO_ROOT, ".github", "workflows", "base-image.yaml"),
Expand Down
43 changes: 38 additions & 5 deletions test/reviewed-npm-audit-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
normalizeOpenClawSignatureAlias,
parseAuditConfig,
selectReviewedLockSha256,
} from "../scripts/audit-reviewed-npm-graph.mts";
import { readYaml } from "./helpers/e2e-workflow-contract";
Expand Down Expand Up @@ -53,18 +54,50 @@ describe("trusted reviewed npm audit workflow (#5896)", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-lock-transition-"));
const lockfile = path.join(root, "package-lock.json");
fs.writeFileSync(lockfile, "reviewed lock\n");
const reviewed = "534ade489fdb2d8ff619a8b110c28fedbd2066e16ebf434738f64a5a44ec9860";
const previous = "a".repeat(64);
const actualLock = "534ade489fdb2d8ff619a8b110c28fedbd2066e16ebf434738f64a5a44ec9860";
const previousLock = "a".repeat(64);
const unreviewedLock = "b".repeat(64);
try {
expect(selectReviewedLockSha256(lockfile, [previous, reviewed], "test graph")).toBe(reviewed);
expect(() => selectReviewedLockSha256(lockfile, [previous], "test graph")).toThrow(
"lock SHA-256 mismatch",
expect(selectReviewedLockSha256(lockfile, actualLock, undefined, "test graph")).toBe(
actualLock,
);
expect(selectReviewedLockSha256(lockfile, previousLock, actualLock, "test graph")).toBe(
actualLock,
);
expect(() =>
selectReviewedLockSha256(lockfile, previousLock, unreviewedLock, "test graph"),
).toThrow("lock SHA-256 mismatch");
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

it("rejects a replacement lock digest that duplicates the current digest", () => {
const digest = "a".repeat(64);
const config = {
archiveGraphId: "reviewed-archive-graph",
archivePackages: [],
artifactDirectory: "artifacts/reviewed-npm-audit",
exceptionFile: "ci/npm-audit-exceptions.json",
lockedGraphs: [
{
directory: "agents/openclaw/openclaw-runtime",
id: "openclaw-runtime",
lockSha256: digest,
replacementLockSha256: digest,
},
],
nodeVersion: "22.23.1",
registryOrigin: "https://registry.npmjs.org/",
schemaVersion: 2,
severityThreshold: "high",
};

expect(() => parseAuditConfig(JSON.stringify(config))).toThrow(
"ci/reviewed-npm-audit.json is invalid",
);
});

// source-shape-contract: security -- PR dependency audit code must come from the base SHA or the one-time signed bootstrap
it("runs PR audits from trusted code and keeps the main audit on the checked-in action", () => {
const pr = readYaml<Workflow>(".github/workflows/pr.yaml");
Expand Down
Loading