@rightkit/release 0.2.135 → 0.2.137
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/README.md +62 -0
- package/addon-command.mjs +53 -34
- package/apple-profile-renew.mjs +30 -12
- package/artifact-storage.mjs +47 -10
- package/background-process.mjs +18 -0
- package/build-release.mjs +1 -1
- package/cache-command.mjs +4 -3
- package/cargo-contract.mjs +1 -1
- package/cargo-guard.mjs +4 -3
- package/cargo-registry-readback.mjs +51 -0
- package/cargo-target.mjs +3 -1
- package/cli/right-release.mjs +26 -0
- package/codesign-environment.mjs +26 -2
- package/codesign-shim.mjs +2 -1
- package/committed-source.mjs +3 -3
- package/development.mjs +7 -7
- package/direct-bootstrap.mjs +19 -9
- package/dmg-build.mjs +117 -0
- package/dmg-layout.mjs +123 -0
- package/github-release.mjs +74 -37
- package/heavy-command.mjs +3 -2
- package/install-app.mjs +358 -0
- package/inventory-check.mjs +980 -0
- package/macos-signing-session.mjs +3 -0
- package/managed-cargo.mjs +23 -19
- package/managed-host-secrets.mjs +30 -0
- package/managed-roots.mjs +39 -0
- package/model-promote.mjs +31 -37
- package/notary-auth.mjs +4 -0
- package/osascript-guard.mjs +24 -0
- package/ownership-approval-command.mjs +15 -4
- package/ownership-approval.mjs +114 -49
- package/ownership-availability.mjs +25 -0
- package/ownership-catalog-config.json +18 -0
- package/ownership-catalog.mjs +94 -0
- package/ownership-command.mjs +20 -10
- package/ownership-declaration.json +15 -0
- package/ownership-default-policy.json +1 -11
- package/ownership-evidence.mjs +10 -8
- package/ownership-git-inventory.mjs +29 -1
- package/ownership-helper-paths.json +2 -2
- package/ownership-hooks.mjs +107 -0
- package/ownership-internal-owners.json +44 -0
- package/ownership-key-authority.mjs +112 -7
- package/ownership-key-command.mjs +76 -19
- package/ownership-key-trust.mjs +97 -9
- package/ownership-lockfiles.mjs +311 -0
- package/ownership-report-command.mjs +12 -3
- package/ownership-report.mjs +20 -17
- package/ownership-repositories.json +19 -0
- package/ownership-scan-worker.mjs +96 -11
- package/ownership-source.mjs +39 -0
- package/ownership-staged.mjs +193 -0
- package/ownership-verdict.mjs +74 -0
- package/ownership.mjs +215 -107
- package/package.json +1 -1
- package/protected-credentials.mjs +111 -0
- package/prune-r2.mjs +4 -3
- package/pub-upload.mjs +3 -85
- package/publication-artifacts.mjs +108 -0
- package/publication-gate.mjs +28 -7
- package/publish-cargo.mjs +47 -16
- package/publish-swift.mjs +46 -17
- package/publish-update.mjs +61 -39
- package/r2-verified-upload.mjs +41 -0
- package/registry-parity.mjs +5 -3
- package/release-state.mjs +1 -0
- package/release-token.mjs +2 -0
- package/rightkit-drift.mjs +3 -2
- package/rightkit-ownership.json +2028 -139
- package/rightkit-signing-agent.mjs +5 -3
- package/rightkit-sync.mjs +6 -2
- package/rightkit-versions.json +215 -103
- package/runtime-artifact-manifest.mjs +20 -6
- package/sign-updater.mjs +5 -0
- package/signing-agent-client.mjs +2 -1
- package/signing-agent-install.mjs +3 -2
- package/standalone-clone-verify.mjs +3 -2
- package/tauri-bundle-marker.mjs +3 -2
- package/upload-large.mjs +36 -82
- package/verifier.mjs +5 -1
package/ownership-key-trust.mjs
CHANGED
|
@@ -2,9 +2,10 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { spawnSync } from "./background-process.mjs";
|
|
5
|
+
import { spawnSync, windowsPowerShellEnvironment } from "./background-process.mjs";
|
|
6
6
|
import { canonicalJson, canonicalRepositoryIdentity, __testOnly as approvalTestOnly, evaluateWindowsAcl, validateTrustRegistry } from "./ownership-approval.mjs";
|
|
7
7
|
import { verifyKeyLog } from "./ownership-key-log.mjs";
|
|
8
|
+
import { SCANNER_VERSION } from "./ownership.mjs";
|
|
8
9
|
|
|
9
10
|
const HEX = /^[a-f0-9]{64}$/;
|
|
10
11
|
const MAX_REQUEST_BYTES = 2 * 1024 * 1024;
|
|
@@ -149,7 +150,7 @@ function ensureNoLinks(file) {
|
|
|
149
150
|
|
|
150
151
|
function assertElevated() {
|
|
151
152
|
if (process.platform === "win32") {
|
|
152
|
-
const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "$p=New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()); if (!$p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { exit 9 }"]);
|
|
153
|
+
const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "$p=New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()); if (!$p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { exit 9 }"], { env: windowsPowerShellEnvironment() });
|
|
153
154
|
if (r.status !== 0) throw new Error("protected trust updater requires an elevated Administrator process");
|
|
154
155
|
} else if (process.platform === "darwin") {
|
|
155
156
|
if (process.getuid?.() !== 0) throw new Error("protected trust updater requires root");
|
|
@@ -158,7 +159,7 @@ function assertElevated() {
|
|
|
158
159
|
|
|
159
160
|
function hardenWindows(directory, file, runner = spawnSync) {
|
|
160
161
|
const script = "$ErrorActionPreference='Stop'; $sidSys=New-Object Security.Principal.SecurityIdentifier('S-1-5-18'); $sidAdm=New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544'); $sidUsers=New-Object Security.Principal.SecurityIdentifier('S-1-5-32-545'); foreach($p in @($env:RK_TRUST_DIR,$env:RK_TRUST_FILE)){ $a=New-Object Security.AccessControl.DirectorySecurity; if($p -eq $env:RK_TRUST_FILE){$a=New-Object Security.AccessControl.FileSecurity}; $a.SetAccessRuleProtection($true,$false); $a.SetOwner($sidAdm); $inherit=if($p -eq $env:RK_TRUST_FILE){[Security.AccessControl.InheritanceFlags]::None}else{[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'}; foreach($sid in @($sidSys,$sidAdm)){ $rule=New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl',$inherit,[Security.AccessControl.PropagationFlags]::None,'Allow'); $a.AddAccessRule($rule) }; $read=New-Object Security.AccessControl.FileSystemAccessRule($sidUsers,'ReadAndExecute',$inherit,[Security.AccessControl.PropagationFlags]::None,'Allow'); $a.AddAccessRule($read); Set-Acl -LiteralPath $p -AclObject $a }; $a=Get-Acl -LiteralPath $env:RK_TRUST_DIR; $a.SetOwner($sidSys); Set-Acl -LiteralPath $env:RK_TRUST_DIR -AclObject $a";
|
|
161
|
-
const r = runner("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: {
|
|
162
|
+
const r = runner("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: windowsPowerShellEnvironment({ RK_TRUST_DIR: directory, RK_TRUST_FILE: file }) });
|
|
162
163
|
if (r.status !== 0) throw new Error("could not secure protected trust ACL");
|
|
163
164
|
}
|
|
164
165
|
|
|
@@ -172,7 +173,7 @@ export const __testOnly = Object.freeze({
|
|
|
172
173
|
});
|
|
173
174
|
|
|
174
175
|
function runWindowsAclScript(script, environment) {
|
|
175
|
-
const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env:
|
|
176
|
+
const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: windowsPowerShellEnvironment(environment) });
|
|
176
177
|
if (r.status !== 0) throw new Error("could not create or secure protected ownership path");
|
|
177
178
|
}
|
|
178
179
|
|
|
@@ -223,7 +224,7 @@ function auditProtected(file) {
|
|
|
223
224
|
const problems = [];
|
|
224
225
|
for (const target of targets) {
|
|
225
226
|
try { if (lstatSync(target).isSymbolicLink()) problems.push(`trust path contains a reparse/symlink: ${target}`); } catch { problems.push(`cannot inspect trust path: ${target}`); continue; }
|
|
226
|
-
const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: {
|
|
227
|
+
const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: windowsPowerShellEnvironment({ RK_TRUST_CHECK_TARGET: target }) });
|
|
227
228
|
if (r.status !== 0) { problems.push(`cannot inspect trust ACL: ${target}`); continue; }
|
|
228
229
|
try { problems.push(...evaluateWindowsAcl(JSON.parse(r.stdout.trim()), { target, requireNonElevated: false }).problems.map((problem) => `${problem}: ${target}`)); }
|
|
229
230
|
catch { problems.push(`malformed ACL result: ${target}`); }
|
|
@@ -283,6 +284,89 @@ function atomicProtectedWrite(file, trust) {
|
|
|
283
284
|
}
|
|
284
285
|
|
|
285
286
|
/** Elevated child entrypoint. Writes only the fixed native trust path. */
|
|
287
|
+
const versionParts = (value) => String(value).split(".").map(Number);
|
|
288
|
+
function versionAtLeast(value, floor) {
|
|
289
|
+
const a = versionParts(value), b = versionParts(floor);
|
|
290
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) { const x = a[i] ?? 0, y = b[i] ?? 0; if (x !== y) return x > y; }
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Floors follow the installed (digest-verified) RightKit release this elevated code runs from:
|
|
295
|
+
* its own catalog digest/version and scanner version. Floors only ever rise; an older installed
|
|
296
|
+
* release leaves the existing floors untouched. The request never supplies floors.
|
|
297
|
+
*/
|
|
298
|
+
export function raiseFloorsToInstalledRelease(trust, { manifest = installedCatalog(), scannerVersion = SCANNER_VERSION } = {}) {
|
|
299
|
+
const catalogVersion = String(manifest?.version ?? "");
|
|
300
|
+
if (!/^\d+(?:\.\d+)*$/.test(catalogVersion) || !/^\d+(?:\.\d+)*$/.test(scannerVersion)) return trust;
|
|
301
|
+
if (!versionAtLeast(catalogVersion, trust.minimumCatalogVersion ?? "0") || !versionAtLeast(scannerVersion, trust.minimumScannerVersion ?? "0")) return trust;
|
|
302
|
+
const raised = { ...trust, minimumCatalogVersion: catalogVersion, minimumScannerVersion: scannerVersion, minimumCatalogDigest: createHash("sha256").update(canonicalJson(manifest)).digest("hex") };
|
|
303
|
+
if (!validateTrustRegistry(raised).ok) return trust;
|
|
304
|
+
return raised;
|
|
305
|
+
}
|
|
306
|
+
function installedCatalog() { return JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "rightkit-ownership.json"), "utf8")); }
|
|
307
|
+
|
|
308
|
+
/** Typed, non-blocking warnings from the registry merge. The trust update still applies everything else. */
|
|
309
|
+
export const REPOSITORY_DRIFT_CODE = "RK_OWNERSHIP_REPOSITORY_DRIFT";
|
|
310
|
+
export const REPOSITORY_REGISTRY_MALFORMED_CODE = "RK_OWNERSHIP_REPOSITORY_REGISTRY_MALFORMED";
|
|
311
|
+
const warning = (code, message, repositoryIdentity) => ({ code, type: code, message, ...(repositoryIdentity ? { repositoryIdentity } : {}) });
|
|
312
|
+
const REPOSITORY_REGISTRY_FILE = "ownership-repositories.json";
|
|
313
|
+
const MAX_REGISTRY_BYTES = 256 * 1024;
|
|
314
|
+
const REPOSITORY_FIELDS = ["repositoryIdentity", "roots", "visibility"];
|
|
315
|
+
function installedRepositoryRegistry() {
|
|
316
|
+
const file = path.join(path.dirname(fileURLToPath(import.meta.url)), REPOSITORY_REGISTRY_FILE);
|
|
317
|
+
const stat = lstatSync(file);
|
|
318
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (process.platform === "win32" && (stat.attributes & 0x400)) || stat.size > MAX_REGISTRY_BYTES) throw new Error("ownership repository registry must be a bounded regular file");
|
|
319
|
+
try { return JSON.parse(readFileSync(file, "utf8")); }
|
|
320
|
+
catch { throw new Error("ownership repository registry is malformed JSON"); }
|
|
321
|
+
}
|
|
322
|
+
/** Fail closed: every entry must be exact, canonical, unique, and pass validateTrustRegistry's repository rules. */
|
|
323
|
+
function validatedRegistryEntries(registry) {
|
|
324
|
+
if (!exactObject(registry, ["schema", "repositories"]) || registry.schema !== 1 || !Array.isArray(registry.repositories) || !registry.repositories.length) throw new Error("ownership repository registry schema is malformed");
|
|
325
|
+
const identities = new Set();
|
|
326
|
+
for (const entry of registry.repositories) {
|
|
327
|
+
if (!exactObject(entry, REPOSITORY_FIELDS)) throw new Error("ownership repository registry entries must have exactly repositoryIdentity, roots and visibility");
|
|
328
|
+
let identity;
|
|
329
|
+
try { identity = canonicalRepositoryIdentity(entry.repositoryIdentity); }
|
|
330
|
+
catch { throw new Error("ownership repository registry identity is invalid"); }
|
|
331
|
+
if (identity !== entry.repositoryIdentity) throw new Error(`ownership repository registry identity is not canonical: ${entry.repositoryIdentity}`);
|
|
332
|
+
if (identities.has(identity)) throw new Error(`ownership repository registry duplicates ${identity}`);
|
|
333
|
+
identities.add(identity);
|
|
334
|
+
}
|
|
335
|
+
// Floors are placeholders here: only the repository rules of validateTrustRegistry are being checked.
|
|
336
|
+
const probe = validateTrustRegistry({ schema: 1, minimumScannerVersion: "0", minimumCatalogVersion: "0", revokedKeyIds: [], verifiedTrackingEvidence: [], repositories: registry.repositories, provenance: [] });
|
|
337
|
+
if (!probe.ok) throw new Error(`ownership repository registry is invalid: ${probe.problems.join("; ")}`);
|
|
338
|
+
return registry.repositories.map(copyJson);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Additions only, from the committed sibling registry. The trust request never supplies registrations.
|
|
342
|
+
* Returns { trust, warnings }; it never throws for registry problems, so revocations and floors always apply.
|
|
343
|
+
* - An identity already registered with different roots or visibility keeps its protected entry unchanged,
|
|
344
|
+
* that registry entry is skipped, and a RK_OWNERSHIP_REPOSITORY_DRIFT warning is returned.
|
|
345
|
+
* - A malformed registry file skips the whole registry merge with a RK_OWNERSHIP_REPOSITORY_REGISTRY_MALFORMED warning.
|
|
346
|
+
* Provenance, exceptions, and every other field are untouched. firstInstall starts from no registrations.
|
|
347
|
+
*/
|
|
348
|
+
export function mergeRepositoriesFromInstalledRelease(trust, { firstInstall = false, registry } = {}) {
|
|
349
|
+
if (!Array.isArray(trust?.repositories)) throw new Error("protected repository registry missing");
|
|
350
|
+
const existing = firstInstall ? [] : trust.repositories.map(copyJson);
|
|
351
|
+
let incoming;
|
|
352
|
+
try { incoming = validatedRegistryEntries(registry ?? installedRepositoryRegistry()); }
|
|
353
|
+
catch (error) { return { trust: { ...trust, repositories: existing }, warnings: [warning(REPOSITORY_REGISTRY_MALFORMED_CODE, `repository registration merge skipped: ${error.message}`)] }; }
|
|
354
|
+
const byIdentity = new Map(existing.map((entry) => [entry.repositoryIdentity, entry]));
|
|
355
|
+
const additions = [];
|
|
356
|
+
const warnings = [];
|
|
357
|
+
for (const entry of incoming) {
|
|
358
|
+
const current = byIdentity.get(entry.repositoryIdentity);
|
|
359
|
+
if (!current) { additions.push(entry); continue; }
|
|
360
|
+
if (canonicalJson(current.roots) !== canonicalJson(entry.roots) || current.visibility !== entry.visibility) {
|
|
361
|
+
warnings.push(warning(REPOSITORY_DRIFT_CODE, `protected repository registration differs from the installed release; kept protected entry, skipped registry entry: ${entry.repositoryIdentity}`, entry.repositoryIdentity));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const merged = { ...trust, repositories: [...existing, ...additions] };
|
|
365
|
+
const check = validateTrustRegistry(merged);
|
|
366
|
+
if (!check.ok) throw new Error(`merged protected repository registry is invalid: ${check.problems.join("; ")}`);
|
|
367
|
+
return { trust: merged, warnings };
|
|
368
|
+
}
|
|
369
|
+
|
|
286
370
|
export function performProtectedTrustUpdate({ log, rootFingerprint, baseline, authority } = {}) {
|
|
287
371
|
assertElevated();
|
|
288
372
|
const file = OWNERSHIP_TRUST_PATH[process.platform];
|
|
@@ -295,11 +379,13 @@ export function performProtectedTrustUpdate({ log, rootFingerprint, baseline, au
|
|
|
295
379
|
// Re-read under the protected lock so every accepted checkpoint follows latest disk state.
|
|
296
380
|
let existing = null;
|
|
297
381
|
try { lstatSync(file); auditProtected(file); existing = readExisting(file); } catch (error) { if (error.code !== "ENOENT") throw error; existing = null; }
|
|
298
|
-
const
|
|
382
|
+
const prepared = prepareTrustUpdate(existing, { log, rootFingerprint, baseline, authority });
|
|
383
|
+
const merged = mergeRepositoriesFromInstalledRelease(prepared, { firstInstall: existing == null });
|
|
384
|
+
const trust = raiseFloorsToInstalledRelease(merged.trust);
|
|
299
385
|
if (trust.keyAuthority) validateCommittedAuthority(trust.keyAuthority, log, trust.rootFingerprint, trust.keyCheckpoint);
|
|
300
386
|
atomicProtectedWrite(file, trust);
|
|
301
387
|
auditProtected(file);
|
|
302
|
-
return { path: file, trust };
|
|
388
|
+
return { path: file, trust, warnings: merged.warnings };
|
|
303
389
|
} finally { releaseLock(); }
|
|
304
390
|
}
|
|
305
391
|
|
|
@@ -315,12 +401,14 @@ function readPinnedRequest(requestPath, expectedDigest) {
|
|
|
315
401
|
} finally { closeSync(fd); }
|
|
316
402
|
}
|
|
317
403
|
|
|
318
|
-
|
|
404
|
+
const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
|
|
405
|
+
if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
|
|
319
406
|
try {
|
|
320
407
|
if (process.argv.length !== 6 || process.argv[2] !== "--request" || process.argv[4] !== "--sha256") throw new Error("usage: ownership-key-trust.mjs --request <absolute-json> --sha256 <digest>");
|
|
321
408
|
const request = readPinnedRequest(process.argv[3], process.argv[5]);
|
|
322
409
|
if (!exactObject(request, ["schema", "log", "rootFingerprint", ...(Object.hasOwn(request ?? {}, "baseline") ? ["baseline"] : []), ...(Object.hasOwn(request ?? {}, "authority") ? ["authority"] : [])]) || request.schema !== 1 || !Array.isArray(request.log?.events) || request.log.schema !== 1) throw new Error("key trust request schema is malformed");
|
|
323
410
|
const result = performProtectedTrustUpdate({ log: request.log, rootFingerprint: request.rootFingerprint, baseline: request.baseline, authority: request.authority });
|
|
324
|
-
process.
|
|
411
|
+
for (const item of result.warnings) process.stderr.write(`${item.code}: ${item.message}\n`);
|
|
412
|
+
process.stdout.write(`${JSON.stringify({ path: result.path, checkpoint: result.trust.keyCheckpoint, warnings: result.warnings })}\n`);
|
|
325
413
|
} catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 2; }
|
|
326
414
|
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Stage 1: resolved lock graphs, without feature or production-only pruning.
|
|
2
|
+
import { parse as parseToml } from "smol-toml";
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { sha256 } from "./ownership-approval.mjs";
|
|
6
|
+
|
|
7
|
+
export const OWNERSHIP_LOCKFILES = ["Cargo.lock", "pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock"];
|
|
8
|
+
const sections = ["dependencies", "devDependencies", "optionalDependencies"];
|
|
9
|
+
class LockError extends Error {
|
|
10
|
+
constructor(reason, message) { super(message); this.reason = reason; }
|
|
11
|
+
}
|
|
12
|
+
const fail = (message) => { throw new LockError("lockfile-parse-failed", message); };
|
|
13
|
+
const object = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
|
|
15
|
+
// pnpm emits a small YAML subset. Reject unsupported syntax rather than guess.
|
|
16
|
+
// Split only outside quotes/flow collections (URLs, scoped names & peer suffixes are scalars).
|
|
17
|
+
function splitFlow(text, delimiter) {
|
|
18
|
+
const parts = []; let quote = null, depth = 0, start = 0;
|
|
19
|
+
for (let i = 0; i < text.length; i++) {
|
|
20
|
+
const c = text[i];
|
|
21
|
+
if (quote) {
|
|
22
|
+
if (c === "\\" && quote === '"') { i++; continue; }
|
|
23
|
+
if (c === quote) { if (quote === "'" && text[i + 1] === "'") i++; else quote = null; }
|
|
24
|
+
} else if (c === '"' || c === "'") quote = c;
|
|
25
|
+
else if (c === "{" || c === "[") depth++;
|
|
26
|
+
else if (c === "}" || c === "]") depth--;
|
|
27
|
+
else if (!depth && c === delimiter && (delimiter !== ":" || !text[i + 1] || /\s/.test(text[i + 1]))) {
|
|
28
|
+
parts.push(text.slice(start, i)); start = i + 1;
|
|
29
|
+
if (delimiter === ":") { parts.push(text.slice(start)); return parts; }
|
|
30
|
+
}
|
|
31
|
+
if (depth < 0) fail("unbalanced YAML flow value");
|
|
32
|
+
}
|
|
33
|
+
if (quote || depth) fail("unterminated YAML value");
|
|
34
|
+
parts.push(text.slice(start)); return parts;
|
|
35
|
+
}
|
|
36
|
+
function scalar(raw) {
|
|
37
|
+
const value = raw.trim();
|
|
38
|
+
if (/^[&*!|>]/.test(value)) throw new LockError("lockfile-unsupported-format", "YAML anchors, tags or block scalars");
|
|
39
|
+
if (value.startsWith('"')) { try { return JSON.parse(value); } catch { fail("invalid YAML quoted scalar"); } }
|
|
40
|
+
if (value.startsWith("'")) {
|
|
41
|
+
if (!value.endsWith("'") || value.length < 2) fail("invalid YAML quoted scalar");
|
|
42
|
+
return value.slice(1, -1).replaceAll("''", "'");
|
|
43
|
+
}
|
|
44
|
+
if (value.startsWith("{")) {
|
|
45
|
+
if (!value.endsWith("}")) fail("invalid YAML flow map");
|
|
46
|
+
const out = Object.create(null);
|
|
47
|
+
if (value.slice(1, -1).trim()) for (const item of splitFlow(value.slice(1, -1), ",")) {
|
|
48
|
+
const pair = splitFlow(item.trim(), ":");
|
|
49
|
+
if (pair.length !== 2) fail("invalid YAML flow entry");
|
|
50
|
+
const key = scalar(pair[0]); if (Object.hasOwn(out, key)) fail("duplicate YAML key");
|
|
51
|
+
out[key] = scalar(pair[1]);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
if (value.startsWith("[")) {
|
|
56
|
+
if (!value.endsWith("]")) fail("invalid YAML flow sequence");
|
|
57
|
+
return value.slice(1, -1).trim() ? splitFlow(value.slice(1, -1), ",").map(scalar) : [];
|
|
58
|
+
}
|
|
59
|
+
if (/[\[\]{}]/.test(value)) fail("invalid YAML scalar");
|
|
60
|
+
return value.replace(/\s+#.*$/, "");
|
|
61
|
+
}
|
|
62
|
+
function parsePnpmYaml(text) {
|
|
63
|
+
const root = Object.create(null), stack = [{ indent: -1, value: root }];
|
|
64
|
+
for (const line of text.split(/\r?\n/)) {
|
|
65
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
66
|
+
if (/^\s*\t/.test(line)) fail("YAML tab indentation");
|
|
67
|
+
const indent = line.match(/^ */)[0].length;
|
|
68
|
+
while (stack.length > 1 && indent <= stack.at(-1).indent) stack.pop();
|
|
69
|
+
const parent = stack.at(-1);
|
|
70
|
+
if (indent !== parent.indent + 2 && parent.indent !== -1 || parent.indent === -1 && indent !== 0) fail("invalid YAML indentation");
|
|
71
|
+
// Block sequences occur in package metadata (os/cpu/bundledDependencies), never graph maps.
|
|
72
|
+
const content = line.slice(indent);
|
|
73
|
+
if (content.startsWith("- ")) {
|
|
74
|
+
if (!parent.sequence) {
|
|
75
|
+
if (Object.keys(parent.value).length) fail("mixed YAML collection");
|
|
76
|
+
parent.sequence = []; parent.owner[parent.key] = parent.sequence;
|
|
77
|
+
}
|
|
78
|
+
parent.sequence.push(scalar(content.slice(2))); continue;
|
|
79
|
+
}
|
|
80
|
+
if (parent.sequence) fail("mixed YAML collection");
|
|
81
|
+
const pair = splitFlow(content, ":");
|
|
82
|
+
if (pair.length !== 2) fail("invalid YAML mapping");
|
|
83
|
+
const key = scalar(pair[0]);
|
|
84
|
+
if (typeof key !== "string" || !key || Object.hasOwn(parent.value, key)) fail("invalid or duplicate YAML key");
|
|
85
|
+
const value = pair[1].trim();
|
|
86
|
+
parent.value[key] = value ? scalar(value) : Object.create(null);
|
|
87
|
+
if (!value) stack.push({ indent, value: parent.value[key], owner: parent.value, key });
|
|
88
|
+
}
|
|
89
|
+
return root;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function graph() {
|
|
93
|
+
const nodes = new Map(), roots = [];
|
|
94
|
+
return { nodes, roots, diagnostics: [], add(id, name, version, source, local = false) {
|
|
95
|
+
if (typeof name !== "string" || !name || typeof version !== "string" || !version) fail("package name/version missing");
|
|
96
|
+
const node = { id, name, version, source, local, edges: [] }; nodes.set(id, node); return node;
|
|
97
|
+
} };
|
|
98
|
+
}
|
|
99
|
+
function connect(node, target, reference, optional = false) {
|
|
100
|
+
if (!target) {
|
|
101
|
+
if (optional) return; // Optional platform packages can be absent from a resolved install.
|
|
102
|
+
(node.unresolved ??= []).push(`${node.name}: ${reference}`); return;
|
|
103
|
+
}
|
|
104
|
+
node.edges.push(target.id);
|
|
105
|
+
}
|
|
106
|
+
function sourceFor(value, fallback = "registry") {
|
|
107
|
+
if (typeof value === "string" && /^(git\+|git:|https?:.*\.git)/.test(value)) return value.startsWith("git+") ? value : `git+${value}`;
|
|
108
|
+
return value || fallback;
|
|
109
|
+
}
|
|
110
|
+
function cargoGraph(text, manifests) {
|
|
111
|
+
const lock = parseToml(text), g = graph();
|
|
112
|
+
if (!Array.isArray(lock.package) || ![1, 2, 3, 4].includes(lock.version ?? 1)) throw new LockError("lockfile-unsupported-format", "Cargo lock version/package list");
|
|
113
|
+
for (const [i, pkg] of lock.package.entries()) g.add(String(i), pkg.name, pkg.version, pkg.source ?? `path:${pkg.name}`, !pkg.source);
|
|
114
|
+
for (const [i, pkg] of lock.package.entries()) {
|
|
115
|
+
if (pkg.dependencies !== undefined && !Array.isArray(pkg.dependencies)) fail("Cargo dependencies must be a list");
|
|
116
|
+
for (const ref of pkg.dependencies ?? []) {
|
|
117
|
+
if (typeof ref !== "string") fail("invalid Cargo dependency reference");
|
|
118
|
+
const match = /^(\S+)(?: (\S+))?(?: \((.+)\))?$/.exec(ref);
|
|
119
|
+
if (!match) fail("invalid Cargo dependency reference");
|
|
120
|
+
const candidates = [...g.nodes.values()].filter((n) => n.name === match[1] && (!match[2] || n.version === match[2]) && (!match[3] || n.source === match[3]));
|
|
121
|
+
connect(g.nodes.get(String(i)), candidates.length === 1 ? candidates[0] : null, ref);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const { rel, bytes } of manifests) {
|
|
125
|
+
const pkg = parseToml(bytes.toString("utf8")).package;
|
|
126
|
+
if (!pkg) continue; // Virtual workspace: member manifests supply roots.
|
|
127
|
+
const candidates = [...g.nodes.values()].filter((n) => n.name === pkg.name && n.local && (typeof pkg.version !== "string" || n.version === pkg.version));
|
|
128
|
+
if (candidates.length !== 1) { g.diagnostics.push({ reason: "lockfile-root-unresolved", detail: rel }); continue; }
|
|
129
|
+
g.roots.push(candidates[0].id);
|
|
130
|
+
}
|
|
131
|
+
return g;
|
|
132
|
+
}
|
|
133
|
+
function packageLockGraph(text, manifests, lockDir) {
|
|
134
|
+
const lock = JSON.parse(text), g = graph();
|
|
135
|
+
if (![2, 3].includes(lock.lockfileVersion) || !object(lock.packages)) throw new LockError("lockfile-unsupported-format", "npm requires v2/v3 packages");
|
|
136
|
+
for (const [id, pkg] of Object.entries(lock.packages)) {
|
|
137
|
+
if (!object(pkg)) fail("invalid npm package");
|
|
138
|
+
if (pkg.link) continue;
|
|
139
|
+
const name = pkg.name ?? (id.includes("node_modules/") ? id.split("node_modules/").at(-1) : id || lock.name || "app");
|
|
140
|
+
g.add(id, name, pkg.version ?? (!id.includes("node_modules/") ? "0.0.0" : undefined), sourceFor(pkg.resolved), !id.includes("node_modules/") || /^(?:file:|link:)/.test(pkg.resolved ?? ""));
|
|
141
|
+
}
|
|
142
|
+
const resolve = (from, name) => {
|
|
143
|
+
let dir = from;
|
|
144
|
+
while (true) {
|
|
145
|
+
const id = path.posix.join(dir, "node_modules", name);
|
|
146
|
+
const linked = lock.packages[id];
|
|
147
|
+
const target = linked?.link ? g.nodes.get(path.posix.normalize(linked.resolved)) : g.nodes.get(id);
|
|
148
|
+
if (target) return target;
|
|
149
|
+
if (!dir || dir === ".") return null;
|
|
150
|
+
dir = path.posix.dirname(dir); if (dir === ".") dir = "";
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
for (const [id, node] of g.nodes) {
|
|
154
|
+
const pkg = lock.packages[id];
|
|
155
|
+
for (const section of [...(node.local ? sections : ["dependencies", "optionalDependencies"]), "peerDependencies"]) {
|
|
156
|
+
if (pkg[section] !== undefined && !object(pkg[section])) fail("invalid npm dependencies");
|
|
157
|
+
for (const name of Object.keys(pkg[section] ?? {})) connect(node, resolve(id, name), name, section === "optionalDependencies" || section === "peerDependencies" && pkg.peerDependenciesMeta?.[name]?.optional === true);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const { rel, bytes } of manifests) {
|
|
161
|
+
const id = path.posix.relative(lockDir, path.posix.dirname(rel));
|
|
162
|
+
const node = g.nodes.get(id);
|
|
163
|
+
if (!node) { g.diagnostics.push({ reason: "lockfile-root-unresolved", detail: rel }); continue; }
|
|
164
|
+
// Root display names come from app manifests, not declarations in ownership files.
|
|
165
|
+
const pkg = JSON.parse(bytes.toString("utf8"));
|
|
166
|
+
node.name = pkg.name ?? node.name;
|
|
167
|
+
for (const section of [...sections, "peerDependencies"]) for (const name of Object.keys(pkg[section] ?? {})) connect(node, resolve(id, name), name, section === "optionalDependencies" || section === "peerDependencies" && pkg.peerDependenciesMeta?.[name]?.optional === true);
|
|
168
|
+
g.roots.push(id);
|
|
169
|
+
}
|
|
170
|
+
return g;
|
|
171
|
+
}
|
|
172
|
+
function pnpmGraph(text, manifests, lockDir) {
|
|
173
|
+
const lock = parsePnpmYaml(text), version = String(lock.lockfileVersion), g = graph();
|
|
174
|
+
if (!/^(?:9|6)(?:\.\d+)?$/.test(version) || !object(lock.importers) || !object(lock.packages ?? {})) throw new LockError("lockfile-unsupported-format", "pnpm requires v6/v9 importers");
|
|
175
|
+
const v9 = version.startsWith("9"), packages = lock.packages ?? {}, snapshots = v9 ? lock.snapshots ?? {} : packages;
|
|
176
|
+
if (!object(snapshots)) fail("invalid pnpm snapshots");
|
|
177
|
+
const packageIdentity = (id) => {
|
|
178
|
+
const bare = id.replace(/^\//, ""), match = /^(@[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(bare);
|
|
179
|
+
if (!match) throw new LockError("lockfile-unsupported-format", `pnpm package key: ${id}`);
|
|
180
|
+
return { name: match[1], version: match[2].split(/[(_]/)[0] };
|
|
181
|
+
};
|
|
182
|
+
for (const [id, pkg] of Object.entries(snapshots)) {
|
|
183
|
+
if (!object(pkg)) fail("invalid pnpm snapshot");
|
|
184
|
+
const identity = packageIdentity(id), metadata = packages[id] ?? packages[id.split("(")[0]] ?? {};
|
|
185
|
+
const resolved = metadata.resolution?.tarball ?? (metadata.resolution?.directory ? `path:${metadata.resolution.directory}` : undefined);
|
|
186
|
+
g.add(id, identity.name, metadata.version ?? identity.version, sourceFor(resolved), metadata.resolution?.type === "directory" || identity.version.startsWith("file:"));
|
|
187
|
+
}
|
|
188
|
+
for (const [id, importer] of Object.entries(lock.importers)) {
|
|
189
|
+
if (!object(importer)) fail("invalid pnpm importer");
|
|
190
|
+
g.add(`importer:${id}`, id, "0.0.0", `path:${id}`, true);
|
|
191
|
+
}
|
|
192
|
+
const resolve = (node, name, raw) => {
|
|
193
|
+
const ref = object(raw) ? raw.version : raw;
|
|
194
|
+
if (typeof ref !== "string") fail("invalid pnpm dependency version");
|
|
195
|
+
if (/^(?:link:|workspace:)/.test(ref)) {
|
|
196
|
+
if (!ref.startsWith("link:")) throw new LockError("lockfile-unresolved-dependency", ref);
|
|
197
|
+
const importer = node.id.replace(/^importer:/, "");
|
|
198
|
+
return g.nodes.get(`importer:${path.posix.normalize(path.posix.join(importer, ref.slice(5)))}`);
|
|
199
|
+
}
|
|
200
|
+
const candidates = [ref, `${name}@${ref}`, `/${name}@${ref}`];
|
|
201
|
+
const exact = candidates.map((id) => g.nodes.get(id)).find(Boolean);
|
|
202
|
+
if (exact) return exact;
|
|
203
|
+
// Aliases encode the actual package name in the version reference.
|
|
204
|
+
const normalized = ref.replace(/^npm:/, "");
|
|
205
|
+
return g.nodes.get(normalized) ?? g.nodes.get(`/${normalized}`);
|
|
206
|
+
};
|
|
207
|
+
for (const node of g.nodes.values()) {
|
|
208
|
+
const isImporter = node.id.startsWith("importer:"), pkg = isImporter ? lock.importers[node.id.slice(9)] : snapshots[node.id];
|
|
209
|
+
for (const section of isImporter ? sections : ["dependencies", "optionalDependencies"]) {
|
|
210
|
+
if (pkg[section] !== undefined && !object(pkg[section])) fail("invalid pnpm dependency map");
|
|
211
|
+
for (const [name, raw] of Object.entries(pkg[section] ?? {})) connect(node, resolve(node, name, raw), name, section === "optionalDependencies");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const { rel, bytes } of manifests) {
|
|
215
|
+
const id = path.posix.relative(lockDir, path.posix.dirname(rel)) || ".", node = g.nodes.get(`importer:${id}`);
|
|
216
|
+
if (!node) { g.diagnostics.push({ reason: "lockfile-root-unresolved", detail: rel }); continue; }
|
|
217
|
+
node.name = JSON.parse(bytes.toString("utf8")).name ?? id;
|
|
218
|
+
const pkg = JSON.parse(bytes.toString("utf8")), importer = lock.importers[id];
|
|
219
|
+
for (const section of sections) for (const name of Object.keys(pkg[section] ?? {})) if (!Object.hasOwn(importer[section] ?? {}, name)) g.diagnostics.push({ reason: "lockfile-root-unresolved", detail: `${rel}: ${section}.${name}` });
|
|
220
|
+
g.roots.push(node.id);
|
|
221
|
+
}
|
|
222
|
+
return g;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Lock bytes stay bound to the scan inventory or committed snapshot; no installed graph reads. */
|
|
226
|
+
export function transitiveDependencies(appRoot, inventory, manifests, catalogOwners, coverage, contentDigests, targets) {
|
|
227
|
+
const result = [], cache = new Map(), groups = new Map(), files = new Map(inventory.files.map((file) => [file.rel, file])), targetNames = new Set(targets);
|
|
228
|
+
const diagnostic = (rel, reason, detail) => coverage.push({ path: rel, status: "unscanned", reason, ...(detail ? { detail } : {}) });
|
|
229
|
+
const read = (rel) => {
|
|
230
|
+
if (cache.has(rel)) return cache.get(rel);
|
|
231
|
+
let file = files.get(rel), bytes = file?.bytes;
|
|
232
|
+
if (!file && !inventory.committed) {
|
|
233
|
+
const absolute = path.resolve(appRoot, rel);
|
|
234
|
+
if (existsSync(absolute)) {
|
|
235
|
+
const st = lstatSync(absolute), root = realpathSync(appRoot), real = realpathSync(absolute);
|
|
236
|
+
if (!st.isFile() || st.isSymbolicLink() || !real.startsWith(`${root}${path.sep}`) || st.size > 64 * 1024 * 1024) throw new LockError("lockfile-unreadable", rel);
|
|
237
|
+
bytes = readFileSync(absolute); file = { rel, bytes };
|
|
238
|
+
diagnostic(rel, "lockfile-not-in-inventory", "lock bytes are absent from Git-listed scan inputs");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (file && !bytes) throw new LockError("lockfile-unreadable", rel);
|
|
242
|
+
cache.set(rel, bytes);
|
|
243
|
+
if (bytes && !contentDigests.some((item) => item.path === rel)) contentDigests.push({ path: rel, digest: sha256(bytes) });
|
|
244
|
+
return bytes;
|
|
245
|
+
};
|
|
246
|
+
for (const manifest of manifests) {
|
|
247
|
+
const base = path.posix.basename(manifest.rel), ecosystem = base === "Cargo.toml" ? "cargo" : base === "package.json" ? "npm" : null;
|
|
248
|
+
if (!ecosystem || !manifest.bytes) continue;
|
|
249
|
+
const names = ecosystem === "cargo" ? ["Cargo.lock"] : OWNERSHIP_LOCKFILES.slice(1);
|
|
250
|
+
let dir = path.posix.dirname(manifest.rel), found = false;
|
|
251
|
+
try {
|
|
252
|
+
while (true) {
|
|
253
|
+
const locks = names.map((name) => path.posix.join(dir, name)).filter((rel) => read(rel) !== undefined);
|
|
254
|
+
if (locks.length) {
|
|
255
|
+
for (const rel of locks) {
|
|
256
|
+
if (!groups.has(rel)) groups.set(rel, { ecosystem, manifests: [] });
|
|
257
|
+
groups.get(rel).manifests.push(manifest);
|
|
258
|
+
}
|
|
259
|
+
found = true; break;
|
|
260
|
+
}
|
|
261
|
+
if (dir === ".") break;
|
|
262
|
+
dir = path.posix.dirname(dir);
|
|
263
|
+
}
|
|
264
|
+
if (!found) diagnostic(manifest.rel, "lockfile-missing", ecosystem);
|
|
265
|
+
} catch (error) { diagnostic(manifest.rel, error.reason ?? "lockfile-unreadable", error.message); }
|
|
266
|
+
}
|
|
267
|
+
for (const [rel, group] of groups) {
|
|
268
|
+
try {
|
|
269
|
+
const text = read(rel).toString("utf8"), base = path.posix.basename(rel), dir = path.posix.dirname(rel);
|
|
270
|
+
if (base === "yarn.lock") throw new LockError("lockfile-unsupported-format", "yarn transitive graph");
|
|
271
|
+
const g = group.ecosystem === "cargo" ? cargoGraph(text, group.manifests) : base === "pnpm-lock.yaml" ? pnpmGraph(text, group.manifests, dir) : packageLockGraph(text, group.manifests, dir);
|
|
272
|
+
for (const entry of g.diagnostics) diagnostic(rel, entry.reason, entry.detail);
|
|
273
|
+
for (const node of g.nodes.values()) for (const detail of node.unresolved ?? []) diagnostic(rel, "lockfile-unresolved-dependency", detail);
|
|
274
|
+
// Retain only nodes leading to block fingerprints. Unrelated lock inventory cannot
|
|
275
|
+
// multiply traversal work; owner boundaries cannot authorize app/workspace nodes.
|
|
276
|
+
const reverse = new Map(), viable = new Set();
|
|
277
|
+
for (const node of g.nodes.values()) {
|
|
278
|
+
if (!node.local && catalogOwners.has(node.name)) continue;
|
|
279
|
+
if (targetNames.has(`${group.ecosystem}:${node.name}`)) viable.add(node.id);
|
|
280
|
+
for (const id of node.edges) {
|
|
281
|
+
if (!reverse.has(id)) reverse.set(id, []);
|
|
282
|
+
reverse.get(id).push(node.id);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const ancestors = [...viable];
|
|
286
|
+
for (let at = 0; at < ancestors.length; at++) for (const id of reverse.get(ancestors[at]) ?? []) if (!viable.has(id)) { viable.add(id); ancestors.push(id); }
|
|
287
|
+
const seen = new Set(); let steps = 0;
|
|
288
|
+
for (const root of new Set(g.roots)) {
|
|
289
|
+
const queue = [[root]];
|
|
290
|
+
for (let at = 0; at < queue.length; at++) {
|
|
291
|
+
if (++steps > 20000) throw new LockError("lockfile-traversal-limit", "more than 20000 dependency paths");
|
|
292
|
+
const ids = queue[at], node = g.nodes.get(ids.at(-1));
|
|
293
|
+
// App/workspace names & app ownership declarations never grant an owner boundary.
|
|
294
|
+
if (ids.length > 1 && !node.local && catalogOwners.has(node.name)) continue;
|
|
295
|
+
const dependencyPath = ids.map((id) => g.nodes.get(id).name);
|
|
296
|
+
if (ids.length > 2 && targetNames.has(`${group.ecosystem}:${node.name}`)) {
|
|
297
|
+
const key = JSON.stringify([node.name, node.version, node.source, dependencyPath]);
|
|
298
|
+
if (!seen.has(key)) {
|
|
299
|
+
seen.add(key); result.push({ ecosystem: group.ecosystem, name: node.name, resolvedVersion: node.version, source: node.source, file: rel, lockfile: rel, dependencyPath });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const id of node.edges) if (viable.has(id) && !ids.includes(id)) {
|
|
303
|
+
if (queue.length >= 20000) throw new LockError("lockfile-traversal-limit", "more than 20000 dependency paths");
|
|
304
|
+
queue.push([...ids, id]);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch (error) { diagnostic(rel, error.reason ?? "lockfile-parse-failed", error.message); }
|
|
309
|
+
}
|
|
310
|
+
return result.sort((a, b) => a.dependencyPath.length - b.dependencyPath.length || JSON.stringify(a.dependencyPath).localeCompare(JSON.stringify(b.dependencyPath)));
|
|
311
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { spawn } from "./background-process.mjs";
|
|
@@ -63,7 +63,15 @@ export async function main(args, io = {}) {
|
|
|
63
63
|
const scanned = scanResults.get(app.key);
|
|
64
64
|
if (!scanned) throw new Error("configured app root missing; marked unscanned");
|
|
65
65
|
if (scanned.error) throw new Error(scanned.error);
|
|
66
|
-
|
|
66
|
+
// The protected worker returns { inputs, trust, report, verdict } (or { error }); never treat
|
|
67
|
+
// that envelope itself as a report, which would read as a false zero-finding scan.
|
|
68
|
+
if (scanned.value?.error) throw new Error(`${scanned.value.code ?? "RK_OWNERSHIP_UNVERIFIED"}: ${scanned.value.error}`);
|
|
69
|
+
// A protected preflight refusal returns without evidence: nothing was scanned.
|
|
70
|
+
if (!scanned.value?.evidence && scanned.value?.verdict && !scanned.value.verdict.admit)
|
|
71
|
+
throw new Error(`${scanned.value.verdict.code}: ${(scanned.value.verdict.problems ?? []).join("; ") || "protected preflight refused"}; marked unscanned`);
|
|
72
|
+
const scan = scanned.value?.report;
|
|
73
|
+
if (!scan || typeof scan !== "object" || !Array.isArray(scan.violations) || !Array.isArray(scan.problems)) throw new Error("scanner worker returned no ownership report; marked unscanned");
|
|
74
|
+
return scan;
|
|
67
75
|
} });
|
|
68
76
|
const markdown = renderOwnershipMarkdown(report);
|
|
69
77
|
const json = formatOwnershipJson(report);
|
|
@@ -81,7 +89,8 @@ export async function main(args, io = {}) {
|
|
|
81
89
|
return report.apps.some((app) => app.status === "blocked" || app.status === "unscanned" || app.rows.some((row) => row.status === "blocked" || row.status === "unscanned")) ? 1 : 0;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
|
|
92
|
+
const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
|
|
93
|
+
if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
|
|
85
94
|
try { process.exitCode = await main(process.argv.slice(2)); }
|
|
86
95
|
catch (error) { console.error(`[right-release] ownership report: ${error.message}`); process.exitCode = 1; }
|
|
87
96
|
}
|
package/ownership-report.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { checkApp, EXCEPTIONS_FILE } from "./ownership.mjs";
|
|
|
4
4
|
import { DEFAULT_APPS } from "./standalone-clone-verify.mjs";
|
|
5
5
|
import { sha256 } from "./ownership-approval.mjs";
|
|
6
6
|
import { findInstallerCopyScripts, findLargeRepositorySubdirectories } from "./installer-storage-doctor.mjs";
|
|
7
|
+
import { ownershipAvailability, ownershipDetection } from "./ownership-availability.mjs";
|
|
7
8
|
|
|
8
9
|
export const OWNERSHIP_APPS = Object.freeze([
|
|
9
10
|
...DEFAULT_APPS.map(({ key, appDir }) => ({ key, root: key === "coderight" ? key : path.posix.join(key, appDir === "." ? "" : appDir), scope: key === "coderight" ? "repository" : appDir })),
|
|
@@ -11,6 +12,9 @@ export const OWNERSHIP_APPS = Object.freeze([
|
|
|
11
12
|
{ key: "genright", root: "genright" },
|
|
12
13
|
{ key: "rightsites", root: "rightsites" },
|
|
13
14
|
{ key: "voiceright", root: "voiceright" },
|
|
15
|
+
{ key: "pulse", root: "cockpit", scope: "repository" },
|
|
16
|
+
{ key: "postright", root: "postright", scope: "repository" },
|
|
17
|
+
{ key: "legion", root: "legion", scope: "repository" },
|
|
14
18
|
{ key: "membrane", root: "coderight/context/apps/membrane-hub", scope: "context/apps/membrane-hub" },
|
|
15
19
|
]);
|
|
16
20
|
|
|
@@ -22,23 +26,13 @@ function catalogVersion(manifest) {
|
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export function publishedAvailability(capability, versions) {
|
|
25
|
-
|
|
26
|
-
const cargoPackage = capability.cargoPackage ?? (capability.package?.startsWith("rightkit-") ? capability.package : undefined);
|
|
27
|
-
const npmVersion = npmPackage && (versions.npm?.[npmPackage] ?? versions.stagedNpm?.[npmPackage]);
|
|
28
|
-
const cargoVersion = cargoPackage && (versions.cargo?.[cargoPackage] ?? versions.stagedCargo?.[cargoPackage]);
|
|
29
|
-
const swift = capability.package === "rightkit-swift" ? (versions.swift?.["rightkit-swift"] ?? versions.stagedSwift?.["rightkit-swift"]) : undefined;
|
|
30
|
-
const published = [];
|
|
31
|
-
if (npmVersion) published.push({ registry: "npm", package: npmPackage, version: npmVersion, state: versions.npm?.[npmPackage] ? "registry-pinned" : "staged-unverified" });
|
|
32
|
-
if (cargoVersion) published.push({ registry: "crates.io", package: cargoPackage, version: cargoVersion, state: versions.cargo?.[cargoPackage] ? "registry-pinned" : "staged-unverified" });
|
|
33
|
-
if (swift) published.push({ registry: "SwiftPM", package: "rightkit-swift", version: swift.version, url: swift.url, state: versions.swift?.["rightkit-swift"] ? "registry-pinned" : "staged-unverified" });
|
|
34
|
-
return published;
|
|
29
|
+
return ownershipAvailability(capability, versions);
|
|
35
30
|
}
|
|
36
31
|
|
|
37
32
|
function statusFor(row, report) {
|
|
38
33
|
if (report.enrollmentPending && (row.status === "blocked" || row.status === "blocked-by-findings")) return "enrollment-pending";
|
|
39
34
|
if (row.status === "blocked" || row.status === "blocked-by-findings") return "blocked";
|
|
40
35
|
if (row.status === "excepted") return "excepted";
|
|
41
|
-
if (row.status === "qa-only") return "qa-only";
|
|
42
36
|
if (row.status === "verified-integration") return "verified-integration";
|
|
43
37
|
if (row.status === "declared-dependency" || row.uses === true) return "declared-dependency";
|
|
44
38
|
if (report.coverage?.length) return "unscanned";
|
|
@@ -60,14 +54,22 @@ function exceptionRequests(root, app, report) {
|
|
|
60
54
|
}
|
|
61
55
|
|
|
62
56
|
function asRecord(app, root, scope, report, manifest, versions, scannedAt, storageFindings = []) {
|
|
57
|
+
const policies = new Map(manifest.capabilities.flatMap((entry) => entry.fingerprints.map((fingerprint) => [fingerprint.id, fingerprint])));
|
|
58
|
+
const isAdvice = (finding) => policies.get(finding.fingerprint)?.tier === "advise";
|
|
59
|
+
const advisories = (report.findings ?? report.violations ?? []).filter(isAdvice);
|
|
60
|
+
report = { ...report, violations: (report.violations ?? []).filter((finding) => !isAdvice(finding)) };
|
|
63
61
|
const meta = report.metadata ?? {};
|
|
64
|
-
const findings = (report.findings ?? []).map((f) => ({ fingerprint: f.fingerprint, capability: f.capability, file: f.file, line: f.line ?? 0, occurrenceHash: f.occurrenceHash ?? sha256(canonicalJson(f))
|
|
62
|
+
const findings = (report.findings ?? []).map((f) => ({ fingerprint: f.fingerprint, capability: f.capability, file: f.file, line: f.line ?? 0, occurrenceHash: f.occurrenceHash ?? sha256(canonicalJson(f)) }));
|
|
65
63
|
const files = [...new Set(findings.map((f) => f.file))].sort();
|
|
66
64
|
const contentDigestSource = meta.contentDigests ? "scanner-inputs" : "finding-occurrences-only";
|
|
67
65
|
const contentDigests = meta.contentDigests ?? findings.map((f) => ({ path: f.file, digest: f.occurrenceHash, kind: "finding-occurrence" }));
|
|
68
66
|
const rows = (report.rows ?? []).map((row) => {
|
|
69
|
-
const
|
|
70
|
-
|
|
67
|
+
const catalogEntries = manifest.capabilities.filter((c) => c.capability === row.capability);
|
|
68
|
+
const declarationOnly = catalogEntries.length > 0 && catalogEntries.every((entry) => ownershipDetection(entry) === "declaration-only");
|
|
69
|
+
let status = statusFor(row, report);
|
|
70
|
+
if (declarationOnly && status === "verified-integration") status = row.uses ? "declared-dependency" : "not-detected";
|
|
71
|
+
if (status === "blocked" && !report.violations.some((finding) => finding.capability === row.capability) && advisories.some((finding) => finding.capability === row.capability)) status = "advisory";
|
|
72
|
+
return { ...row, status, detection: declarationOnly ? "declaration-only" : "fingerprinted", available: catalogEntries.flatMap((entry) => publishedAvailability(entry, versions)) };
|
|
71
73
|
});
|
|
72
74
|
const enrollmentIsPending = report.enrollmentPending === true;
|
|
73
75
|
const record = {
|
|
@@ -90,13 +92,14 @@ function asRecord(app, root, scope, report, manifest, versions, scannedAt, stora
|
|
|
90
92
|
coverage: report.coverage ?? [],
|
|
91
93
|
rows,
|
|
92
94
|
findings,
|
|
95
|
+
advisories,
|
|
93
96
|
violations: report.violations ?? [],
|
|
94
97
|
covered: report.covered ?? [],
|
|
95
98
|
problems: report.problems ?? [],
|
|
96
99
|
warnings: report.warnings ?? [],
|
|
97
100
|
storageFindings,
|
|
98
101
|
};
|
|
99
|
-
record.resultDigest = sha256(canonicalJson({ app: record.app, commit: record.commit, catalogDigest: record.catalogDigest, contentDigests: record.contentDigests, coverage: record.coverage, rows: record.rows, problems: record.problems, warnings: record.warnings, storageFindings: record.storageFindings, enrollmentPending: record.enrollmentPending }));
|
|
102
|
+
record.resultDigest = sha256(canonicalJson({ app: record.app, commit: record.commit, catalogDigest: record.catalogDigest, contentDigests: record.contentDigests, coverage: record.coverage, rows: record.rows, advisories: record.advisories, problems: record.problems, warnings: record.warnings, storageFindings: record.storageFindings, enrollmentPending: record.enrollmentPending }));
|
|
100
103
|
record.exceptionRequests = exceptionRequests(root, app, report);
|
|
101
104
|
return record;
|
|
102
105
|
}
|
|
@@ -143,8 +146,8 @@ export function renderOwnershipMarkdown(report) {
|
|
|
143
146
|
const state = app.status ?? (states.has("blocked") ? "blocked" : states.has("unscanned") || app.coverage.length ? "unscanned" : "scanned");
|
|
144
147
|
lines.push(`| ${app.app} | ${app.scope ?? "."} | ${state} | ${app.commit ?? "unknown"} | ${app.host ?? "unknown"} | ${app.scanAgeSeconds}s | ${app.findings.length} | ${app.coverage.length} |`);
|
|
145
148
|
}
|
|
146
|
-
lines.push("", "## Capability status", "", "| App | Capability | Status |
|
|
147
|
-
for (const app of report.apps) for (const row of app.rows) lines.push(`| ${app.app} | ${row.capability} | ${row.status} | ${row.available.map((x) => `${x.package}@${x.version} (${x.state})`).join(", ") || "none recorded"} |`);
|
|
149
|
+
lines.push("", "## Capability status", "", "| App | Capability | Status | Detection | Availability |", "|---|---|---|---|---|");
|
|
150
|
+
for (const app of report.apps) for (const row of app.rows) lines.push(`| ${app.app} | ${row.capability} | ${row.status} | ${row.detection} | ${row.available.map((x) => `${x.package}@${x.version} (${x.state})`).join(", ") || "none recorded"} |`);
|
|
148
151
|
lines.push("", `Blocks: ${report.blocks.length} · Exception requests awaiting verification: ${report.exceptionRequests.length}`, "", "Exception requests are unverified records; only scanner-validated signed exceptions are reported as excepted.", "");
|
|
149
152
|
if (report.warnings?.length) {
|
|
150
153
|
lines.splice(-2, 0, "## Warnings", "", ...report.warnings.map((warning) => `- ${warning.app}: WARNING: ${warning.message}; ${warning.findings.length} finding(s), ${warning.problems.length} problem(s) retained as diagnostics`), "");
|