@rightkit/release 0.2.71 → 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.
@@ -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 { createReadStream } from "node:fs";
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 { fileURLToPath } from "node:url";
6
+ import { HARDENING_RULES } from "./hardening-evidence.mjs";
6
7
 
7
- const args = process.argv.slice(2);
8
- if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
9
- console.error("usage: node hardeningscan.mjs <artifact-path>...");
10
- process.exit(args.length === 0 ? 2 : 0);
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(([r]) => String(r).length), 256);
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(p) {
54
- const st = await lstat(p);
55
- const normalized = p.split(path.sep).join("/");
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(p);
61
- await Promise.all(entries.map((name) => walk(path.join(p, name))));
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(p);
84
+ if (st.isFile()) await scanFile(target);
65
85
  }
66
86
 
67
87
  function scanFile(file) {
68
- return new Promise((resolve) => {
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) && message === "GPL/nonfree ffmpeg flag leaked") {
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
- if (rule.test(text)) add("content", file, message, rule.source);
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", (err) => {
83
- add("read", file, "could not scan file", err.message);
84
- resolve();
110
+ stream.on("error", (error) => {
111
+ add("read", file, "could not scan file", error.message);
112
+ resolveScan();
85
113
  });
86
- stream.on("end", resolve);
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 (err) {
95
- add("target", target, "could not scan target", err.message);
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
- unique.set(`${finding.kind}\0${finding.file}\0${finding.message}`, finding);
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(`hardeningscan: ${deduped.length} finding(s)`);
110
- for (const f of deduped) {
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
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.71",
4
- "description": "Portable Right Suite release CLI/SDK: native-host signed installers, updater artifacts, hardening, immutable GitHub Release upload, and add-on adoption.",
3
+ "version": "0.2.72",
4
+ "description": "Portable Right Suite release CLI/SDK: signed native archives, direct bootstrap transactions, immutable GitHub Release upload, R2 bootstrap publication, and add-on adoption.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
package/release.mjs CHANGED
@@ -21,6 +21,7 @@ import { collectPreflight, formatPreflight, preflightFailures } from "./prefligh
21
21
  import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
22
22
  import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
23
23
  import { terminateProcessTree } from "./heavy-command.mjs";
24
+ import { materializeHardeningEvidence } from "./hardening-evidence.mjs";
24
25
 
25
26
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
26
27
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -49,7 +50,6 @@ const opts = {
49
50
  install: false,
50
51
  dryRun: false,
51
52
  skipChecks: false,
52
- skipHardening: false,
53
53
  doctor: false,
54
54
  tier: null,
55
55
  };
@@ -64,7 +64,7 @@ for (let i = 0; i < args.length; i++) {
64
64
  else if (arg === "--install") opts.install = true;
65
65
  else if (arg === "--dry-run") opts.dryRun = true;
66
66
  else if (arg === "--skip-checks") opts.skipChecks = true;
67
- else if (arg === "--skip-hardening") opts.skipHardening = true;
67
+ else if (arg === "--skip-hardening") fail("--skip-hardening is forbidden; use exact artifact/source-bound hardeningAllowances");
68
68
  else if (arg === "--doctor") opts.doctor = true;
69
69
  else if (arg === "--tier") opts.tier = args[++i];
70
70
  else if (arg.startsWith("--tier=")) opts.tier = arg.slice("--tier=".length);
@@ -279,10 +279,21 @@ if (target.postSign && opts.platform !== "win") {
279
279
  await runCommand(target.postSign, root);
280
280
  }
281
281
 
282
- if (!opts.skipHardening) {
282
+ {
283
283
  const scanTargets = target.hardening ?? target.artifacts ?? [];
284
284
  if (scanTargets.length) {
285
- await run("node", [HARDENING_SCAN, ...scanTargets.map((p) => path.resolve(root, p))], root);
285
+ let hardeningEvidence = null;
286
+ if (target.hardeningAllowances?.length) {
287
+ const receiptRoot = path.resolve(root, ".right-release", "receipts");
288
+ mkdirSync(receiptRoot, { recursive: true });
289
+ hardeningEvidence = path.join(receiptRoot, opts.platform + "-hardening-evidence.json");
290
+ materializeHardeningEvidence({ root, allowances: target.hardeningAllowances, outputPath: hardeningEvidence });
291
+ }
292
+ await run("node", [
293
+ HARDENING_SCAN,
294
+ ...(hardeningEvidence ? ["--allow-evidence", hardeningEvidence] : []),
295
+ ...scanTargets.map((p) => path.resolve(root, p)),
296
+ ], root);
286
297
  }
287
298
  }
288
299
 
@@ -13,7 +13,7 @@
13
13
  "@rightkit/updates": "0.2.3"
14
14
  },
15
15
  "stagedNpm": {
16
- "@rightkit/ax": "0.2.0",
16
+ "@rightkit/ax": "0.2.1",
17
17
  "@rightkit/git": "0.2.1",
18
18
  "@rightkit/hooks": "0.1.1",
19
19
  "@rightkit/legal": "0.3.1",
@@ -22,11 +22,14 @@
22
22
  "@rightkit/logs": "0.1.4",
23
23
  "@rightkit/platform-ui": "0.1.1",
24
24
  "@rightkit/qa": "0.2.1",
25
- "@rightkit/release": "0.2.71",
25
+ "@rightkit/release": "0.2.72",
26
26
  "@rightkit/tauri": "0.1.1",
27
27
  "@rightkit/updates": "0.2.4"
28
28
  },
29
29
  "legacyNpm": {
30
+ "@rightkit/ax": [
31
+ "0.2.0"
32
+ ],
30
33
  "@rightkit/legal-ui": [
31
34
  "0.1.0"
32
35
  ],
@@ -66,7 +69,8 @@
66
69
  "0.2.67",
67
70
  "0.2.68",
68
71
  "0.2.69",
69
- "0.2.70"
72
+ "0.2.70",
73
+ "0.2.71"
70
74
  ],
71
75
  "@rightkit/qa": [
72
76
  "0.1.0",