@rightkit/release 0.2.143 → 0.2.145
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 +13 -2
- package/cli/right-release.mjs +3 -0
- package/github-release.mjs +9 -0
- package/ownership-approval.mjs +14 -1
- package/ownership-command.mjs +48 -2
- package/ownership-declaration.json +175 -4
- package/ownership-dependency-allowlist-seed.mjs +86 -0
- package/ownership-dependency-allowlist.json +1087 -0
- package/ownership-dependency-allowlist.mjs +124 -0
- package/ownership-evidence.mjs +13 -2
- package/ownership-key-command.mjs +55 -13
- package/ownership-key-elevation.mjs +114 -8
- package/ownership-key-trust.mjs +25 -4
- package/ownership-policy-bundle.mjs +155 -0
- package/ownership-report-command.mjs +1 -1
- package/ownership-trust-summary.mjs +119 -0
- package/ownership-windows-presence.mjs +171 -0
- package/ownership.mjs +42 -6
- package/package.json +1 -1
- package/pe-imports.mjs +3 -1
- package/protected-credentials.mjs +6 -1
- package/publish-update.mjs +8 -1
- package/release-qa-command.mjs +145 -0
- package/release-qa-floor.json +9 -0
- package/release-qa-receipt.mjs +276 -0
- package/release.mjs +1 -1
- package/rightkit-drift.mjs +1 -1
- package/rightkit-ownership.json +2276 -429
- package/rightkit-sync.mjs +3 -0
- package/rightkit-versions.json +10 -6
- package/swift-package-command.mjs +1 -1
- package/swift-windows-stage.mjs +11 -1
- package/upload-release.mjs +8 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Protected policy bundle: one digest over everything that decides ownership enforcement, so editing the
|
|
2
|
+
// scanner/verifier code (or the allowlist/registry) while keeping the catalog cannot weaken enforcement.
|
|
3
|
+
// Trust-sync pins the bundle into protected trust; release/admission gates refuse when the running bundle differs.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { canonicalJson } from "./ownership-approval.mjs";
|
|
10
|
+
import { ALLOWLIST_FILE, diffAllowlists, validateAllowlist } from "./ownership-dependency-allowlist.mjs";
|
|
11
|
+
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const MODULE_SPECIFIERS = /^\s*(?:import|export)\s[^'";]*?from\s*['"]([^'"]+)['"]|^\s*import\s*['"]([^'"]+)['"]/gm;
|
|
14
|
+
const HEX = /^[a-f0-9]{64}$/;
|
|
15
|
+
export const POLICY_BUNDLE_SCHEMA = 1;
|
|
16
|
+
const CATALOG_FILE = "rightkit-ownership.json";
|
|
17
|
+
const REGISTRY_FILE = "ownership-repositories.json";
|
|
18
|
+
// Step 4: the release QA requirement floor is protected like the catalog. Dropping a required check changes the digest.
|
|
19
|
+
export const QA_FLOOR_FILE = "release-qa-floor.json";
|
|
20
|
+
// Modules that make or carry enforcement decisions. Their import closure is pinned.
|
|
21
|
+
export const POLICY_BUNDLE_ENTRYPOINTS = Object.freeze([
|
|
22
|
+
"ownership-scan-worker.mjs", "ownership-evidence.mjs", "ownership-verdict.mjs", "ownership-admission.mjs",
|
|
23
|
+
"ownership-staged.mjs", "ownership-hooks.mjs", "ownership-key-trust.mjs", "release-qa-receipt.mjs",
|
|
24
|
+
]);
|
|
25
|
+
// Data files are pinned through their own digests, not as source files.
|
|
26
|
+
const DATA_FILES = new Set([CATALOG_FILE, REGISTRY_FILE, ALLOWLIST_FILE, QA_FLOOR_FILE]);
|
|
27
|
+
|
|
28
|
+
const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
29
|
+
const canonicalDigest = (value) => sha256Hex(canonicalJson(value));
|
|
30
|
+
// Normalize CRLF so a checkout's line-ending policy does not change the digest.
|
|
31
|
+
const textDigest = (bytes) => sha256Hex(Buffer.from(bytes.toString("utf8").replace(/\r\n/g, "\n")));
|
|
32
|
+
|
|
33
|
+
function sourceName(file, dir) {
|
|
34
|
+
const relative = path.relative(dir, file).split(path.sep).join("/");
|
|
35
|
+
if (!relative.startsWith("../")) return relative;
|
|
36
|
+
const dependency = /^.*node_modules\/((?:@[^/]+\/)?[^/]+)\/(.+)$/.exec(file.split(path.sep).join("/"));
|
|
37
|
+
return dependency ? `node_modules/${dependency[1]}/${dependency[2]}` : relative;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const closureCache = new Map();
|
|
41
|
+
/** Import closure of the enforcement entrypoints as [{ path, sha256 }] sorted by path. */
|
|
42
|
+
export function scannerSourceFiles({ dir = here, entrypoints = POLICY_BUNDLE_ENTRYPOINTS } = {}) {
|
|
43
|
+
const key = `${dir}\0${entrypoints.join("\0")}`;
|
|
44
|
+
if (closureCache.has(key)) return closureCache.get(key);
|
|
45
|
+
const files = new Map(), queue = entrypoints.map((name) => path.join(dir, name));
|
|
46
|
+
while (queue.length) {
|
|
47
|
+
const file = queue.shift();
|
|
48
|
+
if (files.has(file)) continue;
|
|
49
|
+
let bytes;
|
|
50
|
+
try { bytes = readFileSync(file); } catch (error) { if (error.code === "ENOENT" && !file.startsWith(dir)) continue; throw error; }
|
|
51
|
+
files.set(file, file.endsWith(".json") ? sha256Hex(Buffer.from(canonicalJson(JSON.parse(bytes.toString("utf8"))))) : textDigest(bytes));
|
|
52
|
+
if (!/\.(?:mjs|js|cjs)$/.test(file)) continue;
|
|
53
|
+
for (const match of bytes.toString("utf8").matchAll(MODULE_SPECIFIERS)) {
|
|
54
|
+
const specifier = match[1] ?? match[2];
|
|
55
|
+
if (specifier.startsWith("node:")) continue;
|
|
56
|
+
try { queue.push(specifier.startsWith(".") ? path.resolve(path.dirname(file), specifier) : createRequire(file).resolve(specifier)); }
|
|
57
|
+
catch { queue.push(path.resolve(path.dirname(file), specifier)); }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const result = [...files].map(([file, digest]) => ({ path: sourceName(file, dir), sha256: digest }))
|
|
61
|
+
.filter((entry) => !DATA_FILES.has(entry.path)).sort((a, b) => a.path.localeCompare(b.path));
|
|
62
|
+
closureCache.set(key, result);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readJsonOrNull(file) {
|
|
67
|
+
try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {{ dir?: string, manifest?: object, registry?: object|null, allowlist?: object|null, qaFloor?: object|null, scannerVersion: string, files?: Array<{path:string,sha256:string}> }} input
|
|
72
|
+
* Missing registry/allowlist digest as null; the digest still binds that absence.
|
|
73
|
+
*/
|
|
74
|
+
export function computePolicyBundle({ dir = here, manifest, registry, allowlist, qaFloor, scannerVersion, files } = {}) {
|
|
75
|
+
if (!/^\d+(?:\.\d+)*$/.test(String(scannerVersion ?? ""))) throw new Error("policy bundle requires a scanner version");
|
|
76
|
+
const catalog = manifest ?? readJsonOrNull(path.join(dir, CATALOG_FILE));
|
|
77
|
+
if (!catalog) throw new Error("policy bundle requires the ownership catalog");
|
|
78
|
+
const registryValue = registry === undefined ? readJsonOrNull(path.join(dir, REGISTRY_FILE)) : registry;
|
|
79
|
+
const allowlistValue = allowlist === undefined ? readJsonOrNull(path.join(dir, ALLOWLIST_FILE)) : allowlist;
|
|
80
|
+
const qaFloorValue = qaFloor === undefined ? readJsonOrNull(path.join(dir, QA_FLOOR_FILE)) : qaFloor;
|
|
81
|
+
const parts = {
|
|
82
|
+
schema: POLICY_BUNDLE_SCHEMA,
|
|
83
|
+
scannerVersion: String(scannerVersion),
|
|
84
|
+
catalogDigest: canonicalDigest(catalog),
|
|
85
|
+
registryDigest: registryValue === null ? null : canonicalDigest(registryValue),
|
|
86
|
+
allowlistDigest: allowlistValue === null ? null : canonicalDigest(allowlistValue),
|
|
87
|
+
qaFloorDigest: qaFloorValue === null ? null : canonicalDigest(qaFloorValue),
|
|
88
|
+
files: files ?? scannerSourceFiles({ dir }),
|
|
89
|
+
};
|
|
90
|
+
return { ...parts, digest: canonicalDigest(parts) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Running bundle for the code that is executing now. */
|
|
94
|
+
export function runningPolicyBundle({ scannerVersion, manifest, dir = here, ...rest } = {}) {
|
|
95
|
+
return computePolicyBundle({ dir, scannerVersion, manifest, ...rest });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function validatePolicyBundle(bundle) {
|
|
99
|
+
const problems = [];
|
|
100
|
+
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) return { ok: false, problems: ["policy bundle must be an object"] };
|
|
101
|
+
if (bundle.schema !== POLICY_BUNDLE_SCHEMA) problems.push("policy bundle schema is unsupported");
|
|
102
|
+
if (!HEX.test(bundle.digest ?? "")) problems.push("policy bundle digest is malformed");
|
|
103
|
+
if (!/^\d+(?:\.\d+)*$/.test(bundle.scannerVersion ?? "")) problems.push("policy bundle scanner version is malformed");
|
|
104
|
+
for (const key of ["catalogDigest"]) if (!HEX.test(bundle[key] ?? "")) problems.push(`policy bundle ${key} is malformed`);
|
|
105
|
+
for (const key of ["registryDigest", "allowlistDigest"]) if (bundle[key] !== null && !HEX.test(bundle[key] ?? "")) problems.push(`policy bundle ${key} is malformed`);
|
|
106
|
+
// Bundles pinned before step 4 carry no qaFloorDigest; they stay well-formed but can never match a running bundle.
|
|
107
|
+
if (bundle.qaFloorDigest !== undefined && bundle.qaFloorDigest !== null && !HEX.test(bundle.qaFloorDigest)) problems.push("policy bundle qaFloorDigest is malformed");
|
|
108
|
+
if (!Array.isArray(bundle.files) || bundle.files.some((file) => !file || typeof file.path !== "string" || !HEX.test(file.sha256 ?? ""))) problems.push("policy bundle file list is malformed");
|
|
109
|
+
else {
|
|
110
|
+
const { digest, ...parts } = bundle;
|
|
111
|
+
if (HEX.test(digest ?? "") && canonicalDigest(parts) !== digest) problems.push("policy bundle digest does not match its contents");
|
|
112
|
+
}
|
|
113
|
+
return { ok: problems.length === 0, problems };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Semantic diff of two bundles (the previous one may be null/absent). */
|
|
117
|
+
export function diffPolicyBundles(before, after) {
|
|
118
|
+
const prior = new Map((before?.files ?? []).map((file) => [file.path, file.sha256]));
|
|
119
|
+
const current = new Map((after?.files ?? []).map((file) => [file.path, file.sha256]));
|
|
120
|
+
return {
|
|
121
|
+
filesAdded: [...current.keys()].filter((p) => !prior.has(p)).sort(),
|
|
122
|
+
filesRemoved: [...prior.keys()].filter((p) => !current.has(p)).sort(),
|
|
123
|
+
filesChanged: [...current.keys()].filter((p) => prior.has(p) && prior.get(p) !== current.get(p)).sort(),
|
|
124
|
+
scannerVersion: before?.scannerVersion !== after?.scannerVersion ? { from: before?.scannerVersion ?? null, to: after?.scannerVersion ?? null } : null,
|
|
125
|
+
catalogChanged: before?.catalogDigest !== after?.catalogDigest,
|
|
126
|
+
registryChanged: before?.registryDigest !== after?.registryDigest,
|
|
127
|
+
allowlistChanged: before?.allowlistDigest !== after?.allowlistDigest,
|
|
128
|
+
qaFloorChanged: (before?.qaFloorDigest ?? null) !== (after?.qaFloorDigest ?? null),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Host-independent fingerprint of protected trust for cross-host comparison (`ownership trust-compare`).
|
|
134
|
+
* Excludes only the per-host key-authority checkout path; every other field, including the pinned policy
|
|
135
|
+
* bundle and dependency allowlist, must be identical on two hosts that synced the same release.
|
|
136
|
+
*/
|
|
137
|
+
export function trustComparison(trust) {
|
|
138
|
+
if (!trust || typeof trust !== "object") throw new Error("no protected trust to compare");
|
|
139
|
+
const { keyAuthority, ...rest } = trust;
|
|
140
|
+
const comparable = { ...rest, ...(keyAuthority ? { keyAuthority: { repositoryIdentity: keyAuthority.repositoryIdentity } } : {}) };
|
|
141
|
+
return {
|
|
142
|
+
digest: canonicalDigest(comparable),
|
|
143
|
+
components: {
|
|
144
|
+
policyBundle: trust.policyBundle?.digest ?? null,
|
|
145
|
+
dependencyAllowlist: trust.dependencyAllowlist ? canonicalDigest(trust.dependencyAllowlist) : null,
|
|
146
|
+
repositories: canonicalDigest(trust.repositories ?? []),
|
|
147
|
+
minimumCatalogDigest: trust.minimumCatalogDigest ?? null,
|
|
148
|
+
minimumScannerVersion: trust.minimumScannerVersion ?? null,
|
|
149
|
+
keyCheckpoint: trust.keyCheckpoint ?? null,
|
|
150
|
+
rootFingerprint: trust.rootFingerprint ?? null,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export { diffAllowlists, validateAllowlist };
|
|
@@ -7,7 +7,7 @@ import { OWNERSHIP_APPS, formatOwnershipJson, buildOwnershipReport, renderOwners
|
|
|
7
7
|
import { MANIFEST_FILE } from "./ownership.mjs";
|
|
8
8
|
|
|
9
9
|
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
-
const defaultWorkspace = path.resolve(packageRoot, "
|
|
10
|
+
const defaultWorkspace = path.resolve(packageRoot, "../../..");
|
|
11
11
|
const MAX_SCAN_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
12
12
|
const flag = (args, name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
|
|
13
13
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Human-readable summary of what a protected ownership trust update will change. It runs the SAME pure
|
|
2
|
+
// functions the elevated child runs (prepare -> registry merge -> floor raise) over the exact request
|
|
3
|
+
// bytes about to be installed, so the approval dialog describes the real change.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { canonicalJson } from "./ownership-approval.mjs";
|
|
9
|
+
import { verifyKeyLog } from "./ownership-key-log.mjs";
|
|
10
|
+
import { diffAllowlists } from "./ownership-dependency-allowlist.mjs";
|
|
11
|
+
import { diffPolicyBundles } from "./ownership-policy-bundle.mjs";
|
|
12
|
+
import { installedPolicyInputs, mergeRepositoriesFromInstalledRelease, prepareTrustUpdate, raiseFloorsToInstalledRelease } from "./ownership-key-trust.mjs";
|
|
13
|
+
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const short = (hex, n = 12) => (typeof hex === "string" && hex ? hex.replace(/^(?:win-|mac-)/i, "").slice(0, n) : "none");
|
|
16
|
+
const repoName = (identity) => String(identity).replace(/^https:\/\/github\.com\//i, "");
|
|
17
|
+
const diffSets = (before, after) => ({ added: [...after].filter((x) => !before.has(x)).sort(), removed: [...before].filter((x) => !after.has(x)).sort() });
|
|
18
|
+
|
|
19
|
+
const NAME_LIMIT = 12;
|
|
20
|
+
const names = (list) => list.length > NAME_LIMIT ? `${list.slice(0, NAME_LIMIT).join(", ")}, +${list.length - NAME_LIMIT} more` : list.join(", ");
|
|
21
|
+
|
|
22
|
+
/** Semantic description of a policy-bundle change: scanner files, allowlist entries, registry and catalog digests. */
|
|
23
|
+
function policyLines(existing, next) {
|
|
24
|
+
const before = existing?.policyBundle, after = next.policyBundle;
|
|
25
|
+
const lines = [];
|
|
26
|
+
if (!after) { if (before) lines.push("Policy bundle: no longer pinned by this update"); return lines; }
|
|
27
|
+
const bundle = diffPolicyBundles(before, after);
|
|
28
|
+
if (!before) lines.push(`Policy bundle: unpinned -> ${short(after.digest, 12)} (pins ${after.files.length} scanner source files, scanner ${after.scannerVersion}, catalog, repository registry and dependency allowlist; enforced by release gates from now on)`);
|
|
29
|
+
else if (before.digest !== after.digest) lines.push(`Policy bundle: ${short(before.digest, 12)} -> ${short(after.digest, 12)}`);
|
|
30
|
+
else return lines;
|
|
31
|
+
if (before) {
|
|
32
|
+
if (bundle.scannerVersion) lines.push(` scanner version: ${bundle.scannerVersion.from ?? "none"} -> ${bundle.scannerVersion.to}`);
|
|
33
|
+
if (bundle.filesChanged.length) lines.push(` scanner/verifier files changed (${bundle.filesChanged.length}): ${names(bundle.filesChanged)}`);
|
|
34
|
+
if (bundle.filesAdded.length) lines.push(` scanner/verifier files added (${bundle.filesAdded.length}): ${names(bundle.filesAdded)}`);
|
|
35
|
+
if (bundle.filesRemoved.length) lines.push(` scanner/verifier files removed (${bundle.filesRemoved.length}): ${names(bundle.filesRemoved)}`);
|
|
36
|
+
if (bundle.catalogChanged) lines.push(" catalog contents changed");
|
|
37
|
+
if (bundle.registryChanged) lines.push(" repository registry file changed");
|
|
38
|
+
}
|
|
39
|
+
const allow = diffAllowlists(existing?.dependencyAllowlist ?? null, next.dependencyAllowlist ?? null);
|
|
40
|
+
if (!before && next.dependencyAllowlist) {
|
|
41
|
+
const total = (next.dependencyAllowlist.repositories ?? []).reduce((n, r) => n + (r.dependencies?.length ?? 0), 0);
|
|
42
|
+
lines.push(` dependency allowlist: ${next.dependencyAllowlist.global.length} global + ${total} per-repository entries across ${next.dependencyAllowlist.repositories.length} repositories (first activation; review the file in packages/release)`);
|
|
43
|
+
} else if (allow.changed) {
|
|
44
|
+
if (allow.global.added.length) lines.push(` allowlist global added (${allow.global.added.length}): ${names(allow.global.added)}`);
|
|
45
|
+
if (allow.global.removed.length) lines.push(` allowlist global removed (${allow.global.removed.length}): ${names(allow.global.removed)}`);
|
|
46
|
+
for (const repo of allow.repositories) {
|
|
47
|
+
const label = repoName(repo.repositoryIdentity);
|
|
48
|
+
if (repo.baseline === "added") lines.push(` allowlist baseline added for ${label} (${repo.added.length} entries)`);
|
|
49
|
+
else if (repo.baseline === "removed") lines.push(` allowlist baseline removed for ${label}`);
|
|
50
|
+
if (repo.baseline !== "changed") continue;
|
|
51
|
+
if (repo.added.length) lines.push(` allowlist ${label} added (${repo.added.length}): ${names(repo.added)}`);
|
|
52
|
+
if (repo.removed.length) lines.push(` allowlist ${label} removed (${repo.removed.length}): ${names(repo.removed)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readInstalledManifest() { return JSON.parse(readFileSync(path.join(here, "rightkit-ownership.json"), "utf8")); }
|
|
59
|
+
|
|
60
|
+
function keysAt(log, rootFingerprint, sequence) {
|
|
61
|
+
const events = log.events.slice(0, Math.max(0, sequence) + 1);
|
|
62
|
+
const state = verifyKeyLog({ schema: 1, events }, { rootFingerprint });
|
|
63
|
+
return new Map((state.ok ? state.keys : []).map((k) => [k.keyId, k]));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {{ existing: object, requestBytes: Buffer|string, manifest?: object, registry?: object, scannerVersion?: string }} input
|
|
68
|
+
* @returns {{ changed: boolean, lines: string[], payloadDigest: string, next: object }}
|
|
69
|
+
*/
|
|
70
|
+
export function summarizeTrustChange({ existing, requestBytes, manifest, registry, scannerVersion, policy }) {
|
|
71
|
+
const bytes = Buffer.from(requestBytes);
|
|
72
|
+
const payloadDigest = createHash("sha256").update(bytes).digest("hex");
|
|
73
|
+
const request = JSON.parse(bytes.toString("utf8"));
|
|
74
|
+
const catalog = manifest ?? readInstalledManifest();
|
|
75
|
+
const prepared = prepareTrustUpdate(existing, request);
|
|
76
|
+
const merged = mergeRepositoriesFromInstalledRelease(prepared, { firstInstall: existing == null, ...(registry ? { registry } : {}) });
|
|
77
|
+
const next = raiseFloorsToInstalledRelease(merged.trust, { manifest: catalog, ...(scannerVersion ? { scannerVersion } : {}), ...(policy || registry ? { policy: policy ?? { ...installedPolicyInputs(), registry } } : {}) });
|
|
78
|
+
const changed = canonicalJson(existing ?? null) !== canonicalJson(next);
|
|
79
|
+
const lines = [];
|
|
80
|
+
if (!changed) return { changed, lines: ["Ownership trust already current."], payloadDigest, next };
|
|
81
|
+
|
|
82
|
+
const repos = diffSets(new Set((existing?.repositories ?? []).map((r) => r.repositoryIdentity)), new Set(next.repositories.map((r) => r.repositoryIdentity)));
|
|
83
|
+
if (repos.added.length) lines.push(`Repositories added (${repos.added.length}): ${repos.added.map(repoName).join(", ")}`);
|
|
84
|
+
if (repos.removed.length) lines.push(`Repositories removed (${repos.removed.length}): ${repos.removed.map(repoName).join(", ")}`);
|
|
85
|
+
for (const w of merged.warnings) lines.push(`Warning: ${w.message}`);
|
|
86
|
+
|
|
87
|
+
const caps = Array.isArray(catalog.capabilities) ? catalog.capabilities : [];
|
|
88
|
+
const fingerprints = caps.reduce((n, c) => n + (Array.isArray(c.fingerprints) ? c.fingerprints.length : 0), 0);
|
|
89
|
+
const oldDigest = existing?.minimumCatalogDigest;
|
|
90
|
+
if (oldDigest !== next.minimumCatalogDigest || existing?.minimumCatalogVersion !== next.minimumCatalogVersion) {
|
|
91
|
+
lines.push(`Catalog: v${existing?.minimumCatalogVersion ?? "none"} ${short(oldDigest, 8)} -> v${next.minimumCatalogVersion} ${short(next.minimumCatalogDigest, 8)} (${caps.length} capabilities, ${fingerprints} fingerprints; previous catalog contents are not stored, so per-capability diffs are unavailable)`);
|
|
92
|
+
}
|
|
93
|
+
lines.push(...policyLines(existing, next));
|
|
94
|
+
if (existing?.minimumScannerVersion !== next.minimumScannerVersion) lines.push(`Scanner floor: ${existing?.minimumScannerVersion ?? "none"} -> ${next.minimumScannerVersion}`);
|
|
95
|
+
|
|
96
|
+
const oldSeq = existing?.keyCheckpoint?.sequence ?? -1;
|
|
97
|
+
const newSeq = next.keyCheckpoint.sequence;
|
|
98
|
+
if (oldSeq !== newSeq || existing?.keyCheckpoint?.head !== next.keyCheckpoint.head) {
|
|
99
|
+
lines.push(`Key log sequence: ${oldSeq < 0 ? "none" : oldSeq} -> ${newSeq}`);
|
|
100
|
+
const rootFingerprint = request.rootFingerprint;
|
|
101
|
+
const before = oldSeq < 0 ? new Map() : keysAt(request.log, rootFingerprint, oldSeq);
|
|
102
|
+
const after = keysAt(request.log, rootFingerprint, newSeq);
|
|
103
|
+
const fmt = (k) => `${short(k.keyId)} (${k.platform ?? "unknown host"})`;
|
|
104
|
+
const added = [...after.values()].filter((k) => !before.has(k.keyId));
|
|
105
|
+
const revoked = [...before.values()].filter((k) => !after.has(k.keyId));
|
|
106
|
+
if (added.length) lines.push(`Approval keys added: ${added.map(fmt).join(", ")}`);
|
|
107
|
+
if (revoked.length) lines.push(`Approval keys revoked: ${revoked.map(fmt).join(", ")}`);
|
|
108
|
+
if (!added.length && !revoked.length) lines.push("Approval keys: no change in active keys");
|
|
109
|
+
}
|
|
110
|
+
lines.push(`Payload SHA-256: ${short(payloadDigest, 16)}`);
|
|
111
|
+
return { changed, lines, payloadDigest, next };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Parse + verify the pinned request file, then summarize exactly those bytes. */
|
|
115
|
+
export function summarizeRequestFile({ file, digest, existing, ...rest }) {
|
|
116
|
+
const bytes = readFileSync(file);
|
|
117
|
+
if (createHash("sha256").update(bytes).digest("hex") !== digest) throw new Error("trust request bytes do not match pinned SHA-256; refusing to summarize");
|
|
118
|
+
return summarizeTrustChange({ existing, requestBytes: bytes, ...rest });
|
|
119
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Human-presence gate for the Windows protected trust update. It runs INSIDE the elevated updater, so it
|
|
2
|
+
// cannot be skipped by invoking the updater directly from an already-elevated (e.g. SSH) process: such a
|
|
3
|
+
// process has no interactive desktop and no way to answer a password prompt for the signed-in user.
|
|
4
|
+
//
|
|
5
|
+
// Primary check: Windows Hello user presence (UserConsentVerifier: PIN, fingerprint, face or password, whatever the
|
|
6
|
+
// user signs in with), shown owned by a foreground window. Fallback when Hello is not available: the system
|
|
7
|
+
// credential dialog plus LogonUser (password-only sign-in).
|
|
8
|
+
//
|
|
9
|
+
// Why not UAC alone: UAC is not a human gate when the machine elevates silently (ConsentPromptBehaviorAdmin=0),
|
|
10
|
+
// and an elevated process can launch further elevated processes with no prompt at all. Verifying the signed-in
|
|
11
|
+
// user's password via the system credential dialog needs a secret and a person at the screen, and needs no new
|
|
12
|
+
// infrastructure. Approval is affirmative-only: the script prints a per-run token on the single success path
|
|
13
|
+
// and the caller accepts nothing else (kill, timeout, cancel, error and empty output all mean "no").
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { spawnSync, windowsPowerShellEnvironment } from "./background-process.mjs";
|
|
16
|
+
|
|
17
|
+
const POWERSHELL = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
|
|
18
|
+
export const PRESENCE_TITLE = "RightKit ownership trust-sync - confirm it is you";
|
|
19
|
+
export const PRESENCE_APPROVE_PREFIX = "RK_PRESENCE_VERIFIED:";
|
|
20
|
+
export const PRESENCE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
const psq = (value) => `'${String(value).replaceAll("'", "''")}'`;
|
|
23
|
+
|
|
24
|
+
const CSHARP = `
|
|
25
|
+
using System; using System.Runtime.InteropServices; using System.Text;
|
|
26
|
+
public static class RkPresence {
|
|
27
|
+
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] public struct CREDUI_INFO { public int cbSize; public IntPtr hwndParent; public string pszMessageText; public string pszCaptionText; public IntPtr hbmBanner; }
|
|
28
|
+
[DllImport("credui.dll", CharSet=CharSet.Unicode)] static extern int CredUIPromptForWindowsCredentials(ref CREDUI_INFO ui, int authError, ref uint authPackage, IntPtr inBuf, uint inSize, out IntPtr outBuf, out uint outSize, ref bool save, int flags);
|
|
29
|
+
[DllImport("credui.dll", CharSet=CharSet.Unicode)] static extern bool CredUnPackAuthenticationBufferW(int flags, IntPtr buf, uint size, StringBuilder user, ref int userLen, StringBuilder domain, ref int domLen, StringBuilder pass, ref int passLen);
|
|
30
|
+
[DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern bool LogonUserW(string user, string domain, string pass, int type, int provider, out IntPtr token);
|
|
31
|
+
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
|
|
32
|
+
[DllImport("ole32.dll")] static extern void CoTaskMemFree(IntPtr p);
|
|
33
|
+
[ComImport, Guid("39E050C3-4E74-441A-8DC0-B81104DF949C"), InterfaceType(ComInterfaceType.InterfaceIsIInspectable)]
|
|
34
|
+
interface IUserConsentVerifierInterop { [PreserveSig] int RequestVerificationForWindowAsync(IntPtr appWindow, IntPtr message, [In] ref Guid riid, out IntPtr op); }
|
|
35
|
+
[DllImport("combase.dll")] static extern int RoGetActivationFactory(IntPtr cls, [In] ref Guid iid, out IntPtr factory);
|
|
36
|
+
[DllImport("combase.dll", CharSet=CharSet.Unicode)] static extern int WindowsCreateString(string s, int len, out IntPtr h);
|
|
37
|
+
[DllImport("combase.dll")] static extern int WindowsDeleteString(IntPtr h);
|
|
38
|
+
[DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr h);
|
|
39
|
+
[DllImport("user32.dll")] static extern void keybd_event(byte vk, byte scan, int flags, UIntPtr extra);
|
|
40
|
+
// Raw IAsyncOperation pointer for a Hello request owned by hwnd (so the dialog opens in front of it), or Zero on failure.
|
|
41
|
+
public static IntPtr RequestForWindow(IntPtr hwnd, string message, Guid riid) {
|
|
42
|
+
IntPtr cls = IntPtr.Zero, msg = IntPtr.Zero, fac = IntPtr.Zero;
|
|
43
|
+
try {
|
|
44
|
+
string name = "Windows.Security.Credentials.UI.UserConsentVerifier";
|
|
45
|
+
if (WindowsCreateString(name, name.Length, out cls) != 0) return IntPtr.Zero;
|
|
46
|
+
Guid iid = typeof(IUserConsentVerifierInterop).GUID;
|
|
47
|
+
if (RoGetActivationFactory(cls, ref iid, out fac) != 0 || fac == IntPtr.Zero) return IntPtr.Zero;
|
|
48
|
+
if (WindowsCreateString(message, message.Length, out msg) != 0) return IntPtr.Zero;
|
|
49
|
+
IUserConsentVerifierInterop f = (IUserConsentVerifierInterop)Marshal.GetObjectForIUnknown(fac);
|
|
50
|
+
IntPtr op; int rc = f.RequestVerificationForWindowAsync(hwnd, msg, ref riid, out op);
|
|
51
|
+
return rc == 0 ? op : IntPtr.Zero;
|
|
52
|
+
} finally {
|
|
53
|
+
if (msg != IntPtr.Zero) WindowsDeleteString(msg);
|
|
54
|
+
if (cls != IntPtr.Zero) WindowsDeleteString(cls);
|
|
55
|
+
if (fac != IntPtr.Zero) Marshal.Release(fac);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Best-effort foreground grab: a synthetic Alt press lets SetForegroundWindow succeed from a freshly launched elevated process.
|
|
59
|
+
public static void Foreground(IntPtr h) { keybd_event(0x12, 0, 0, UIntPtr.Zero); keybd_event(0x12, 0, 2, UIntPtr.Zero); SetForegroundWindow(h); }
|
|
60
|
+
// Returns the verified "DOMAIN\\\\user" or null. Three attempts; Cancel ends immediately.
|
|
61
|
+
public static string Verify(string message, string caption) {
|
|
62
|
+
int authError = 0;
|
|
63
|
+
for (int attempt = 0; attempt < 3; attempt++) {
|
|
64
|
+
CREDUI_INFO ui = new CREDUI_INFO(); ui.cbSize = Marshal.SizeOf(typeof(CREDUI_INFO)); ui.pszMessageText = message; ui.pszCaptionText = caption;
|
|
65
|
+
uint pkg = 0; IntPtr outBuf; uint outSize; bool save = false;
|
|
66
|
+
int rc = CredUIPromptForWindowsCredentials(ref ui, authError, ref pkg, IntPtr.Zero, 0, out outBuf, out outSize, ref save, 0x200);
|
|
67
|
+
if (rc != 0) return null;
|
|
68
|
+
StringBuilder u = new StringBuilder(513), d = new StringBuilder(257), p = new StringBuilder(513);
|
|
69
|
+
int ul = u.Capacity, dl = d.Capacity, pl = p.Capacity;
|
|
70
|
+
bool ok = CredUnPackAuthenticationBufferW(0, outBuf, outSize, u, ref ul, d, ref dl, p, ref pl);
|
|
71
|
+
byte[] zero = new byte[outSize]; Marshal.Copy(zero, 0, outBuf, (int)outSize); CoTaskMemFree(outBuf);
|
|
72
|
+
if (!ok) return null;
|
|
73
|
+
IntPtr token;
|
|
74
|
+
string dom = d.Length > 0 ? d.ToString() : ".";
|
|
75
|
+
bool logon = LogonUserW(u.ToString(), dom, p.ToString(), 2, 0, out token);
|
|
76
|
+
p.Clear();
|
|
77
|
+
if (logon) { CloseHandle(token); return (d.Length > 0 ? d.ToString() + "\\\\" : "") + u.ToString(); }
|
|
78
|
+
authError = 1326;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}`;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* PowerShell run in the interactive desktop session. Prints the per-run token only after Windows Hello reports Verified
|
|
86
|
+
* (or, when Hello is unavailable, after a verified password for the signed-in user). Exit codes: 0 approved, 1 denied
|
|
87
|
+
* (cancel, busy, not configured, retries exhausted, wrong user), 2 error, 3 non-interactive.
|
|
88
|
+
*/
|
|
89
|
+
export function buildPresenceScript(summary, nonce, helloMessage = presenceHelloMessage(summary)) {
|
|
90
|
+
const clean = (v) => String(v).replaceAll("\0", "");
|
|
91
|
+
return [
|
|
92
|
+
"$ErrorActionPreference='Stop'",
|
|
93
|
+
"try {",
|
|
94
|
+
// Not interactive (session 0, SSH, service) => no human can be present: refuse before any prompt.
|
|
95
|
+
"if (-not [Environment]::UserInteractive) { exit 3 }",
|
|
96
|
+
"if ([System.Diagnostics.Process]::GetCurrentProcess().SessionId -eq 0) { exit 3 }",
|
|
97
|
+
"$console=(Get-CimInstance -ClassName Win32_ComputerSystem).UserName; if (-not $console) { exit 3 }",
|
|
98
|
+
"$consoleSid=(New-Object Security.Principal.NTAccount($console)).Translate([Security.Principal.SecurityIdentifier]).Value",
|
|
99
|
+
`Add-Type -TypeDefinition @'${CSHARP}\n'@`,
|
|
100
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
101
|
+
"Add-Type -AssemblyName System.Runtime.WindowsRuntime",
|
|
102
|
+
"[void][Windows.Security.Credentials.UI.UserConsentVerifier,Windows.Security.Credentials.UI,ContentType=WindowsRuntime]",
|
|
103
|
+
"[void][Windows.Security.Credentials.UI.UserConsentVerifierAvailability,Windows.Security.Credentials.UI,ContentType=WindowsRuntime]",
|
|
104
|
+
"[void][Windows.Security.Credentials.UI.UserConsentVerificationResult,Windows.Security.Credentials.UI,ContentType=WindowsRuntime]",
|
|
105
|
+
"[void][Windows.Foundation.IAsyncOperation`1,Windows.Foundation,ContentType=WindowsRuntime]",
|
|
106
|
+
"$asTask=@([System.WindowsRuntimeSystemExtensions].GetMethods()|Where-Object{ $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]",
|
|
107
|
+
"function Await($op,$type){ $t=$asTask.MakeGenericMethod($type).Invoke($null,[object[]]@($op)); [void]$t.Wait(); $t.Result }",
|
|
108
|
+
// Hello availability; any failure to ask means "not available" and drops to the password path.
|
|
109
|
+
"$avail='Error'; try { $avail=[string](Await ([Windows.Security.Credentials.UI.UserConsentVerifier]::CheckAvailabilityAsync()) ([Windows.Security.Credentials.UI.UserConsentVerifierAvailability])) } catch { $avail='Error' }",
|
|
110
|
+
"if ($avail -eq 'Available') {",
|
|
111
|
+
// The verifying user is the elevated process identity; it must be the signed-in console user.
|
|
112
|
+
" if ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -ne $consoleSid) { exit 1 }",
|
|
113
|
+
` $msg=${psq(clean(helloMessage))}`,
|
|
114
|
+
" $rtype=[Windows.Security.Credentials.UI.UserConsentVerificationResult]",
|
|
115
|
+
// Owner window: a tiny topmost form forced to the foreground so the Hello dialog opens in front, not behind.
|
|
116
|
+
" $form=New-Object Windows.Forms.Form; $form.TopMost=$true; $form.ShowInTaskbar=$false; $form.FormBorderStyle='None'; $form.StartPosition='CenterScreen'; $form.Opacity=0.01; $form.Size=New-Object Drawing.Size(2,2)",
|
|
117
|
+
" $form.Show(); $form.Activate(); [RkPresence]::Foreground($form.Handle)",
|
|
118
|
+
" $res=$null",
|
|
119
|
+
" try {",
|
|
120
|
+
" $iid=([Windows.Foundation.IAsyncOperation`1].MakeGenericType($rtype)).GUID",
|
|
121
|
+
" $ptr=[RkPresence]::RequestForWindow($form.Handle,$msg,$iid)",
|
|
122
|
+
" if ($ptr -ne [IntPtr]::Zero) { $res=[string](Await ([Runtime.InteropServices.Marshal]::GetObjectForIUnknown($ptr)) $rtype) }",
|
|
123
|
+
" } catch { $res=$null }",
|
|
124
|
+
// Owner-window request failed to start: plain request (no owner) is still better than no Hello.
|
|
125
|
+
" if ($null -eq $res) { try { $res=[string](Await ([Windows.Security.Credentials.UI.UserConsentVerifier]::RequestVerificationAsync($msg)) $rtype) } catch { $form.Close(); exit 2 } }",
|
|
126
|
+
" $form.Close()",
|
|
127
|
+
" if ($res -ne 'Verified') { exit 1 }",
|
|
128
|
+
"} else {",
|
|
129
|
+
` $who=[RkPresence]::Verify(${psq(clean(summary))}, ${psq(PRESENCE_TITLE)})`,
|
|
130
|
+
" if (-not $who) { exit 1 }",
|
|
131
|
+
// The verified account must be the signed-in console user, not just any account whose password is known.
|
|
132
|
+
" $a=(New-Object Security.Principal.NTAccount($who)).Translate([Security.Principal.SecurityIdentifier]).Value",
|
|
133
|
+
" if ($a -ne $consoleSid) { exit 1 }",
|
|
134
|
+
"}",
|
|
135
|
+
`[Console]::Out.WriteLine(${psq(`${PRESENCE_APPROVE_PREFIX}${nonce}`)}); exit 0`,
|
|
136
|
+
"} catch { exit 2 }",
|
|
137
|
+
].join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One-line message for the Windows Hello dialog: the catalog/key/repository lines of the summary, bounded. */
|
|
141
|
+
export function presenceHelloMessage(summary) {
|
|
142
|
+
const lines = String(summary).split(/\r?\n/).slice(1, 4).map((l) => l.trim()).filter(Boolean);
|
|
143
|
+
return `RightKit ownership trust-sync: ${lines.join("; ")}`.slice(0, 200);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Compact, bounded description of the change for the credential dialog. */
|
|
147
|
+
export function presenceSummary({ existing, next }) {
|
|
148
|
+
const keys = (t) => t?.keyCheckpoint?.sequence ?? "none";
|
|
149
|
+
const digest = (t) => String(t?.minimumCatalogDigest ?? "none").slice(0, 8);
|
|
150
|
+
return [
|
|
151
|
+
"RightKit will update the protected ownership trust on this computer.",
|
|
152
|
+
`Catalog: ${digest(existing)} -> ${digest(next)}`,
|
|
153
|
+
`Key log sequence: ${keys(existing)} -> ${keys(next)}`,
|
|
154
|
+
`Repositories: ${existing?.repositories?.length ?? 0} -> ${next?.repositories?.length ?? 0}`,
|
|
155
|
+
"Approve with Windows Hello (or your Windows password). Cancel to refuse.",
|
|
156
|
+
].join("\n").slice(0, 900);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Throws unless a human verified their presence (Windows Hello, else password) on this machine's desktop for exactly this run.
|
|
161
|
+
* Affirmative-only: exit 0 AND the exact per-run token on stdout; kill, timeout, spawn error and anything else deny.
|
|
162
|
+
*/
|
|
163
|
+
export function requireWindowsPresence({ summary, run = spawnSync, nonce = randomBytes(16).toString("hex"), timeoutMs = PRESENCE_TIMEOUT_MS } = {}) {
|
|
164
|
+
const script = buildPresenceScript(summary, nonce);
|
|
165
|
+
const r = run(POWERSHELL, ["-NoProfile", "-Sta", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
|
|
166
|
+
encoding: "utf8", windowsHide: false, timeout: timeoutMs, killSignal: "SIGKILL", env: windowsPowerShellEnvironment(),
|
|
167
|
+
});
|
|
168
|
+
const approved = r && !r.error && r.status === 0 && String(r.stdout ?? "").split(/\r?\n/).includes(`${PRESENCE_APPROVE_PREFIX}${nonce}`);
|
|
169
|
+
if (!approved) throw Object.assign(new Error("RK_OWNERSHIP_PRESENCE_DENIED: the signed-in user did not verify their presence (Windows Hello or password) at the desktop; trust was not changed"), { code: "RK_OWNERSHIP_PRESENCE_DENIED" });
|
|
170
|
+
return true;
|
|
171
|
+
}
|
package/ownership.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import installedManifest from "./rightkit-ownership.json" with { type: "json" };
|
|
|
6
6
|
import { parse as parseToml } from "smol-toml";
|
|
7
7
|
import { sourceNonCode } from "./ownership-source.mjs";
|
|
8
8
|
import { OWNERSHIP_LOCKFILES, transitiveDependencies } from "./ownership-lockfiles.mjs";
|
|
9
|
+
import { ALLOWLIST_FILE, ALLOWLIST_FINDING_ID, evaluateDependency, sourceKind, validateAllowlist } from "./ownership-dependency-allowlist.mjs";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
9
11
|
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
10
12
|
import { createHash } from "node:crypto";
|
|
11
13
|
import { acquireCommittedSource, committedSourceDataForInternalUse } from "./committed-source.mjs";
|
|
@@ -537,7 +539,7 @@ function trustedProvenance(appRoot, inventory, trust, repositoryIdentity, covera
|
|
|
537
539
|
return { excluded, classifications };
|
|
538
540
|
}
|
|
539
541
|
|
|
540
|
-
function scanInternal(appRoot, manifest, { inventory = fileInventory(appRoot), trust, repositoryIdentity, repositoryRoot = appRoot, pathPrefix = "" } = {}) {
|
|
542
|
+
function scanInternal(appRoot, manifest, { inventory = fileInventory(appRoot), trust, repositoryIdentity, repositoryRoot = appRoot, pathPrefix = "", allowlistPolicy = null } = {}) {
|
|
541
543
|
const findings = [], coverage = [...inventory.coverage], deps = dependenciesFromInventory(appRoot, inventory), contentDigests = [...(deps.contentDigests ?? [])];
|
|
542
544
|
const catalogOwners = new Set(manifest.capabilities.map((entry) => entry.package));
|
|
543
545
|
coverage.push(...(deps.coverage ?? []));
|
|
@@ -550,6 +552,19 @@ function scanInternal(appRoot, manifest, { inventory = fileInventory(appRoot), t
|
|
|
550
552
|
pushDependency(c, f, d);
|
|
551
553
|
}
|
|
552
554
|
}
|
|
555
|
+
if (allowlistPolicy && repositoryIdentity) {
|
|
556
|
+
// Default-deny: a direct dependency that is not a RightKit package, not a first-party path dependency, and
|
|
557
|
+
// not in the repository baseline or global list is a finding. Block-tier catalog dependencies are reported by
|
|
558
|
+
// their own (always blocking) fingerprints above, so allowlisting can never relax them.
|
|
559
|
+
const owners = [...catalogOwners, ...(manifest.internalOwners ?? []).map((owner) => typeof owner === "string" ? owner : owner.package)];
|
|
560
|
+
const blocked = new Set(manifest.capabilities.flatMap((c) => c.fingerprints.filter((f) => f.type === "dependency" && findingPolicy(c, f).tier === "block").map((f) => `${f.ecosystem}:${f.name}`)));
|
|
561
|
+
for (const d of deps) {
|
|
562
|
+
if (blocked.has(`${d.ecosystem}:${d.name}`) || evaluateDependency(allowlistPolicy.allowlist, repositoryIdentity, { ecosystem: d.ecosystem, name: d.name, kind: sourceKind(d.source) }, { owners }).allowed) continue;
|
|
563
|
+
findings.push({ fingerprint: ALLOWLIST_FINDING_ID, capability: "dependency-allowlist", package: "RightKit dependency allowlist", tier: allowlistPolicy.tier, file: d.file, line: 0, kind: "dependency", ecosystem: d.ecosystem, name: d.name,
|
|
564
|
+
alias: d.alias, section: d.section, requirement: d.requirement, features: d.features, defaultFeatures: d.defaultFeatures, local: d.local, source: d.source, resolvedVersion: d.resolvedVersion,
|
|
565
|
+
text: `${d.ecosystem} dependency ${d.name} is not in ${ALLOWLIST_FILE}; propose it there and Adrian activates it via trust-sync`, provenance: d.source });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
553
568
|
const blockRules = manifest.capabilities.flatMap((c) => c.fingerprints.filter((f) => f.type === "dependency" && findingPolicy(c, f).tier === "block").map((f) => ({ c, f })));
|
|
554
569
|
const transitiveOwners = new Set([...catalogOwners, ...(manifest.internalOwners ?? []).map((owner) => typeof owner === "string" ? owner : owner.package)]);
|
|
555
570
|
const transitive = transitiveDependencies(appRoot, inventory, deps.manifests, transitiveOwners, coverage, contentDigests, blockRules.map(({ f }) => `${f.ecosystem}:${f.name}`));
|
|
@@ -578,6 +593,24 @@ function scanInternal(appRoot, manifest, { inventory = fileInventory(appRoot), t
|
|
|
578
593
|
return { findings, coverage, deps, contentDigests, provenance: provenance.classifications };
|
|
579
594
|
}
|
|
580
595
|
|
|
596
|
+
const shippedAllowlistFile = path.join(path.dirname(fileURLToPath(import.meta.url)), ALLOWLIST_FILE);
|
|
597
|
+
function shippedAllowlist() {
|
|
598
|
+
try { const parsed = JSON.parse(readFileSync(shippedAllowlistFile, "utf8")); return validateAllowlist(parsed).ok ? parsed : null; } catch { return null; }
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Which allowlist applies and how hard. The protected trust copy (activated by trust-sync) governs every repository
|
|
602
|
+
* registered in trust and blocks. Before trust-sync, or while approval enrollment is pending, the shipped proposal
|
|
603
|
+
* is advisory and only for repositories it has a baseline for.
|
|
604
|
+
*/
|
|
605
|
+
export function dependencyAllowlistPolicy(trust, repositoryIdentity, { pending = false, shipped } = {}) {
|
|
606
|
+
if (!repositoryIdentity) return null;
|
|
607
|
+
const trusted = trust?.dependencyAllowlist && validateAllowlist(trust.dependencyAllowlist).ok ? trust.dependencyAllowlist : null;
|
|
608
|
+
if (trusted && trust.repositories?.some((entry) => entry.repositoryIdentity === repositoryIdentity)) return { allowlist: trusted, tier: pending ? "advise" : "block", source: "protected-trust" };
|
|
609
|
+
const proposal = shipped === undefined ? shippedAllowlist() : shipped;
|
|
610
|
+
if (proposal?.repositories?.some((entry) => entry.repositoryIdentity === repositoryIdentity)) return { allowlist: proposal, tier: "advise", source: "shipped-proposal" };
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
|
|
581
614
|
function trustForScan(options = {}) {
|
|
582
615
|
if (options.trust !== undefined) return options.trust?.trust ?? options.trust;
|
|
583
616
|
try { return loadTrustedExternal().trust; } catch { return null; }
|
|
@@ -610,7 +643,7 @@ function scanAppInternal(appRoot, manifest, options, readAuthority) {
|
|
|
610
643
|
const repositoryIdentity = repositoryIdentityFor(appRoot);
|
|
611
644
|
const authority = initialTrust?.keyAuthority ? resolveSharedAuthority(initialTrust, readAuthority) : resolveRepositoryKeyLog(initialTrust, location.root, repositoryIdentity, readAuthority);
|
|
612
645
|
const trust = authority.trust;
|
|
613
|
-
const result = scanInternal(appRoot, manifest, { inventory: fileInventory(appRoot, trust, location.prefix, location.root), trust, repositoryIdentity, repositoryRoot: location.root, pathPrefix: location.prefix });
|
|
646
|
+
const result = scanInternal(appRoot, manifest, { inventory: fileInventory(appRoot, trust, location.prefix, location.root), trust, repositoryIdentity, repositoryRoot: location.root, pathPrefix: location.prefix, allowlistPolicy: dependencyAllowlistPolicy(trust, repositoryIdentity, { pending: enrollmentPending(trust, options.trust), shipped: options.shippedAllowlist }) });
|
|
614
647
|
Object.defineProperty(result.findings, "coverage", { value: result.coverage, enumerable: false });
|
|
615
648
|
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 });
|
|
616
649
|
return result.findings;
|
|
@@ -702,12 +735,12 @@ function checkAppInternal(appRoot, manifest, options, readAuthority) {
|
|
|
702
735
|
const resolved = initialTrust?.keyAuthority ? resolveSharedAuthority(initialTrust, readAuthority) : resolveRepositoryKeyLog(initialTrust, repositoryLocation.root, repositoryIdentity, readAuthority);
|
|
703
736
|
const trust = resolved.trust;
|
|
704
737
|
const location = repositoryLocation;
|
|
705
|
-
const scanned = scanInternal(appRoot, manifest, { inventory: fileInventory(appRoot, trust, location.prefix, location.root), trust, repositoryIdentity, repositoryRoot: location.root, pathPrefix: location.prefix });
|
|
706
|
-
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")) };
|
|
707
|
-
if (trust?.rootFingerprint && Array.isArray(trust.ownershipKeys) && !trust.ownershipKeys.length) throw new Error("protected ownership key log has no active approval keys");
|
|
708
738
|
const enrollment = trust?.rootFingerprint
|
|
709
739
|
? { enrolled: trust.enrollmentComplete === true, pending: trust.enrollmentComplete !== true }
|
|
710
740
|
: trustState?.pending !== undefined || trustState?.enrolled !== undefined ? trustState : undefined;
|
|
741
|
+
const scanned = scanInternal(appRoot, manifest, { inventory: fileInventory(appRoot, trust, location.prefix, location.root), trust, repositoryIdentity, repositoryRoot: location.root, pathPrefix: location.prefix, allowlistPolicy: dependencyAllowlistPolicy(trust, repositoryIdentity, { pending: enrollmentPending(trust, enrollment), shipped: options.shippedAllowlist }) });
|
|
742
|
+
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")) };
|
|
743
|
+
if (trust?.rootFingerprint && Array.isArray(trust.ownershipKeys) && !trust.ownershipKeys.length) throw new Error("protected ownership key log has no active approval keys");
|
|
711
744
|
const result = evaluate(scanned.findings, loadExceptions(appRoot), { ...options, manifest, trust, trustEnrollment: enrollment, metadata });
|
|
712
745
|
const coverage = [...new Map(metadata.coverage.map((x) => [`${x.path}\0${x.reason}`, x])).values()];
|
|
713
746
|
const rows = capabilityTable(manifest, scanned.deps, { ...result, coverage });
|
|
@@ -754,7 +787,10 @@ function checkCommitted(appRoot, manifest, options, acquire, readAuthority = rea
|
|
|
754
787
|
if (location.prefix && !committed.entries.some((entry) => entry.rel.startsWith(location.prefix))) {
|
|
755
788
|
inventory.coverage.push({ path: ".", status: "unscanned", reason: "registered-root-missing-from-committed-tree" });
|
|
756
789
|
}
|
|
757
|
-
const
|
|
790
|
+
const committedEnrollment = trust?.rootFingerprint
|
|
791
|
+
? { enrolled: trust.enrollmentComplete === true, pending: trust.enrollmentComplete !== true }
|
|
792
|
+
: trustState?.pending !== undefined || trustState?.enrolled !== undefined ? trustState : undefined;
|
|
793
|
+
const scanned = scanInternal(appRoot, manifest, { inventory, trust, repositoryIdentity, repositoryRoot: location.root, pathPrefix: location.prefix, allowlistPolicy: dependencyAllowlistPolicy(trust, repositoryIdentity, { pending: enrollmentPending(trust, committedEnrollment), shipped: options.shippedAllowlist }) });
|
|
758
794
|
const exceptionPath = [location.prefix.replace(/\/$/, ""), EXCEPTIONS_FILE.split(path.sep).join("/")].filter(Boolean).join("/");
|
|
759
795
|
const exceptionObject = committed.entries.find((entry) => entry.rel === exceptionPath);
|
|
760
796
|
const exceptionEntry = exceptionObject?.bytes;
|
package/package.json
CHANGED
package/pe-imports.mjs
CHANGED
|
@@ -55,5 +55,7 @@ export function readPeImports(bytes) {
|
|
|
55
55
|
delayImports.add(cstring(name));
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
// Optional header Subsystem is at offset 68 in both PE32 and PE32+ (2 = WINDOWS_GUI, 3 = WINDOWS_CUI).
|
|
59
|
+
const subsystem = buf.readUInt16LE(optional + 68);
|
|
60
|
+
return { machine, architecture: PE_MACHINES[machine] ?? `0x${machine.toString(16)}`, subsystem, imports: [...imports], delayImports: [...delayImports] };
|
|
59
61
|
}
|
|
@@ -15,6 +15,7 @@ export const PROTECTED_SECRET_MISSING = "PROTECTED_SECRET_MISSING";
|
|
|
15
15
|
// from the release config or environment, and the .p8 is read from its file.
|
|
16
16
|
export const PROTECTED_SECRETS = Object.freeze({
|
|
17
17
|
"cloudflare-api-token": { label: "Cloudflare API token", keychain: "rightkit-cloudflare-api-token", file: "cloudflare-api-token.token" },
|
|
18
|
+
"qa-receipt-key": { label: "RightKit QA receipt key", keychain: null, file: "qa-receipt-key.token", darwinFile: true },
|
|
18
19
|
});
|
|
19
20
|
|
|
20
21
|
export class ProtectedSecretMissingError extends Error {
|
|
@@ -35,6 +36,7 @@ export class ProtectedSecretMissingError extends Error {
|
|
|
35
36
|
export function protectedSecretStoreCommand(name, platform = process.platform) {
|
|
36
37
|
const spec = PROTECTED_SECRETS[name];
|
|
37
38
|
if (!spec) throw new Error(`unknown protected secret: ${name}`);
|
|
39
|
+
if (platform === "darwin" && spec.darwinFile) return null;
|
|
38
40
|
if (platform === "darwin") return `security add-generic-password -a "$USER" -s ${spec.keychain} -U -w`;
|
|
39
41
|
if (platform === "win32") {
|
|
40
42
|
return `powershell -NoProfile -Command "$s = Read-Host -AsSecureString '${spec.label}'; $d = Join-Path $env:APPDATA 'RightKit'; New-Item -ItemType Directory -Force -Path $d | Out-Null; [IO.File]::WriteAllText((Join-Path $d '${spec.file}'), [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s)))"`;
|
|
@@ -61,6 +63,8 @@ function readProtectedFile(file) {
|
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
export const darwinKeyFile = (env, spec) => path.join(env.HOME || os.homedir(), "Library", "Application Support", "RightKit", spec.file);
|
|
67
|
+
|
|
64
68
|
// The store is injectable so tests can use fakes without touching a real
|
|
65
69
|
// Keychain or the Windows profile directory.
|
|
66
70
|
export function createCredentialStore({
|
|
@@ -76,7 +80,8 @@ export function createCredentialStore({
|
|
|
76
80
|
const spec = PROTECTED_SECRETS[name];
|
|
77
81
|
let value;
|
|
78
82
|
if (platform === "darwin") {
|
|
79
|
-
|
|
83
|
+
// Receipt MAC keys are provisioned by right-release itself as a 0600 file, never from the Keychain.
|
|
84
|
+
value = spec.darwinFile ? readFile(darwinKeyFile(env, spec)) : readKeychain({ service: spec.keychain, account: env.USER || os.userInfo().username });
|
|
80
85
|
} else if (platform === "win32") {
|
|
81
86
|
const appData = env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
82
87
|
value = readFile(path.win32.join(appData, "RightKit", spec.file));
|