@rightkit/release 0.2.120 → 0.2.122

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/ownership.mjs CHANGED
@@ -1,273 +1,580 @@
1
- // RightKit ownership gate: no Right Suite app keeps its own copy of a capability RightKit owns.
2
- //
3
- // Each RightKit package declares what it owns in `rightkit-ownership.json` beside its manifest
4
- // (capability + fingerprints of a reimplementation). `aggregateOwnership` folds those into the
5
- // shipped `rightkit-ownership.json`, so an app is always checked against the data of the
6
- // RightKit version it pins. `scanApp` reports fingerprint matches in an app repository; each
7
- // must be covered by a live, specific entry in the app's `.rightkit/ownership-exceptions.json`
8
- // (the migration backlog) or the gate fails.
9
- //
10
- // Fingerprint types:
11
- // dependency direct dependency by name (cargo | npm | swiftpm). Transitive use through a
12
- // RightKit package is never flagged.
13
- // source regex over code files (comments-only lines, docs, tests fixtures, vendored and
14
- // generated trees are skipped).
1
+ // RightKit ownership scan. Public exports retain the original findings-array API.
15
2
  import { spawnSync } from "./background-process.mjs";
16
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { canonicalRepositoryIdentity, loadTrustedExternal, occurrenceHash, resolveKeyLogTrust, sha256, verifyException, verifyProtectedProvenance } from "./ownership-approval.mjs";
4
+ import { parse as parseToml } from "smol-toml";
5
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
6
+ import { createHash } from "node:crypto";
7
+ import { acquireCommittedSource, committedSourceDataForInternalUse } from "./committed-source.mjs";
8
+ import { readCommittedKeyAuthority } from "./ownership-key-authority.mjs";
9
+ import { gitListedInventory } from "./ownership-git-inventory.mjs";
10
+ import os from "node:os";
17
11
  import path from "node:path";
18
12
 
19
13
  export const MANIFEST_FILE = "rightkit-ownership.json";
20
14
  export const EXCEPTIONS_FILE = path.join(".rightkit", "ownership-exceptions.json");
21
-
22
- const SKIP_DIRS = new Set([
23
- ".git", "node_modules", "target", "dist", "build", "out", ".next", ".turbo", ".cache",
24
- "vendor", "third_party", "Pods", "DerivedData", ".build", "generated", "fixtures",
25
- "test-fixtures", "__fixtures__", ".rightkit", ".audit",
26
- ]);
27
15
  const CODE_EXT = new Set([".rs", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".jsx", ".swift", ".m", ".mm", ".c", ".cc", ".cpp", ".h", ".py", ".go", ".kt", ".java", ".cs"]);
28
- const MAX_FILE_BYTES = 2 * 1024 * 1024;
16
+ // Listed installed/build inputs retain explicit coverage diagnostics.
17
+ const OPAQUE_DIRS = new Set(["target", ".build", "DerivedData", "Pods"]);
18
+ export const SCANNER_VERSION = "2.2.0";
19
+ export const catalogDigest = (manifest) => sha256(manifest);
20
+ const byteDigest = (bytes) => createHash("sha256").update(bytes).digest("hex");
21
+ const inputDigest = (file) => file.digest ?? byteDigest(file.bytes);
22
+ const sourceCache = new Map();
23
+ const catalogCache = new Map();
24
+
25
+ function sourceMatchers(manifest) {
26
+ const digest = catalogDigest(manifest);
27
+ if (catalogCache.has(digest)) return catalogCache.get(digest);
28
+ const sources = manifest.capabilities.flatMap((c) => c.fingerprints.filter((f) => f.type === "source").map((f) => ({ c, f, re: new RegExp(f.pattern, f.flags ?? "") })));
29
+ let prefilter;
30
+ try {
31
+ // Backreferences cannot be relocated safely inside one alternation. In that
32
+ // case use a permissive prefilter; individual expressions stay authoritative.
33
+ if (sources.some(({ f }) => /\\[1-9]|\\k</.test(f.pattern))) throw new Error("backreference");
34
+ const flags = [...new Set(`m${sources.map(({ re }) => re.flags.replace(/[gy]/g, "")).join("")}`)].join("");
35
+ prefilter = new RegExp(sources.map(({ f }) => `(?:${f.pattern})`).join("|") || "(?!)", flags);
36
+ } catch { prefilter = /[\s\S]/; }
37
+ const matchers = { digest, sources, prefilter };
38
+ catalogCache.set(digest, matchers);
39
+ if (catalogCache.size > 8) catalogCache.delete(catalogCache.keys().next().value);
40
+ return matchers;
41
+ }
42
+
43
+ function sourceFindings(file, matchers) {
44
+ const key = `${matchers.digest}:${file.cacheKey ?? inputDigest(file)}`;
45
+ if (sourceCache.has(key)) return sourceCache.get(key);
46
+ const text = file.bytes.toString("utf8"), findings = [];
47
+ if (matchers.prefilter.test(text)) {
48
+ for (const [i, line] of text.split(/\r?\n/).entries()) if (!commentOnly(line)) for (const { c, f, re } of matchers.sources) {
49
+ re.lastIndex = 0;
50
+ if (re.test(line)) findings.push({ fingerprint: f.id, capability: c.capability, package: c.package, line: i + 1, text: line.trim().slice(0, 160) });
51
+ }
52
+ }
53
+ sourceCache.set(key, findings);
54
+ if (sourceCache.size > 16384) sourceCache.delete(sourceCache.keys().next().value);
55
+ return findings;
56
+ }
29
57
 
30
- /** Collect every package's ownership declaration under a RightKit checkout. */
31
58
  export function aggregateOwnership(rightkitRoot) {
32
59
  const entries = [];
33
- for (const group of ["crates", "packages"]) {
60
+ for (const group of ["crates", "packages", "swift"]) {
34
61
  const dir = path.join(rightkitRoot, group);
35
62
  if (!existsSync(dir)) continue;
36
63
  for (const name of readdirSync(dir).sort()) {
37
- const file = path.join(dir, name, MANIFEST_FILE);
64
+ const child = path.join(dir, name);
65
+ let file = path.join(child, MANIFEST_FILE);
66
+ if (!existsSync(file) && group === "swift") file = path.join(child, "rightkit-ownership.json");
38
67
  if (!existsSync(file)) continue;
39
68
  const declared = JSON.parse(readFileSync(file, "utf8"));
40
- if (declared.schema !== undefined) continue; // the shipped aggregate itself
41
- for (const capability of declared.capabilities ?? []) {
42
- entries.push({ package: declared.package, ...capability });
43
- }
69
+ if (declared.schema !== undefined) continue;
70
+ for (const capability of declared.capabilities ?? []) entries.push({ package: declared.package, ...capability });
44
71
  }
45
72
  }
46
- const manifest = { schema: 1, capabilities: entries };
73
+ const manifest = { schema: 1, version: "1", capabilities: entries };
47
74
  validateManifest(manifest);
48
75
  return manifest;
49
76
  }
50
77
 
51
78
  export function validateManifest(manifest) {
52
79
  const ids = new Set();
53
- for (const c of manifest.capabilities) {
54
- if (!c.package || !c.capability || !Array.isArray(c.fingerprints) || c.fingerprints.length === 0) {
55
- throw new Error(`ownership: incomplete capability ${JSON.stringify(c).slice(0, 120)}`);
56
- }
80
+ for (const c of manifest.capabilities ?? []) {
81
+ if (!c.package || !c.capability || !Array.isArray(c.fingerprints) || !c.fingerprints.length) throw new Error(`ownership: incomplete capability ${JSON.stringify(c).slice(0, 120)}`);
57
82
  for (const f of c.fingerprints) {
58
83
  if (!f.id || ids.has(f.id)) throw new Error(`ownership: missing or duplicate fingerprint id ${f.id}`);
59
84
  ids.add(f.id);
60
85
  if (f.type === "dependency") {
61
86
  if (!["cargo", "npm", "swiftpm"].includes(f.ecosystem) || !f.name) throw new Error(`ownership: bad dependency fingerprint ${f.id}`);
62
- } else if (f.type === "source") {
63
- new RegExp(f.pattern); // throws on a bad pattern
64
- } else {
65
- throw new Error(`ownership: unknown fingerprint type ${f.type} (${f.id})`);
66
- }
87
+ } else if (f.type === "source") new RegExp(f.pattern);
88
+ else throw new Error(`ownership: unknown fingerprint type ${f.type} (${f.id})`);
67
89
  }
68
90
  }
69
91
  }
70
92
 
71
- function skippedDir(name) {
72
- return SKIP_DIRS.has(name) || name.startsWith(".") || /^(node_modules|target)/.test(name);
93
+ function addDependency(out, ecosystem, name, file, details = {}) {
94
+ if (!name) return;
95
+ out.push({ ecosystem, name, file, ...details });
73
96
  }
74
97
 
75
- const RIGHTKIT_MARKER = `packages/release/${MANIFEST_FILE}`;
98
+ function cargoWorkspace(parsed) { return parsed.workspace?.dependencies ?? {}; }
99
+ function resolveLocalPath(root, sourceFile, pathValue, coverage, display, inventory, reasonPrefix = "local-dependency", detail = "") {
100
+ if (!pathValue) return;
101
+ if (inventory?.committed) {
102
+ if (path.posix.isAbsolute(pathValue) || path.win32.isAbsolute(pathValue) || pathValue.includes("\\")) { coverage.push({ path: display, status: "unscanned", reason: `${reasonPrefix}-outside-repository${detail ? `:${detail}` : ""}` }); return; }
103
+ const relative = path.posix.normalize(path.posix.join(path.posix.dirname(sourceFile), pathValue));
104
+ if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) { coverage.push({ path: display, status: "unscanned", reason: `${reasonPrefix}-outside-repository${detail ? `:${detail}` : ""}` }); return; }
105
+ const rel = `${inventory.pathPrefix ?? ""}${relative === "." ? "" : `${relative}/`}`.replace(/\/$/, "");
106
+ const exists = relative === "." || inventory.repositoryFiles.some((item) => item.rel === rel || item.rel.startsWith(`${rel}/`));
107
+ if (!exists) coverage.push({ path: display, status: "unscanned", reason: `${reasonPrefix}-path-unresolved${detail ? `:${detail}` : ""}` });
108
+ return;
109
+ }
110
+ const resolved = path.resolve(root, path.posix.dirname(sourceFile), pathValue);
111
+ try {
112
+ const rootReal = realpathSync(root), targetReal = realpathSync(resolved);
113
+ if (targetReal !== rootReal && !targetReal.startsWith(rootReal + path.sep)) coverage.push({ path: display, status: "unscanned", reason: `${reasonPrefix}-outside-repository${detail ? `:${detail}` : ""}` });
114
+ } catch { coverage.push({ path: display, status: "unscanned", reason: `${reasonPrefix}-path-unresolved${detail ? `:${detail}` : ""}` }); }
115
+ }
76
116
 
77
- // A nested repository is its own app with its own exceptions file, and RightKit's own sources
78
- // are the owner, never a copy.
79
- function walkTree(root, visit, rel = "") {
80
- let entries;
81
- try { entries = readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { return; }
82
- for (const e of entries) {
83
- const r = rel ? `${rel}/${e.name}` : e.name;
84
- if (e.isDirectory()) {
85
- if (!skippedDir(e.name) && !existsSync(path.join(root, r, ".git"))
86
- && !existsSync(path.join(root, r, RIGHTKIT_MARKER))) walkTree(root, visit, r);
87
- } else if (e.isFile()) {
88
- visit(r);
117
+ function parseCargo(parsed, file, out, workspace, root, coverage, inventory) {
118
+ const pathSpec = (pathValue, sourceFile, display) => {
119
+ resolveLocalPath(root, sourceFile, pathValue, coverage, display, inventory);
120
+ };
121
+ const addTable = (table) => {
122
+ if (!table || typeof table !== "object") return;
123
+ for (const section of ["dependencies", "dev-dependencies", "build-dependencies"]) for (const [alias, raw] of Object.entries(table[section] ?? {})) {
124
+ const spec = raw && typeof raw === "object" ? raw : {};
125
+ const inherited = spec.workspace === true ? workspace.deps?.[alias] ?? {} : {};
126
+ const name = spec.package ?? inherited.package ?? alias;
127
+ const source = spec.path ?? inherited.path ? "path" : spec.git ?? inherited.git ? "git" : undefined;
128
+ addDependency(out, "cargo", name, file, { local: source === "path" || source === "git", source });
129
+ if (source === "path") pathSpec(spec.path ?? inherited.path, spec.path ? file : path.posix.join(workspace.dir ?? ".", "Cargo.toml"), file);
130
+ if (source === "git") coverage.push({ path: file, status: "unscanned", reason: `git-dependency-source:${name}` });
131
+ }
132
+ for (const target of Object.values(table.target ?? {})) addTable(target);
133
+ };
134
+ addTable(parsed);
135
+ }
136
+
137
+ function listedCoverage(inventory) {
138
+ const reported = new Set();
139
+ for (const { rel } of inventory.files) {
140
+ const parts = rel.split("/");
141
+ for (const [at, reason] of [[parts.indexOf("node_modules"), "node-modules-provenance-not-verified"], [parts.findIndex((part) => OPAQUE_DIRS.has(part)), "installed-or-build-cache"]]) {
142
+ if (at < 0) continue;
143
+ const dir = parts.slice(0, at + 1).join("/"), key = `${dir}:${reason}`;
144
+ if (!reported.has(key)) { inventory.coverage.push({ path: dir, status: "unscanned", reason }); reported.add(key); }
89
145
  }
90
146
  }
147
+ return inventory;
91
148
  }
92
149
 
93
- // Tracked plus untracked-but-not-ignored files. Git never descends into nested repositories,
94
- // and honoring .gitignore keeps model, data and build trees out of the scan.
95
- function gitFiles(root) {
96
- if (!existsSync(path.join(root, ".git"))) return null;
97
- const r = spawnSync("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
98
- encoding: "utf8", maxBuffer: 256 * 1024 * 1024, windowsHide: true,
150
+ function fileInventory(appRoot, trust, pathPrefix = "", repositoryRoot = appRoot) {
151
+ const protectedPaths = new Set((trust?.provenance ?? []).flatMap((record) => (record.paths ?? []).map((p) => pathPrefix && p.startsWith(pathPrefix) ? p.slice(pathPrefix.length) : p)));
152
+ const inventory = gitListedInventory(appRoot, {
153
+ repositoryRoot,
154
+ protectedPaths: [...protectedPaths],
155
+ relevant: (rel) => ["Cargo.toml", "package.json", "Package.swift"].includes(path.posix.basename(rel)) || CODE_EXT.has(path.posix.extname(rel).toLowerCase()),
156
+ maxFileBytes: (rel) => CODE_EXT.has(path.posix.extname(rel).toLowerCase()) || protectedPaths.has(rel) ? 2 * 1024 * 1024 : 64 * 1024 * 1024,
99
157
  });
100
- if (r.status !== 0) return null;
101
- const files = r.stdout.split("\0").filter((f) => f && !f.endsWith("/"));
102
- const rightkitRoots = files.filter((f) => f === RIGHTKIT_MARKER || f.endsWith(`/${RIGHTKIT_MARKER}`))
103
- .map((f) => f.slice(0, f.length - RIGHTKIT_MARKER.length));
104
- return files.filter((f) => !rightkitRoots.some((p) => f.startsWith(p))
105
- && !f.split("/").slice(0, -1).some(skippedDir));
158
+ for (const item of inventory.coverage) if (item.reason === "file-over-size-limit") item.reason = protectedPaths.has(item.path) ? "protected-provenance-file-over-2MiB" : CODE_EXT.has(path.posix.extname(item.path).toLowerCase()) ? "source-file-over-2MiB" : "manifest-file-over-64MiB";
159
+ return listedCoverage(inventory);
106
160
  }
107
161
 
108
- function walk(root, visit) {
109
- const files = gitFiles(root);
110
- if (files) files.forEach((f) => visit(f));
111
- else walkTree(root, visit);
162
+ function committedFileInventory(appRoot, trust, pathPrefix, committed) {
163
+ const prefix = pathPrefix ? `${pathPrefix.replace(/\/$/, "")}/` : "";
164
+ const repositoryFiles = committed.entries.filter((entry) => entry.type === "blob").map((entry) => ({ rel: entry.rel, size: entry.size, bytes: entry.bytes, mode: entry.mode }));
165
+ const files = [], coverage = committed.coverage.filter((entry) => !prefix || entry.path.startsWith(prefix)).map((entry) => ({ ...entry, path: prefix ? entry.path.slice(prefix.length) : entry.path }));
166
+ const protectedPaths = new Set((trust?.provenance ?? []).flatMap((record) => record.paths ?? []));
167
+ const reportedOpaque = new Set(), reportedNodeModules = new Set();
168
+ for (const entry of committed.entries) {
169
+ if (prefix && !entry.rel.startsWith(prefix)) continue;
170
+ const rel = prefix ? entry.rel.slice(prefix.length) : entry.rel;
171
+ if (!rel) continue;
172
+ if (entry.mode === "120000") { coverage.push({ path: rel, status: "unscanned", reason: "committed-symlink" }); continue; }
173
+ if (entry.mode === "160000" || entry.type === "commit") { coverage.push({ path: rel, status: "unscanned", reason: "committed-gitlink" }); continue; }
174
+ if (entry.type !== "blob") { coverage.push({ path: rel, status: "unscanned", reason: "committed-unsupported-entry" }); continue; }
175
+ const relevant = protectedPaths.has(entry.rel) || ["Cargo.toml", "package.json", "Package.swift", "ownership-exceptions.json"].includes(path.posix.basename(rel)) || CODE_EXT.has(path.posix.extname(rel).toLowerCase());
176
+ const bytes = relevant ? entry.bytes : undefined;
177
+ if (relevant && !bytes) coverage.push({ path: rel, status: "unscanned", reason: "committed-blob-unavailable" });
178
+ const isExceptionFile = path.posix.basename(rel) === "ownership-exceptions.json";
179
+ const limit = CODE_EXT.has(path.posix.extname(rel).toLowerCase()) || protectedPaths.has(entry.rel) || isExceptionFile ? 2 * 1024 * 1024 : 64 * 1024 * 1024;
180
+ if (relevant && entry.size > limit) coverage.push({ path: rel, status: "unscanned", reason: isExceptionFile ? "committed-exceptions-over-2MiB" : protectedPaths.has(entry.rel) ? "protected-provenance-file-over-2MiB" : "source-file-over-2MiB" });
181
+ const parts = rel.split("/");
182
+ const nodeModulesAt = parts.indexOf("node_modules");
183
+ if (nodeModulesAt >= 0) {
184
+ const dir = parts.slice(0, nodeModulesAt + 1).join("/");
185
+ if (!reportedNodeModules.has(dir)) { coverage.push({ path: dir, status: "unscanned", reason: "node-modules-provenance-not-verified" }); reportedNodeModules.add(dir); }
186
+ }
187
+ const opaqueAt = parts.findIndex((part) => OPAQUE_DIRS.has(part));
188
+ if (opaqueAt >= 0) {
189
+ const dir = parts.slice(0, opaqueAt + 1).join("/");
190
+ if (!reportedOpaque.has(dir)) { coverage.push({ path: dir, status: "unscanned", reason: "installed-or-build-cache" }); reportedOpaque.add(dir); }
191
+ }
192
+ files.push({ rel, abs: path.join(appRoot, ...rel.split("/")), size: entry.size, bytes: entry.size <= limit ? bytes : undefined, inNodeModules: rel.split("/").includes("node_modules"), oid: entry.oid, digest: `git:${entry.oid}`, cacheKey: `gitblob:${entry.oid}` });
193
+ }
194
+ return { files, coverage, committed: true, pathPrefix, repositoryFiles, snapshotFiles: new Map(repositoryFiles.map((file) => [file.rel, file])) };
112
195
  }
113
196
 
114
- /** Direct dependencies declared by an app repository: [{ecosystem, name, file}]. */
115
- export function directDependencies(appRoot) {
116
- const deps = [];
117
- walk(appRoot, (rel) => {
118
- const base = path.posix.basename(rel);
119
- const abs = path.join(appRoot, rel);
120
- if (base === "Cargo.toml") {
121
- let section = "";
122
- for (const line of readFileSync(abs, "utf8").split(/\r?\n/)) {
123
- const header = line.match(/^\s*\[([^\]]+)\]/);
124
- if (header) { section = header[1]; continue; }
125
- const dotted = section.match(/(^|\.)(dev-|build-)?dependencies\.([A-Za-z0-9_-]+)$/);
126
- if (dotted) { deps.push({ ecosystem: "cargo", name: dotted[3], file: rel }); section = "~"; continue; }
127
- if (!/(^|\.)((dev|build)-)?dependencies$/.test(section)) continue;
128
- const m = line.match(/^\s*([A-Za-z0-9_-]+)\s*=/);
129
- if (m) deps.push({ ecosystem: "cargo", name: m[1], file: rel });
197
+ function dependenciesFromInventory(appRoot, inventory) {
198
+ const deps = [], coverage = [], contentDigests = [];
199
+ // Workspace inheritance is loaded before member manifests.
200
+ const manifestNames = new Set(["Cargo.toml", "package.json", "Package.swift"]);
201
+ const manifests = inventory.files.filter((x) => manifestNames.has(path.posix.basename(x.rel)));
202
+ const primaryFiles = manifests.filter((x) => !x.inNodeModules);
203
+ // Follow only explicitly declared local package roots into installed layouts. Their own
204
+ // manifests are first-party inputs; unrelated installed transitive manifests stay opaque.
205
+ const selected = new Set(primaryFiles.map((x) => x.rel));
206
+ const visitedRoots = new Set();
207
+ const queue = primaryFiles.filter((x) => path.posix.basename(x.rel) === "package.json");
208
+ while (queue.length) {
209
+ const manifest = queue.shift();
210
+ try {
211
+ const pkg = JSON.parse(manifest.bytes.toString("utf8"));
212
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) for (const spec of Object.values(pkg[field] ?? {})) {
213
+ if (typeof spec !== "string") continue;
214
+ const local = spec.match(/^(?:file:|link:)(.+)$/)?.[1];
215
+ if (!local) continue;
216
+ const target = path.resolve(appRoot, path.posix.dirname(manifest.rel), local);
217
+ const relative = path.relative(appRoot, target).split(path.sep).join("/").replace(/\/$/, "");
218
+ if (visitedRoots.has(relative)) continue;
219
+ visitedRoots.add(relative);
220
+ for (const x of manifests) if (x.rel === `${relative}/package.json` || x.rel.startsWith(`${relative}/`) && manifestNames.has(path.posix.basename(x.rel))) {
221
+ if (!selected.has(x.rel)) selected.add(x.rel);
222
+ if (path.posix.basename(x.rel) === "package.json") queue.push(x);
223
+ }
130
224
  }
131
- } else if (base === "package.json") {
132
- let pkg;
133
- try { pkg = JSON.parse(readFileSync(abs, "utf8")); } catch { return; }
134
- for (const field of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
135
- for (const name of Object.keys(pkg[field] ?? {})) deps.push({ ecosystem: "npm", name, file: rel });
225
+ } catch { /* malformed manifests are reported in the normal parsing pass */ }
226
+ }
227
+ const files = manifests.filter((x) => selected.has(x.rel));
228
+ const cargoFiles = files.filter((x) => path.posix.basename(x.rel) === "Cargo.toml");
229
+ const workspaces = [];
230
+ for (const x of cargoFiles) {
231
+ try { const parsed = parseToml(x.bytes.toString("utf8")); const deps = cargoWorkspace(parsed); if (Object.keys(deps).length) workspaces.push({ dir: path.posix.dirname(x.rel), deps }); }
232
+ catch { coverage.push({ path: x.rel, status: "unscanned", reason: "invalid-cargo-toml" }); }
233
+ }
234
+ for (const x of files) {
235
+ const { rel, bytes } = x;
236
+ if (!bytes) continue;
237
+ const base = path.posix.basename(rel), text = bytes.toString("utf8");
238
+ contentDigests.push({ path: rel, digest: inputDigest(x) });
239
+ if (base === "Cargo.toml") {
240
+ const dir = path.posix.dirname(rel);
241
+ const workspace = workspaces.filter((x) => x.dir === "." || dir === x.dir || dir.startsWith(`${x.dir}/`)).sort((a, b) => b.dir.length - a.dir.length)[0] ?? { dir: ".", deps: {} };
242
+ try { parseCargo(parseToml(text), rel, deps, workspace, appRoot, coverage, inventory); }
243
+ catch { coverage.push({ path: rel, status: "unscanned", reason: "invalid-cargo-toml" }); }
244
+ }
245
+ else if (base === "package.json") {
246
+ let pkg; try { pkg = JSON.parse(text); } catch { coverage.push({ path: rel, status: "unscanned", reason: "invalid-json" }); continue; }
247
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) for (const [name, spec] of Object.entries(pkg[field] ?? {})) {
248
+ if (typeof spec !== "string") continue;
249
+ const alias = spec.match(/^(?:npm:)(@[^/]+\/[^@]+|[^@]+)(?:@|$)/)?.[1];
250
+ const localPath = spec.match(/^(?:file:|link:)(.+)$/)?.[1];
251
+ const isGit = /^(git\+|https?:.*\.git)/i.test(spec);
252
+ addDependency(deps, "npm", alias ?? name, rel, { alias: alias ? name : undefined, local: !!localPath, source: localPath ? spec.split(":")[0] : isGit ? "git" : spec.startsWith("workspace:") ? "workspace" : undefined });
253
+ if (localPath) {
254
+ resolveLocalPath(appRoot, rel, localPath, coverage, rel, inventory, "local-npm-dependency", name);
255
+ } else if (isGit) coverage.push({ path: rel, status: "unscanned", reason: `git-npm-dependency-source:${name}` });
136
256
  }
137
- } else if (base === "Package.swift") {
138
- for (const m of readFileSync(abs, "utf8").matchAll(/\.package\([^)]*url:\s*"([^"]+)"/g)) {
139
- const name = m[1].replace(/\.git$/, "").split("/").pop();
140
- deps.push({ ecosystem: "swiftpm", name, file: rel });
257
+ } else {
258
+ for (const m of text.matchAll(/\.package\s*\(([^)]*)\)/g)) {
259
+ const arg = m[1], url = arg.match(/url:\s*["']([^"']+)["']/)?.[1], pathDep = arg.match(/path:\s*["']([^"']+)["']/)?.[1];
260
+ const id = arg.match(/name:\s*["']([^"']+)["']/)?.[1] ?? url?.replace(/\.git$/i, "").split("/").pop()?.toLowerCase() ?? pathDep?.split(/[\\/]/).pop();
261
+ addDependency(deps, "swiftpm", id, rel, { local: !!pathDep, source: pathDep ? "path" : url });
262
+ if (pathDep) {
263
+ resolveLocalPath(appRoot, rel, pathDep, coverage, rel, inventory, "local-swift-dependency", id);
264
+ }
141
265
  }
142
266
  }
143
- });
267
+ }
268
+ Object.defineProperty(deps, "coverage", { value: coverage, enumerable: false });
269
+ Object.defineProperty(deps, "contentDigests", { value: contentDigests, enumerable: false });
144
270
  return deps;
145
271
  }
146
272
 
147
- function isCommentOnly(line) {
148
- const t = line.trim();
149
- return t.startsWith("//") || t.startsWith("#") || t.startsWith("*") || t.startsWith("/*") || t.startsWith("--");
273
+ export function directDependencies(appRoot) {
274
+ const inventory = fileInventory(appRoot);
275
+ const deps = dependenciesFromInventory(appRoot, inventory);
276
+ deps.coverage.push(...inventory.coverage);
277
+ return deps;
150
278
  }
151
279
 
152
- /** All fingerprint matches in an app: [{fingerprint, capability, package, file, line, text}]. */
153
- export function scanApp(appRoot, manifest) {
154
- const findings = [];
155
- const deps = directDependencies(appRoot);
156
- const sources = [];
157
- for (const c of manifest.capabilities) {
158
- for (const f of c.fingerprints) {
159
- if (f.type === "dependency") {
160
- for (const d of deps) {
161
- if (d.ecosystem === f.ecosystem && d.name === f.name) {
162
- findings.push({ fingerprint: f.id, capability: c.capability, package: c.package, file: d.file, line: 0, text: `${d.ecosystem} dependency ${d.name}` });
163
- }
164
- }
165
- } else {
166
- sources.push({ c, f, re: new RegExp(f.pattern, f.flags ?? "") });
280
+ /** Git-listed content inventory; clean inputs use their blob IDs. */
281
+ export function contentInventory(appRoot) {
282
+ const inventory = listedCoverage(gitListedInventory(appRoot, { maxFileBytes: () => 64 * 1024 * 1024 }));
283
+ const files = inventory.files.filter((file) => file.digest).map((file) => ({ path: file.rel, size: file.size, digest: file.digest }));
284
+ files.sort((a, b) => a.path.localeCompare(b.path));
285
+ return { digest: sha256(files), files, coverage: inventory.coverage, complete: inventory.coverage.length === 0 };
286
+ }
287
+
288
+ function commentOnly(line) { const t = line.trim(); return t.startsWith("//") || t.startsWith("#") || t.startsWith("*") || t.startsWith("/*") || t.startsWith("--"); }
289
+ function repositoryIdentityFor(root) {
290
+ const origin = spawnSync("git", ["-C", root, "config", "--get", "remote.origin.url"], { encoding: "utf8", windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
291
+ if (origin.status !== 0) return null;
292
+ try { return canonicalRepositoryIdentity(origin.stdout.trim()); } catch { return null; }
293
+ }
294
+
295
+ function scanRepositoryRoot(appRoot) {
296
+ const result = spawnSync("git", ["-C", appRoot, "rev-parse", "--show-toplevel", "HEAD"], { encoding: "utf8", windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
297
+ const [rootPath, head] = String(result.stdout ?? "").trimEnd().split(/\r?\n/);
298
+ const commit = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(head ?? "") ? head : null;
299
+ // Unborn repositories still print their root before HEAD resolution fails.
300
+ if (!rootPath || !path.isAbsolute(rootPath)) return { root: path.resolve(appRoot), prefix: "", pathBase: "scan-root", commit };
301
+ // Git prints the resolved root (macOS /private/var/...); resolve the app root the same way so a
302
+ // symlinked spelling (/var, /tmp) is not mistaken for a path outside the repository.
303
+ const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
304
+ const root = real(rootPath), app = real(appRoot);
305
+ const relative = path.relative(root, app);
306
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return { root: app, prefix: "", pathBase: "scan-root", commit };
307
+ return { root, prefix: relative ? `${relative.split(path.sep).join("/")}/` : "", pathBase: "repository", commit };
308
+ }
309
+
310
+ function trustedProvenance(appRoot, inventory, trust, repositoryIdentity, coverage, repositoryRoot, pathPrefix) {
311
+ const excluded = new Map(), classifications = [];
312
+ if (!trust || !repositoryIdentity) return { excluded, classifications };
313
+ const scannedFiles = new Map(inventory.files.map((file) => [file.rel, file]));
314
+ for (const record of trust.provenance ?? []) {
315
+ if (record.repositoryIdentity !== repositoryIdentity) continue;
316
+ if (!(record.paths ?? []).some((rel) => !pathPrefix || rel.startsWith(pathPrefix))) continue;
317
+ const files = [];
318
+ let safe = true;
319
+ for (const rel of record.paths ?? []) {
320
+ if (inventory.committed) {
321
+ const snapshotFile = inventory.snapshotFiles.get(rel);
322
+ if (!snapshotFile?.bytes || snapshotFile.size > 2 * 1024 * 1024) { safe = false; break; }
323
+ files.push({ path: rel, content: snapshotFile.bytes });
324
+ continue;
167
325
  }
326
+ const rootReal = realpathSync(repositoryRoot);
327
+ const filePath = path.resolve(rootReal, ...rel.split("/"));
328
+ if (!filePath.startsWith(rootReal + path.sep)) { safe = false; break; }
329
+ try {
330
+ let current = repositoryRoot, st;
331
+ for (const part of rel.split("/")) {
332
+ current = path.join(current, part);
333
+ st = lstatSync(current);
334
+ if (st.isSymbolicLink()) { safe = false; break; }
335
+ }
336
+ if (!safe || !st?.isFile()) { safe = false; break; }
337
+ let content;
338
+ if (!pathPrefix || rel.startsWith(pathPrefix)) {
339
+ const inventoried = scannedFiles.get(pathPrefix ? rel.slice(pathPrefix.length) : rel);
340
+ if (!inventoried?.bytes || inventoried.size > 2 * 1024 * 1024) { safe = false; break; }
341
+ content = inventoried.bytes;
342
+ } else {
343
+ // Multi-root records may include repository siblings; read them only to verify the full signed record.
344
+ if (st.size > 2 * 1024 * 1024) { safe = false; break; }
345
+ content = readFileSync(current);
346
+ }
347
+ files.push({ path: rel, content });
348
+ } catch { safe = false; break; }
349
+ }
350
+ const verified = safe && verifyProtectedProvenance(trust, { repositoryIdentity, paths: record.paths, files, kind: record.kind, requestId: record.requestId });
351
+ if (!verified?.ok) {
352
+ for (const rel of record.paths ?? []) if (!pathPrefix || rel.startsWith(pathPrefix)) coverage.push({ path: pathPrefix ? rel.slice(pathPrefix.length) : rel, status: "unscanned", reason: "protected-provenance-stale-or-unreadable" });
353
+ continue;
168
354
  }
355
+ const classification = { kind: record.kind, requestId: record.requestId, contentDigest: record.contentDigest };
356
+ classifications.push({ ...classification, paths: [...record.paths].sort() });
357
+ for (const rel of record.paths) if (!pathPrefix || rel.startsWith(pathPrefix)) excluded.set(pathPrefix ? rel.slice(pathPrefix.length) : rel, classification);
169
358
  }
170
- if (sources.length) {
171
- // One combined pass rejects almost every file and line before the per-fingerprint tests.
172
- const any = new RegExp(sources.map(({ f }) => `(?:${f.pattern})`).join("|"));
173
- walk(appRoot, (rel) => {
174
- if (!CODE_EXT.has(path.posix.extname(rel))) return;
175
- const abs = path.join(appRoot, rel);
176
- try { if (statSync(abs).size > MAX_FILE_BYTES) return; } catch { return; }
177
- const text = readFileSync(abs, "utf8");
178
- if (!any.test(text)) return;
179
- const lines = text.split(/\r?\n/);
180
- lines.forEach((line, i) => {
181
- if (!any.test(line) || isCommentOnly(line)) return;
182
- for (const { c, f, re } of sources) {
183
- if (re.test(line)) findings.push({ fingerprint: f.id, capability: c.capability, package: c.package, file: rel, line: i + 1, text: line.trim().slice(0, 160) });
184
- }
185
- });
186
- });
359
+ return { excluded, classifications };
360
+ }
361
+
362
+ function scanInternal(appRoot, manifest, { inventory = fileInventory(appRoot), trust, repositoryIdentity, repositoryRoot = appRoot, pathPrefix = "" } = {}) {
363
+ const findings = [], coverage = [...inventory.coverage], deps = dependenciesFromInventory(appRoot, inventory), contentDigests = [...(deps.contentDigests ?? [])];
364
+ coverage.push(...(deps.coverage ?? []));
365
+ const provenance = trustedProvenance(appRoot, inventory, trust, repositoryIdentity, coverage, repositoryRoot, pathPrefix);
366
+ for (const c of manifest.capabilities) for (const f of c.fingerprints.filter((x) => x.type === "dependency")) for (const d of deps) {
367
+ if (d.ecosystem === f.ecosystem && (d.name === f.name || d.alias === f.name)) {
368
+ findings.push({ fingerprint: f.id, capability: c.capability, package: c.package, file: d.file, line: 0, text: `${d.ecosystem} dependency ${d.alias ? `${d.alias} aliases ` : ""}${d.name}`, local: d.local, provenance: d.source });
369
+ }
187
370
  }
188
- return findings;
371
+ const matchers = sourceMatchers(manifest);
372
+ for (const file of inventory.files) {
373
+ const { rel, size, bytes } = file;
374
+ if (!CODE_EXT.has(path.posix.extname(rel).toLowerCase()) || !bytes) continue;
375
+ if (provenance.excluded.has(rel)) {
376
+ contentDigests.push({ path: rel, digest: inputDigest(file), provenance: provenance.excluded.get(rel) });
377
+ continue;
378
+ }
379
+ if (size > 2 * 1024 * 1024) { coverage.push({ path: rel, status: "unscanned", reason: "source-file-over-2MiB" }); continue; }
380
+ contentDigests.push({ path: rel, digest: inputDigest(file) });
381
+ for (const finding of sourceFindings(file, matchers)) findings.push({ ...finding, file: rel });
382
+ }
383
+ if (pathPrefix) {
384
+ const prefix = (p) => p === "." ? pathPrefix.slice(0, -1) : `${pathPrefix}${p}`;
385
+ for (const f of findings) f.file = prefix(f.file);
386
+ for (const c of coverage) c.path = prefix(c.path);
387
+ for (const d of deps) d.file = prefix(d.file);
388
+ for (const d of contentDigests) d.path = prefix(d.path);
389
+ }
390
+ for (const f of findings) f.occurrenceHash = occurrenceHash(f);
391
+ contentDigests.sort((a, b) => a.path.localeCompare(b.path));
392
+ return { findings, coverage, deps, contentDigests, provenance: provenance.classifications };
393
+ }
394
+
395
+ function trustForScan(options = {}) {
396
+ if (options.trust !== undefined) return options.trust?.trust ?? options.trust;
397
+ try { return loadTrustedExternal().trust; } catch { return null; }
398
+ }
399
+
400
+ function resolveSharedAuthority(trust) {
401
+ if (!trust?.keyAuthority) return { trust };
402
+ const authority = readCommittedKeyAuthority(trust);
403
+ if (!authority.ok) throw new Error(`protected ownership key authority failed: ${authority.problems.join("; ")}`);
404
+ if (!authority.resolvedTrust.ownershipKeys.length) throw new Error("protected ownership key authority has no active approval keys");
405
+ return { trust: authority.resolvedTrust, keyLogDigest: authority.digest, keyCheckpoint: authority.keyCheckpoint, rootFingerprint: authority.rootFingerprint, keyAuthority: authority.authority };
406
+ }
407
+
408
+ function resolveRepositoryKeyLog(trust, repositoryRoot, repositoryIdentity) {
409
+ if (!trust?.rootFingerprint || trust.keyAuthority) return { trust };
410
+ const authority = readCommittedKeyAuthority({ ...trust, keyAuthority: { repositoryIdentity, checkoutPath: repositoryRoot } });
411
+ if (!authority.ok) throw new Error(`protected ownership key log failed: ${authority.problems.join("; ")}`);
412
+ if (!authority.resolvedTrust.ownershipKeys.length) throw new Error("protected ownership key log has no active approval keys");
413
+ return { trust: authority.resolvedTrust, keyLogDigest: authority.digest, keyCheckpoint: authority.keyCheckpoint, rootFingerprint: authority.rootFingerprint };
414
+ }
415
+
416
+ export function scanApp(appRoot, manifest, options = {}) {
417
+ const initialTrust = trustForScan(options), location = scanRepositoryRoot(appRoot);
418
+ const repositoryIdentity = repositoryIdentityFor(appRoot);
419
+ const authority = initialTrust?.keyAuthority ? resolveSharedAuthority(initialTrust) : resolveRepositoryKeyLog(initialTrust, location.root, repositoryIdentity);
420
+ const trust = authority.trust;
421
+ const result = scanInternal(appRoot, manifest, { inventory: fileInventory(appRoot, trust, location.prefix, location.root), trust, repositoryIdentity, repositoryRoot: location.root, pathPrefix: location.prefix });
422
+ Object.defineProperty(result.findings, "coverage", { value: result.coverage, enumerable: false });
423
+ Object.defineProperty(result.findings, "metadata", { value: { ...scanMetadata(appRoot, manifest, result.coverage, result.contentDigests, repositoryIdentity, location.commit), pathBase: location.pathBase, pathPrefix: location.prefix, provenance: result.provenance, ...Object.fromEntries(Object.entries(authority).filter(([key]) => key !== "trust")) }, enumerable: false });
424
+ return result.findings;
425
+ }
426
+
427
+ function scanMetadata(root, manifest, coverage = [], contentDigests = [], knownIdentity, commit = null) {
428
+ const fullCoverage = [...coverage];
429
+ let repositoryIdentity = null;
430
+ if (knownIdentity !== undefined) repositoryIdentity = knownIdentity;
431
+ else repositoryIdentity = repositoryIdentityFor(root);
432
+ return { repositoryIdentity, commit, host: `${os.hostname()}/${process.platform}`, scannedAt: new Date().toISOString(), catalogDigest: catalogDigest(manifest), catalogVersion: manifest.version ?? String(manifest.schema ?? "1"), scannerVersion: SCANNER_VERSION, contentDigests, coverage: fullCoverage };
189
433
  }
190
434
 
191
435
  function globToRegExp(glob) {
192
- let re = "";
193
- for (let i = 0; i < glob.length; i++) {
194
- const ch = glob[i];
195
- if (ch === "*" && glob[i + 1] === "*") { re += ".*"; i++; if (glob[i + 1] === "/") i++; }
196
- else if (ch === "*") re += "[^/]*";
197
- else if (ch === "?") re += "[^/]";
198
- else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
199
- }
200
- return new RegExp(`^${re}$`);
436
+ let out = ""; for (let i = 0; i < glob.length; i++) { const c = glob[i]; if (c === "*" && glob[i + 1] === "*") { out += ".*"; i++; if (glob[i + 1] === "/") i++; } else if (c === "*") out += "[^/]*"; else if (c === "?") out += "[^/]"; else out += c.replace(/[.+^${}()|[\]\\]/g, "\\$&"); }
437
+ return new RegExp(`^${out}$`);
201
438
  }
439
+ export function loadExceptions(root) { const file = path.join(root, EXCEPTIONS_FILE); if (!existsSync(file)) return []; return (JSON.parse(readFileSync(file, "utf8")).exceptions ?? []).map((e, index) => ({ ...e, index })); }
202
440
 
203
- /** Load and validate an app's exceptions (the migration backlog). */
204
- export function loadExceptions(appRoot) {
205
- const file = path.join(appRoot, EXCEPTIONS_FILE);
206
- if (!existsSync(file)) return [];
207
- const parsed = JSON.parse(readFileSync(file, "utf8"));
208
- return (parsed.exceptions ?? []).map((e, i) => ({ ...e, index: i }));
441
+ function enrollmentPending(trust, state) {
442
+ if (state?.pending !== undefined) return state.pending;
443
+ if (state?.enrolled !== undefined) return !state.enrolled;
444
+ if (trust?.rootFingerprint) return !Array.isArray(trust.ownershipKeys) || trust.ownershipKeys.length === 0;
445
+ const keys = [trust?.hosts?.windows, trust?.hosts?.macos];
446
+ return !keys.every((key) => key?.keyId && key.publicKeyPem && !trust.revokedKeyIds?.includes(key.keyId)) || keys[0]?.keyId === keys[1]?.keyId || keys[0]?.publicKeyPem === keys[1]?.publicKeyPem;
209
447
  }
210
448
 
211
- /**
212
- * Apply exceptions. Returns {violations, covered, problems}: uncovered findings, findings an
213
- * exception covers, and exception defects (expired, stale, wildcard-only, incomplete).
214
- */
215
- export function evaluate(findings, exceptions, { today = new Date().toISOString().slice(0, 10) } = {}) {
216
- const problems = [];
217
- const live = [];
449
+ export function evaluate(findings, exceptions, options = {}) {
450
+ const today = options.today ?? new Date().toISOString().slice(0, 10), problems = [], covered = [], violations = [], warnings = [];
451
+ const metadata = options.metadata ?? findings.metadata ?? {};
452
+ const catalogDigest = metadata.catalogDigest ?? options.catalogDigest ?? "";
453
+ const identity = options.repositoryIdentity ?? metadata.repositoryIdentity ?? "";
218
454
  for (const e of exceptions) {
219
- const where = `exception #${e.index} (${e.fingerprint ?? "?"})`;
220
- const missing = ["fingerprint", "paths", "reason", "owner", "approvedBy"].filter((k) => !e[k] || (Array.isArray(e[k]) && e[k].length === 0));
455
+ const p = e.payload ?? e;
456
+ const where = `exception #${e.index} (${p.fingerprint ?? "?"})`;
457
+ const missing = ["fingerprint", "paths", "reason", "owner", "expires", "requestId", "occurrenceHashes"].filter((k) => !p[k] || (Array.isArray(p[k]) && !p[k].length));
221
458
  if (missing.length) { problems.push(`${where}: missing ${missing.join(", ")}`); continue; }
222
- if (!e.expires && !e.tracking) { problems.push(`${where}: needs an expires date or a tracking item`); continue; }
223
- if (e.paths.some((p) => /^\*+(\/\*+)*$/.test(p))) { problems.push(`${where}: wildcard-only paths are not allowed`); continue; }
224
- if (e.expires && e.expires < today) { problems.push(`${where}: expired ${e.expires}`); continue; }
225
- live.push({ ...e, matchers: e.paths.map(globToRegExp), used: false });
226
- }
227
- const violations = [];
228
- const covered = [];
229
- for (const f of findings) {
230
- const e = live.find((x) => x.fingerprint === f.fingerprint && x.matchers.some((m) => m.test(f.file)));
231
- if (e) { e.used = true; covered.push({ ...f, exception: e }); } else violations.push(f);
459
+ const approvedBy = e.approvedBy ?? p.approvedBy;
460
+ if (approvedBy !== undefined && approvedBy !== "Adrian") { problems.push(`${where}: approvedBy must be Adrian`); continue; }
461
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(p.expires) || Number.isNaN(Date.parse(`${p.expires}T00:00:00Z`)) || p.expires < today) { problems.push(`${where}: invalid or expired expires date`); continue; }
462
+ if (!p.trackingRef || typeof p.trackingRef !== "object" || !p.trackingRef.verified || !/^https:\/\//.test(p.trackingRef.url ?? "") || !p.trackingRef.id) { problems.push(`${where}: tracking must be a verified RightKit request reference`); continue; }
463
+ if (p.paths.some((item) => !item || item.includes("\\") || item.startsWith("/") || item.split("/").includes("..") || /[*?\[\]{}]/.test(item))) { problems.push(`${where}: exception paths must be exact repository-relative paths`); continue; }
464
+ const verification = verifyException(e, { repositoryIdentity: identity, catalogDigest, findings, trust: options.trust, today, scannerVersion: options.scannerVersion ?? metadata.scannerVersion ?? SCANNER_VERSION, catalogVersion: options.catalogVersion ?? metadata.catalogVersion });
465
+ if (!verification?.ok) { problems.push(`${where}: signature verification failed${verification?.problems?.length ? ` (${verification.problems.join("; ")})` : ""}`); continue; }
466
+ const matching = findings.filter((f) => f.fingerprint === p.fingerprint && p.paths.includes(f.file) && p.occurrenceHashes.includes(occurrenceHash(f)));
467
+ if (!matching.length) { problems.push(`${where}: stale, matches no signed occurrence`); continue; }
468
+ for (const f of matching) covered.push({ ...f, occurrenceHash: occurrenceHash(f), exception: e });
232
469
  }
233
- for (const e of live) if (!e.used) problems.push(`exception #${e.index} (${e.fingerprint}): stale, matches nothing; delete it`);
234
- return { violations, covered, problems };
470
+ const pending = enrollmentPending(options.trust, options.trustEnrollment);
471
+ if (pending) warnings.push("approval enrollment pending; detected findings remain open backlog requests until distinct Windows & macOS keys are enrolled");
472
+ for (const f of findings) if (!covered.some((x) => x.occurrenceHash === occurrenceHash(f))) violations.push({ ...f, occurrenceHash: occurrenceHash(f) });
473
+ return { violations, covered, problems, warnings, enrollmentPending: pending };
235
474
  }
236
475
 
237
- /** One row per capability: uses RightKit | own copy (exception, expiry) | not needed. */
238
476
  export function capabilityTable(manifest, deps, result) {
239
- const rows = [];
240
- const byCapability = new Map();
241
- for (const c of manifest.capabilities) {
242
- if (!byCapability.has(c.capability)) byCapability.set(c.capability, c.package);
477
+ return [...new Map(manifest.capabilities.map((c) => [c.capability, c.package])).entries()].map(([capability, owner]) => {
478
+ const declared = deps.some((d) => d.name === owner || d.name === owner.replace(/^rightkit-/, "@rightkit/"));
479
+ const own = result.violations.filter((x) => x.capability === capability), covered = result.covered.filter((x) => x.capability === capability);
480
+ const status = own.length ? "blocked" : covered.length ? "excepted" : declared ? "declared-dependency" : result.coverage?.length ? "unscanned" : "not-detected";
481
+ return { capability, owner, uses: declared, status, count: own.length + covered.length };
482
+ });
483
+ }
484
+ export function formatTable(rows) { return ["capability owner status", ...rows.map((x) => `${x.capability} ${x.owner} ${x.status}`)].join("\n"); }
485
+
486
+ export function checkApp(appRoot, manifest, options = {}) {
487
+ let trustState = options.trust;
488
+ if (!trustState) trustState = loadTrustedExternal();
489
+ const initialTrust = trustState && Object.prototype.hasOwnProperty.call(trustState, "trust") ? trustState.trust : trustState;
490
+ const repositoryLocation = scanRepositoryRoot(appRoot);
491
+ const repositoryIdentity = repositoryIdentityFor(appRoot);
492
+ const resolved = initialTrust?.keyAuthority ? resolveSharedAuthority(initialTrust) : resolveRepositoryKeyLog(initialTrust, repositoryLocation.root, repositoryIdentity);
493
+ const trust = resolved.trust;
494
+ const location = repositoryLocation;
495
+ const scanned = scanInternal(appRoot, manifest, { inventory: fileInventory(appRoot, trust, location.prefix, location.root), trust, repositoryIdentity, repositoryRoot: location.root, pathPrefix: location.prefix });
496
+ const metadata = { ...scanMetadata(appRoot, manifest, scanned.coverage, scanned.contentDigests, repositoryIdentity, location.commit), pathBase: location.pathBase, pathPrefix: location.prefix, provenance: scanned.provenance, ...Object.fromEntries(Object.entries(resolved).filter(([key]) => key !== "trust")) };
497
+ if (trust?.rootFingerprint && Array.isArray(trust.ownershipKeys) && !trust.ownershipKeys.length) throw new Error("protected ownership key log has no active approval keys");
498
+ const enrollment = trust?.rootFingerprint
499
+ ? { enrolled: trust.enrollmentComplete === true, pending: trust.enrollmentComplete !== true }
500
+ : trustState?.pending !== undefined || trustState?.enrolled !== undefined ? trustState : undefined;
501
+ const result = evaluate(scanned.findings, loadExceptions(appRoot), { ...options, trust, trustEnrollment: enrollment, metadata });
502
+ const coverage = [...new Map(metadata.coverage.map((x) => [`${x.path}\0${x.reason}`, x])).values()];
503
+ const rows = capabilityTable(manifest, scanned.deps, { ...result, coverage });
504
+ return { ok: result.enrollmentPending || (!result.violations.length && !result.problems.length && !coverage.length), rows, ...result, findings: scanned.findings, coverage, metadata };
505
+ }
506
+
507
+ function checkCommitted(appRoot, manifest, options, acquire) {
508
+ let trustState = options.trust;
509
+ if (!trustState) trustState = loadTrustedExternal();
510
+ let trust = trustState && Object.prototype.hasOwnProperty.call(trustState, "trust") ? trustState.trust : trustState;
511
+ const requiredPaths = [...(trust?.provenance ?? []).flatMap((record) => record.paths ?? []), ...(trust?.rootFingerprint && !trust?.keyAuthority ? [".rightkit/ownership-keys.json"] : [])];
512
+ const sourceHandle = options.source ?? acquire(appRoot, { expectedCommit: options.expectedCommit, expectedTree: options.expectedTree, requiredPaths });
513
+ const committed = committedSourceDataForInternalUse(sourceHandle);
514
+ if (!committed) throw new Error("committed ownership: supplied source handle was not acquired by trusted Git reader");
515
+ if ((options.expectedCommit && committed.commit !== options.expectedCommit) || (options.expectedTree && committed.tree !== options.expectedTree)) throw new Error("committed ownership: source handle does not match expected commit/tree");
516
+ // Compare real paths: Git reports the resolved root (macOS /private/var/...), while callers
517
+ // may pass a symlinked spelling of the same directory (/var/..., /tmp/...).
518
+ const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
519
+ const relativeRoot = path.relative(real(committed.repositoryRoot), real(appRoot));
520
+ if (relativeRoot === ".." || relativeRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRoot)) throw new Error("committed ownership: scan root is outside committed repository");
521
+ const pathPrefix = relativeRoot ? `${relativeRoot.split(path.sep).join("/")}/` : "";
522
+ const repositoryIdentity = committed.origin ? canonicalRepositoryIdentity(committed.origin) : null;
523
+ if (!repositoryIdentity) throw new Error("committed ownership: repository origin is missing or invalid");
524
+ let keyLogDigest, keyCheckpoint, keyAuthorityBinding;
525
+ if (trust?.keyAuthority) {
526
+ const authority = readCommittedKeyAuthority(trust);
527
+ if (!authority.ok) throw new Error(`committed ownership key authority failed: ${authority.problems.join("; ")}`);
528
+ trust = authority.resolvedTrust;
529
+ if (!trust.ownershipKeys.length) throw new Error("protected ownership key authority has no active approval keys");
530
+ keyLogDigest = authority.digest;
531
+ keyCheckpoint = authority.keyCheckpoint;
532
+ keyAuthorityBinding = authority.authority;
533
+ } else if (trust?.rootFingerprint) {
534
+ const keyLog = committed.entries.find((entry) => entry.rel === ".rightkit/ownership-keys.json");
535
+ const resolved = resolveKeyLogTrust(trust, keyLog?.bytes);
536
+ if (!resolved.ok) throw new Error(`committed ownership key log failed: ${resolved.problems.join("; ")}`);
537
+ trust = resolved.trust;
538
+ if (!trust.ownershipKeys.length) throw new Error("protected ownership key log has no active approval keys");
539
+ keyLogDigest = resolved.keyLogDigest;
540
+ keyCheckpoint = resolved.checkpoint;
243
541
  }
244
- for (const [capability, owner] of byCapability) {
245
- const uses = deps.some((d) => d.name === owner || d.name === owner.replace(/^rightkit-/, "@rightkit/"));
246
- const own = result.violations.filter((v) => v.capability === capability);
247
- const excepted = result.covered.filter((v) => v.capability === capability);
248
- let status;
249
- if (own.length) status = `OWN COPY, NOT EXCEPTED (${own.length})`;
250
- else if (excepted.length) {
251
- const e = excepted[0].exception;
252
- status = `own copy, excepted until ${e.expires ?? e.tracking} (${excepted.length})`;
253
- } else status = uses ? "uses RightKit" : "not needed";
254
- rows.push({ capability, owner, uses, status });
542
+ const location = { root: committed.repositoryRoot, prefix: pathPrefix, pathBase: "repository" };
543
+ const inventory = committedFileInventory(appRoot, trust, location.prefix, committed);
544
+ if (location.prefix && !committed.entries.some((entry) => entry.rel.startsWith(location.prefix))) {
545
+ inventory.coverage.push({ path: ".", status: "unscanned", reason: "registered-root-missing-from-committed-tree" });
255
546
  }
256
- return rows;
547
+ const scanned = scanInternal(appRoot, manifest, { inventory, trust, repositoryIdentity, repositoryRoot: location.root, pathPrefix: location.prefix });
548
+ const exceptionPath = [location.prefix.replace(/\/$/, ""), EXCEPTIONS_FILE.split(path.sep).join("/")].filter(Boolean).join("/");
549
+ const exceptionObject = committed.entries.find((entry) => entry.rel === exceptionPath);
550
+ const exceptionEntry = exceptionObject?.bytes;
551
+ let exceptions = [];
552
+ if (exceptionEntry && exceptionObject.size <= 2 * 1024 * 1024) {
553
+ try { exceptions = (JSON.parse(exceptionEntry.toString("utf8")).exceptions ?? []).map((entry, index) => ({ ...entry, index })); }
554
+ catch { scanned.coverage.push({ path: EXCEPTIONS_FILE.split(path.sep).join("/"), status: "unscanned", reason: "invalid-committed-exceptions-json" }); }
555
+ }
556
+ const exceptionDigest = exceptionEntry ? byteDigest(exceptionEntry) : exceptionObject ? sha256({ unavailableBlob: exceptionObject.oid, size: exceptionObject.size }) : sha256([]);
557
+ const metadata = {
558
+ repositoryIdentity, commit: committed.commit, tree: committed.tree, host: `${os.hostname()}/${process.platform}`,
559
+ scannedAt: new Date().toISOString(), catalogDigest: catalogDigest(manifest), catalogVersion: manifest.version ?? String(manifest.schema ?? "1"),
560
+ scannerVersion: SCANNER_VERSION, contentDigests: scanned.contentDigests, coverage: scanned.coverage,
561
+ pathBase: location.pathBase, pathPrefix: location.prefix, provenance: scanned.provenance, exceptionDigest,
562
+ source: "committed-git-tree", ...(keyLogDigest ? { keyLogDigest, keyCheckpoint, rootFingerprint: trust.rootFingerprint, ...(keyAuthorityBinding ? { keyAuthority: keyAuthorityBinding } : {}) } : {}),
563
+ };
564
+ const result = evaluate(scanned.findings, exceptions, { ...options, trust, trustEnrollment: trust?.rootFingerprint
565
+ ? { enrolled: trust.enrollmentComplete === true, pending: trust.enrollmentComplete !== true }
566
+ : trustState?.pending !== undefined || trustState?.enrolled !== undefined ? trustState : undefined, metadata });
567
+ const coverage = [...new Map(metadata.coverage.map((x) => [`${x.path}\0${x.reason}`, x])).values()];
568
+ const rows = capabilityTable(manifest, scanned.deps, { ...result, coverage });
569
+ return { ok: !result.enrollmentPending && !result.violations.length && !result.problems.length && !coverage.length, rows, ...result, findings: scanned.findings, coverage, metadata };
257
570
  }
258
571
 
259
- export function formatTable(rows) {
260
- const w1 = Math.max(10, ...rows.map((r) => r.capability.length));
261
- const w2 = Math.max(7, ...rows.map((r) => r.owner.length));
262
- const lines = [`${"capability".padEnd(w1)} ${"owner".padEnd(w2)} status`];
263
- for (const r of rows) lines.push(`${r.capability.padEnd(w1)} ${r.owner.padEnd(w2)} ${r.status}`);
264
- return lines.join("\n");
572
+ /** Protected production scan: acquire and inspect exact HEAD blobs without reading checkout contents. */
573
+ export function checkCommittedApp(appRoot, manifest, options = {}) {
574
+ return checkCommitted(appRoot, manifest, options, acquireCommittedSource);
265
575
  }
266
576
 
267
- /** Full check for one app. `ok` is false on any uncovered copy or exception defect. */
268
- export function checkApp(appRoot, manifest, options = {}) {
269
- const findings = scanApp(appRoot, manifest);
270
- const result = evaluate(findings, loadExceptions(appRoot), options);
271
- const rows = capabilityTable(manifest, directDependencies(appRoot), result);
272
- return { ok: result.violations.length === 0 && result.problems.length === 0, rows, ...result };
577
+ /** Explicit test-only seam for bounded Git process failures and fixtures. */
578
+ export function checkCommittedAppForTest(appRoot, manifest, options = {}) {
579
+ return checkCommitted(appRoot, manifest, options, options.acquireCommittedSourceForTest ?? acquireCommittedSource);
273
580
  }