@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/build-release.mjs +6 -2
- package/cli/right-release.mjs +12 -0
- package/committed-source.mjs +200 -0
- package/ownership-approval-command.mjs +39 -0
- package/ownership-approval.mjs +370 -0
- package/ownership-command.mjs +50 -14
- package/ownership-default-policy.json +21 -0
- package/ownership-evidence.mjs +224 -0
- package/ownership-git-inventory.mjs +195 -0
- package/ownership-helper-paths.json +11 -0
- package/ownership-key-authority.mjs +85 -0
- package/ownership-key-command.mjs +530 -0
- package/ownership-key-log.mjs +185 -0
- package/ownership-key-trust.mjs +326 -0
- package/ownership-report-command.mjs +87 -0
- package/ownership-report.mjs +144 -0
- package/ownership-scan-worker.mjs +15 -0
- package/ownership.mjs +504 -197
- package/package.json +4 -1
- package/preflight.mjs +19 -16
- package/publication-gate.mjs +31 -3
- package/publish-cargo.mjs +5 -0
- package/release.mjs +1 -1
- package/rightkit-drift.mjs +112 -0
- package/rightkit-ownership.json +131 -0
- package/rightkit-sync.mjs +150 -0
- package/rightkit-versions.json +111 -26
- package/upload-release.mjs +3 -0
package/ownership.mjs
CHANGED
|
@@ -1,273 +1,580 @@
|
|
|
1
|
-
// RightKit ownership
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
93
|
+
function addDependency(out, ecosystem, name, file, details = {}) {
|
|
94
|
+
if (!name) return;
|
|
95
|
+
out.push({ ecosystem, name, file, ...details });
|
|
73
96
|
}
|
|
74
97
|
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 (
|
|
101
|
-
|
|
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
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
138
|
-
for (const m of
|
|
139
|
-
const
|
|
140
|
-
|
|
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
|
|
148
|
-
const
|
|
149
|
-
|
|
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
|
-
/**
|
|
153
|
-
export function
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (!
|
|
207
|
-
const
|
|
208
|
-
return (
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
220
|
-
const
|
|
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
|
-
|
|
223
|
-
if (
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
/**
|
|
268
|
-
export function
|
|
269
|
-
|
|
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
|
}
|