@rightkit/release 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/adhoc-entitlements.mjs +8 -0
- package/advisory-exceptions.mjs +371 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1079 -0
- package/cli/right-release.mjs +29 -0
- package/deterministic-scans.mjs +966 -0
- package/development-signing.mjs +2 -1
- package/host-upload-refusal.mjs +90 -0
- package/local-publish.mjs +9 -0
- package/local-qualify.mjs +728 -0
- package/local-runtime.mjs +319 -0
- package/lock-graph.mjs +51 -0
- package/managed-cargo.mjs +4 -1
- package/ownership-approval.mjs +23 -6
- package/ownership-mandatory-set.json +53 -13
- package/ownership-mandatory-set.mjs +10 -4
- package/package.json +6 -2
- package/pipeline-admission.mjs +1 -1
- package/pipeline-receipts.mjs +302 -0
- package/publication-gate.mjs +12 -1
- package/publish-cargo.mjs +13 -1
- package/rightkit-ownership.json +57 -0
- package/rightkit-versions.json +77 -61
- package/runtime-budget-classes.json +8 -0
- package/scan-digest.mjs +107 -0
- package/sign-qa-bundle.mjs +210 -0
- package/stage-sidecar.mjs +54 -9
- package/workflow-yaml.mjs +177 -0
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ Every Right app has absolutely NO access to DevTools; the single exception is Vi
|
|
|
69
69
|
| Build (SwiftPM apps) | `swift-package` (`windows-stage`, `msix`, `mac-app`) (config schema 1: `windows.{exe,out,searchDirs,include,dependencies,publisher,assets}`, `mac.{executable,bundleId,out,frameworks,resources,entitlements}`) |
|
|
70
70
|
| Install | `install-app` |
|
|
71
71
|
| Publish | `publish`, `upload`, `publish-update`, `github`, `mirror-root-artifact` |
|
|
72
|
-
| Signing | `sign-windows`, `sign-updater`, `signing-agent`, `create-mac-updater`, `sign-dev-artifact`, `sign-candidate` |
|
|
72
|
+
| Signing | `sign-windows`, `sign-updater`, `signing-agent`, `create-mac-updater`, `sign-dev-artifact`, `sign-candidate`, `sign-qa-bundle` |
|
|
73
73
|
| Engine locator | `engine-locate --engine cutright\|genright [--sha256 <hex>] [--json]`: the verified route to the installed CutRight or GenRight engine, also importable as `locateEngine` from `engine-locator.mjs`. The path is fixed (`<LocalAppData or ~/Library/Application Support>/Orthic Labs/<App>/current/bin/<engine>`), must be a regular file (no symlink or junction), and must pass signature and identity checks (macOS: strict codesign, Developer ID Team ID, fixed Identifier, hardened runtime, not ad hoc; Windows: valid Authenticode with the Orthic Labs signer). An optional sha256 pins the exact build. There is no environment variable, path option or PATH search, so release builds never trust a caller-supplied engine path. Exit 1 with a typed `ENGINE_*` code on any failure |
|
|
74
74
|
| Candidate signing | `sign-candidate --platform win\|mac --artifact <dir> --candidate-receipt <receipt.json> --out <binding.json> [--manifest <sign.json>] [--publisher <subject>] [--dry-run]`: signs the exact unsigned candidate bytes in place instead of rebuilding from source. The candidate receipt (`{ schema: 1, files: [{ path, sha256 }] }`) is checked first and any mismatch refuses; Windows signs through Azure Artifact Signing (`--publisher` pins the MSIX signer subject), macOS through Developer ID with fixed identifiers from `--manifest`. Writes a binding receipt (`rebuilt: false`, candidate sha -> signed sha per file). Never builds, notarizes or publishes |
|
|
75
75
|
| Dev artifact signing | `sign-dev-artifact --artifact <dir> --manifest <sign.json> [--metadata <payload.json>] [--evidence <path>] [--dry-run]`: Developer ID signs an existing CI dev artifact per file with fixed identifiers (macOS). It verifies the recorded hashes in the payload metadata (default `payload.json`, `{ schema: 1, files: [{ path, sha256 }] }`) first, signs each sign-manifest entry (`file` relative to the artifact, `identifier` required, nested code before its bundle) through the dev-signing identity (missing Developer ID: `DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED`, no ad hoc fallback), verifies with `codesign --verify --strict`, reads back Identifier and TeamIdentifier, refreshes the metadata hashes, and writes evidence JSON. Never builds, notarizes or publishes. The payload metadata must sit outside every signed entry (`SIGN_DEV_ARTIFACT_METADATA_IN_SIGNED_ENTRY`). An optional declarative `refresh` in the sign manifest recomputes digests that other JSON files record about signed bytes: `{ "refreshRoot": "<dir inside the artifact>", "refresh": [{ "file", "pointer" (JSON pointer to an existing string), "compute": "sha256-file" \| "sha256-tree-v1" \| "verify-sha256-file", "of", "format"?, "currentMatches"? }] }` (`file` and `of` relative to `refreshRoot`; `sha256-tree-v1` = sha256 over the sorted relative paths, each as path, NUL, 8-byte big-endian length, bytes; `verify-sha256-file` writes nothing and refuses a digest that changed; `format` embeds the digest in a locator string through one `{value}`; `currentMatches` refuses to overwrite a value of another kind, such as a signed attestation locator). Order is enforced: operations run after the entries they read are signed and before the first entry that contains a refreshed file, so that bundle (the wrapper) signs last over the final bytes; an operation may not read what a later one writes. JSON is rewritten in place with key order kept, and a file the writer would have to reformat is refused. Evidence gains a `refresh` list |
|
|
@@ -243,7 +243,7 @@ reads current digest.
|
|
|
243
243
|
|
|
244
244
|
`right-release ownership-mandatory [--workspace <dir>] [--app <name>]... [--amendment v2.1] [--enforce] [--json]`
|
|
245
245
|
checks Table 1 owner declarations & minimum versions. Default mode reports findings; `--enforce` applies current mandatory
|
|
246
|
-
policy. M21
|
|
246
|
+
policy. M21 and M23-M25 (issue tracker `@rightkit/issues-tracker`, end-to-end journeys via `rightkit-qa` / `@rightkit/qa`; both unpublished; M24 applies to every profile, M25 to desktop and apple) remain pending until qualified owner receipts authorize signed policy activation through authenticated
|
|
247
247
|
trust-sync. Their `pendingOwners` & M22 `{ role, entrypoints }` applicability records are report metadata; neither changes
|
|
248
248
|
enforcement. D4 is ratified; pending activation does not mean pending ratification.
|
|
249
249
|
|
|
@@ -255,6 +255,15 @@ enforcement. D4 is ratified; pending activation does not mean pending ratificati
|
|
|
255
255
|
- **Adoption record** (`<app>/rightkit-adoption.json`, schema 1): `app`, `manifestSha256` (the manifest file's hash; a stale record is `adoption-stale`), `target`, optional `locks`, `cargoTree { file, sha256 }` and `tasks.<AP-id>.adopted[] { capability, packages, entrypoints, receipt { file, sha256 } }`. A task must be `ready` in the manifest and every capability it requires must be adopted. Each adopted package must be pinned by the lockfile at exactly the manifest version and integrity from the crates.io registry (`Cargo.lock`), the npm registry (`pnpm-lock.yaml` v9) or the tag commit (`Package.resolved`), with no other version of the name in the lock. For cargo, the saved `cargo tree -e features` must show that exact version, every declared feature active and published, and the declared feature set equal to a passing qualification row for the target's host. The active target must be published and qualified.
|
|
256
256
|
- **Receipt** (JSON only, schema 1): `kind` `test` or `run`, `app`, `capability`, one of the adopted `entrypoint`s (a real file inside the app, optional `#symbol` that must appear in it), `packages` naming the pinned `<id>@<version>`, `target`, a `command`, `exitCode: 0`, `finishedAt`, and `lockfileSha256.<cargo|pnpm|swift>` equal to the current lockfile (a receipt from before the last lock change is refused).
|
|
257
257
|
|
|
258
|
+
## QA-only ad hoc bundle signing (`sign-qa-bundle`)
|
|
259
|
+
|
|
260
|
+
`right-release sign-qa-bundle --app <abs .app> --qa-root <abs dir> [--entitlements <abs plist>] [--out <evidence.json>]` ad hoc signs an assembled QA bundle (for example an Accessibility QA copy) that lives inside a QA scratch root. It exists because `codesign --force --deep --sign -` sent through the signing agent (SSH or non-Aqua session) is refused by design; those refusals are unchanged and this command never calls the agent.
|
|
261
|
+
|
|
262
|
+
- macOS only. Refused before any command runs: a relative path, `/Applications` or `~/Applications` (lexical or resolved), a bundle whose real path is not inside the real QA root (links are resolved first), a QA root that is `/`, the home directory or an install location, and a target that is not a `.app` directory.
|
|
263
|
+
- Nested code is signed inside-out, deepest first (`Contents/Frameworks/*` frameworks and dylibs, other `*.dylib`, `*.xpc`, nested `*.app`, `Contents/Library/LoginItems`, extra `Contents/MacOS` executables), then the outer bundle, each as `/usr/bin/codesign --force --sign - [--entitlements <reduced>] <item>`. Never `--deep`, never a real identity. `codesign --verify --strict` runs on the bundle last.
|
|
264
|
+
- `--entitlements` is reduced like ad hoc development signing (only `com.apple.security.cs.*` and `com.apple.security.device.audio-input` survive) and applies to the outer bundle only.
|
|
265
|
+
- The receipt (returned, and written with `--out`) records `signature: "adhoc"`, `releaseArtifact: false`, `qaOnly: true` and the items signed in order. The result is never notarized, sealed, published or installed; use the Developer ID paths for anything that ships.
|
|
266
|
+
|
|
258
267
|
## Entry points
|
|
259
268
|
|
|
260
269
|
Bin: `right-release` (`cli/right-release.mjs`). The package has no `exports` map. The CLI is the supported surface.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Ad hoc signatures cannot carry profile-restricted entitlements (iCloud, keychain-access-groups, application-identifier):
|
|
2
|
+
// AMFI would kill the app at launch. Ad hoc mode keeps only the hardened-runtime relaxations and audio input.
|
|
3
|
+
// Dependency-free so the QA ad hoc signer and development signing share one reduction.
|
|
4
|
+
export const isAdhocSafeEntitlement = (key) => key.startsWith("com.apple.security.cs.") || key === "com.apple.security.device.audio-input";
|
|
5
|
+
|
|
6
|
+
export function adhocSafeEntitlements(entitlements) {
|
|
7
|
+
return Object.fromEntries(Object.entries(entitlements ?? {}).filter(([key]) => isAdhocSafeEntitlement(key)));
|
|
8
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// Advisory exception gate. ci/advisory-exceptions.json is the single source; deny.toml
|
|
2
|
+
// [advisories].ignore and .cargo/audit.toml must match it exactly, every entry needs an owner,
|
|
3
|
+
// evidence and an unexpired absolute reviewBy date, and the entry's versions must match what
|
|
4
|
+
// Cargo.lock actually holds, `dependents` must equal the exact set of direct reverse
|
|
5
|
+
// dependencies of the affected versions in Cargo.lock, and every hop of `path` must be a real
|
|
6
|
+
// dependency edge (scope drift fails in both directions). Policy: Astra 2026-10-22.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
export const EXCEPTIONS_FILE = 'ci/advisory-exceptions.json';
|
|
12
|
+
export const DENY_FILE = 'deny.toml';
|
|
13
|
+
export const AUDIT_FILE = '.cargo/audit.toml';
|
|
14
|
+
export const LOCK_FILE = 'Cargo.lock';
|
|
15
|
+
|
|
16
|
+
const REQUIRED = ['id', 'crate', 'path', 'owner', 'rationale', 'evidence', 'reviewBy', 'approvedBy'];
|
|
17
|
+
const ID_RE = /^RUSTSEC-\d{4}-\d{4}$/;
|
|
18
|
+
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
19
|
+
|
|
20
|
+
// ---- minimal TOML subset: tables, key = value, strings, arrays, inline tables, bare scalars ----
|
|
21
|
+
|
|
22
|
+
export function parseToml(text) {
|
|
23
|
+
let i = 0;
|
|
24
|
+
const n = text.length;
|
|
25
|
+
const root = {};
|
|
26
|
+
let table = root;
|
|
27
|
+
const fail = msg => { throw new Error(`TOML parse error at offset ${i}: ${msg}`); };
|
|
28
|
+
const skipWs = (newlines) => {
|
|
29
|
+
for (;;) {
|
|
30
|
+
const c = text[i];
|
|
31
|
+
if (c === ' ' || c === '\t' || c === '\r') i++;
|
|
32
|
+
else if (c === '\n' && newlines) i++;
|
|
33
|
+
else if (c === '#') { while (i < n && text[i] !== '\n') i++; }
|
|
34
|
+
else break;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const parseString = () => {
|
|
38
|
+
const q = text[i++];
|
|
39
|
+
let out = '';
|
|
40
|
+
if (q === "'") {
|
|
41
|
+
while (i < n && text[i] !== "'") { if (text[i] === '\n') fail('newline in string'); out += text[i++]; }
|
|
42
|
+
if (i >= n) fail('unterminated string');
|
|
43
|
+
i++;
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
if (text.startsWith('""', i)) fail('multiline strings are not supported');
|
|
47
|
+
while (i < n && text[i] !== '"') {
|
|
48
|
+
const c = text[i++];
|
|
49
|
+
if (c === '\n') fail('newline in string');
|
|
50
|
+
if (c !== '\\') { out += c; continue; }
|
|
51
|
+
const e = text[i++];
|
|
52
|
+
const map = { n: '\n', t: '\t', r: '\r', '"': '"', '\\': '\\', b: '\b', f: '\f' };
|
|
53
|
+
if (e in map) out += map[e];
|
|
54
|
+
else if (e === 'u' || e === 'U') {
|
|
55
|
+
const len = e === 'u' ? 4 : 8;
|
|
56
|
+
const hex = text.slice(i, i + len);
|
|
57
|
+
if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length !== len) fail('bad unicode escape');
|
|
58
|
+
out += String.fromCodePoint(parseInt(hex, 16));
|
|
59
|
+
i += len;
|
|
60
|
+
} else fail(`bad escape \\${e}`);
|
|
61
|
+
}
|
|
62
|
+
if (i >= n) fail('unterminated string');
|
|
63
|
+
i++;
|
|
64
|
+
return out;
|
|
65
|
+
};
|
|
66
|
+
const parseKey = () => {
|
|
67
|
+
if (text[i] === '"' || text[i] === "'") return parseString();
|
|
68
|
+
const m = /^[A-Za-z0-9_-]+/.exec(text.slice(i, i + 200));
|
|
69
|
+
if (!m) fail('bad key');
|
|
70
|
+
i += m[0].length;
|
|
71
|
+
return m[0];
|
|
72
|
+
};
|
|
73
|
+
const parseValue = () => {
|
|
74
|
+
skipWs(false);
|
|
75
|
+
const c = text[i];
|
|
76
|
+
if (c === '"' || c === "'") return parseString();
|
|
77
|
+
if (c === '[') {
|
|
78
|
+
i++;
|
|
79
|
+
const arr = [];
|
|
80
|
+
for (;;) {
|
|
81
|
+
skipWs(true);
|
|
82
|
+
if (text[i] === ']') { i++; return arr; }
|
|
83
|
+
arr.push(parseValue());
|
|
84
|
+
skipWs(true);
|
|
85
|
+
if (text[i] === ',') i++;
|
|
86
|
+
else if (text[i] !== ']') fail('expected , or ] in array');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (c === '{') {
|
|
90
|
+
i++;
|
|
91
|
+
const obj = {};
|
|
92
|
+
for (;;) {
|
|
93
|
+
skipWs(false);
|
|
94
|
+
if (text[i] === '}') { i++; return obj; }
|
|
95
|
+
const k = parseKey();
|
|
96
|
+
skipWs(false);
|
|
97
|
+
if (text[i++] !== '=') fail('expected = in inline table');
|
|
98
|
+
obj[k] = parseValue();
|
|
99
|
+
skipWs(false);
|
|
100
|
+
if (text[i] === ',') i++;
|
|
101
|
+
else if (text[i] !== '}') fail('expected , or } in inline table');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const m = /^[^\s,\]}#]+/.exec(text.slice(i, i + 200));
|
|
105
|
+
if (!m) fail('bad value');
|
|
106
|
+
i += m[0].length;
|
|
107
|
+
const raw = m[0];
|
|
108
|
+
if (raw === 'true') return true;
|
|
109
|
+
if (raw === 'false') return false;
|
|
110
|
+
if (/^[+-]?\d[\d_]*(\.\d+)?$/.test(raw)) return Number(raw.replace(/_/g, ''));
|
|
111
|
+
return fail(`unsupported value ${raw}`);
|
|
112
|
+
};
|
|
113
|
+
for (;;) {
|
|
114
|
+
skipWs(true);
|
|
115
|
+
if (i >= n) break;
|
|
116
|
+
if (text[i] === '[') {
|
|
117
|
+
const array = text[i + 1] === '[';
|
|
118
|
+
i += array ? 2 : 1;
|
|
119
|
+
const parts = [];
|
|
120
|
+
for (;;) {
|
|
121
|
+
skipWs(false);
|
|
122
|
+
parts.push(parseKey());
|
|
123
|
+
skipWs(false);
|
|
124
|
+
if (text[i] === '.') { i++; continue; }
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
const close = array ? ']]' : ']';
|
|
128
|
+
if (!text.startsWith(close, i)) fail('bad table header');
|
|
129
|
+
i += close.length;
|
|
130
|
+
let t = root;
|
|
131
|
+
parts.forEach((p, idx) => {
|
|
132
|
+
const last = idx === parts.length - 1;
|
|
133
|
+
if (last && array) { (t[p] ||= []).push({}); t = t[p][t[p].length - 1]; }
|
|
134
|
+
else { const next = t[p] ||= {}; t = Array.isArray(next) ? next[next.length - 1] : next; }
|
|
135
|
+
});
|
|
136
|
+
table = t;
|
|
137
|
+
} else {
|
|
138
|
+
const k = parseKey();
|
|
139
|
+
skipWs(false);
|
|
140
|
+
if (text[i++] !== '=') fail('expected =');
|
|
141
|
+
if (Object.hasOwn(table, k)) fail(`duplicate key ${k}`);
|
|
142
|
+
table[k] = parseValue();
|
|
143
|
+
}
|
|
144
|
+
skipWs(false);
|
|
145
|
+
if (i < n && text[i] !== '\n') fail('unexpected trailing characters');
|
|
146
|
+
}
|
|
147
|
+
return root;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- Cargo.lock ----
|
|
151
|
+
|
|
152
|
+
export function lockedVersions(lockText) {
|
|
153
|
+
const map = new Map();
|
|
154
|
+
for (const block of lockText.split('[[package]]').slice(1)) {
|
|
155
|
+
const name = /^name = "([^"]+)"/m.exec(block)?.[1];
|
|
156
|
+
const version = /^version = "([^"]+)"/m.exec(block)?.[1];
|
|
157
|
+
if (!name || !version) continue;
|
|
158
|
+
if (!map.has(name)) map.set(name, new Set());
|
|
159
|
+
map.get(name).add(version);
|
|
160
|
+
}
|
|
161
|
+
return map;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Full package graph: Map "name version" -> { name, version, deps: Set of "name version" }.
|
|
165
|
+
// A dependency ref is "name" (unambiguous) or "name version" [(source)]; an ambiguous bare
|
|
166
|
+
// name or an unknown ref is recorded in `unresolved` so the check fails closed.
|
|
167
|
+
export function lockedGraph(lockText) {
|
|
168
|
+
const pkgs = [];
|
|
169
|
+
for (const block of lockText.split('[[package]]').slice(1)) {
|
|
170
|
+
const name = /^name = "([^"]+)"/m.exec(block)?.[1];
|
|
171
|
+
const version = /^version = "([^"]+)"/m.exec(block)?.[1];
|
|
172
|
+
if (!name || !version) continue;
|
|
173
|
+
const dm = /^dependencies = \[([\s\S]*?)^\]/m.exec(block);
|
|
174
|
+
const refs = dm ? [...dm[1].matchAll(/"([^"]+)"/g)].map(m => m[1]) : [];
|
|
175
|
+
pkgs.push({ name, version, refs });
|
|
176
|
+
}
|
|
177
|
+
const byName = new Map();
|
|
178
|
+
for (const p of pkgs) {
|
|
179
|
+
if (!byName.has(p.name)) byName.set(p.name, []);
|
|
180
|
+
byName.get(p.name).push(p.version);
|
|
181
|
+
}
|
|
182
|
+
const graph = new Map();
|
|
183
|
+
const unresolved = [];
|
|
184
|
+
for (const p of pkgs) {
|
|
185
|
+
const deps = new Set();
|
|
186
|
+
for (const ref of p.refs) {
|
|
187
|
+
const [dn, dv] = ref.split(' ');
|
|
188
|
+
const versions = byName.get(dn) ?? [];
|
|
189
|
+
const v = dv ?? (versions.length === 1 ? versions[0] : undefined);
|
|
190
|
+
if (!v || !versions.includes(v)) { unresolved.push(`${p.name} ${p.version} -> ${ref}`); continue; }
|
|
191
|
+
deps.add(`${dn} ${v}`);
|
|
192
|
+
}
|
|
193
|
+
graph.set(`${p.name} ${p.version}`, { name: p.name, version: p.version, deps });
|
|
194
|
+
}
|
|
195
|
+
return { graph, unresolved };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A path hop is "name" or "name version"; returns the matching graph node ids.
|
|
199
|
+
function hopNodes(graph, hop) {
|
|
200
|
+
const [n, v] = hop.trim().split(/\s+/);
|
|
201
|
+
return [...graph.keys()].filter(id => {
|
|
202
|
+
const node = graph.get(id);
|
|
203
|
+
return node.name === n && (v === undefined || node.version === v);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- the check ----
|
|
208
|
+
|
|
209
|
+
const isNonEmptyString = v => typeof v === 'string' && v.trim() !== '';
|
|
210
|
+
const isStringArray = v => Array.isArray(v) && v.length > 0 && v.every(isNonEmptyString);
|
|
211
|
+
|
|
212
|
+
function dateProblem(entryLabel, value, now) {
|
|
213
|
+
const m = DATE_RE.exec(value);
|
|
214
|
+
if (!m) return `${entryLabel}: reviewBy ${JSON.stringify(value)} is not an absolute YYYY-MM-DD date`;
|
|
215
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
216
|
+
const dt = new Date(Date.UTC(y, mo - 1, d));
|
|
217
|
+
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) {
|
|
218
|
+
return `${entryLabel}: reviewBy ${value} is not a real calendar date`;
|
|
219
|
+
}
|
|
220
|
+
const today = new Date(now);
|
|
221
|
+
if (Number.isNaN(today.getTime())) return `${entryLabel}: the gate clock is invalid`;
|
|
222
|
+
const todayUtc = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
|
|
223
|
+
if (todayUtc > dt.getTime()) return `${entryLabel}: reviewBy ${value} has expired; renew only with fresh evidence`;
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function checkAdvisoryExceptions({ root = process.cwd(), now = new Date(), readFile, allowPending = false } = {}) {
|
|
228
|
+
const read = readFile ?? (p => fs.readFileSync(path.join(root, p), 'utf8'));
|
|
229
|
+
const problems = [];
|
|
230
|
+
const load = (file, parse) => {
|
|
231
|
+
try { return parse(read(file)); } catch (err) { problems.push(`${file}: cannot read or parse (${err.message})`); return null; }
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const doc = load(EXCEPTIONS_FILE, JSON.parse);
|
|
235
|
+
const deny = load(DENY_FILE, parseToml);
|
|
236
|
+
const audit = load(AUDIT_FILE, parseToml);
|
|
237
|
+
const lockPair = load(LOCK_FILE, t => ({ versions: lockedVersions(t), ...lockedGraph(t) }));
|
|
238
|
+
if (!doc || !deny || !audit || !lockPair) return problems;
|
|
239
|
+
const lock = lockPair.versions;
|
|
240
|
+
const { graph } = lockPair;
|
|
241
|
+
for (const u of lockPair.unresolved) problems.push(`${LOCK_FILE}: dependency ${u} does not resolve to exactly one locked package`);
|
|
242
|
+
|
|
243
|
+
if (doc.schema !== 1) problems.push(`${EXCEPTIONS_FILE}: schema must be 1`);
|
|
244
|
+
if (!Array.isArray(doc.exceptions)) {
|
|
245
|
+
problems.push(`${EXCEPTIONS_FILE}: exceptions must be an array`);
|
|
246
|
+
return problems;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Entries.
|
|
250
|
+
const byId = new Map();
|
|
251
|
+
const valid = [];
|
|
252
|
+
doc.exceptions.forEach((e, idx) => {
|
|
253
|
+
const label = `${EXCEPTIONS_FILE} entry ${e?.id ?? `#${idx}`}`;
|
|
254
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) { problems.push(`${label}: not an object`); return; }
|
|
255
|
+
let ok = true;
|
|
256
|
+
for (const f of REQUIRED) {
|
|
257
|
+
if (!isNonEmptyString(e[f])) { problems.push(`${label}: missing field ${f}`); ok = false; }
|
|
258
|
+
}
|
|
259
|
+
if (!isStringArray(e.affectedVersions)) { problems.push(`${label}: missing field affectedVersions (non-empty array of exact versions)`); ok = false; }
|
|
260
|
+
if (e.unaffectedVersions !== undefined && !isStringArray(e.unaffectedVersions)) { problems.push(`${label}: unaffectedVersions must be a non-empty array when present`); ok = false; }
|
|
261
|
+
if (isNonEmptyString(e.id)) {
|
|
262
|
+
if (!ID_RE.test(e.id)) { problems.push(`${label}: id is not a RUSTSEC id`); ok = false; }
|
|
263
|
+
if (byId.has(e.id)) { problems.push(`${label}: duplicate id`); ok = false; } else byId.set(e.id, e);
|
|
264
|
+
}
|
|
265
|
+
if (isNonEmptyString(e.reviewBy)) {
|
|
266
|
+
const p = dateProblem(label, e.reviewBy, now);
|
|
267
|
+
if (p) { problems.push(p); ok = false; }
|
|
268
|
+
}
|
|
269
|
+
if (isNonEmptyString(e.approvedBy) && !allowPending && /pending/i.test(e.approvedBy)) {
|
|
270
|
+
problems.push(`${label}: approval is pending (${e.approvedBy})`);
|
|
271
|
+
ok = false;
|
|
272
|
+
}
|
|
273
|
+
if (ok) valid.push(e);
|
|
274
|
+
// Consumer scope: the exact reverse-dependency set, machine-checked from Cargo.lock.
|
|
275
|
+
if (isNonEmptyString(e.crate) && Array.isArray(e.affectedVersions)) {
|
|
276
|
+
if (!isStringArray(e.dependents)) {
|
|
277
|
+
problems.push(`${label}: missing field dependents (non-empty array of "name version" direct reverse dependencies)`);
|
|
278
|
+
} else {
|
|
279
|
+
const affected = new Set(e.affectedVersions.map(v => `${e.crate} ${v}`));
|
|
280
|
+
const actual = new Set();
|
|
281
|
+
for (const [id, node] of graph) for (const d of node.deps) if (affected.has(d)) actual.add(id);
|
|
282
|
+
const declared = new Set(e.dependents);
|
|
283
|
+
if (declared.size !== e.dependents.length) problems.push(`${label}: dependents has duplicates`);
|
|
284
|
+
for (const id of actual) if (!declared.has(id)) problems.push(`${label}: ${id} depends on ${e.crate} in ${LOCK_FILE} but is not in dependents (new consumer widens the suppression; needs fresh approval)`);
|
|
285
|
+
for (const id of declared) if (!actual.has(id)) problems.push(`${label}: dependents lists ${id}, which does not depend on an affected ${e.crate} in ${LOCK_FILE} (stale)`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Path documentation: every hop must be a real dependency edge, ending at the advisory crate.
|
|
289
|
+
if (isNonEmptyString(e.path) && isNonEmptyString(e.crate)) {
|
|
290
|
+
const hops = e.path.split('->').map(s => s.trim());
|
|
291
|
+
if (hops.length < 2 || hops.some(h => h === '')) problems.push(`${label}: path must have at least two hops separated by "->"`);
|
|
292
|
+
else {
|
|
293
|
+
if (hops[hops.length - 1].split(/\s+/)[0] !== e.crate) problems.push(`${label}: path must end at ${e.crate}`);
|
|
294
|
+
for (let h = 0; h + 1 < hops.length; h++) {
|
|
295
|
+
const from = hopNodes(graph, hops[h]);
|
|
296
|
+
const to = new Set(hopNodes(graph, hops[h + 1]));
|
|
297
|
+
if (!from.some(id => [...graph.get(id).deps].some(d => to.has(d)))) {
|
|
298
|
+
problems.push(`${label}: path hop "${hops[h]} -> ${hops[h + 1]}" is not a dependency edge in ${LOCK_FILE}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Version scope applies even to an entry that failed another rule.
|
|
304
|
+
if (isNonEmptyString(e.crate) && Array.isArray(e.affectedVersions)) {
|
|
305
|
+
const locked = lock.get(e.crate);
|
|
306
|
+
const listed = new Set([...e.affectedVersions, ...(Array.isArray(e.unaffectedVersions) ? e.unaffectedVersions : [])]);
|
|
307
|
+
for (const v of listed) {
|
|
308
|
+
if (!locked || !locked.has(v)) problems.push(`${label}: version ${e.crate} ${v} is not in ${LOCK_FILE} (scope drift)`);
|
|
309
|
+
}
|
|
310
|
+
for (const v of locked ?? []) {
|
|
311
|
+
if (!listed.has(v)) problems.push(`${label}: ${LOCK_FILE} holds ${e.crate} ${v}, which the entry does not list (scope drift; the advisory may apply to it)`);
|
|
312
|
+
}
|
|
313
|
+
for (const v of e.affectedVersions) {
|
|
314
|
+
if ((e.unaffectedVersions ?? []).includes(v)) problems.push(`${label}: version ${v} is both affected and unaffected`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// deny.toml.
|
|
320
|
+
const adv = deny.advisories;
|
|
321
|
+
const denyIgnore = adv && Array.isArray(adv.ignore) ? adv.ignore : null;
|
|
322
|
+
if (!denyIgnore) problems.push(`${DENY_FILE}: [advisories].ignore is missing`);
|
|
323
|
+
else {
|
|
324
|
+
const seen = new Set();
|
|
325
|
+
for (const item of denyIgnore) {
|
|
326
|
+
const id = typeof item === 'string' ? item : item?.id;
|
|
327
|
+
const reason = typeof item === 'string' ? undefined : item?.reason;
|
|
328
|
+
if (!isNonEmptyString(id)) { problems.push(`${DENY_FILE}: ignore item without an id`); continue; }
|
|
329
|
+
if (seen.has(id)) problems.push(`${DENY_FILE}: duplicate ignore ${id}`);
|
|
330
|
+
seen.add(id);
|
|
331
|
+
const entry = byId.get(id);
|
|
332
|
+
if (!entry) { problems.push(`${DENY_FILE}: ignore ${id} has no entry in ${EXCEPTIONS_FILE}`); continue; }
|
|
333
|
+
if (reason !== entry.rationale) problems.push(`${DENY_FILE}: ignore ${id} reason differs from the rationale in ${EXCEPTIONS_FILE}`);
|
|
334
|
+
}
|
|
335
|
+
for (const id of byId.keys()) if (!seen.has(id)) problems.push(`${EXCEPTIONS_FILE}: entry ${id} has no ignore in ${DENY_FILE}`);
|
|
336
|
+
}
|
|
337
|
+
if (adv) {
|
|
338
|
+
if (adv.yanked !== 'deny') problems.push(`${DENY_FILE}: [advisories] yanked must be "deny"`);
|
|
339
|
+
if (adv.unmaintained !== 'workspace') problems.push(`${DENY_FILE}: [advisories] unmaintained must be "workspace"`);
|
|
340
|
+
} else problems.push(`${DENY_FILE}: [advisories] section is missing`);
|
|
341
|
+
|
|
342
|
+
// audit.toml.
|
|
343
|
+
const auditIgnore = audit.advisories?.ignore;
|
|
344
|
+
if (!Array.isArray(auditIgnore) || !auditIgnore.every(x => typeof x === 'string')) {
|
|
345
|
+
problems.push(`${AUDIT_FILE}: [advisories].ignore must be an array of string ids`);
|
|
346
|
+
} else {
|
|
347
|
+
const set = new Set();
|
|
348
|
+
for (const id of auditIgnore) {
|
|
349
|
+
if (set.has(id)) problems.push(`${AUDIT_FILE}: duplicate ignore ${id}`);
|
|
350
|
+
set.add(id);
|
|
351
|
+
if (!byId.has(id)) problems.push(`${AUDIT_FILE}: ignore ${id} has no entry in ${EXCEPTIONS_FILE}`);
|
|
352
|
+
}
|
|
353
|
+
for (const id of byId.keys()) if (!set.has(id)) problems.push(`${AUDIT_FILE}: entry ${id} from ${EXCEPTIONS_FILE} is not ignored here (sets differ)`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return problems;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---- CLI ----
|
|
360
|
+
|
|
361
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
362
|
+
const args = process.argv.slice(2);
|
|
363
|
+
const root = path.resolve(import.meta.dirname, '..', '..');
|
|
364
|
+
const problems = checkAdvisoryExceptions({ root, allowPending: args.includes('--allow-pending') });
|
|
365
|
+
if (problems.length) {
|
|
366
|
+
for (const p of problems) console.error(`advisory-exceptions: ${p}`);
|
|
367
|
+
console.error(`advisory-exceptions: ${problems.length} problem(s)`);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
console.log('advisory-exceptions: ok');
|
|
371
|
+
}
|
package/bin/darwin/rightkit-swap
CHANGED
|
Binary file
|