@rightkit/release 0.2.151 → 0.2.154
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/deps.mjs +0 -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/lsclean.sh +0 -0
- 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-build.mjs +3 -6
- package/msix-config.mjs +5 -1
- package/msix-repair-guard.mjs +24 -0
- package/msix-repair-guard.ps1 +356 -0
- package/msix-signer.mjs +211 -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 +25 -11
- 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-large.mjs +0 -0
- package/upload-release.mjs +10 -37
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Source-level layer of "no test-automation surface ships": a release or update build must not enable the cargo
|
|
2
|
+
// features that compile one in. The binary layer (`qa-control-scan.mjs`) cannot see every surface (a stripped
|
|
3
|
+
// binary, compressed installers), so right-release also refuses, before it builds anything:
|
|
4
|
+
// - a build command (prePackage, package, native package hook, finalizer) that enables one of the forbidden
|
|
5
|
+
// features, directly (`--features`, `-F`, `-f`, `--features=`, `--all-features`) or through a package.json
|
|
6
|
+
// script it runs (`pnpm run <script>`, followed up to three levels);
|
|
7
|
+
// - an app Cargo.toml whose DEFAULT feature closure reaches one of them, or that lists `devtools` on `tauri`,
|
|
8
|
+
// (`devtools` and rightkit-tauri-shell's `content-devtools` are accepted only for an allow-listed app, see devtools-policy.mjs)
|
|
9
|
+
// `qa-control` on `rightkit-qa-driver`, `dev-inspect` on `rightkit-tauri-shell`, or `tauri-plugin-wdio-webdriver` as a non-optional dependency.
|
|
10
|
+
// Dev builds are unaffected: `development.mjs` never calls this.
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
export const FORBIDDEN_FEATURES = Object.freeze(["devtools", "content-devtools", "qa-native", "qa-control", "dev-native", "dev-inspect",
|
|
15
|
+
// Per-app test-automation features (M20 owner gap): HeardRight `qa-shot`/`qa-tools`, ViewRight `dev-control`/`bench-native`.
|
|
16
|
+
"qa-shot", "qa-tools", "dev-control", "bench-native",
|
|
17
|
+
// D4 (M21/M23): rightkit-daemon `test-mode` compiles the override selectors; never in a release build.
|
|
18
|
+
"test-mode"]);
|
|
19
|
+
// The only forbidden features an allow-listed app may enable: rightkit-tauri-shell's `content-devtools` (and the Tauri
|
|
20
|
+
// `devtools` it switches on). See devtools-policy.mjs; every other app is refused them.
|
|
21
|
+
const DEVTOOLS_FEATURES = Object.freeze(["devtools", "content-devtools"]);
|
|
22
|
+
const FORBIDDEN_DEPENDENCY = "tauri-plugin-wdio-webdriver";
|
|
23
|
+
const MANAGERS = new Set(["pnpm", "npm", "yarn", "bun", "npx"]);
|
|
24
|
+
|
|
25
|
+
// "dep:foo" -> "foo"; "tauri/devtools" and "tauri?/devtools" -> "devtools".
|
|
26
|
+
function featureName(item) {
|
|
27
|
+
const bare = String(item).trim().replace(/^dep:/, "");
|
|
28
|
+
return bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare;
|
|
29
|
+
}
|
|
30
|
+
export function isForbiddenFeature(item, { allowDevtools = false } = {}) {
|
|
31
|
+
const text = String(item).trim();
|
|
32
|
+
const name = featureName(text);
|
|
33
|
+
if (allowDevtools && DEVTOOLS_FEATURES.includes(name)) return false;
|
|
34
|
+
return text === `dep:${FORBIDDEN_DEPENDENCY}` || FORBIDDEN_FEATURES.includes(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function tokenize(text) {
|
|
38
|
+
return (String(text).match(/"[^"]*"|'[^']*'|[^\s"']+/g) ?? []).map((t) => t.replace(/^["']|["']$/g, ""));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Feature names a token list enables, plus whether it asks for every feature.
|
|
42
|
+
export function featuresInTokens(tokens) {
|
|
43
|
+
const enabled = []; let allFeatures = false;
|
|
44
|
+
const take = (value) => { for (const part of String(value).split(/[,\s]+/)) if (part) enabled.push(part); };
|
|
45
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
46
|
+
const t = tokens[i];
|
|
47
|
+
if (t === "--all-features") allFeatures = true;
|
|
48
|
+
else if (t === "--features" || t === "-F" || t === "-f" || t === "--feature") { if (i + 1 < tokens.length) take(tokens[i + 1]); }
|
|
49
|
+
else if (t.startsWith("--features=")) take(t.slice("--features=".length));
|
|
50
|
+
else if (/^-F.+/.test(t)) take(t.slice(2));
|
|
51
|
+
}
|
|
52
|
+
return { enabled, allFeatures };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function scriptTarget(tokens) {
|
|
56
|
+
const first = tokens.findIndex((t) => MANAGERS.has(path.basename(t).replace(/\.(cmd|exe)$/i, "")));
|
|
57
|
+
if (first < 0) return null;
|
|
58
|
+
let i = first + 1;
|
|
59
|
+
while (i < tokens.length && tokens[i].startsWith("-")) i += 1;
|
|
60
|
+
if (["run", "run-script"].includes(tokens[i])) { i += 1; while (i < tokens.length && tokens[i].startsWith("-")) i += 1; }
|
|
61
|
+
return tokens[i] ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Issues (strings) for one command object { cmd, args, env? } or a shell string.
|
|
65
|
+
export function commandFeatureIssues(command, { label = "command", scripts = {}, depth = 0, seen = new Set(), allowDevtools = false } = {}) {
|
|
66
|
+
const parts = typeof command === "string" ? [command] : [command?.cmd, ...(command?.args ?? [])].filter((v) => v !== undefined).map(String);
|
|
67
|
+
// Two views: every argument whole (`--features` followed by "a b qa-native"), and every argument re-tokenised as
|
|
68
|
+
// shell text (`sh -c "cargo build --features qa-native"`).
|
|
69
|
+
const views = [parts, parts.flatMap((p) => tokenize(p))];
|
|
70
|
+
const issues = new Set();
|
|
71
|
+
for (const tokens of views) {
|
|
72
|
+
const { enabled, allFeatures } = featuresInTokens(tokens);
|
|
73
|
+
if (allFeatures) issues.add(`${label}: --all-features enables every feature including ${FORBIDDEN_FEATURES.join(", ")}`);
|
|
74
|
+
for (const feature of enabled) if (isForbiddenFeature(feature, { allowDevtools })) issues.add(`${label}: enables feature \`${feature}\``);
|
|
75
|
+
}
|
|
76
|
+
if (command && typeof command === "object") {
|
|
77
|
+
for (const [key, value] of Object.entries(command.env ?? {})) {
|
|
78
|
+
if (/FEATURES$/i.test(key)) for (const feature of String(value).split(/[,\s]+/)) if (feature && isForbiddenFeature(feature, { allowDevtools })) issues.add(`${label}: env ${key} enables feature \`${feature}\``);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const script = views.map(scriptTarget).find(Boolean);
|
|
82
|
+
if (script && depth < 3 && Object.hasOwn(scripts, script) && !seen.has(script)) {
|
|
83
|
+
for (const issue of commandFeatureIssues(String(scripts[script]), { label: `${label} -> package script \`${script}\``, scripts, depth: depth + 1, seen: new Set([...seen, script]), allowDevtools })) issues.add(issue);
|
|
84
|
+
}
|
|
85
|
+
return [...issues];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- Cargo.toml ---------------------------------------------------------------------------------------------
|
|
89
|
+
function stripComments(text) {
|
|
90
|
+
let out = ""; let quote = null;
|
|
91
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
92
|
+
const c = text[i];
|
|
93
|
+
if (quote) { out += c; if (c === quote && text[i - 1] !== "\\") quote = null; continue; }
|
|
94
|
+
if (c === '"' || c === "'") { quote = c; out += c; continue; }
|
|
95
|
+
if (c === "#") { while (i < text.length && text[i] !== "\n") i += 1; out += "\n"; continue; }
|
|
96
|
+
out += c;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sectionsOf(text) {
|
|
102
|
+
const sections = []; let current = { name: "", body: "" };
|
|
103
|
+
for (const line of text.split(/\r?\n/)) {
|
|
104
|
+
const header = line.match(/^\s*\[\s*([^\[\]]+?)\s*\]\s*$/);
|
|
105
|
+
if (header) { sections.push(current); current = { name: header[1].replace(/\s+/g, ""), body: "" }; }
|
|
106
|
+
else current.body += `${line}\n`;
|
|
107
|
+
}
|
|
108
|
+
sections.push(current);
|
|
109
|
+
return sections;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function balanced(text, from, open, close) {
|
|
113
|
+
let depth = 0; let quote = null;
|
|
114
|
+
for (let i = from; i < text.length; i += 1) {
|
|
115
|
+
const c = text[i];
|
|
116
|
+
if (quote) { if (c === quote && text[i - 1] !== "\\") quote = null; continue; }
|
|
117
|
+
if (c === '"' || c === "'") quote = c;
|
|
118
|
+
else if (c === open) depth += 1;
|
|
119
|
+
else if (c === close) { depth -= 1; if (depth === 0) return text.slice(from, i + 1); }
|
|
120
|
+
}
|
|
121
|
+
return text.slice(from);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const strings = (text) => [...String(text).matchAll(/"([^"]*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
|
|
125
|
+
const unquote = (key) => key.replace(/^["']|["']$/g, "");
|
|
126
|
+
|
|
127
|
+
export function parseCargoFeaturesAndDependencies(manifestText) {
|
|
128
|
+
const sections = sectionsOf(stripComments(manifestText));
|
|
129
|
+
const features = new Map(); const dependencies = new Map();
|
|
130
|
+
for (const { name, body } of sections) {
|
|
131
|
+
if (name === "features") {
|
|
132
|
+
for (const m of body.matchAll(/^\s*([A-Za-z0-9_\-"']+)\s*=\s*\[/gm)) {
|
|
133
|
+
features.set(unquote(m[1]), strings(balanced(body, m.index + m[0].length - 1, "[", "]")));
|
|
134
|
+
}
|
|
135
|
+
} else if (/^(?:target\.[^\n]+\.)?dependencies$/.test(name)) {
|
|
136
|
+
for (const m of body.matchAll(/^\s*([A-Za-z0-9_\-"']+)\s*=\s*(\{)?/gm)) {
|
|
137
|
+
const depName = unquote(m[1]);
|
|
138
|
+
const spec = m[2] ? balanced(body, m.index + m[0].length - 1, "{", "}") : "";
|
|
139
|
+
dependencies.set(depName, dependencyInfo(spec));
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
const table = name.match(/^(?:target\.[^\n]+\.)?dependencies\.([A-Za-z0-9_\-]+)$/);
|
|
143
|
+
if (table) dependencies.set(table[1], dependencyInfo(body));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { features, dependencies };
|
|
147
|
+
}
|
|
148
|
+
function dependencyInfo(spec) {
|
|
149
|
+
const list = spec.match(/features\s*=\s*(\[)/);
|
|
150
|
+
return {
|
|
151
|
+
optional: /optional\s*=\s*true/.test(spec),
|
|
152
|
+
features: list ? strings(balanced(spec, list.index + list[0].length - 1, "[", "]")) : [],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function cargoManifestIssues(manifestText, label = "Cargo.toml", { allowDevtools = false } = {}) {
|
|
157
|
+
const { features, dependencies } = parseCargoFeaturesAndDependencies(manifestText);
|
|
158
|
+
const issues = [];
|
|
159
|
+
const tauri = dependencies.get("tauri");
|
|
160
|
+
if (!allowDevtools && tauri?.features.includes("devtools")) issues.push(`${label}: dependency \`tauri\` lists feature \`devtools\` unconditionally (inspector ships in release builds)`);
|
|
161
|
+
// The crate was renamed from rightkit-control; apps still on the old published name must stay covered.
|
|
162
|
+
for (const name of ["rightkit-qa-driver", "rightkit-control"]) {
|
|
163
|
+
const control = dependencies.get(name);
|
|
164
|
+
if (control && !control.optional && control.features.includes("qa-control")) issues.push(`${label}: dependency \`${name}\` lists \`qa-control\` unconditionally`);
|
|
165
|
+
}
|
|
166
|
+
// The crate was renamed from rightkit-shell; apps still on the old published name must stay covered.
|
|
167
|
+
for (const name of ["rightkit-tauri-shell", "rightkit-shell"]) {
|
|
168
|
+
const shell = dependencies.get(name);
|
|
169
|
+
if (shell && !shell.optional && shell.features.includes("dev-inspect")) issues.push(`${label}: dependency \`${name}\` lists \`dev-inspect\` unconditionally (developer inspection ships in release builds)`);
|
|
170
|
+
}
|
|
171
|
+
const service = dependencies.get("rightkit-daemon");
|
|
172
|
+
if (service && !service.optional && service.features.includes("test-mode")) issues.push(`${label}: dependency \`rightkit-daemon\` lists \`test-mode\` unconditionally (test-mode capability ships in release builds)`);
|
|
173
|
+
const wdio = dependencies.get(FORBIDDEN_DEPENDENCY);
|
|
174
|
+
if (wdio && !wdio.optional) issues.push(`${label}: dependency \`${FORBIDDEN_DEPENDENCY}\` is not optional`);
|
|
175
|
+
const reached = new Set(); const stack = [...(features.get("default") ?? [])];
|
|
176
|
+
while (stack.length) {
|
|
177
|
+
const item = stack.pop();
|
|
178
|
+
if (reached.has(item)) continue;
|
|
179
|
+
reached.add(item);
|
|
180
|
+
if (isForbiddenFeature(item, { allowDevtools })) issues.push(`${label}: default feature set reaches \`${item}\``);
|
|
181
|
+
if (features.has(item)) stack.push(...features.get(item));
|
|
182
|
+
}
|
|
183
|
+
return issues;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// commands: [{ label, command }]. Reads package.json scripts and the app Cargo manifests under `root`.
|
|
187
|
+
// `devtools`: the allowance resolved by devtools-policy.mjs (mode "content-only") for an allow-listed app, else undefined.
|
|
188
|
+
// Single manifest resolver shared by the closure gate (release-switch-scan.mjs) and the default-feature check below:
|
|
189
|
+
// targets.<os>.cargoManifest -> releaseSwitches.manifest -> src-tauri/Cargo.toml -> Cargo.toml. `rel` is the first
|
|
190
|
+
// candidate that exists (undefined when none does); `declared` are the configured paths.
|
|
191
|
+
export function resolveCargoManifestPath({ root, cargoManifest, manifest } = {}) {
|
|
192
|
+
const declared = [cargoManifest, manifest].filter(Boolean);
|
|
193
|
+
const candidates = [...declared, path.join("src-tauri", "Cargo.toml"), "Cargo.toml"];
|
|
194
|
+
return { rel: candidates.find((c) => existsSync(path.join(root, c))), declared, candidates };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function buildFeatureIssues({ root, commands, devtools, cargoManifest, manifest }) {
|
|
198
|
+
const allowDevtools = devtools?.mode === "content-only";
|
|
199
|
+
let scripts = {};
|
|
200
|
+
try { scripts = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).scripts ?? {}; } catch { /* no package.json */ }
|
|
201
|
+
const issues = [];
|
|
202
|
+
for (const { label, command } of commands) if (command) issues.push(...commandFeatureIssues(command, { label, scripts, allowDevtools }));
|
|
203
|
+
const { rel } = resolveCargoManifestPath({ root, cargoManifest, manifest });
|
|
204
|
+
if (rel) issues.push(...cargoManifestIssues(readFileSync(path.join(root, rel), "utf8"), rel, { allowDevtools }));
|
|
205
|
+
return issues;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function assertNoForbiddenBuildFeatures({ root, commands, tier = "release", devtools, cargoManifest, manifest }) {
|
|
209
|
+
const issues = buildFeatureIssues({ root, commands, devtools, cargoManifest, manifest });
|
|
210
|
+
if (issues.length === 0) return;
|
|
211
|
+
throw new Error(
|
|
212
|
+
`${tier} build refused: it enables a test-automation or debugging feature (${FORBIDDEN_FEATURES.join(", ")}); none may ship:\n- ${issues.join("\n- ")}\n`
|
|
213
|
+
+ "Remove the feature from the release build command, the app's default features and its tauri dependency; dev builds (`right-release dev`) may keep it. "
|
|
214
|
+
+ "No app may ship devtools unless it is on the allow-list in devtools-policy.json and declares `devtools: \"content-only\"` (rightkit-tauri-shell DevToolsPolicy::ContentOnly).",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -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
|
+
}
|