@rightkit/release 0.2.70 → 0.2.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build-release.mjs +19 -14
- package/cache-command.mjs +7 -4
- package/cli/right-release.mjs +0 -0
- package/direct-bootstrap.mjs +482 -0
- package/github-release.mjs +160 -2
- package/hardening-evidence.mjs +54 -0
- package/hardeningscan.mjs +78 -55
- package/native-cargo-layout.mjs +239 -0
- package/native-release-finalization.mjs +259 -0
- package/package.json +9 -11
- package/preflight.mjs +6 -3
- package/registry-parity.mjs +2 -2
- package/release-invocation.mjs +10 -2
- package/release-state.mjs +5 -2
- package/release.mjs +235 -16
- package/rightkit-versions.json +8 -3
- package/sign-release-manifest.mjs +223 -0
- package/supply-chain-evidence.mjs +175 -0
package/github-release.mjs
CHANGED
|
@@ -6,9 +6,12 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
7
|
import { verifySealedRelease } from "./release-state.mjs";
|
|
8
8
|
import { addonManifestSha256, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
|
|
9
|
+
import { RIGHTRELEASE_MANIFEST_SIGNER, verifyDetachedCmsSignature } from "./sign-release-manifest.mjs";
|
|
9
10
|
|
|
10
11
|
const REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
11
12
|
const RELEASE_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/;
|
|
13
|
+
const STABLE_SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
|
|
14
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
12
15
|
|
|
13
16
|
export function prepareGitHubRelease({ repoRoot, releaseId, platform, repo, stateRoot }) {
|
|
14
17
|
if (!RELEASE_RE.test(releaseId)) throw new Error(`invalid release id: ${releaseId}`);
|
|
@@ -43,6 +46,157 @@ export function prepareGitHubRelease({ repoRoot, releaseId, platform, repo, stat
|
|
|
43
46
|
};
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Build one shared GitHub payload for direct/no-installer releases. The signed
|
|
51
|
+
* release manifest is authoritative; checksums, provenance, SBOM, and archives
|
|
52
|
+
* are attached convenience/evidence payloads and are never uploaded by a
|
|
53
|
+
* product-local publisher.
|
|
54
|
+
*/
|
|
55
|
+
export function prepareGitHubDirectRelease({
|
|
56
|
+
repoRoot,
|
|
57
|
+
repo,
|
|
58
|
+
product,
|
|
59
|
+
version,
|
|
60
|
+
manifestPath,
|
|
61
|
+
signaturePath,
|
|
62
|
+
checksumsPath,
|
|
63
|
+
archivePaths = [],
|
|
64
|
+
provenancePaths = [],
|
|
65
|
+
sbomPaths = [],
|
|
66
|
+
assets = [],
|
|
67
|
+
stateRoot,
|
|
68
|
+
signing,
|
|
69
|
+
signingResult,
|
|
70
|
+
signer,
|
|
71
|
+
cmsCommandRunner = spawnSync,
|
|
72
|
+
cmsPlatform = process.platform,
|
|
73
|
+
}) {
|
|
74
|
+
if (!REPO_RE.test(repo)) throw new Error(`invalid GitHub repository: ${repo}`);
|
|
75
|
+
const root = path.resolve(repoRoot ?? process.cwd());
|
|
76
|
+
const manifestFile = path.resolve(root, manifestPath);
|
|
77
|
+
const signatureFile = path.resolve(root, signaturePath);
|
|
78
|
+
const checksumsFile = path.resolve(root, checksumsPath);
|
|
79
|
+
for (const file of [manifestFile, signatureFile, checksumsFile]) {
|
|
80
|
+
if (!existsSync(file) || !statSync(file).isFile()) throw new Error(`direct release payload missing: ${file}`);
|
|
81
|
+
}
|
|
82
|
+
const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
83
|
+
if (manifest.schemaVersion !== 1 || manifest.kind !== "rightkit-direct-release-manifest") throw new Error("invalid direct release manifest");
|
|
84
|
+
if (product && manifest.product !== product) throw new Error("direct release product mismatch");
|
|
85
|
+
if (version && manifest.version !== version) throw new Error("direct release version mismatch");
|
|
86
|
+
if (!STABLE_SEMVER_RE.test(manifest.version) || manifest.tag !== `v${manifest.version}`) throw new Error("direct release manifest tag is invalid stable SemVer");
|
|
87
|
+
if (manifest.signingKeyId !== RIGHTRELEASE_MANIFEST_SIGNER.id || manifest.signatureAlgorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm) throw new Error("direct release manifest signer contract is invalid");
|
|
88
|
+
const declared = new Set((manifest.assets ?? []).map((asset) => asset.name));
|
|
89
|
+
if (!declared.size) throw new Error("direct release manifest has no archive assets");
|
|
90
|
+
if ([...declared].some((name) => /(?:installer|setup)\.(?:exe|dmg|pkg|msi)$/i.test(name))) throw new Error("direct release must not contain installer assets");
|
|
91
|
+
const local = [
|
|
92
|
+
...archivePaths,
|
|
93
|
+
...provenancePaths,
|
|
94
|
+
...sbomPaths,
|
|
95
|
+
...assets,
|
|
96
|
+
manifestFile,
|
|
97
|
+
signatureFile,
|
|
98
|
+
checksumsFile,
|
|
99
|
+
].map((value) => typeof value === "string" ? value : value?.path ?? value?.file).filter(Boolean).map((file) => path.resolve(root, file));
|
|
100
|
+
const unique = new Map();
|
|
101
|
+
for (const file of local) {
|
|
102
|
+
if (!existsSync(file) || !statSync(file).isFile()) throw new Error(`direct release payload missing: ${file}`);
|
|
103
|
+
if (unique.has(path.basename(file)) && unique.get(path.basename(file)) !== file) throw new Error(`duplicate direct release payload name: ${path.basename(file)}`);
|
|
104
|
+
unique.set(path.basename(file), file);
|
|
105
|
+
}
|
|
106
|
+
const requiredEvidence = ["release-manifest.json", "release-manifest.sig", "checksums.json"];
|
|
107
|
+
for (const name of requiredEvidence) if (![...unique.keys()].some((candidate) => candidate === name || candidate.endsWith(`-${name}`))) throw new Error(`direct release payload missing ${name}`);
|
|
108
|
+
for (const name of declared) if (!unique.has(name)) throw new Error(`direct release archive is not attached: ${name}`);
|
|
109
|
+
const releaseState = stateRoot ?? path.join(root, ".right-release", "state", "direct", manifest.product, manifest.version, "github");
|
|
110
|
+
mkdirSync(releaseState, { recursive: true });
|
|
111
|
+
const notesFile = path.join(releaseState, "release-notes.md");
|
|
112
|
+
const payload = [...unique.values()];
|
|
113
|
+
const expectedSigner = signing?.signer ?? signingResult?.signer ?? signer;
|
|
114
|
+
const plan = {
|
|
115
|
+
kind: "direct-release",
|
|
116
|
+
manifest,
|
|
117
|
+
manifestPath: manifestFile,
|
|
118
|
+
signaturePath: signatureFile,
|
|
119
|
+
checksumsPath: checksumsFile,
|
|
120
|
+
assets: payload,
|
|
121
|
+
tag: manifest.tag,
|
|
122
|
+
title: `${manifest.product} ${manifest.version}`,
|
|
123
|
+
notes: [`Direct portable ${manifest.product} release.`, "", `Build commit: \`${manifest.sourceCommit}\``, "Manifest, detached signature, checksums, provenance, SBOM, and archives are attached."].join("\n"),
|
|
124
|
+
notesFile,
|
|
125
|
+
manifestUrl: `https://github.com/${repo}/releases/download/${manifest.tag}/${path.basename(manifestFile)}`,
|
|
126
|
+
signer: expectedSigner,
|
|
127
|
+
cmsPlatform,
|
|
128
|
+
};
|
|
129
|
+
Object.defineProperty(plan, "cmsCommandRunner", { value: cmsCommandRunner, enumerable: false });
|
|
130
|
+
validateGitHubDirectReleasePlan(plan, { commandRunner: cmsCommandRunner, platform: cmsPlatform });
|
|
131
|
+
return plan;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const prepareGitHubPortableRelease = prepareGitHubDirectRelease;
|
|
135
|
+
export const prepareGitHubManifestRelease = prepareGitHubDirectRelease;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Recheck every direct-release payload immediately before publication. The
|
|
139
|
+
* signed manifest remains authority; checksums.json is only a digest-bound
|
|
140
|
+
* convenience index for archives and attached provenance/SBOM evidence.
|
|
141
|
+
*/
|
|
142
|
+
export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.cmsCommandRunner ?? spawnSync, platform = plan?.cmsPlatform ?? process.platform } = {}) {
|
|
143
|
+
if (!plan || plan.kind !== "direct-release") throw new Error("invalid direct GitHub release plan");
|
|
144
|
+
if (!plan.manifestPath || !plan.signaturePath || !plan.checksumsPath || !Array.isArray(plan.assets)) throw new Error("direct GitHub release plan is incomplete");
|
|
145
|
+
const manifestFile = path.resolve(plan.manifestPath);
|
|
146
|
+
const signatureFile = path.resolve(plan.signaturePath);
|
|
147
|
+
const checksumsFile = path.resolve(plan.checksumsPath);
|
|
148
|
+
for (const file of [manifestFile, signatureFile, checksumsFile]) {
|
|
149
|
+
if (!existsSync(file) || !statSync(file).isFile()) throw new Error(`direct release payload missing: ${file}`);
|
|
150
|
+
}
|
|
151
|
+
if (statSync(signatureFile).size < 1) throw new Error("detached release manifest signature is missing");
|
|
152
|
+
const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
153
|
+
if (manifest.schemaVersion !== 1 || manifest.kind !== "rightkit-direct-release-manifest" || !STABLE_SEMVER_RE.test(manifest.version ?? "") || manifest.tag !== `v${manifest.version}`) {
|
|
154
|
+
throw new Error("invalid direct release manifest");
|
|
155
|
+
}
|
|
156
|
+
if (plan.tag !== manifest.tag) throw new Error("direct GitHub release tag does not match manifest");
|
|
157
|
+
if (manifest.signingKeyId !== RIGHTRELEASE_MANIFEST_SIGNER.id || manifest.signatureAlgorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm) throw new Error("direct release manifest signer contract is invalid");
|
|
158
|
+
verifyDetachedCmsSignature({ manifestPath: manifestFile, signaturePath: signatureFile, expectedSigner: plan.signer, commandRunner, platform });
|
|
159
|
+
if (!SHA256.test(manifest.checksumsSha256 ?? "")) throw new Error("direct release checksums digest is missing");
|
|
160
|
+
if (sha256(checksumsFile) !== manifest.checksumsSha256) throw new Error("checksums.json is not manifest-bound");
|
|
161
|
+
const checksums = JSON.parse(readFileSync(checksumsFile, "utf8"));
|
|
162
|
+
if (checksums.schemaVersion !== 1 || checksums.algorithm !== "sha256" || !checksums.assets || typeof checksums.assets !== "object" || Array.isArray(checksums.assets)) {
|
|
163
|
+
throw new Error("invalid direct release checksums");
|
|
164
|
+
}
|
|
165
|
+
for (const [name, digest] of Object.entries(checksums.assets)) if (!SHA256.test(digest)) throw new Error(`invalid checksum entry: ${name}`);
|
|
166
|
+
const payloadByName = new Map();
|
|
167
|
+
for (const value of plan.assets) {
|
|
168
|
+
const file = path.resolve(typeof value === "string" ? value : value?.path ?? value?.file ?? "");
|
|
169
|
+
if (!file || !existsSync(file) || !statSync(file).isFile()) throw new Error(`direct release payload missing: ${file}`);
|
|
170
|
+
const name = path.basename(file);
|
|
171
|
+
if (payloadByName.has(name) && payloadByName.get(name) !== file) throw new Error(`duplicate direct release payload name: ${name}`);
|
|
172
|
+
payloadByName.set(name, file);
|
|
173
|
+
}
|
|
174
|
+
const requiredEvidence = ["release-manifest.json", "release-manifest.sig", "checksums.json"];
|
|
175
|
+
for (const name of requiredEvidence) if (![...payloadByName.keys()].some((candidate) => candidate === name || candidate.endsWith(`-${name}`))) throw new Error(`direct release payload missing ${name}`);
|
|
176
|
+
for (const file of [manifestFile, signatureFile, checksumsFile]) if (![...payloadByName.values()].includes(file)) throw new Error(`direct release authority file is not attached: ${path.basename(file)}`);
|
|
177
|
+
const expected = new Map();
|
|
178
|
+
const addExpected = (entry, role) => {
|
|
179
|
+
if (!entry || typeof entry.name !== "string" || !SHA256.test(entry.sha256 ?? "")) throw new Error(`invalid direct release ${role} evidence`);
|
|
180
|
+
if (expected.has(entry.name)) throw new Error(`duplicate direct release evidence name: ${entry.name}`);
|
|
181
|
+
expected.set(entry.name, { digest: entry.sha256, size: role === "archive" ? entry.size : undefined });
|
|
182
|
+
};
|
|
183
|
+
if (!Array.isArray(manifest.assets) || !manifest.assets.length) throw new Error("direct release manifest has no archive assets");
|
|
184
|
+
for (const asset of manifest.assets) {
|
|
185
|
+
if (/(?:installer|setup)\.(?:exe|dmg|pkg|msi)$/i.test(asset.name ?? "")) throw new Error("direct release must not contain installer assets");
|
|
186
|
+
addExpected(asset, "archive");
|
|
187
|
+
addExpected({ name: asset.provenanceName, sha256: asset.provenanceSha256 }, "provenance");
|
|
188
|
+
addExpected({ name: asset.sbomName, sha256: asset.sbomSha256 }, "SBOM");
|
|
189
|
+
}
|
|
190
|
+
for (const [name, evidence] of expected) {
|
|
191
|
+
const file = payloadByName.get(name);
|
|
192
|
+
if (!file) throw new Error(`direct release evidence is not attached: ${name}`);
|
|
193
|
+
if (evidence.size !== undefined && (!Number.isSafeInteger(evidence.size) || evidence.size < 1 || statSync(file).size !== evidence.size)) throw new Error(`direct release archive size mismatch: ${name}`);
|
|
194
|
+
if (sha256(file) !== evidence.digest) throw new Error(`direct release payload hash mismatch: ${name}`);
|
|
195
|
+
if (checksums.assets[name] !== evidence.digest) throw new Error(`direct release checksum mismatch: ${name}`);
|
|
196
|
+
}
|
|
197
|
+
return { ...plan, manifest, checksums };
|
|
198
|
+
}
|
|
199
|
+
|
|
46
200
|
export function repositoryFromRemote(repoRoot) {
|
|
47
201
|
const remote = runCommand("git", ["remote", "get-url", "origin"], { cwd: repoRoot });
|
|
48
202
|
const match = remote.trim().match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/i);
|
|
@@ -78,12 +232,16 @@ export function prepareGitHubAddonRelease({ repoRoot, config, configRoot = repoR
|
|
|
78
232
|
};
|
|
79
233
|
}
|
|
80
234
|
|
|
81
|
-
export function publishGitHubRelease(plan, { repo, dryRun = false, run = runCommand } = {}) {
|
|
235
|
+
export function publishGitHubRelease(plan, { repo, dryRun = false, run = runCommand, cmsCommandRunner = plan?.cmsCommandRunner, cmsPlatform = plan?.cmsPlatform ?? process.platform } = {}) {
|
|
236
|
+
if (plan.kind === "direct-release") validateGitHubDirectReleasePlan(plan, { commandRunner: cmsCommandRunner ?? spawnSync, platform: cmsPlatform });
|
|
82
237
|
const visibility = JSON.parse(run("gh", ["repo", "view", repo, "--json", "visibility"]));
|
|
83
238
|
if (visibility.visibility !== "PUBLIC" && !(plan.kind !== "addon" && visibility.visibility === "PRIVATE")) {
|
|
84
239
|
throw new Error(`GitHub releases require a public or private repository: ${repo}`);
|
|
85
240
|
}
|
|
86
|
-
if (!dryRun) {
|
|
241
|
+
if (!dryRun && plan.kind === "direct-release") {
|
|
242
|
+
// Direct payloads are already sealed by the signed manifest contract. Do
|
|
243
|
+
// not route them through desktop-installer trust checks.
|
|
244
|
+
} else if (!dryRun) {
|
|
87
245
|
if (plan.kind === "addon") verifyAddonTrust(plan);
|
|
88
246
|
else verifyPlatformTrust(plan);
|
|
89
247
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const HARDENING_RULES = Object.freeze({
|
|
6
|
+
"system-prompt-marker": Object.freeze({ exact: "system_prompt" }),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
function hashFile(path) {
|
|
10
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function containedPath(root, candidate, label) {
|
|
14
|
+
const canonicalRoot = realpathSync(root);
|
|
15
|
+
const canonical = realpathSync(candidate);
|
|
16
|
+
const rel = relative(canonicalRoot, canonical);
|
|
17
|
+
if (rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || isAbsolute(rel)) throw new Error(label + " escapes release root");
|
|
18
|
+
return canonical;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function materializeHardeningEvidence({ root, allowances, outputPath }) {
|
|
22
|
+
const normalized = (allowances ?? []).map((allowance) => {
|
|
23
|
+
const rule = HARDENING_RULES[allowance.rule];
|
|
24
|
+
if (!rule) throw new Error("unknown hardening allowance rule: " + allowance.rule);
|
|
25
|
+
if (allowance.exact !== rule.exact) throw new Error(allowance.rule + " allowance must use exact token " + rule.exact);
|
|
26
|
+
if (typeof allowance.file !== "string" || !allowance.file) throw new Error("hardening allowance file is required");
|
|
27
|
+
const sourceMatch = typeof allowance.sourceEvidence === "string" ? /^(.*):([1-9]\d*)$/.exec(allowance.sourceEvidence) : null;
|
|
28
|
+
if (!sourceMatch || isAbsolute(sourceMatch[1])) {
|
|
29
|
+
throw new Error("hardening allowance requires sourceEvidence path:line");
|
|
30
|
+
}
|
|
31
|
+
if (typeof allowance.rationale !== "string" || allowance.rationale.trim().length < 12) {
|
|
32
|
+
throw new Error("hardening allowance requires concrete rationale");
|
|
33
|
+
}
|
|
34
|
+
const file = containedPath(root, isAbsolute(allowance.file) ? allowance.file : resolve(root, allowance.file), "hardening allowance artifact");
|
|
35
|
+
const source = containedPath(root, resolve(root, sourceMatch[1]), "hardening allowance source");
|
|
36
|
+
const sourceLine = Number(sourceMatch[2]);
|
|
37
|
+
const line = readFileSync(source, "utf8").split(/\r?\n/)[sourceLine - 1];
|
|
38
|
+
if (typeof line !== "string" || !line.includes(allowance.exact)) {
|
|
39
|
+
throw new Error(`hardening allowance source line does not contain exact token ${allowance.exact}`);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
rule: allowance.rule,
|
|
43
|
+
exact: allowance.exact,
|
|
44
|
+
artifactSha256: hashFile(file),
|
|
45
|
+
sourceEvidence: allowance.sourceEvidence,
|
|
46
|
+
sourceSha256: hashFile(source),
|
|
47
|
+
sourceLine,
|
|
48
|
+
rationale: allowance.rationale.trim(),
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
const evidence = { schemaVersion: 1, kind: "rightkit-hardening-evidence", allowances: normalized };
|
|
52
|
+
writeFileSync(outputPath, JSON.stringify(evidence, null, 2) + "\n");
|
|
53
|
+
return evidence;
|
|
54
|
+
}
|
package/hardeningscan.mjs
CHANGED
|
@@ -1,17 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream, readFileSync, realpathSync } from "node:fs";
|
|
3
4
|
import { lstat, readdir } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
5
|
-
import {
|
|
6
|
+
import { HARDENING_RULES } from "./hardening-evidence.mjs";
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
-
if (
|
|
9
|
-
console.error("usage: node hardeningscan.mjs <artifact-path>...");
|
|
10
|
-
process.exit(
|
|
8
|
+
const rawArgs = process.argv.slice(2);
|
|
9
|
+
if (rawArgs.length === 0 || rawArgs.includes("-h") || rawArgs.includes("--help")) {
|
|
10
|
+
console.error("usage: node hardeningscan.mjs [--allow-evidence <json>] <artifact-path>...");
|
|
11
|
+
process.exit(rawArgs.length === 0 ? 2 : 0);
|
|
11
12
|
}
|
|
13
|
+
const allowIndex = rawArgs.indexOf("--allow-evidence");
|
|
14
|
+
const allowEvidencePath = allowIndex >= 0 ? rawArgs[allowIndex + 1] : null;
|
|
15
|
+
if (allowIndex >= 0 && !allowEvidencePath) throw new Error("--allow-evidence requires a JSON path");
|
|
16
|
+
const args = rawArgs.filter((_, index) => index !== allowIndex && index !== allowIndex + 1);
|
|
17
|
+
if (!args.length) throw new Error("at least one artifact path is required");
|
|
12
18
|
|
|
13
|
-
const root = process.cwd();
|
|
19
|
+
const root = realpathSync(process.cwd());
|
|
14
20
|
const findings = [];
|
|
21
|
+
const evidence = allowEvidencePath ? JSON.parse(readFileSync(allowEvidencePath, "utf8")) : { allowances: [] };
|
|
22
|
+
if (allowEvidencePath && (evidence.schemaVersion !== 1 || evidence.kind !== "rightkit-hardening-evidence")) {
|
|
23
|
+
throw new Error("invalid hardening evidence");
|
|
24
|
+
}
|
|
25
|
+
const allowances = evidence.allowances ?? [];
|
|
26
|
+
for (const allowance of allowances) {
|
|
27
|
+
const sourceMatch = typeof allowance.sourceEvidence === "string" ? /^(.*):([1-9]\d*)$/.exec(allowance.sourceEvidence) : null;
|
|
28
|
+
const knownRule = HARDENING_RULES[allowance.rule];
|
|
29
|
+
if (!knownRule || allowance.exact !== knownRule.exact || !sourceMatch || !/^[a-f0-9]{64}$/.test(allowance.artifactSha256 ?? "") || !/^[a-f0-9]{64}$/.test(allowance.sourceSha256 ?? "") || allowance.sourceLine !== Number(sourceMatch[2]) || typeof allowance.rationale !== "string" || allowance.rationale.trim().length < 12) {
|
|
30
|
+
throw new Error("invalid hardening allowance source binding");
|
|
31
|
+
}
|
|
32
|
+
const sourcePath = realpathSync(path.resolve(root, sourceMatch[1]));
|
|
33
|
+
const sourceRelative = path.relative(root, sourcePath);
|
|
34
|
+
if (sourceRelative === ".." || sourceRelative.startsWith("../") || sourceRelative.startsWith("..\\") || path.isAbsolute(sourceRelative)) {
|
|
35
|
+
throw new Error("hardening allowance source escapes release root");
|
|
36
|
+
}
|
|
37
|
+
const sourceBytes = readFileSync(sourcePath);
|
|
38
|
+
if (createHash("sha256").update(sourceBytes).digest("hex") !== allowance.sourceSha256) throw new Error("hardening allowance source digest mismatch");
|
|
39
|
+
const line = sourceBytes.toString("utf8").split(/\r?\n/)[allowance.sourceLine - 1];
|
|
40
|
+
if (typeof line !== "string" || !line.includes(allowance.exact)) throw new Error("hardening allowance source token mismatch");
|
|
41
|
+
}
|
|
15
42
|
|
|
16
43
|
const fileNameRules = [
|
|
17
44
|
[/\.map$/i, "source map shipped"],
|
|
@@ -27,63 +54,64 @@ const fileNameRules = [
|
|
|
27
54
|
];
|
|
28
55
|
|
|
29
56
|
const contentRules = [
|
|
30
|
-
[/\/Users\/adrdsouza\/claude/gi, "local source path leaked"],
|
|
31
|
-
[/\/Users\/adrdsouza\/\.cargo/gi, "local cargo path leaked"],
|
|
32
|
-
[/C:\\Users\\adrdsouza\\(?:claude|\.cargo)/gi, "local Windows source path leaked"],
|
|
33
|
-
[/docs\/experiments/gi, "experiment path leaked"],
|
|
34
|
-
[/l3_cleanup_prompt/gi, "L3 cleanup prompt marker leaked"],
|
|
35
|
-
[/SYSTEM_PROMPT/gi, "system prompt marker leaked"],
|
|
36
|
-
[/OPENAI_API_KEY|ANTHROPIC_API_KEY|APPLE_PASSWORD|NOTARY_PROFILE/gi, "secret/env key name leaked"],
|
|
37
|
-
[/--enable-gpl|--enable-nonfree/gi, "GPL/nonfree ffmpeg flag leaked"],
|
|
38
|
-
[/tdt_w15_p6/gi, "stale TDT p6 string leaked"],
|
|
39
|
-
[/Canary-Qwen|Nemotron|dqlstm|bias-experiments/gi, "experiment/model-history marker leaked"],
|
|
57
|
+
["local-source-path", /\/Users\/adrdsouza\/claude/gi, "local source path leaked"],
|
|
58
|
+
["local-cargo-path", /\/Users\/adrdsouza\/\.cargo/gi, "local cargo path leaked"],
|
|
59
|
+
["local-windows-path", /C:\\Users\\adrdsouza\\(?:claude|\.cargo)/gi, "local Windows source path leaked"],
|
|
60
|
+
["experiment-path", /docs\/experiments/gi, "experiment path leaked"],
|
|
61
|
+
["cleanup-prompt-marker", /l3_cleanup_prompt/gi, "L3 cleanup prompt marker leaked"],
|
|
62
|
+
["system-prompt-marker", /SYSTEM_PROMPT/gi, "system prompt marker leaked"],
|
|
63
|
+
["secret-env-name", /OPENAI_API_KEY|ANTHROPIC_API_KEY|APPLE_PASSWORD|NOTARY_PROFILE/gi, "secret/env key name leaked"],
|
|
64
|
+
["gpl-flag", /--enable-gpl|--enable-nonfree/gi, "GPL/nonfree ffmpeg flag leaked"],
|
|
65
|
+
["stale-model", /tdt_w15_p6/gi, "stale TDT p6 string leaked"],
|
|
66
|
+
["experiment-model", /Canary-Qwen|Nemotron|dqlstm|bias-experiments/gi, "experiment/model-history marker leaked"],
|
|
40
67
|
];
|
|
41
68
|
|
|
42
|
-
const maxPatternLen = Math.max(...contentRules.map(([
|
|
69
|
+
const maxPatternLen = Math.max(...contentRules.map(([, rule]) => String(rule).length), 256);
|
|
43
70
|
|
|
44
71
|
function add(kind, file, message, detail = "") {
|
|
45
|
-
findings.push({
|
|
46
|
-
kind,
|
|
47
|
-
file: path.relative(root, file) || file,
|
|
48
|
-
message,
|
|
49
|
-
detail,
|
|
50
|
-
});
|
|
72
|
+
findings.push({ kind, file: path.relative(root, file) || file, message, detail });
|
|
51
73
|
}
|
|
52
74
|
|
|
53
|
-
async function walk(
|
|
54
|
-
const st = await lstat(
|
|
55
|
-
const normalized =
|
|
56
|
-
for (const [rule, message] of fileNameRules)
|
|
57
|
-
if (rule.test(normalized)) add("path", p, message);
|
|
58
|
-
}
|
|
75
|
+
async function walk(target) {
|
|
76
|
+
const st = await lstat(target);
|
|
77
|
+
const normalized = target.split(path.sep).join("/");
|
|
78
|
+
for (const [rule, message] of fileNameRules) if (rule.test(normalized)) add("path", target, message);
|
|
59
79
|
if (st.isDirectory()) {
|
|
60
|
-
const entries = await readdir(
|
|
61
|
-
await Promise.all(entries.map((name) => walk(path.join(
|
|
80
|
+
const entries = await readdir(target);
|
|
81
|
+
await Promise.all(entries.map((name) => walk(path.join(target, name))));
|
|
62
82
|
return;
|
|
63
83
|
}
|
|
64
|
-
if (st.isFile()) await scanFile(
|
|
84
|
+
if (st.isFile()) await scanFile(target);
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
function scanFile(file) {
|
|
68
|
-
return new Promise((
|
|
88
|
+
return new Promise((resolveScan) => {
|
|
89
|
+
const artifactSha256 = createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
69
90
|
const stream = createReadStream(file, { highWaterMark: 256 * 1024 });
|
|
70
91
|
let tail = "";
|
|
71
92
|
stream.on("data", (chunk) => {
|
|
72
93
|
const text = tail + chunk.toString("latin1");
|
|
73
|
-
for (const [rule, message] of contentRules) {
|
|
74
|
-
if (/ffmpeg-LICENSE\.txt$/i.test(file) &&
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
94
|
+
for (const [ruleId, rule, message] of contentRules) {
|
|
95
|
+
if (/ffmpeg-LICENSE\.txt$/i.test(file) && ruleId === "gpl-flag") continue;
|
|
77
96
|
rule.lastIndex = 0;
|
|
78
|
-
|
|
97
|
+
for (const match of text.matchAll(rule)) {
|
|
98
|
+
const allowed = allowances.some((allowance) =>
|
|
99
|
+
allowance.rule === ruleId &&
|
|
100
|
+
allowance.artifactSha256 === artifactSha256 &&
|
|
101
|
+
allowance.exact === match[0] &&
|
|
102
|
+
typeof allowance.sourceEvidence === "string" &&
|
|
103
|
+
typeof allowance.rationale === "string"
|
|
104
|
+
);
|
|
105
|
+
if (!allowed) add("content", file, message, rule.source);
|
|
106
|
+
}
|
|
79
107
|
}
|
|
80
108
|
tail = text.slice(-maxPatternLen);
|
|
81
109
|
});
|
|
82
|
-
stream.on("error", (
|
|
83
|
-
add("read", file, "could not scan file",
|
|
84
|
-
|
|
110
|
+
stream.on("error", (error) => {
|
|
111
|
+
add("read", file, "could not scan file", error.message);
|
|
112
|
+
resolveScan();
|
|
85
113
|
});
|
|
86
|
-
stream.on("end",
|
|
114
|
+
stream.on("end", resolveScan);
|
|
87
115
|
});
|
|
88
116
|
}
|
|
89
117
|
|
|
@@ -91,25 +119,20 @@ for (const arg of args) {
|
|
|
91
119
|
const target = path.resolve(arg);
|
|
92
120
|
try {
|
|
93
121
|
await walk(target);
|
|
94
|
-
} catch (
|
|
95
|
-
add("target", target, "could not scan target",
|
|
122
|
+
} catch (error) {
|
|
123
|
+
add("target", target, "could not scan target", error.message);
|
|
96
124
|
}
|
|
97
125
|
}
|
|
98
126
|
|
|
99
127
|
const unique = new Map();
|
|
100
|
-
for (const finding of findings)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const deduped = [...unique.values()].sort((a, b) =>
|
|
105
|
-
`${a.file} ${a.message}`.localeCompare(`${b.file} ${b.message}`),
|
|
128
|
+
for (const finding of findings) unique.set([finding.kind, finding.file, finding.message].join("\0"), finding);
|
|
129
|
+
const deduped = [...unique.values()].sort((left, right) =>
|
|
130
|
+
(left.file + " " + left.message).localeCompare(right.file + " " + right.message),
|
|
106
131
|
);
|
|
107
132
|
|
|
108
133
|
if (deduped.length) {
|
|
109
|
-
console.error(
|
|
110
|
-
for (const
|
|
111
|
-
console.error(`- [${f.kind}] ${f.file}: ${f.message}`);
|
|
112
|
-
}
|
|
134
|
+
console.error("hardeningscan: " + deduped.length + " finding(s)");
|
|
135
|
+
for (const finding of deduped) console.error("- [" + finding.kind + "] " + finding.file + ": " + finding.message);
|
|
113
136
|
process.exit(1);
|
|
114
137
|
}
|
|
115
138
|
|