@rightkit/release 0.2.121 → 0.2.123

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.
@@ -0,0 +1,370 @@
1
+ // Signed RightKit ownership exception records. Trust is deliberately external to repositories.
2
+ import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
3
+ import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { spawnSync } from "./background-process.mjs";
6
+ import { verifyKeyLog, verifyOwnershipSignature } from "./ownership-key-log.mjs";
7
+
8
+ export const APPROVAL_SCHEMA = 1;
9
+ export const REQUIRED_HOSTS = Object.freeze(["windows", "macos"]);
10
+ export const DEFAULT_TRUST_PATH = Object.freeze({
11
+ win32: path.join("C:\\ProgramData", "Orthic Labs", "RightKit", "ownership", "trust.json"),
12
+ darwin: "/Library/Application Support/Orthic Labs/RightKit/ownership/trust.json",
13
+ });
14
+ const HEX = /^[a-f0-9]{64}$/;
15
+ const keyLogCache = new Map();
16
+ const WINDOWS_TRUSTED_SIDS = new Set(["S-1-5-18", "S-1-5-32-544"]);
17
+ const WINDOWS_READ_MASK = 0x001200a9;
18
+ const WINDOWS_ANCESTOR_CREATE_MASK = 0x00000116;
19
+ const WINDOWS_GENERIC_WRITE = 0x40000000;
20
+ const WINDOWS_GENERIC_ALL = 0x10000000;
21
+
22
+ /** Evaluate one native Windows security descriptor using numeric SIDs & masks. */
23
+ export function evaluateWindowsAcl(info, { target, requireNonElevated = true } = {}) {
24
+ const problems = [];
25
+ const fail = (message) => problems.push(message);
26
+ const ownerSID = info?.ownerSID;
27
+ const ownerName = info?.ownerName;
28
+ const trustedInstallerOwner = ownerSID === "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464";
29
+ const trustedOwner = WINDOWS_TRUSTED_SIDS.has(ownerSID) || trustedInstallerOwner;
30
+ if (!/^S-1-\d+(?:-\d+)+$/.test(ownerSID ?? "") || !trustedOwner) fail("owner is not SYSTEM, Administrators, or the native TrustedInstaller service owner");
31
+ if (/ReparsePoint/i.test(info?.attributes ?? "")) fail("path object is a reparse point");
32
+ if (requireNonElevated && info?.elevated !== false) fail("current process is elevated or elevation state is unknown");
33
+
34
+ const normalizedTarget = String(target ?? "").replace(/\//g, "\\").replace(/\\+$/, "").toLowerCase();
35
+ const isDriveRoot = /^[a-z]:$/.test(normalizedTarget);
36
+ const isProgramData = /^[a-z]:\\programdata$/.test(normalizedTarget);
37
+ const permitCreation = (isDriveRoot || isProgramData) && info?.directory === true && info?.nonempty === true;
38
+ if ((isDriveRoot || isProgramData) && !permitCreation) fail("system ancestor must be an existing nonempty directory before create rights are accepted");
39
+ if (!Array.isArray(info?.access) || !info.access.length) fail("ACL access entries are missing, empty, or malformed");
40
+ for (const ace of Array.isArray(info?.access) ? info.access : []) {
41
+ const sid = ace?.identitySID;
42
+ const mask = ace?.mask;
43
+ const propagationFlags = ace?.propagationFlags;
44
+ if (!/^S-1-\d+(?:-\d+)+$/.test(sid ?? "") || !Number.isInteger(mask) || mask < -2147483648 || mask > 4294967295 || !Number.isInteger(propagationFlags) || propagationFlags < 0 || propagationFlags > 3 || !["Allow", "Deny"].includes(ace?.type)) {
45
+ fail("ACL contains a malformed ACE");
46
+ continue;
47
+ }
48
+ if ((propagationFlags & 2) !== 0 || ace.type !== "Allow") continue;
49
+ if (WINDOWS_TRUSTED_SIDS.has(sid) || (trustedInstallerOwner && sid === ownerSID)) continue;
50
+ const unsignedMask = mask >>> 0;
51
+ if ((unsignedMask & (WINDOWS_GENERIC_WRITE | WINDOWS_GENERIC_ALL)) !== 0) {
52
+ fail(`untrusted principal ${sid} has generic write or all access`);
53
+ continue;
54
+ }
55
+ const allowedMask = permitCreation ? (WINDOWS_READ_MASK | WINDOWS_ANCESTOR_CREATE_MASK) : WINDOWS_READ_MASK;
56
+ if ((unsignedMask & ~allowedMask) !== 0) fail(`untrusted principal ${sid} has write, delete, control, or unknown access mask 0x${unsignedMask.toString(16)}`);
57
+ }
58
+ return { ok: problems.length === 0, problems };
59
+ }
60
+
61
+ export function canonicalRepositoryIdentity(value) {
62
+ if (typeof value !== "string" || !value.trim()) throw new Error("repository identity is required");
63
+ let raw = value.trim();
64
+ const scp = raw.match(/^([^@/:]+)@([^:]+):(.+)$/);
65
+ if (scp) raw = `ssh://${scp[2]}/${scp[3]}`;
66
+ let url;
67
+ try { url = new URL(raw); } catch { throw new Error("repository identity must be an absolute HTTPS/SSH URL or SSH scp remote"); }
68
+ if (!["https:", "ssh:"].includes(url.protocol) || !url.hostname || (url.protocol === "https:" && (url.username || url.password)) || url.password || url.search || url.hash) throw new Error("unsupported repository identity URL");
69
+ const hostname = url.hostname.toLowerCase();
70
+ let repoPath = url.pathname.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
71
+ if (hostname === "github.com") repoPath = repoPath.toLowerCase();
72
+ if (!repoPath || repoPath.split("/").some((x) => !x || x === "." || x === "..")) throw new Error("invalid repository identity path");
73
+ return `https://${hostname}${url.port ? `:${url.port}` : ""}/${repoPath}`;
74
+ }
75
+
76
+ export function canonicalJson(value) {
77
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
78
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
79
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
80
+ }
81
+ export function sha256(value) {
82
+ const bytes = Buffer.isBuffer(value) || value instanceof Uint8Array;
83
+ return createHash("sha256").update(bytes ? value : typeof value === "string" ? value : canonicalJson(value)).digest("hex");
84
+ }
85
+ export function keyIdForPublicKey(host, publicKey) {
86
+ if (!REQUIRED_HOSTS.includes(host)) throw new Error("unknown ownership approval host");
87
+ const key = publicKey?.type === "public" ? publicKey : createPublicKey(publicKey);
88
+ const prefix = host === "windows" ? "win-" : "mac-";
89
+ return prefix + createHash("sha256").update(key.export({ type: "spki", format: "der" })).digest("hex");
90
+ }
91
+ export function occurrenceHash(finding) {
92
+ return sha256({ fingerprint: finding.fingerprint, file: finding.file, line: finding.line ?? 0, text: finding.text ?? "" });
93
+ }
94
+ export function approvalPayload(record) {
95
+ return record?.payload;
96
+ }
97
+
98
+ function validateRecord(record) {
99
+ const p = [];
100
+ if (!record || record.schema !== APPROVAL_SCHEMA || !record.payload || !record.signature) return ["malformed or unsigned approval record"];
101
+ const r = record.payload;
102
+ for (const key of ["repositoryIdentity", "catalogDigest", "fingerprint", "paths", "occurrenceHashes", "reason", "owner", "expires", "requestId", "trackingRef"]) {
103
+ if (r[key] === undefined || r[key] === "" || (Array.isArray(r[key]) && !r[key].length)) p.push(`missing ${key}`);
104
+ }
105
+ if (typeof r.repositoryIdentity !== "string" || r.repositoryIdentity.length > 512) p.push("invalid repository identity");
106
+ if (!HEX.test(r.catalogDigest ?? "")) p.push("invalid catalog digest");
107
+ if (!Array.isArray(r.paths) || r.paths.some((x) => typeof x !== "string" || !x || x.startsWith("/") || x.includes("\\") || x.split("/").some((s) => !s || s === "." || s === "..") || /[*?\[\]{}]/.test(x))) p.push("paths must be exact repository-relative paths");
108
+ if (!Array.isArray(r.occurrenceHashes) || r.occurrenceHashes.some((x) => !HEX.test(x)) || new Set(r.occurrenceHashes).size !== r.occurrenceHashes.length) p.push("invalid occurrence hashes");
109
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(r.expires ?? "") || Number.isNaN(Date.parse(`${r.expires}T00:00:00Z`)) || new Date(`${r.expires}T00:00:00Z`).toISOString().slice(0, 10) !== r.expires) p.push("invalid expiry date");
110
+ if (typeof r.requestId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$/.test(r.requestId)) p.push("invalid request id");
111
+ let trackingUrl;
112
+ try { trackingUrl = new URL(r.trackingRef?.url); } catch {}
113
+ if (!trackingUrl || trackingUrl.protocol !== "https:" || !trackingUrl.hostname || trackingUrl.username || trackingUrl.password || typeof r.trackingRef.id !== "string" || !r.trackingRef.id || r.trackingRef.verified !== true) p.push("tracking reference must be verified and https-backed");
114
+ if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 4000) p.push("invalid approval reason");
115
+ if (typeof r.owner !== "string" || !r.owner.trim() || r.owner.length > 256) p.push("invalid owner");
116
+ if (!record.signature.keyId || !record.signature.host || typeof record.signature.value !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(record.signature.value) || Buffer.from(record.signature.value, "base64").toString("base64") !== record.signature.value) p.push("malformed signature envelope");
117
+ return p;
118
+ }
119
+
120
+ function exactRepositoryPath(value) {
121
+ return typeof value === "string" && (value === "." || (!value.startsWith("/") && !value.includes("\\") && !value.split("/").some((s) => !s || s === "." || s === "..") && !/[*?\[\]{}]/.test(value)));
122
+ }
123
+ function withinRoot(file, root) { return root === "." || file === root || file.startsWith(`${root}/`); }
124
+ export function validateTrustRegistry(trust) {
125
+ const problems = [];
126
+ if (trust?.schema !== 1) problems.push("protected trust schema must be 1");
127
+ if (!Array.isArray(trust?.revokedKeyIds) || !Array.isArray(trust?.verifiedTrackingEvidence) || trust.verifiedTrackingEvidence.some((x) => !HEX.test(x))) problems.push("trust revocation or tracking evidence lists are malformed");
128
+ if (!/^\d+(?:\.\d+)*$/.test(trust?.minimumScannerVersion ?? "") || !/^\d+(?:\.\d+)*$/.test(trust?.minimumCatalogVersion ?? "")) problems.push("scanner/catalog floors are missing or malformed");
129
+ if (trust?.minimumCatalogDigest !== undefined && !HEX.test(trust.minimumCatalogDigest)) problems.push("minimum catalog digest is malformed");
130
+ if (trust?.rootFingerprint !== undefined || trust?.keyCheckpoint !== undefined) {
131
+ if (!HEX.test(trust.rootFingerprint ?? "") || !trust.keyCheckpoint || !Number.isSafeInteger(trust.keyCheckpoint.sequence) || trust.keyCheckpoint.sequence < 0 || !HEX.test(trust.keyCheckpoint.head ?? "")) problems.push("protected key-log root pin or checkpoint is malformed");
132
+ }
133
+ if (trust?.keyAuthority !== undefined) {
134
+ const authority = trust.keyAuthority;
135
+ if (!authority || typeof authority !== "object" || Array.isArray(authority) || Object.keys(authority).sort().join("\0") !== ["checkoutPath", "repositoryIdentity"].sort().join("\0")
136
+ || typeof authority.checkoutPath !== "string" || !path.isAbsolute(authority.checkoutPath)) problems.push("protected key authority is malformed");
137
+ try { if (canonicalRepositoryIdentity(authority.repositoryIdentity) !== authority.repositoryIdentity) problems.push("protected key authority identity is noncanonical"); }
138
+ catch { problems.push("protected key authority identity is invalid"); }
139
+ }
140
+ if (!Array.isArray(trust?.repositories)) return { ok: false, problems: ["protected repository registry missing"] };
141
+ const seen = new Set();
142
+ for (const entry of trust.repositories) {
143
+ let identity;
144
+ try { identity = canonicalRepositoryIdentity(entry.repositoryIdentity); } catch { problems.push("repository registry contains invalid identity"); continue; }
145
+ if (identity !== entry.repositoryIdentity || seen.has(identity)) problems.push(`repository registry identity is noncanonical or duplicated: ${entry.repositoryIdentity}`);
146
+ seen.add(identity);
147
+ if (!Array.isArray(entry.roots) || !entry.roots.length || entry.roots.some((root) => !exactRepositoryPath(root))) problems.push(`repository registry roots must be exact paths: ${identity}`);
148
+ if (!["public", "private"].includes(entry.visibility)) problems.push(`repository visibility is invalid: ${identity}`);
149
+ }
150
+ if (!Array.isArray(trust.provenance ?? [])) problems.push("protected provenance registry is malformed");
151
+ for (const record of trust.provenance ?? []) {
152
+ if (!record.repositoryIdentity || !Array.isArray(record.paths) || !record.paths.length || record.paths.some((p) => !exactRepositoryPath(p)) || !HEX.test(record.contentDigest ?? "") || !["third-party", "generated"].includes(record.kind) || !record.requestId) problems.push("protected provenance entry is incomplete or malformed");
153
+ try { if (canonicalRepositoryIdentity(record.repositoryIdentity) !== record.repositoryIdentity) problems.push("provenance repository identity is noncanonical"); } catch { problems.push("provenance repository identity is invalid"); }
154
+ }
155
+ return { ok: problems.length === 0, problems };
156
+ }
157
+
158
+ /** Resolve protected N-device keys against the supplied immutable log bytes. */
159
+ export function resolveKeyLogTrust(trust, logBytes) {
160
+ if (!trust?.rootFingerprint) return { ok: false, problems: ["protected key-log root pin is missing"], trust };
161
+ if (!HEX.test(trust.rootFingerprint) || !trust.keyCheckpoint || !Number.isSafeInteger(trust.keyCheckpoint.sequence) || trust.keyCheckpoint.sequence < 0 || !HEX.test(trust.keyCheckpoint.head ?? "")) return { ok: false, problems: ["protected key-log root pin or checkpoint is malformed"], trust };
162
+ let log;
163
+ try { log = JSON.parse(Buffer.isBuffer(logBytes) ? logBytes.toString("utf8") : String(logBytes)); }
164
+ catch { return { ok: false, problems: ["committed ownership key log is missing or invalid JSON"], trust }; }
165
+ const logDigest = sha256(logBytes);
166
+ const cacheKey = `${logDigest}:${trust.rootFingerprint}:${trust.keyCheckpoint.sequence}:${trust.keyCheckpoint.head}`;
167
+ let verified = keyLogCache.get(cacheKey);
168
+ if (!verified) {
169
+ verified = verifyKeyLog(log, { rootFingerprint: trust.rootFingerprint, checkpoint: trust.keyCheckpoint });
170
+ keyLogCache.set(cacheKey, verified);
171
+ if (keyLogCache.size > 64) keyLogCache.delete(keyLogCache.keys().next().value);
172
+ }
173
+ if (!verified.ok) return { ok: false, problems: verified.problems, trust };
174
+ const completedPlatforms = new Set(verified.allKeys.map((key) => key.platform));
175
+ const enrollmentComplete = REQUIRED_HOSTS.every((platform) => completedPlatforms.has(platform));
176
+ return { ok: true, trust: { ...trust, ownershipKeys: verified.keys, ownershipAllKeys: verified.allKeys, enrollmentComplete, revokedKeyIds: [...new Set([...(trust.revokedKeyIds ?? []), ...verified.revokedKeyIds])] }, keyLogDigest: logDigest, checkpoint: verified.checkpoint };
177
+ }
178
+ export function verifyRepositoryRegistration(trust, repositoryIdentity, { files = [] } = {}) {
179
+ const validation = validateTrustRegistry(trust);
180
+ if (!validation.ok) return { ok: false, problems: validation.problems };
181
+ let identity;
182
+ try { identity = canonicalRepositoryIdentity(repositoryIdentity); } catch { return { ok: false, problems: ["invalid repository identity"] }; }
183
+ const registration = trust.repositories.find((entry) => entry.repositoryIdentity === identity);
184
+ if (!registration) return { ok: false, problems: ["repository is not registered in protected trust"] };
185
+ const problems = files.filter((file) => !exactRepositoryPath(file) || !registration.roots.some((root) => withinRoot(file, root))).map((file) => `path outside registered repository roots or malformed: ${file}`);
186
+ return { ok: problems.length === 0, problems, registration };
187
+ }
188
+ export function provenanceContentDigest(files) {
189
+ return sha256([...files].map(({ path: file, content }) => ({ path: file, sha256: createHash("sha256").update(content).digest("hex") })).sort((a, b) => a.path.localeCompare(b.path)));
190
+ }
191
+ export function verifyProtectedProvenance(trust, { repositoryIdentity, paths, files, kind, requestId } = {}) {
192
+ const validation = validateTrustRegistry(trust);
193
+ if (!validation.ok) return { ok: false, problems: validation.problems };
194
+ let identity;
195
+ try { identity = canonicalRepositoryIdentity(repositoryIdentity); } catch { return { ok: false, problems: ["invalid repository identity"] }; }
196
+ if (!Array.isArray(paths) || !paths.length || paths.some((p) => !exactRepositoryPath(p))) return { ok: false, problems: ["provenance paths must be exact repository-relative paths"] };
197
+ const sortedPaths = [...new Set(paths)].sort();
198
+ if (sortedPaths.length !== paths.length) return { ok: false, problems: ["duplicate provenance paths"] };
199
+ if (!Array.isArray(files) || files.length !== sortedPaths.length || files.some((f) => !f || typeof f.path !== "string" || typeof f.content !== "string" && !Buffer.isBuffer(f.content)) || canonicalJson(files.map((f) => f.path).sort()) !== canonicalJson(sortedPaths)) return { ok: false, problems: ["provenance content must exactly cover every declared path"] };
200
+ const registration = verifyRepositoryRegistration(trust, identity, { files: sortedPaths });
201
+ if (!registration.ok) return registration;
202
+ const contentDigest = provenanceContentDigest(files);
203
+ const match = (trust.provenance ?? []).find((p) => p.repositoryIdentity === identity && p.kind === kind && p.requestId === requestId && p.contentDigest === contentDigest && canonicalJson([...p.paths].sort()) === canonicalJson(sortedPaths));
204
+ return match ? { ok: true, problems: [], record: match } : { ok: false, problems: ["no protected provenance record matches paths and content"] };
205
+ }
206
+
207
+ export function createDraft({ repositoryIdentity, catalogDigest, fingerprint, findings, paths, reason, owner, expires, requestId, trackingRef }) {
208
+ const payload = { repositoryIdentity: canonicalRepositoryIdentity(repositoryIdentity), catalogDigest, fingerprint, paths: [...new Set(paths ?? [])].sort(), occurrenceHashes: findings.map(occurrenceHash).sort(), reason, owner, expires, requestId, trackingRef };
209
+ return { schema: APPROVAL_SCHEMA, payload, signature: null };
210
+ }
211
+
212
+ // External trust can be loaded only from the platform-owned canonical location.
213
+ export function trustedPath() {
214
+ const platform = process.platform;
215
+ const selected = DEFAULT_TRUST_PATH[platform];
216
+ if (!selected) throw new Error(`ownership approval unsupported platform: ${platform}`);
217
+ return selected;
218
+ }
219
+ function versionAtLeast(actual, minimum) {
220
+ const parse = (v) => String(v ?? "").split(".").map((x) => /^\d+$/.test(x) ? Number(x) : NaN);
221
+ const a = parse(actual), b = parse(minimum);
222
+ if (!a.length || !b.length || [...a, ...b].some(Number.isNaN)) return false;
223
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
224
+ if ((a[i] ?? 0) > (b[i] ?? 0)) return true;
225
+ if ((a[i] ?? 0) < (b[i] ?? 0)) return false;
226
+ }
227
+ return true;
228
+ }
229
+ export function validateOwnershipFloor(trust, { scannerVersion, catalogVersion, catalogDigest } = {}) {
230
+ const problems = [];
231
+ if (!trust) return { ok: false, problems: ["external ownership trust unavailable"] };
232
+ if (!versionAtLeast(scannerVersion, trust.minimumScannerVersion)) problems.push("scanner version missing, malformed, or below protected minimum");
233
+ if (!versionAtLeast(catalogVersion, trust.minimumCatalogVersion)) problems.push("catalog version missing, malformed, or below protected minimum");
234
+ if (trust.minimumCatalogDigest && trust.minimumCatalogDigest !== catalogDigest) problems.push("catalog digest below protected pinned digest");
235
+ return { ok: problems.length === 0, problems };
236
+ }
237
+ function protectedLocation(file, platform) {
238
+ const problems = [];
239
+ if (platform === "win32") {
240
+ const dirs = [];
241
+ let current = path.dirname(file);
242
+ while (true) {
243
+ dirs.push(current);
244
+ if (current.toLowerCase() === "c:\\") break;
245
+ current = path.dirname(current);
246
+ }
247
+ dirs.push(file);
248
+ for (const target of dirs) {
249
+ try { if (lstatSync(target).isSymbolicLink()) problems.push(`trust path contains a reparse/symlink: ${target}`); } catch { problems.push(`cannot inspect trust path: ${target}`); continue; }
250
+ const script = "$ErrorActionPreference='Stop'; $a=Get-Acl -LiteralPath $env:RK_TRUST_CHECK_TARGET; $item=Get-Item -Force -LiteralPath $env:RK_TRUST_CHECK_TARGET; $id=[Security.Principal.WindowsIdentity]::GetCurrent(); $p=New-Object Security.Principal.WindowsPrincipal($id); $ownerSid=$a.GetOwner([Security.Principal.SecurityIdentifier]).Value; $ownerName=$a.Owner; $out=[ordered]@{ownerSID=$ownerSid;ownerName=$ownerName;attributes=$item.Attributes.ToString();directory=[bool]$item.PSIsContainer;nonempty=(!$item.PSIsContainer -or [System.IO.Directory]::EnumerateFileSystemEntries($item.FullName).GetEnumerator().MoveNext());elevated=$p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator);access=@($a.Access | ForEach-Object { [ordered]@{identitySID=$_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value;type=$_.AccessControlType.ToString();mask=[int64]$_.FileSystemRights.value__;propagationFlags=[int]$_.PropagationFlags} })}; $out | ConvertTo-Json -Compress -Depth 5";
251
+ const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: { ...process.env, RK_TRUST_CHECK_TARGET: target } });
252
+ if (r.status !== 0) { problems.push(`cannot inspect trust ACL: ${target}`); continue; }
253
+ try {
254
+ const acl = JSON.parse(r.stdout.trim());
255
+ const result = evaluateWindowsAcl(acl, { target });
256
+ problems.push(...result.problems.map((problem) => `${problem}: ${target}`));
257
+ } catch { problems.push(`malformed ACL result: ${target}`); }
258
+ }
259
+ return { ok: problems.length === 0, problems };
260
+ }
261
+ let current = file;
262
+ while (true) {
263
+ try { if (lstatSync(current).isSymbolicLink()) problems.push(`trust path contains a symlink: ${current}`); } catch { problems.push(`cannot inspect trust path: ${current}`); }
264
+ try {
265
+ const st = statSync(current);
266
+ if (st.uid !== 0 || (st.mode & 0o022)) problems.push(`trust location is not root-owned/private: ${current}`);
267
+ } catch { problems.push(`cannot inspect trust location: ${current}`); }
268
+ const acl = spawnSync("/bin/ls", ["-lde", current], { encoding: "utf8", windowsHide: true });
269
+ if (acl.status !== 0) problems.push(`cannot inspect trust ACL: ${current}`);
270
+ else for (const line of acl.stdout.split(/\r?\n/).slice(1)) {
271
+ if (!/allow/i.test(line) || !/\b(?:write|delete|append|writeattr|writeextattr|chown|delete_child|add_file|add_subdirectory)\b/i.test(line)) continue;
272
+ if (!/^\s*\d+:\s+user:root\s+allow\b/i.test(line)) problems.push(`trust ACL grants write authority to a non-root principal: ${current}`);
273
+ }
274
+ const parent = path.dirname(current);
275
+ if (parent === current || current === "/") break;
276
+ current = parent;
277
+ }
278
+ return { ok: problems.length === 0, problems };
279
+ }
280
+ export function loadTrustedExternal() {
281
+ const platform = process.platform;
282
+ const file = trustedPath();
283
+ if (!existsSync(file)) return { enrolled: false, pending: true, protection: { ok: false, reason: "external trust file is absent" }, trust: null, path: file };
284
+ if (lstatSync(file).isSymbolicLink()) throw new Error("ownership trust file may not be a symlink");
285
+ const st = statSync(file);
286
+ if (!st.isFile()) throw new Error("ownership trust file must be a regular protected file");
287
+ const protection = protectedLocation(file, platform);
288
+ if (!protection.ok) throw new Error(protection.problems.join("; "));
289
+ const trust = JSON.parse(readFileSync(file, "utf8"));
290
+ if (trust.schema !== 1) throw new Error("ownership trust file schema is unsupported");
291
+ const trustCheck = validateTrustRegistry(trust);
292
+ if (!trustCheck.ok) throw new Error(trustCheck.problems.join("; "));
293
+ for (const host of REQUIRED_HOSTS) {
294
+ const entry = trust.hosts?.[host];
295
+ if (!entry?.keyId && !entry?.publicKeyPem) continue;
296
+ if (!entry.keyId || !entry.publicKeyPem || entry.keyId !== keyIdForPublicKey(host, entry.publicKeyPem)) throw new Error(`invalid or mismatched ${host} approval key enrollment`);
297
+ const key = createPublicKey(entry.publicKeyPem);
298
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") throw new Error(`${host} approval key must be P-256`);
299
+ }
300
+ const protectionResult = { ok: true, mechanism: platform === "win32" ? "PowerShell ACL owner/ACE audit" : "owner/mode/ACL audit" };
301
+ const hostKeys = REQUIRED_HOSTS.map((host) => trust.hosts?.[host]);
302
+ const enrolled = trust.rootFingerprint
303
+ ? Boolean(HEX.test(trust.rootFingerprint) && trust.keyCheckpoint && Number.isSafeInteger(trust.keyCheckpoint.sequence) && trust.keyCheckpoint.sequence >= 0 && HEX.test(trust.keyCheckpoint.head ?? "")) // Provisional; scan paths replace this from verified log history.
304
+ : hostKeys.every((host) => host?.keyId && host.publicKeyPem && !trust.revokedKeyIds?.includes(host.keyId))
305
+ && hostKeys[0]?.keyId !== hostKeys[1]?.keyId && hostKeys[0]?.publicKeyPem !== hostKeys[1]?.publicKeyPem;
306
+ return { enrolled, pending: !enrolled, protection: protectionResult, trust, path: file };
307
+ }
308
+
309
+ // Test-only seam: production callers must use loadTrustedExternal's fixed canonical path.
310
+ export const __testOnly = Object.freeze({ protectedLocation });
311
+
312
+ function signatureProblems(record, trust) {
313
+ const s = record.signature;
314
+ if (!REQUIRED_HOSTS.includes(s.host)) return ["unknown signing host"];
315
+ if (trust.revokedKeyIds?.includes(s.keyId)) return ["signing key is revoked"];
316
+ if (trust.rootFingerprint) {
317
+ const normalizedId = String(s.keyId).replace(/^(?:win-|mac-)/, "");
318
+ const key = trust.ownershipKeys?.find((candidate) => candidate.keyId === normalizedId && candidate.platform === s.host);
319
+ if (!key) return ["signing key is not active in protected ownership key log"];
320
+ try {
321
+ const valid = verifyOwnershipSignature(key.publicKeyPem, Buffer.from(canonicalJson(approvalPayload(record))), Buffer.from(s.value, "base64"));
322
+ return valid ? [] : ["invalid signature"];
323
+ } catch { return ["malformed signature or public key"]; }
324
+ }
325
+ const host = trust.hosts?.[s.host];
326
+ if (!host || host.keyId !== s.keyId || !host.publicKeyPem) return ["signing key is not enrolled for host"];
327
+ const keys = REQUIRED_HOSTS.map((name) => trust.hosts?.[name]);
328
+ if (!keys.every((k) => k?.keyId && k.publicKeyPem && !trust.revokedKeyIds?.includes(k.keyId)) || keys[0]?.keyId === keys[1]?.keyId || keys[0]?.publicKeyPem === keys[1]?.publicKeyPem) return ["pending: distinct Windows and macOS approval keys must be enrolled"];
329
+ try {
330
+ const publicKey = createPublicKey(host.publicKeyPem);
331
+ if (s.keyId !== keyIdForPublicKey(s.host, publicKey)) return ["signing key ID does not match enrolled public key"];
332
+ const valid = cryptoVerify("sha256", Buffer.from(canonicalJson(approvalPayload(record))), publicKey, Buffer.from(s.value, "base64"));
333
+ return valid ? [] : ["invalid signature"];
334
+ } catch { return ["malformed signature or public key"];
335
+ }
336
+ }
337
+
338
+ /** Verify signature, protected enrollment, floor, and exact scan coverage. */
339
+ export function verifyException(record, { repositoryIdentity, catalogDigest, findings, trust, today = new Date().toISOString().slice(0, 10), scannerVersion, catalogVersion } = {}) {
340
+ const problems = validateRecord(record);
341
+ if (problems.length) return { ok: false, problems };
342
+ if (!trust) return { ok: false, problems: ["pending: external ownership trust is not enrolled"] };
343
+ const p = signatureProblems(record, trust);
344
+ if (p.length) return { ok: false, problems: p };
345
+ const r = record.payload;
346
+ const trackingEvidence = sha256({ repositoryIdentity: r.repositoryIdentity, requestId: r.requestId, trackingRef: r.trackingRef });
347
+ if (!trust.verifiedTrackingEvidence?.includes(trackingEvidence)) problems.push("tracking reference lacks protected verification evidence");
348
+ let recordIdentity, currentIdentity;
349
+ try { recordIdentity = canonicalRepositoryIdentity(r.repositoryIdentity); currentIdentity = canonicalRepositoryIdentity(repositoryIdentity); }
350
+ catch { problems.push("invalid repository identity"); }
351
+ if (recordIdentity && recordIdentity !== r.repositoryIdentity) problems.push("approval repository identity is not canonical");
352
+ if (recordIdentity && currentIdentity && recordIdentity !== currentIdentity) problems.push("repository identity changed");
353
+ const registration = verifyRepositoryRegistration(trust, r.repositoryIdentity, { files: (findings ?? []).map((f) => f.file) });
354
+ problems.push(...registration.problems);
355
+ if (r.catalogDigest !== catalogDigest) problems.push("ownership catalog digest changed");
356
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(today) || Number.isNaN(Date.parse(`${today}T00:00:00Z`)) || new Date(`${today}T00:00:00Z`).toISOString().slice(0, 10) !== today) problems.push("invalid verification date");
357
+ else if (r.expires < today) problems.push(`approval expired ${r.expires}`);
358
+ const floor = validateOwnershipFloor(trust, { scannerVersion, catalogVersion, catalogDigest });
359
+ problems.push(...floor.problems);
360
+ const selected = (findings ?? []).filter((f) => f.fingerprint === r.fingerprint && r.paths.includes(f.file));
361
+ const actual = selected.map(occurrenceHash).sort();
362
+ if (canonicalJson(actual) !== canonicalJson([...r.occurrenceHashes].sort())) problems.push("baseline occurrence set changed");
363
+ if (!actual.length) problems.push("approval is stale and matches no current occurrences");
364
+ return { ok: problems.length === 0, problems };
365
+ }
366
+
367
+ export function signPayloadForNative(record, { keyId, host, signature }) {
368
+ if (!record?.payload || !signature) throw new Error("native signer must return a signature over displayed approval payload");
369
+ return { schema: APPROVAL_SCHEMA, payload: record.payload, signature: { keyId, host, value: signature } };
370
+ }
@@ -4,10 +4,12 @@
4
4
  //
5
5
  // right-release ownership [--app <dir>] [--json]
6
6
  // right-release ownership --write-manifest [--rightkit <tools/rightkit>]
7
- import { readFileSync, writeFileSync } from "node:fs";
7
+ import { readFileSync, realpathSync, writeFileSync } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
- import { aggregateOwnership, checkApp, formatTable, MANIFEST_FILE } from "./ownership.mjs";
10
+ import { aggregateOwnership, formatTable, MANIFEST_FILE } from "./ownership.mjs";
11
+ import { createOwnershipEvidence } from "./ownership-evidence.mjs";
12
+ import { spawnSync } from "./background-process.mjs";
11
13
 
12
14
  const packageRoot = path.dirname(fileURLToPath(import.meta.url));
13
15
  const shipped = path.join(packageRoot, MANIFEST_FILE);
@@ -17,31 +19,65 @@ function flag(args, name) {
17
19
  return i >= 0 ? args[i + 1] : undefined;
18
20
  }
19
21
 
20
- export function main(args) {
22
+ export async function main(args, { evidenceFactory = createOwnershipEvidence, gitRunner = spawnSync, print = (value) => console.log(value) } = {}) {
23
+ if (["enroll", "approve-key", "revoke-key"].includes(args[0])) {
24
+ const { main: keyMain } = await import("./ownership-key-command.mjs");
25
+ return keyMain(args, { runner: gitRunner, print });
26
+ }
21
27
  if (args.includes("--write-manifest")) {
22
28
  const root = path.resolve(flag(args, "--rightkit") ?? path.join(packageRoot, "..", ".."));
23
29
  const manifest = aggregateOwnership(root);
24
30
  writeFileSync(shipped, `${JSON.stringify(manifest, null, 2)}\n`);
31
+ const { syncOwnershipCatalog } = await import("../build-control-clients/src/ownership-catalog-sync.mjs");
32
+ syncOwnershipCatalog({ source: packageRoot });
25
33
  console.log(`[right-release] ownership manifest: ${manifest.capabilities.length} capabilities -> ${shipped}`);
26
34
  return 0;
27
35
  }
28
36
  const app = path.resolve(flag(args, "--app") ?? process.cwd());
29
- const manifest = JSON.parse(readFileSync(shipped, "utf8"));
30
- const result = checkApp(app, manifest);
37
+ const top = gitRunner("git", ["-C", app, "rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true });
38
+ if (top.status !== 0) return failClosed(args, "repository", "could not resolve --app to a Git repository");
39
+ const repoRoot = path.resolve(String(top.stdout).trim());
40
+ let checked;
41
+ try { checked = evidenceFactory(repoRoot, app); }
42
+ catch (error) { return failClosed(args, "protected-evidence", error.message, print); }
43
+ const result = checked.result;
31
44
  if (args.includes("--json")) {
32
- console.log(JSON.stringify({ ok: result.ok, rows: result.rows, violations: result.violations, problems: result.problems }, null, 2));
45
+ const error = checked.enrollmentPending ? undefined : !checked.floor.ok
46
+ ? { type: "ownership-floor", message: checked.floor.problems.join("; ") }
47
+ : result.coverage.length
48
+ ? { type: "ownership-coverage", message: `${result.coverage.length} ownership coverage gap(s)` }
49
+ : undefined;
50
+ print(JSON.stringify({ ok: checked.ok, ...(checked.enrollmentPending ? { enrollmentPending: true, warning: checked.warning } : {}), ...(error ? { error } : {}), rows: result.rows, violations: result.violations, covered: result.covered, problems: [...result.problems, ...checked.floor.problems], coverage: result.coverage, metadata: result.metadata }, null, 2));
33
51
  } else {
34
- console.log(`RightKit ownership: ${path.basename(app)}`);
35
- console.log(formatTable(result.rows));
52
+ print(`RightKit ownership: ${path.basename(app)}`);
53
+ print(formatTable(result.rows));
36
54
  for (const v of result.violations) {
37
- console.log(` OWN COPY ${v.fingerprint} (${v.package}) ${v.file}${v.line ? `:${v.line}` : ""} ${v.text}`);
55
+ print(` OWN COPY ${v.fingerprint} (${v.package}) ${v.file}${v.line ? `:${v.line}` : ""} ${v.text}`);
38
56
  }
39
- for (const p of result.problems) console.log(` EXCEPTION ${p}`);
40
- console.log(result.ok ? "[right-release] ownership passed" : "[right-release] ownership FAILED: move these to RightKit or add a reviewed, dated exception");
57
+ for (const p of result.problems) print(` ${checked.enrollmentPending ? "DIAGNOSTIC" : "EXCEPTION"} ${p}`);
58
+ print(checked.enrollmentPending ? `[right-release] ${checked.warning}` : checked.ok ? "[right-release] ownership passed" : `[right-release] ownership FAILED: ${[...result.problems, ...checked.floor.problems].join("; ") || "move these to RightKit or add a reviewed, dated exception"}`);
41
59
  }
42
- return result.ok ? 0 : 1;
60
+ return checked.ok ? 0 : 1;
61
+ }
62
+
63
+ function failClosed(args, type, message, print = (value) => console.log(value)) {
64
+ if (args.includes("--json")) print(JSON.stringify({ ok: false, error: { type, message }, rows: [], violations: [], covered: [], problems: [message], coverage: [], metadata: null }, null, 2));
65
+ else print(`[right-release] ownership FAILED: ${message}`);
66
+ return 1;
43
67
  }
44
68
 
45
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
46
- process.exitCode = main(process.argv.slice(2));
69
+ // Compare real paths: a symlinked spelling of this file (pnpm links, macOS /var -> /private/var)
70
+ // must still run the command.
71
+ const isEntry = (() => {
72
+ if (!process.argv[1]) return false;
73
+ try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
74
+ catch { return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
75
+ })();
76
+ if (isEntry) {
77
+ main(process.argv.slice(2)).then((code) => {
78
+ process.exitCode = code;
79
+ }).catch((error) => {
80
+ console.error(`[right-release] ownership failed: ${error.message}`);
81
+ process.exitCode = 1;
82
+ });
47
83
  }
@@ -0,0 +1,21 @@
1
+ {
2
+ "schema": 1,
3
+ "hosts": {},
4
+ "minimumScannerVersion": "2.2.0",
5
+ "minimumCatalogVersion": "1",
6
+ "minimumCatalogDigest": "875594919178efe146b32276cb21e21a715ca787fb1cc39db425b21bafb02df6",
7
+ "revokedKeyIds": [],
8
+ "verifiedTrackingEvidence": [],
9
+ "repositories": [
10
+ { "repositoryIdentity": "https://github.com/bogusyogi/coderight", "roots": ["."], "visibility": "private" },
11
+ { "repositoryIdentity": "https://github.com/bogusyogi/cutright", "roots": ["."], "visibility": "private" },
12
+ { "repositoryIdentity": "https://github.com/bogusyogi/genright", "roots": ["."], "visibility": "private" },
13
+ { "repositoryIdentity": "https://github.com/bogusyogi/heardright", "roots": ["."], "visibility": "private" },
14
+ { "repositoryIdentity": "https://github.com/bogusyogi/mailright", "roots": ["."], "visibility": "private" },
15
+ { "repositoryIdentity": "https://github.com/bogusyogi/rightsites", "roots": ["."], "visibility": "private" },
16
+ { "repositoryIdentity": "https://github.com/bogusyogi/scraperight", "roots": ["."], "visibility": "private" },
17
+ { "repositoryIdentity": "https://github.com/bogusyogi/viewright", "roots": ["."], "visibility": "private" },
18
+ { "repositoryIdentity": "https://github.com/bogusyogi/voiceright", "roots": ["."], "visibility": "private" }
19
+ ],
20
+ "provenance": []
21
+ }