@rightkit/release 0.2.151 → 0.2.153
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/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +117 -8
- package/build-release.mjs +7 -2
- package/cli/right-release.mjs +18 -5
- package/committed-source.mjs +19 -1
- package/crate-graph.mjs +121 -0
- package/development-signing.mjs +8 -0
- package/development.mjs +46 -13
- package/devtools-policy.json +5 -0
- package/devtools-policy.mjs +119 -0
- package/direct-bootstrap.mjs +9 -3
- package/dmg-build.mjs +19 -5
- package/dmg-layout.mjs +2 -2
- package/dmg-spec.mjs +97 -0
- package/execution-policy.mjs +17 -1
- package/github-release.mjs +25 -7
- package/github-upload-gate.mjs +84 -0
- package/hardeningscan.mjs +3 -0
- package/inventory-check.mjs +4 -4
- package/mac-bundle-signing.mjs +1 -1
- package/mac-sign-core.mjs +97 -0
- package/mac-signing-matrix.mjs +186 -0
- package/msix-appinstaller.mjs +4 -1
- package/msix-config.mjs +5 -1
- package/msix-repair-guard.mjs +24 -0
- package/msix-repair-guard.ps1 +356 -0
- package/ownership-catalog-config.json +6 -7
- package/ownership-command.mjs +69 -8
- package/ownership-evidence.mjs +1 -1
- package/ownership-key-authority.mjs +1 -1
- package/ownership-key-command.mjs +15 -3
- package/ownership-key-elevation.mjs +11 -2
- package/ownership-mandatory-set.json +420 -0
- package/ownership-mandatory-set.mjs +273 -0
- package/ownership.mjs +2 -2
- package/package.json +15 -3
- package/packaged-app-identity.mjs +88 -0
- package/preflight.mjs +25 -3
- package/prune-r2.mjs +28 -3
- package/publication-gate.mjs +49 -10
- package/publish-cargo.mjs +21 -1
- package/publish-swift.mjs +78 -141
- package/publish-update.mjs +24 -2
- package/qa-build-features.mjs +216 -0
- package/qa-control-scan.mjs +435 -0
- package/registry-parity.mjs +18 -1
- package/release-invocation.mjs +2 -1
- package/release-state.mjs +9 -0
- package/release-switch-registry.json +33 -0
- package/release-switch-scan.mjs +452 -0
- package/release.mjs +320 -26
- package/renamed-away.mjs +29 -0
- package/rightkit-ownership.json +682 -709
- package/rightkit-sync.mjs +8 -4
- package/rightkit-version-policy.mjs +18 -0
- package/rightkit-versions.json +211 -127
- package/runtime-budget-classes.json +4 -0
- package/runtime-budgets.mjs +870 -0
- package/runtime-bundle-scan.mjs +61 -0
- package/sign-macos.mjs +28 -30
- package/sign-updater.mjs +5 -2
- package/swift-mac-dmg.mjs +7 -2
- package/swift-package-command.mjs +1 -1
- package/upload-release.mjs +10 -37
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// Release-blocking scan: a shipped binary must not contain any test-automation or debugging surface.
|
|
2
|
+
//
|
|
3
|
+
// Owner requirement: NO test-automation surface of any kind ships; dev builds may contain them. This module is
|
|
4
|
+
// the binary layer of that rule. It scans Mach-O, PE and ELF files (and the native files inside ZIP-based
|
|
5
|
+
// containers such as MSIX/APPX) for four independent rules, and is called for release and update builds
|
|
6
|
+
// (signing set, staged payloads, hardening targets, sealed files). The development lane (`development.mjs`)
|
|
7
|
+
// never calls it: dev builds are the QA builds. The source-level layer is `qa-build-features.mjs`.
|
|
8
|
+
//
|
|
9
|
+
// qa-control-server `rightkit-qa-driver` feature `qa-control`: every binary with the embedded server carries
|
|
10
|
+
// QA_CONTROL_MARKER (a `#[used]` static in `embedded/mod.rs`, also read by `Control::build`).
|
|
11
|
+
// wdio-webdriver `tauri-plugin-wdio-webdriver` (HeardRight/MailRight/ViewRight `qa-native`): needs 2 of 3
|
|
12
|
+
// independent signatures. WEAKER than the marker: the plugin source was not on disk when
|
|
13
|
+
// the signatures were chosen, they come from Rust path/module/permission naming rules.
|
|
14
|
+
// shell-dev-inspect `rightkit-tauri-shell` feature `dev-inspect` (developer inspection of an app's own webviews): every
|
|
15
|
+
// binary built with it carries DEV_INSPECT_MARKER. Refused in every tier this module scans.
|
|
16
|
+
// remote-debugging `--remote-debugging-port` / `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` baked into a binary.
|
|
17
|
+
// tauri-devtools Tauri's `devtools` feature (or a debug build): the toggle-devtools init script. Accepted only for an
|
|
18
|
+
// allow-listed app (devtools-policy.json) whose binary carries CONTENT_ONLY_MARKER.
|
|
19
|
+
import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { inflateRawSync } from "node:zlib";
|
|
22
|
+
|
|
23
|
+
// Keep identical to `QA_CONTROL_MARKER` in crates/rightkit-qa-driver/src/embedded/mod.rs.
|
|
24
|
+
export const QA_CONTROL_MARKER = "RIGHTKIT-QA-CONTROL-SERVER-PRESENT";
|
|
25
|
+
// Keep identical to `DEV_INSPECT_MARKER` in crates/rightkit-tauri-shell/src/devtools.rs (a `#[used]` static compiled only
|
|
26
|
+
// with rightkit-tauri-shell's `dev-inspect` feature and read by the process guard). No allow-list lifts this rule.
|
|
27
|
+
export const DEV_INSPECT_MARKER = "RIGHTKIT-SHELL-DEV-INSPECT-COMPILED";
|
|
28
|
+
const MARKER = Buffer.from(QA_CONTROL_MARKER, "latin1");
|
|
29
|
+
const CHUNK = 4 * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
// Each signature is matched as Latin-1/UTF-8 bytes and as UTF-16LE (Windows wide strings). `min` signatures of a
|
|
32
|
+
// rule must be present for it to fire; a signature counts once however often or in whichever encoding it occurs.
|
|
33
|
+
export const QA_RULES = Object.freeze([
|
|
34
|
+
{ id: "qa-control-server", min: 1, label: "RightKit QA control server (rightkit-qa-driver qa-control)",
|
|
35
|
+
signatures: [{ id: "marker", text: QA_CONTROL_MARKER }] },
|
|
36
|
+
{ id: "shell-dev-inspect", min: 1, marker: DEV_INSPECT_MARKER, label: "rightkit-tauri-shell developer inspection (rightkit-tauri-shell dev-inspect)",
|
|
37
|
+
signatures: [{ id: "marker", text: DEV_INSPECT_MARKER }] },
|
|
38
|
+
{ id: "wdio-webdriver", min: 2, label: "tauri-plugin-wdio-webdriver test driver (app qa-native/dev-native)",
|
|
39
|
+
signatures: [
|
|
40
|
+
{ id: "crate-path", text: "tauri-plugin-wdio-webdriver" },
|
|
41
|
+
{ id: "module-path", text: "tauri_plugin_wdio_webdriver" },
|
|
42
|
+
{ id: "permission", text: "wdio-webdriver:default" },
|
|
43
|
+
] },
|
|
44
|
+
{ id: "remote-debugging", min: 1, label: "remote-debugging enablement baked into the binary",
|
|
45
|
+
signatures: [
|
|
46
|
+
{ id: "chromium-flag", text: "--remote-debugging-port" },
|
|
47
|
+
{ id: "webview2-env", text: "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS" },
|
|
48
|
+
] },
|
|
49
|
+
{ id: "tauri-devtools", min: 1, label: "Tauri devtools (feature `devtools` or a debug build)",
|
|
50
|
+
signatures: [
|
|
51
|
+
{ id: "invoke", text: "plugin:webview|internal_toggle_devtools" },
|
|
52
|
+
{ id: "hotkey-script", text: "toggleDevtoolsHotkey" },
|
|
53
|
+
] },
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
// Keep identical to `CONTENT_ONLY_MARKER` in crates/rightkit-tauri-shell/src/devtools.rs. A binary carries it when it was
|
|
57
|
+
// built with rightkit-tauri-shell's `content-devtools` feature. It is not a finding; it is the evidence that lets an
|
|
58
|
+
// allow-listed app (devtools-policy.json) ship Tauri's devtools code for ViewRight-style content inspection.
|
|
59
|
+
export const CONTENT_ONLY_MARKER = "RIGHTKIT-SHELL-DEVTOOLS-CONTENT-ONLY-COMPILED";
|
|
60
|
+
const CONTENT_ONLY_KEY = "content-only-marker/marker";
|
|
61
|
+
|
|
62
|
+
const NEEDLES = [...QA_RULES.flatMap((rule) => rule.signatures.map((signature) => ({
|
|
63
|
+
rule: rule.id, signature: signature.id,
|
|
64
|
+
buffers: [Buffer.from(signature.text, "latin1"), Buffer.from(signature.text, "utf16le")],
|
|
65
|
+
}))), {
|
|
66
|
+
rule: "content-only-marker", signature: "marker",
|
|
67
|
+
buffers: [Buffer.from(CONTENT_ONLY_MARKER, "latin1"), Buffer.from(CONTENT_ONLY_MARKER, "utf16le")],
|
|
68
|
+
}];
|
|
69
|
+
const MAX_NEEDLE = Math.max(...NEEDLES.flatMap((n) => n.buffers.map((b) => b.length)));
|
|
70
|
+
|
|
71
|
+
// Incremental matcher: feed chunks in order; keeps an overlap so a signature split across chunks is found.
|
|
72
|
+
export class RuleScanner {
|
|
73
|
+
constructor() { this.tail = Buffer.alloc(0); this.hits = new Map(); }
|
|
74
|
+
push(chunk) {
|
|
75
|
+
const window = this.tail.length ? Buffer.concat([this.tail, chunk]) : chunk;
|
|
76
|
+
for (const needle of NEEDLES) {
|
|
77
|
+
const key = `${needle.rule}/${needle.signature}`;
|
|
78
|
+
if (this.hits.has(key)) continue;
|
|
79
|
+
if (needle.buffers.some((b) => window.includes(b))) this.hits.set(key, needle);
|
|
80
|
+
}
|
|
81
|
+
this.tail = Buffer.from(window.subarray(Math.max(0, window.length - (MAX_NEEDLE - 1))));
|
|
82
|
+
}
|
|
83
|
+
// `allowContentOnlyDevtools`: the app is allow-listed for rightkit-tauri-shell's ContentOnly DevTools policy. Then Tauri's
|
|
84
|
+
// devtools signatures are accepted only in a binary that also carries CONTENT_ONLY_MARKER; one without it is still
|
|
85
|
+
// a finding (devtools compiled in by some other route).
|
|
86
|
+
findings({ allowContentOnlyDevtools = false } = {}) {
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const rule of QA_RULES) {
|
|
89
|
+
if (rule.id === "tauri-devtools" && allowContentOnlyDevtools && this.hits.has(CONTENT_ONLY_KEY)) continue;
|
|
90
|
+
const matched = rule.signatures.filter((s) => this.hits.has(`${rule.id}/${s.id}`));
|
|
91
|
+
if (matched.length < rule.min) continue;
|
|
92
|
+
out.push({ rule: rule.id, reason: rule.id === "qa-control-server" || rule.marker
|
|
93
|
+
? `contains ${rule.marker ?? QA_CONTROL_MARKER}`
|
|
94
|
+
: `${rule.label}: ${matched.length} of ${rule.signatures.length} signatures (${matched.map((s) => s.text).join(", ")})` });
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Mach-O thin (both endians, 32/64-bit), fat/universal (0xCAFEBABE, 0xCAFEBABF), PE ("MZ"), ELF.
|
|
101
|
+
const MAGICS = [
|
|
102
|
+
[0xfe, 0xed, 0xfa, 0xce], [0xfe, 0xed, 0xfa, 0xcf], [0xce, 0xfa, 0xed, 0xfe], [0xcf, 0xfa, 0xed, 0xfe],
|
|
103
|
+
[0xca, 0xfe, 0xba, 0xbe], [0xca, 0xfe, 0xba, 0xbf], [0x7f, 0x45, 0x4c, 0x46],
|
|
104
|
+
].map((bytes) => Buffer.from(bytes));
|
|
105
|
+
const MZ = Buffer.from([0x4d, 0x5a]);
|
|
106
|
+
|
|
107
|
+
export function isNativeBinaryHeader(head) {
|
|
108
|
+
if (!Buffer.isBuffer(head)) return false;
|
|
109
|
+
if (head.length >= 2 && head.subarray(0, 2).equals(MZ)) return true;
|
|
110
|
+
return MAGICS.some((magic) => head.length >= 4 && head.subarray(0, 4).equals(magic));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isZipHeader(head) {
|
|
114
|
+
return Buffer.isBuffer(head) && head.length >= 4 && head[0] === 0x50 && head[1] === 0x4b
|
|
115
|
+
&& ((head[2] === 0x03 && head[3] === 0x04) || (head[2] === 0x05 && head[3] === 0x06));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function bufferHasQaControlMarker(buffer) {
|
|
119
|
+
return Buffer.from(buffer).includes(MARKER);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// All rules over one in-memory buffer (a sealed file). Looks inside ZIP containers too.
|
|
123
|
+
export function bufferQaFindings(buffer, options = {}) {
|
|
124
|
+
const bytes = Buffer.from(buffer);
|
|
125
|
+
if (isZipHeader(bytes.subarray(0, 4))) {
|
|
126
|
+
const findings = []; const stats = newStats();
|
|
127
|
+
scanZip(bufferSource(bytes), "<buffer>", { findings, stats, allowContentOnlyDevtools: options.allowContentOnlyDevtools === true, limits: limitsOf(options), budget: { left: limitsOf(options).totalInflated } }, 0);
|
|
128
|
+
return findings;
|
|
129
|
+
}
|
|
130
|
+
const scanner = new RuleScanner();
|
|
131
|
+
for (let at = 0; at < bytes.length; at += CHUNK) scanner.push(bytes.subarray(at, Math.min(bytes.length, at + CHUNK)));
|
|
132
|
+
return scanner.findings({ allowContentOnlyDevtools: options.allowContentOnlyDevtools === true });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Streams the file in chunks with an overlap, so a marker split across chunks is found. Marker rule only.
|
|
136
|
+
export function fileHasQaControlMarker(file) {
|
|
137
|
+
const fd = openSync(file, "r");
|
|
138
|
+
try {
|
|
139
|
+
const buffer = Buffer.alloc(CHUNK);
|
|
140
|
+
let tail = Buffer.alloc(0);
|
|
141
|
+
for (;;) {
|
|
142
|
+
const read = readSync(fd, buffer, 0, CHUNK, null);
|
|
143
|
+
if (read === 0) return false;
|
|
144
|
+
const window = Buffer.concat([tail, buffer.subarray(0, read)]);
|
|
145
|
+
if (window.includes(MARKER)) return true;
|
|
146
|
+
tail = Buffer.from(window.subarray(Math.max(0, window.length - (MARKER.length - 1))));
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
closeSync(fd);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readHead(file) {
|
|
154
|
+
const fd = openSync(file, "r");
|
|
155
|
+
try {
|
|
156
|
+
const head = Buffer.alloc(4);
|
|
157
|
+
const read = readSync(fd, head, 0, 4, 0);
|
|
158
|
+
return head.subarray(0, read);
|
|
159
|
+
} finally {
|
|
160
|
+
closeSync(fd);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- byte sources and the ZIP reader ------------------------------------------------------------------------
|
|
165
|
+
const DEFAULT_LIMITS = Object.freeze({
|
|
166
|
+
maxEntries: 100_000,
|
|
167
|
+
maxCentralDirectory: 256 * 1024 * 1024,
|
|
168
|
+
maxEntryBytes: 1024 * 1024 * 1024, // largest single entry we will inflate in memory
|
|
169
|
+
totalInflated: 8 * 1024 * 1024 * 1024, // zip-bomb budget per scanned container
|
|
170
|
+
maxDepth: 3,
|
|
171
|
+
});
|
|
172
|
+
const limitsOf = (options) => ({ ...DEFAULT_LIMITS, ...(options.limits ?? {}) });
|
|
173
|
+
const newStats = () => ({ binaries: 0, containers: 0, entries: 0, opaque: [] });
|
|
174
|
+
|
|
175
|
+
function bufferSource(buffer) {
|
|
176
|
+
return { size: buffer.length, read: (position, length) => buffer.subarray(position, position + length) };
|
|
177
|
+
}
|
|
178
|
+
function fdSource(fd, size) {
|
|
179
|
+
return { size, read: (position, length) => {
|
|
180
|
+
const out = Buffer.alloc(length); let got = 0;
|
|
181
|
+
while (got < length) {
|
|
182
|
+
const n = readSync(fd, out, got, length - got, position + got);
|
|
183
|
+
if (n === 0) break;
|
|
184
|
+
got += n;
|
|
185
|
+
}
|
|
186
|
+
return out.subarray(0, got);
|
|
187
|
+
} };
|
|
188
|
+
}
|
|
189
|
+
const sliceSource = (source, start, length) => ({ size: length, read: (position, n) => source.read(start + position, Math.min(n, length - position)) });
|
|
190
|
+
|
|
191
|
+
function u64(buffer, offset) {
|
|
192
|
+
const value = buffer.readBigUInt64LE(offset);
|
|
193
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("zip64 value out of range");
|
|
194
|
+
return Number(value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readEndOfCentralDirectory(source) {
|
|
198
|
+
const tailLength = Math.min(source.size, 22 + 65535);
|
|
199
|
+
const tail = source.read(source.size - tailLength, tailLength);
|
|
200
|
+
for (let i = tail.length - 22; i >= 0; i -= 1) {
|
|
201
|
+
if (tail.readUInt32LE(i) !== 0x06054b50) continue;
|
|
202
|
+
if (i + 22 + tail.readUInt16LE(i + 20) > tail.length) continue;
|
|
203
|
+
let entries = tail.readUInt16LE(i + 10); let size = tail.readUInt32LE(i + 12); let offset = tail.readUInt32LE(i + 16);
|
|
204
|
+
if (entries === 0xffff || size === 0xffffffff || offset === 0xffffffff) {
|
|
205
|
+
const eocdPosition = source.size - tailLength + i;
|
|
206
|
+
if (eocdPosition < 20) throw new Error("zip64 locator missing");
|
|
207
|
+
const locator = source.read(eocdPosition - 20, 20);
|
|
208
|
+
if (locator.length < 20 || locator.readUInt32LE(0) !== 0x07064b50) throw new Error("zip64 locator missing");
|
|
209
|
+
const record = source.read(u64(locator, 8), 56);
|
|
210
|
+
if (record.length < 56 || record.readUInt32LE(0) !== 0x06064b50) throw new Error("zip64 end record missing");
|
|
211
|
+
entries = u64(record, 32); size = u64(record, 40); offset = u64(record, 48);
|
|
212
|
+
}
|
|
213
|
+
return { entries, size, offset };
|
|
214
|
+
}
|
|
215
|
+
throw new Error("end of central directory not found");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function* zipEntries(source, limits) {
|
|
219
|
+
const eocd = readEndOfCentralDirectory(source);
|
|
220
|
+
if (eocd.entries > limits.maxEntries) throw new Error(`more than ${limits.maxEntries} entries`);
|
|
221
|
+
if (eocd.size > limits.maxCentralDirectory || eocd.offset + eocd.size > source.size) throw new Error("central directory out of bounds");
|
|
222
|
+
const directory = source.read(eocd.offset, eocd.size);
|
|
223
|
+
if (directory.length !== eocd.size) throw new Error("central directory truncated");
|
|
224
|
+
let at = 0;
|
|
225
|
+
for (let n = 0; n < eocd.entries; n += 1) {
|
|
226
|
+
if (at + 46 > directory.length || directory.readUInt32LE(at) !== 0x02014b50) throw new Error("bad central directory entry");
|
|
227
|
+
const flags = directory.readUInt16LE(at + 8); const method = directory.readUInt16LE(at + 10);
|
|
228
|
+
let compressed = directory.readUInt32LE(at + 20); let uncompressed = directory.readUInt32LE(at + 24);
|
|
229
|
+
const nameLength = directory.readUInt16LE(at + 28); const extraLength = directory.readUInt16LE(at + 30);
|
|
230
|
+
const commentLength = directory.readUInt16LE(at + 32); let localOffset = directory.readUInt32LE(at + 42);
|
|
231
|
+
const end = at + 46 + nameLength + extraLength + commentLength;
|
|
232
|
+
if (end > directory.length) throw new Error("central directory entry out of bounds");
|
|
233
|
+
const name = directory.subarray(at + 46, at + 46 + nameLength).toString("utf8");
|
|
234
|
+
const extra = directory.subarray(at + 46 + nameLength, at + 46 + nameLength + extraLength);
|
|
235
|
+
for (let e = 0; e + 4 <= extra.length;) {
|
|
236
|
+
const id = extra.readUInt16LE(e); const size = extra.readUInt16LE(e + 2);
|
|
237
|
+
if (id === 0x0001) {
|
|
238
|
+
let p = e + 4;
|
|
239
|
+
if (uncompressed === 0xffffffff) { uncompressed = u64(extra, p); p += 8; }
|
|
240
|
+
if (compressed === 0xffffffff) { compressed = u64(extra, p); p += 8; }
|
|
241
|
+
if (localOffset === 0xffffffff) { localOffset = u64(extra, p); }
|
|
242
|
+
}
|
|
243
|
+
e += 4 + size;
|
|
244
|
+
}
|
|
245
|
+
yield { name, flags, method, compressed, uncompressed, localOffset };
|
|
246
|
+
at = end;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function entryDataStart(source, entry) {
|
|
251
|
+
const local = source.read(entry.localOffset, 30);
|
|
252
|
+
if (local.length < 30 || local.readUInt32LE(0) !== 0x04034b50) throw new Error("bad local header");
|
|
253
|
+
const start = entry.localOffset + 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
|
|
254
|
+
if (start + entry.compressed > source.size) throw new Error("entry data out of bounds");
|
|
255
|
+
return start;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function pushBuffer(scanner, buffer) {
|
|
259
|
+
for (let at = 0; at < buffer.length; at += CHUNK) scanner.push(buffer.subarray(at, Math.min(buffer.length, at + CHUNK)));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function scanZip(source, label, ctx, depth) {
|
|
263
|
+
const { findings, stats, limits } = ctx;
|
|
264
|
+
const bad = (file, why) => findings.push({ file, rule: "unscannable", reason: `could not scan: ${why}` });
|
|
265
|
+
if (depth > limits.maxDepth) { bad(label, `containers nested deeper than ${limits.maxDepth}`); return; }
|
|
266
|
+
stats.containers += 1;
|
|
267
|
+
try {
|
|
268
|
+
for (const entry of zipEntries(source, limits)) {
|
|
269
|
+
if (entry.name.endsWith("/")) continue;
|
|
270
|
+
stats.entries += 1;
|
|
271
|
+
const file = `${label}!/${entry.name}`;
|
|
272
|
+
try {
|
|
273
|
+
if (entry.flags & 1) { bad(file, "encrypted entry"); continue; }
|
|
274
|
+
const start = entryDataStart(source, entry);
|
|
275
|
+
let body; // a byte source for the entry's content
|
|
276
|
+
if (entry.method === 0) {
|
|
277
|
+
body = sliceSource(source, start, entry.compressed);
|
|
278
|
+
} else if (entry.method === 8) {
|
|
279
|
+
if (entry.uncompressed > limits.maxEntryBytes || entry.compressed > limits.maxEntryBytes) { bad(file, `entry larger than ${limits.maxEntryBytes} bytes`); continue; }
|
|
280
|
+
if ((ctx.budget.left -= entry.uncompressed) < 0) { bad(file, "container exceeds its inflate budget"); continue; }
|
|
281
|
+
const inflated = inflateRawSync(source.read(start, entry.compressed), { maxOutputLength: Math.max(1, Math.min(limits.maxEntryBytes, entry.uncompressed + 1)) });
|
|
282
|
+
body = bufferSource(inflated);
|
|
283
|
+
} else {
|
|
284
|
+
bad(file, `unsupported compression method ${entry.method}`); continue;
|
|
285
|
+
}
|
|
286
|
+
scanBody(body, file, ctx, depth);
|
|
287
|
+
} catch (error) { bad(file, error.message); }
|
|
288
|
+
}
|
|
289
|
+
} catch (error) { bad(label, `invalid ZIP container (${error.message})`); }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// A byte source holding one file's content: ZIP -> recurse; native binary -> run the rules; anything else ignored.
|
|
293
|
+
function scanBody(body, file, ctx, depth) {
|
|
294
|
+
const head = body.read(0, 4);
|
|
295
|
+
if (isZipHeader(head)) { scanZip(body, file, ctx, depth + 1); return; }
|
|
296
|
+
if (!isNativeBinaryHeader(head)) return;
|
|
297
|
+
ctx.stats.binaries += 1;
|
|
298
|
+
const scanner = new RuleScanner();
|
|
299
|
+
for (let at = 0; at < body.size; at += CHUNK) scanner.push(body.read(at, Math.min(CHUNK, body.size - at)));
|
|
300
|
+
for (const finding of scanner.findings({ allowContentOnlyDevtools: ctx.allowContentOnlyDevtools === true })) ctx.findings.push({ file, ...finding });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---- directory / file / container entry point ---------------------------------------------------------------
|
|
304
|
+
// Returns { findings, stats }. Fails closed: a path that cannot be read is a finding, not a pass. Directories (a
|
|
305
|
+
// `.app`, a staging dir) are walked. Symlinks are not followed (they would escape the bundle). A file with a ZIP
|
|
306
|
+
// header (MSIX, APPX, zip) is read through its central directory and each entry is scanned (stored or deflate).
|
|
307
|
+
export function scanWithStats(paths, options = {}) {
|
|
308
|
+
const limits = limitsOf(options);
|
|
309
|
+
const findings = [];
|
|
310
|
+
const stats = newStats();
|
|
311
|
+
const visit = (target) => {
|
|
312
|
+
let stat;
|
|
313
|
+
try { stat = lstatSync(target); } catch (error) { findings.push({ file: target, rule: "unscannable", reason: `could not scan: ${error.code ?? error.message}` }); return; }
|
|
314
|
+
if (stat.isSymbolicLink()) return;
|
|
315
|
+
if (stat.isDirectory()) {
|
|
316
|
+
let names;
|
|
317
|
+
try { names = readdirSync(target); } catch (error) { findings.push({ file: target, rule: "unscannable", reason: `could not list: ${error.code ?? error.message}` }); return; }
|
|
318
|
+
for (const name of names.sort()) visit(path.join(target, name));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!stat.isFile()) return;
|
|
322
|
+
let fd;
|
|
323
|
+
try {
|
|
324
|
+
const head = readHead(target);
|
|
325
|
+
const isZip = isZipHeader(head);
|
|
326
|
+
if (!isZip && !isNativeBinaryHeader(head)) {
|
|
327
|
+
if (/\.dmg$/i.test(target)) stats.opaque.push({ file: target, kind: "dmg" });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
fd = openSync(target, "r");
|
|
331
|
+
const source = fdSource(fd, stat.size);
|
|
332
|
+
if (isZip) { scanZip(source, target, { findings, stats, allowContentOnlyDevtools: options.allowContentOnlyDevtools === true, limits, budget: { left: limits.totalInflated } }, 0); return; }
|
|
333
|
+
stats.binaries += 1;
|
|
334
|
+
const scanner = new RuleScanner();
|
|
335
|
+
for (let at = 0; at < stat.size; at += CHUNK) scanner.push(source.read(at, Math.min(CHUNK, stat.size - at)));
|
|
336
|
+
if (looksLikeNsis(source, stat.size)) stats.opaque.push({ file: target, kind: "nsis" });
|
|
337
|
+
for (const finding of scanner.findings({ allowContentOnlyDevtools: options.allowContentOnlyDevtools === true })) findings.push({ file: target, ...finding });
|
|
338
|
+
} catch (error) {
|
|
339
|
+
findings.push({ file: target, rule: "unscannable", reason: `could not scan: ${error.code ?? error.message}` });
|
|
340
|
+
} finally {
|
|
341
|
+
if (fd !== undefined) closeSync(fd);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
for (const target of paths) visit(path.resolve(target));
|
|
345
|
+
return { findings, stats };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// NSIS installers carry the "NullsoftInst" signature in their first-stage header; the payload behind it is
|
|
349
|
+
// LZMA/zlib compressed, so the rules cannot see inside it.
|
|
350
|
+
const NSIS_SIGNATURE = Buffer.from("NullsoftInst", "latin1");
|
|
351
|
+
function looksLikeNsis(source, size) {
|
|
352
|
+
const window = Math.min(size, 4 * 1024 * 1024);
|
|
353
|
+
return source.read(0, window).includes(NSIS_SIGNATURE);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function scanForQaControl(paths, options = {}) {
|
|
357
|
+
return scanWithStats(paths, options).findings;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// `requireBinary`: fail when the scanned inputs contain no native binary at all (a missing or empty staging
|
|
361
|
+
// directory must not read as "clean"). Returns the scan stats so a caller can report what stayed unscannable.
|
|
362
|
+
export function assertNoQaControl(paths, label = "release", { requireBinary = false, limits, allowContentOnlyDevtools = false } = {}) {
|
|
363
|
+
const { findings, stats } = scanWithStats(paths, { limits, allowContentOnlyDevtools });
|
|
364
|
+
if (requireBinary && stats.binaries === 0 && findings.length === 0) {
|
|
365
|
+
findings.push({ file: paths.join(", ") || "<none>", rule: "unscannable", reason: "no Mach-O, PE or ELF file found to scan (missing or empty staging input); cannot verify" });
|
|
366
|
+
}
|
|
367
|
+
if (findings.length === 0) return stats;
|
|
368
|
+
throw new Error(
|
|
369
|
+
`${label}: the QA control server and every other test-automation or debugging surface must never ship (wdio WebDriver plugin, remote debugging, Tauri devtools); `
|
|
370
|
+
+ `${findings.length} finding(s):\n${findings.map((f) => `- ${f.file}: ${f.reason}`).join("\n")}\n`
|
|
371
|
+
+ "Build release and update tiers without the app's qa-native, dev-native, qa-control and devtools features.",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---- web-bundle mock scan (M20 owner gap) ----------------------------------------------------------------------
|
|
376
|
+
// The binary scan above is blind to the frontend bundle (a Vite `dist/`, a Tauri `frontendDist`, an installer
|
|
377
|
+
// payload folder). An app's in-page QA mock (HeardRight tauriQaMock, GenRight browser-mock-api, ScrapeRight and
|
|
378
|
+
// MailRight qaMock, PostRight MockDataSource) must never be reachable from a release bundle. Each marker is a
|
|
379
|
+
// string that survives minification (an identifier that is also a string literal, a file/chunk name, a protocol or
|
|
380
|
+
// a build-flag name). Source maps are scanned too: a map that names qaMock.ts proves the module was bundled.
|
|
381
|
+
export const WEB_MOCK_MARKERS = Object.freeze([
|
|
382
|
+
{ id: "browser-qa-fake", pattern: /browser-qa-fake/ },
|
|
383
|
+
{ id: "browser-mock-api", pattern: /browser-mock-api/ },
|
|
384
|
+
{ id: "browser-qa-mock-scheme", pattern: /browser-qa-mock:/ },
|
|
385
|
+
{ id: "qaMock", pattern: /qaMock/ },
|
|
386
|
+
{ id: "tauriQaMock", pattern: /tauriQaMock/ },
|
|
387
|
+
{ id: "isBrowserQaMode", pattern: /isBrowserQaMode/ },
|
|
388
|
+
{ id: "MockDataSource", pattern: /MockDataSource/ },
|
|
389
|
+
{ id: "qa-build-flag", pattern: /\bVITE_[A-Z0-9_]*_QA\b/ },
|
|
390
|
+
]);
|
|
391
|
+
const WEB_EXTENSIONS = /\.(?:m?js|cjs|html?|css|map)$/i;
|
|
392
|
+
|
|
393
|
+
export function webBundleMockFindings(text) {
|
|
394
|
+
const out = [];
|
|
395
|
+
for (const marker of WEB_MOCK_MARKERS) {
|
|
396
|
+
const match = marker.pattern.exec(text);
|
|
397
|
+
if (match) out.push({ rule: "web-bundle-mock", marker: marker.id, reason: `web bundle carries browser-QA mock marker \`${match[0]}\`` });
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Walks files and directories (symlinks not followed, as in the binary scan). Fails closed on an unreadable path
|
|
403
|
+
// and, with `requireFiles`, on an input that holds no scannable web file at all.
|
|
404
|
+
export function scanWebBundle(paths, { requireFiles = false } = {}) {
|
|
405
|
+
const findings = []; let files = 0;
|
|
406
|
+
const visit = (target) => {
|
|
407
|
+
let stat;
|
|
408
|
+
try { stat = lstatSync(target); } catch (error) { findings.push({ file: target, rule: "unscannable", reason: `could not scan: ${error.code ?? error.message}` }); return; }
|
|
409
|
+
if (stat.isSymbolicLink()) return;
|
|
410
|
+
if (stat.isDirectory()) {
|
|
411
|
+
let names;
|
|
412
|
+
try { names = readdirSync(target); } catch (error) { findings.push({ file: target, rule: "unscannable", reason: `could not list: ${error.code ?? error.message}` }); return; }
|
|
413
|
+
for (const name of names.sort()) visit(path.join(target, name));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (!stat.isFile() || !WEB_EXTENSIONS.test(target)) return;
|
|
417
|
+
files += 1;
|
|
418
|
+
try {
|
|
419
|
+
for (const finding of webBundleMockFindings(readFileSync(target, "utf8"))) findings.push({ file: target, ...finding });
|
|
420
|
+
} catch (error) { findings.push({ file: target, rule: "unscannable", reason: `could not scan: ${error.code ?? error.message}` }); }
|
|
421
|
+
};
|
|
422
|
+
for (const target of paths) visit(path.resolve(target));
|
|
423
|
+
if (requireFiles && files === 0 && findings.length === 0) findings.push({ file: paths.join(", ") || "<none>", rule: "unscannable", reason: "no web bundle file (.js, .html, .css) found to scan; cannot verify" });
|
|
424
|
+
return { findings, files };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function assertNoWebBundleMocks(paths, label = "release", { requireFiles = false } = {}) {
|
|
428
|
+
const { findings, files } = scanWebBundle(paths, { requireFiles });
|
|
429
|
+
if (findings.length === 0) return { files };
|
|
430
|
+
throw new Error(
|
|
431
|
+
`${label}: a release web bundle must not contain browser-QA mocks or test-mode markers; ${findings.length} finding(s):\n`
|
|
432
|
+
+ `${findings.map((f) => `- ${f.file}: ${f.reason}`).join("\n")}\n`
|
|
433
|
+
+ "Exclude the mock from the production build (dynamic import behind a build-time constant that tree-shakes, or a separate entry).",
|
|
434
|
+
);
|
|
435
|
+
}
|
package/registry-parity.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { execFileSync } from './background-process.mjs';
|
|
|
5
5
|
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { basename, join, relative, resolve } from 'node:path';
|
|
8
|
+
import { RENAMED_AWAY_NPM } from './renamed-away.mjs';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
|
|
10
11
|
const packageRoot = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -72,6 +73,17 @@ export async function publishedPackageNames() {
|
|
|
72
73
|
return [...new Set([...Object.keys(versions.npm ?? {}), ...Object.keys(versions.stagedNpm ?? {}), ...Object.keys(versions.legacyNpm ?? {})])].sort();
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
// name -> directory for every package under packages/*, resolved by package.json `name`.
|
|
77
|
+
async function treePackageDirs() {
|
|
78
|
+
const dirs = new Map();
|
|
79
|
+
for (const entry of await readdir(join(packageRoot, '..'), { withFileTypes: true })) {
|
|
80
|
+
if (!entry.isDirectory()) continue;
|
|
81
|
+
const dir = join(packageRoot, '..', entry.name);
|
|
82
|
+
try { dirs.set(JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')).name, dir); } catch { /* not a package */ }
|
|
83
|
+
}
|
|
84
|
+
return dirs;
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
export async function verifyAllRegistryParity({ allowUnpublished = false, fetchImpl = fetch, names } = {}) {
|
|
76
88
|
const results = [];
|
|
77
89
|
for (const name of names ?? await publishedPackageNames()) {
|
|
@@ -81,7 +93,12 @@ export async function verifyAllRegistryParity({ allowUnpublished = false, fetchI
|
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
export async function verifyRegistryParity({ allowUnpublished = false, fetchImpl = fetch, packageName = '@rightkit/release' } = {}) {
|
|
84
|
-
const sourceRoot = packageName === '@rightkit/release' ? packageRoot :
|
|
96
|
+
const sourceRoot = packageName === '@rightkit/release' ? packageRoot : (await treePackageDirs()).get(packageName);
|
|
97
|
+
if (!sourceRoot) {
|
|
98
|
+
const successor = RENAMED_AWAY_NPM.get(packageName);
|
|
99
|
+
if (successor) return Object.freeze({ status: 'RENAMED_AWAY', name: packageName, reason: `renamed to ${successor}; no tree package` });
|
|
100
|
+
throw new Error(`no packages/* directory has package.json name ${packageName}`);
|
|
101
|
+
}
|
|
85
102
|
const manifest = JSON.parse(await readFile(join(sourceRoot, 'package.json'), 'utf8'));
|
|
86
103
|
if (manifest.name !== packageName) throw new Error(`source ${sourceRoot} is ${manifest.name}, expected ${packageName}`);
|
|
87
104
|
const encodedName = encodeURIComponent(manifest.name);
|
package/release-invocation.mjs
CHANGED
|
@@ -12,8 +12,9 @@ function git(cwd, args) {
|
|
|
12
12
|
return result.stdout;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// The native call expands Windows 8.3 short names (RUNNER~1), which Git reports in long form.
|
|
15
16
|
function canonical(file) {
|
|
16
|
-
return path.normalize(realpathSync(file)).toLowerCase();
|
|
17
|
+
return path.normalize(realpathSync.native(file)).toLowerCase();
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export function assertPrimaryReleaseCheckout(repoRoot, env = process.env) {
|
package/release-state.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { existsSync, readFileSync, readdirSync, watch } from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { readCachePolicy, resolveCacheLayout } from "./cache-policy.mjs";
|
|
6
6
|
import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
|
|
7
|
+
import { bufferQaFindings } from "./qa-control-scan.mjs";
|
|
8
|
+
import { sealedManifestAllowsContentDevtools } from "./devtools-policy.mjs";
|
|
7
9
|
|
|
8
10
|
export function cacheFingerprint({ cargoLockSha256, rustc, target, architecture, features = [] }) {
|
|
9
11
|
const payload = JSON.stringify({
|
|
@@ -130,6 +132,13 @@ export function verifySealedRelease(sealedDir) {
|
|
|
130
132
|
const actualHash = createHash("sha256").update(bytes).digest("hex");
|
|
131
133
|
if (actualHash !== item.sha256) throw new Error(`sealed file hash mismatch: ${item.name}`);
|
|
132
134
|
if (bytes.length !== item.sizeBytes) throw new Error(`sealed file size mismatch: ${item.name}`);
|
|
135
|
+
// Every seal and upload path verifies through here. A compressed installer hides the bytes, so
|
|
136
|
+
// release.mjs also scans the raw binaries before packaging; this catches a payload that is not compressed.
|
|
137
|
+
// Covers the QA control server marker, the wdio WebDriver plugin, remote-debugging and devtools strings, and
|
|
138
|
+
// reads inside ZIP containers (MSIX/APPX). Compressed NSIS/DMG installers stay opaque here by nature.
|
|
139
|
+
// An allow-listed app (devtools-policy.json) that recorded its content-only DevTools allowance may carry Tauri's devtools code.
|
|
140
|
+
const surfaces = bufferQaFindings(bytes, { allowContentOnlyDevtools: sealedManifestAllowsContentDevtools(manifest) });
|
|
141
|
+
if (surfaces.length) throw new Error(`sealed file contains a test-automation or debugging surface that must never ship (the QA control server and the like): ${item.name}: ${surfaces.map((f) => f.reason).join("; ")}`);
|
|
133
142
|
}
|
|
134
143
|
return { manifest, manifestPath, sealedDir };
|
|
135
144
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"owner": "rightkit-daemon::mode (D4, ratified)",
|
|
4
|
+
"forbiddenFeatures": ["test-mode", "qa-native", "dev-native", "qa-control", "dev-inspect"],
|
|
5
|
+
"testModeMarker": "RIGHTKIT-TEST-MODE-COMPILED",
|
|
6
|
+
"denyNames": [
|
|
7
|
+
{ "name": "RIGHTKIT_QA_NATIVE", "class": "S1" },
|
|
8
|
+
{ "name": "RIGHTKIT_QA_HIDDEN", "class": "S1" },
|
|
9
|
+
{ "name": "RIGHTKIT_QA_DATA_DIR", "class": "S4" },
|
|
10
|
+
{ "name": "RIGHTKIT_QA_BACKGROUND", "class": "S1" },
|
|
11
|
+
{ "name": "RIGHTKIT_UNDER_TEST", "class": "S1" },
|
|
12
|
+
{ "name": "RIGHTKIT_SUITE_ROOT", "class": "S4" },
|
|
13
|
+
{ "name": "RIGHT_SUITE_DIR", "class": "S4" },
|
|
14
|
+
{ "name": "ORTHIC_SHARED_DIR", "class": "S4" },
|
|
15
|
+
{ "name": "RIGHTKIT_CONTROL_SERVICE", "class": "S3" },
|
|
16
|
+
{ "name": "HEARDRIGHT_ENGINE_TEST_MODE", "class": "S1" },
|
|
17
|
+
{ "name": "HR_APP_DATA_DIR", "class": "S4" },
|
|
18
|
+
{ "name": "MAILRIGHT_DATA_DIR", "class": "S4" },
|
|
19
|
+
{ "name": "MAILRIGHT_EMBED_MODEL_DIR", "class": "S3" },
|
|
20
|
+
{ "name": "MAILRIGHT_ONNXRUNTIME_DYLIB", "class": "S3" },
|
|
21
|
+
{ "name": "CUTRIGHT_DATA_DIR", "class": "S4" },
|
|
22
|
+
{ "name": "GENRIGHT_QA", "class": "S1" },
|
|
23
|
+
{ "name": "GENRIGHT_APP_DATA", "class": "S4" },
|
|
24
|
+
{ "name": "GENRIGHT_SD_SERVER", "class": "S3" },
|
|
25
|
+
{ "name": "CODERIGHT_HOME", "class": "S4" },
|
|
26
|
+
{ "name": "POSTRIGHT_ROLE=daemon-dev", "class": "S1" },
|
|
27
|
+
{ "name": "RIGHTAPP_ENGINE_BIN", "class": "S3" },
|
|
28
|
+
{ "name": "LIGHTCRAFT_SAM3_DIR", "class": "S3" },
|
|
29
|
+
{ "name": "LIGHTCRAFT_DEVICE_ROOTS", "class": "S3" },
|
|
30
|
+
{ "name": "--qa", "class": "S1" },
|
|
31
|
+
{ "name": "--demo", "class": "S1" }
|
|
32
|
+
]
|
|
33
|
+
}
|