evershield 0.2.0 → 0.3.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 CHANGED
@@ -42,6 +42,12 @@ npx evershield scan ./dist --format json --fail-on medium --min-confidence low
42
42
 
43
43
  # SARIF for code scanning, written to a file
44
44
  npx evershield scan ./dist --format sarif --output evershield.sarif
45
+
46
+ # mobile and desktop app packages: everything inside ships to the device
47
+ npx evershield scan app-release.apk # Android (.apk, .aab), incl. Hermes bytecode and DEX strings
48
+ npx evershield scan MyApp.ipa # iOS: main.jsbundle and the app executable's strings
49
+ npx evershield scan app.asar # Electron
50
+ npx evershield scan ./dist # an Expo / React Native export (*.hbc, *.bundle)
45
51
  ```
46
52
 
47
53
  Exit code `0` means nothing blocking, `1` means findings at or above the
@@ -57,6 +63,37 @@ confidence, is in [docs/rules.md](docs/rules.md):
57
63
  - `artifact/sensitive-env-name`: `SERVICE_ROLE`, `*_SECRET`, `DATABASE_URL`, …
58
64
  - `artifact/internal-endpoint`: private IPs, `.internal` hosts, dev fallbacks
59
65
 
66
+ ## Leave reverse engineers empty-handed
67
+
68
+ Tools like [REA](https://github.com/morluto/rea) cannot be blocked: they run
69
+ in the attacker's own browser, on their own copy of your app. What you
70
+ control is what they get. `recon` shows it from their side and fails the
71
+ build while anything is worth taking:
72
+
73
+ ```bash
74
+ npx evershield recon ./dist # or an .apk, .ipa, .asar
75
+ npx evershield recon ./dist --rea # also run REA itself and check each finding against its output
76
+ ```
77
+
78
+ ```
79
+ Worth taking: 2 blocking, 3 total
80
+ ✖ [critical/high] Stripe secret key shipped in assets/index.js · REA: recovered
81
+ ✖ [high/high] Source map shipped: assets/index.js.map · REA: not checked
82
+ • [medium/medium] Private IP address in assets/index.js: 10.0.3.7 · REA: recovered
83
+
84
+ Map (informational: any tool reconstructs this from shipped code; keep server-side checks on it)
85
+ hosts: api.example.com (12)
86
+ API paths: /api/report, /api/user
87
+ source maps: 1 file(s), 2 original source(s), 2 with full content
88
+
89
+ Verdict: 2 item(s) worth taking. Anyone running a reverse-engineering tool on this target gets them.
90
+ ```
91
+
92
+ The map is what no build can hide (routes, hosts, field names); recon lists
93
+ it so you know which endpoints need server-side authorization. With `--rea`,
94
+ values are compared in memory and never printed. Pair it with `audit` and
95
+ `surface` for what each plan receives at runtime.
96
+
60
97
  ## Audit what each plan actually receives
61
98
 
62
99
  Layer 2 calls your own API as each identity you configure and compares the
@@ -195,6 +232,30 @@ evershield refuses a config whose ignores have no `reason`, and reports how
195
232
  many findings the config removed. Details in
196
233
  [docs/configuration.md](docs/configuration.md).
197
234
 
235
+ ## Measured on real code
236
+
237
+ Every rule change runs against a [false-positive corpus](docs/corpus.md): 42
238
+ pinned npm packages that ship to browsers (React, Vue, Firebase, Supabase,
239
+ Auth0, Sentry, Stripe.js, PostHog, three.js, pdf.js and more) plus 10 default
240
+ apps from Next.js, Vite, SvelteKit, Astro and Expo (Hermes bytecode)
241
+ generators, built as deployed. **False positives at the default gate: 0**
242
+ across 8,886 files and 175 MiB.
243
+ The one blocking finding is a true positive (a development build with an
244
+ inline source map). The first run found 8 false positives, all in
245
+ auth0-spa-js; fixing them is how the rules got here.
246
+
247
+ ## Tested against REA
248
+
249
+ [experiments/rea](experiments/rea/README.md) points
250
+ [REA](https://github.com/morluto/rea) at a deliberately leaky app and at the
251
+ same app fixed with evershield. REA recovered all five leaks from the leaky
252
+ build (a secret key, an internal address, source via source maps, blurred
253
+ Pro data in the DOM, Pro fields in a free response); evershield flagged all
254
+ five; after the fix REA recovered none and evershield reported nothing.
255
+ evershield does not hide your app from REA. It makes sure there is nothing
256
+ in it worth taking, and `evershield recon --rea` checks that against REA
257
+ itself on every build.
258
+
198
259
  ## Principles
199
260
 
200
261
  - **Defensive only.** It audits targets you own, listed in your config.
package/dist/cli.js CHANGED
@@ -4,6 +4,8 @@ import { parseArgs } from "node:util";
4
4
  import { runAudit } from "./audit/run.js";
5
5
  import { applyIgnores, ConfigError, loadConfig } from "./config.js";
6
6
  import { runInit } from "./init.js";
7
+ import { runRecon } from "./recon.js";
8
+ import { renderReconJson, renderReconSarif, renderReconText } from "./report/recon.js";
7
9
  import { runSurface } from "./surface/run.js";
8
10
  import { VERSION } from "./index.js";
9
11
  import { CONFIDENCES, DEFAULT_GATE, isBlocking, renderJson, renderText, SEVERITIES } from "./report/index.js";
@@ -16,7 +18,14 @@ Shield everything, everywhere.
16
18
  Usage:
17
19
  evershield init [--force] Detect your framework and write evershield.config.json
18
20
  and a GitHub workflow that scans every pull request
19
- evershield scan <dir> [options] Layer 1: scan a built web artifact directory
21
+ evershield scan <dir|app> [options]
22
+ Layer 1: scan a built web directory, or an app package
23
+ (.apk, .aab, .ipa, Electron .asar)
24
+ evershield recon <dir|app> [--rea] [--rea-bin <path>]
25
+ The attacker's view: what reverse-engineering tools such
26
+ as REA recover from a build or app package. Fails when
27
+ anything is worth taking; --rea checks each finding
28
+ against REA's own output
20
29
  evershield audit [options] Layer 2: call configured endpoints as each identity
21
30
  and compare what they receive (needs config "audit")
22
31
  evershield surface [options] Layer 3: open configured pages as each identity in a
@@ -148,6 +157,8 @@ export async function run(argv) {
148
157
  return failure("init", error);
149
158
  }
150
159
  }
160
+ if (command === "recon")
161
+ return reconCommand(rest);
151
162
  if (command !== "scan" && command !== "audit" && command !== "surface") {
152
163
  return usageError(`unknown command "${command}"`);
153
164
  }
@@ -211,3 +222,49 @@ export async function run(argv) {
211
222
  return failure("audit", error);
212
223
  }
213
224
  }
225
+ async function reconCommand(rest) {
226
+ const args = [...rest];
227
+ let reaBin;
228
+ const reaAt = args.indexOf("--rea");
229
+ if (reaAt >= 0) {
230
+ args.splice(reaAt, 1);
231
+ reaBin = "rea";
232
+ }
233
+ const binAt = args.indexOf("--rea-bin");
234
+ if (binAt >= 0) {
235
+ const value = args[binAt + 1];
236
+ if (value === undefined || value.startsWith("--"))
237
+ return usageError("recon: --rea-bin needs a path");
238
+ args.splice(binAt, 2);
239
+ reaBin = value;
240
+ }
241
+ const options = parseCommonOptions(args);
242
+ if ("code" in options)
243
+ return options;
244
+ const target = options.positionals[0];
245
+ if (target === undefined)
246
+ return usageError("recon: missing <dir|app>");
247
+ try {
248
+ const { config } = await loadConfig(options.configPath);
249
+ const gate = gateFor(options, config);
250
+ const result = await runRecon(target, {
251
+ ...(options.adapter === undefined ? {} : { adapter: options.adapter }),
252
+ ...(reaBin === undefined ? {} : { reaBin }),
253
+ });
254
+ const { kept, ignored } = applyIgnores(result.findings, config.ignore);
255
+ const keptSet = new Set(kept);
256
+ const filtered = { ...result, findings: kept, items: result.items.filter((item) => keptSet.has(item.finding)), ignored: ignored.length };
257
+ const blocking = kept.filter((finding) => isBlocking(finding, gate)).length;
258
+ // A requested REA check that could not run is not a pass.
259
+ const code = result.rea.status === "failed" ? 2 : blocking > 0 ? 1 : 0;
260
+ const rendered = options.format === "json" ? renderReconJson(filtered, gate) : options.format === "sarif" ? renderReconSarif(filtered, gate) : renderReconText(filtered, gate);
261
+ const stderr = result.rea.status === "failed" ? `evershield recon: ${result.rea.detail ?? "REA failed"}\n` : "";
262
+ if (options.outputPath === undefined)
263
+ return { code, stdout: rendered, stderr };
264
+ await writeFile(path.resolve(options.outputPath), rendered);
265
+ return { code, stdout: `evershield recon: ${filtered.items.length} item(s), ${blocking} blocking · ${options.format} report written to ${options.outputPath}\n`, stderr };
266
+ }
267
+ catch (error) {
268
+ return failure("recon", error);
269
+ }
270
+ }
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * every layer reports in, so CLI, GitHub Action and MCP surfaces can share
6
6
  * one finding model from day one. See docs/design.md for the rationale.
7
7
  */
8
- export declare const VERSION = "0.2.0";
8
+ export declare const VERSION = "0.3.0";
9
9
  /** How sure evershield is that a finding is real. Drives exit codes and CI gating. */
10
10
  export type Confidence = "high" | "medium" | "low";
11
11
  /** How bad it is if the finding is real. */
package/dist/index.js CHANGED
@@ -5,4 +5,4 @@
5
5
  * every layer reports in, so CLI, GitHub Action and MCP surfaces can share
6
6
  * one finding model from day one. See docs/design.md for the rationale.
7
7
  */
8
- export const VERSION = "0.2.0";
8
+ export const VERSION = "0.3.0";
@@ -0,0 +1,53 @@
1
+ import type { Finding } from "./index.ts";
2
+ import { type CollectedTarget } from "./scan/artifact.ts";
3
+ import type { ScannedFile } from "./scan/files.ts";
4
+ export type ReconCategory = "credential" | "source" | "internal-address" | "other";
5
+ export interface ReconItem {
6
+ category: ReconCategory;
7
+ finding: Finding;
8
+ /** true: REA's output contains the value; false: it does not; null: not checked. */
9
+ reaRecovered: boolean | null;
10
+ }
11
+ export interface ReconMap {
12
+ /** Hosts referenced by absolute URLs, most frequent first. */
13
+ hosts: Array<{
14
+ host: string;
15
+ count: number;
16
+ }>;
17
+ /** API-looking paths in string literals. */
18
+ apiPaths: string[];
19
+ sourceMaps: {
20
+ files: number;
21
+ originalSources: number;
22
+ withContent: number;
23
+ };
24
+ /** Framework-public env names still present in the output. */
25
+ publicEnvNames: string[];
26
+ }
27
+ export interface ReconRea {
28
+ status: "not-run" | "ran" | "skipped" | "failed";
29
+ detail?: string;
30
+ }
31
+ export interface ReconResult {
32
+ version: string;
33
+ layer: "recon";
34
+ target: string;
35
+ adapter: CollectedTarget["adapterName"];
36
+ filesScanned: number;
37
+ startedAt: string;
38
+ finishedAt: string;
39
+ items: ReconItem[];
40
+ /** The same findings, for the gate, ignores and SARIF. */
41
+ findings: Finding[];
42
+ ignored: number;
43
+ map: ReconMap;
44
+ rea: ReconRea;
45
+ }
46
+ export interface ReconOptions {
47
+ adapter?: "nextjs" | "plain";
48
+ /** Run REA's static analyzer; the executable name or path. */
49
+ reaBin?: string;
50
+ reaTimeoutMs?: number;
51
+ }
52
+ export declare function buildMap(files: ScannedFile[]): ReconMap;
53
+ export declare function runRecon(target: string, options?: ReconOptions): Promise<ReconResult>;
package/dist/recon.js ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `evershield recon`: the attacker's view of a build or app package.
3
+ *
4
+ * Reverse-engineering tools such as REA cannot be blocked: they run in the
5
+ * attacker's own browser or on their own copy of the app. What can be
6
+ * controlled is what they get. recon lists it in two parts:
7
+ *
8
+ * - **Worth taking**: findings an attacker can use directly (credentials,
9
+ * original source, internal addresses). The gate applies to these, so a
10
+ * passing recon means the tools come back empty-handed.
11
+ * - **Map**: what any tool reconstructs from shipped code no matter what
12
+ * (hosts, API paths, source map coverage, public env names). Informational:
13
+ * it is the attack surface to keep server-side checks on.
14
+ *
15
+ * With `--rea`, REA's static JavaScript analyzer runs on the same target and
16
+ * each finding's unredacted value is looked up in REA's output, in memory
17
+ * only, to show whether the tool actually recovers it.
18
+ */
19
+ import { execFile } from "node:child_process";
20
+ import { existsSync } from "node:fs";
21
+ import path from "node:path";
22
+ import { VERSION } from "./index.js";
23
+ import { collectTarget, runArtifactRules } from "./scan/artifact.js";
24
+ function categorize(finding) {
25
+ if (finding.ruleId === "artifact/source-map-published")
26
+ return "source";
27
+ if (finding.ruleId === "artifact/internal-endpoint")
28
+ return "internal-address";
29
+ if (finding.ruleId === "artifact/secret-like-string" || finding.ruleId === "artifact/sensitive-env-name")
30
+ return "credential";
31
+ return "other";
32
+ }
33
+ const URL_HOST = /\bhttps?:\/\/((?:[a-z0-9-]+\.)+[a-z]{2,}|localhost|\d{1,3}(?:\.\d{1,3}){3})(?::\d{2,5})?/gi;
34
+ const API_PATH = /["'`](\/(?:api|graphql|rest|rpc|trpc|admin|internal|v\d+)(?:\/[\w\-.:{}$]*)*)(?=["'`?])/g;
35
+ const PUBLIC_ENV = /\b(?:NEXT_PUBLIC|VITE|PUBLIC|REACT_APP|NUXT_PUBLIC|EXPO_PUBLIC|GATSBY|VUE_APP)_[A-Z0-9_]{2,}\b/g;
36
+ const INLINE_MAP = /sourceMappingURL=data:application\/json[^,]*;base64,([A-Za-z0-9+/=]+)/g;
37
+ function countSources(json, into) {
38
+ try {
39
+ const map = JSON.parse(json);
40
+ into.files += 1;
41
+ into.originalSources += Array.isArray(map.sources) ? map.sources.length : 0;
42
+ into.withContent += Array.isArray(map.sourcesContent) ? map.sourcesContent.filter((content) => typeof content === "string").length : 0;
43
+ }
44
+ catch {
45
+ // Not a parseable map; the source-map rule still reports the file.
46
+ }
47
+ }
48
+ export function buildMap(files) {
49
+ const hosts = new Map();
50
+ const apiPaths = new Set();
51
+ const publicEnvNames = new Set();
52
+ const sourceMaps = { files: 0, originalSources: 0, withContent: 0 };
53
+ for (const file of files) {
54
+ if (file.relativePath.endsWith(".map")) {
55
+ countSources(file.text, sourceMaps);
56
+ continue;
57
+ }
58
+ for (const match of file.text.matchAll(URL_HOST)) {
59
+ const host = match[1].toLowerCase();
60
+ hosts.set(host, (hosts.get(host) ?? 0) + 1);
61
+ }
62
+ for (const match of file.text.matchAll(API_PATH))
63
+ if (apiPaths.size < 200)
64
+ apiPaths.add(match[1]);
65
+ for (const match of file.text.matchAll(PUBLIC_ENV))
66
+ publicEnvNames.add(match[0]);
67
+ for (const match of file.text.matchAll(INLINE_MAP))
68
+ countSources(Buffer.from(match[1], "base64").toString("utf8"), sourceMaps);
69
+ }
70
+ return {
71
+ hosts: [...hosts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, 50).map(([host, count]) => ({ host, count })),
72
+ apiPaths: [...apiPaths].sort(),
73
+ sourceMaps,
74
+ publicEnvNames: [...publicEnvNames].sort(),
75
+ };
76
+ }
77
+ /**
78
+ * npm installs `rea` on Windows as `rea.cmd`, which only a shell can run, so
79
+ * Windows goes through cmd.exe with every argument quoted.
80
+ */
81
+ function reaInvocation(bin, args) {
82
+ if (process.platform !== "win32")
83
+ return { file: bin, args, shell: false };
84
+ const quote = (value) => `"${value.replace(/"/g, '""')}"`;
85
+ return { file: quote(bin), args: args.map(quote), shell: true };
86
+ }
87
+ function runRea(bin, target, timeoutMs) {
88
+ const notFound = `"${bin}" not found; install REA (npm install --global rea-agents) or pass --rea-bin <path>`;
89
+ // A path that does not exist is caught here, without relying on shell messages that are localized.
90
+ if (/[\\/]/.test(bin) && !existsSync(bin))
91
+ return Promise.resolve({ ok: false, detail: notFound });
92
+ const invocation = reaInvocation(bin, ["analyze-javascript-application", target, "--format", "json"]);
93
+ return new Promise((resolve) => {
94
+ execFile(invocation.file, invocation.args, { maxBuffer: 1024 * 1024 * 1024, timeout: timeoutMs, env: { ...process.env, NO_COLOR: "1" }, shell: invocation.shell }, (error, stdout, stderr) => {
95
+ // cmd.exe exits 9009 for a command it cannot find, in every locale; its message is localized.
96
+ // execFile puts the errno string or the exit code number in `code`.
97
+ const code = error?.code;
98
+ const missing = code === "ENOENT" || (invocation.shell && code === 9009);
99
+ if (error !== null && missing) {
100
+ resolve({ ok: false, detail: notFound });
101
+ }
102
+ else if (error !== null && stdout.length === 0) {
103
+ resolve({ ok: false, detail: `REA failed: ${(stderr || error.message).trim().split("\n")[0]}` });
104
+ }
105
+ else {
106
+ resolve({ ok: true, output: stdout });
107
+ }
108
+ });
109
+ });
110
+ }
111
+ export async function runRecon(target, options = {}) {
112
+ const startedAt = new Date().toISOString();
113
+ const collected = await collectTarget(target, options.adapter === undefined ? {} : { adapter: options.adapter });
114
+ // Unredacted values stay in this map and are only compared, never written.
115
+ const raw = new Map();
116
+ const findings = runArtifactRules(collected.files, collected.adapter, (finding, value) => raw.set(finding, value));
117
+ let rea = { status: "not-run" };
118
+ let reaOutput = null;
119
+ if (options.reaBin !== undefined) {
120
+ const isAsar = collected.adapterName === "electron";
121
+ if (collected.adapterName === "android" || collected.adapterName === "ios") {
122
+ rea = { status: "skipped", detail: "REA's JavaScript analyzer takes a directory or an .asar; scan the extracted bundle directory with --rea" };
123
+ }
124
+ else {
125
+ const result = await runRea(options.reaBin, isAsar ? collected.root : path.resolve(collected.root), options.reaTimeoutMs ?? 600_000);
126
+ if (result.ok) {
127
+ rea = { status: "ran" };
128
+ reaOutput = result.output;
129
+ }
130
+ else {
131
+ rea = { status: "failed", detail: result.detail };
132
+ }
133
+ }
134
+ }
135
+ const items = findings.map((finding) => {
136
+ const category = categorize(finding);
137
+ let reaRecovered = null;
138
+ if (reaOutput !== null && category !== "source") {
139
+ const first = finding.evidence[0];
140
+ const value = raw.get(finding) ?? (category === "internal-address" && first?.kind === "file" ? first.excerpt : undefined);
141
+ if (value !== undefined)
142
+ reaRecovered = reaOutput.includes(value) || reaOutput.includes(JSON.stringify(value).slice(1, -1));
143
+ }
144
+ return { category, finding, reaRecovered };
145
+ });
146
+ return {
147
+ version: VERSION,
148
+ layer: "recon",
149
+ target: collected.root,
150
+ adapter: collected.adapterName,
151
+ filesScanned: collected.files.length,
152
+ startedAt,
153
+ finishedAt: new Date().toISOString(),
154
+ items,
155
+ findings,
156
+ ignored: 0,
157
+ map: buildMap(collected.files),
158
+ rea,
159
+ };
160
+ }
@@ -0,0 +1,5 @@
1
+ import type { ReconResult } from "../recon.ts";
2
+ import { type Gate } from "./index.ts";
3
+ export declare function renderReconText(result: ReconResult, gate: Gate): string;
4
+ export declare function renderReconJson(result: ReconResult, gate: Gate): string;
5
+ export declare function renderReconSarif(result: ReconResult, gate: Gate): string;
@@ -0,0 +1,71 @@
1
+ import { isBlocking } from "./index.js";
2
+ import { renderSarif } from "./sarif.js";
3
+ const KIND = {
4
+ nextjs: "Next.js build",
5
+ plain: "build directory",
6
+ android: "Android package",
7
+ ios: "iOS package",
8
+ electron: "Electron archive",
9
+ };
10
+ function reaLabel(item, ran) {
11
+ if (!ran)
12
+ return "";
13
+ if (item.reaRecovered === true)
14
+ return " · REA: recovered";
15
+ if (item.reaRecovered === false)
16
+ return " · REA: not in its output";
17
+ return " · REA: not checked";
18
+ }
19
+ function list(values, max) {
20
+ if (values.length === 0)
21
+ return "none";
22
+ const shown = values.slice(0, max).join(", ");
23
+ return values.length > max ? `${shown}, … (${values.length} total)` : shown;
24
+ }
25
+ export function renderReconText(result, gate) {
26
+ const ran = result.rea.status === "ran";
27
+ const blocking = result.items.filter((item) => isBlocking(item.finding, gate));
28
+ const lines = [
29
+ `evershield ${result.version} · recon · ${KIND[result.adapter]} · ${result.filesScanned} files`,
30
+ `target: ${result.target}`,
31
+ "What reverse-engineering tools recover from this target.",
32
+ "",
33
+ `Worth taking: ${blocking.length} blocking, ${result.items.length} total${result.ignored > 0 ? ` (${result.ignored} ignored by config)` : ""}`,
34
+ ];
35
+ for (const item of result.items) {
36
+ const first = item.finding.evidence[0];
37
+ lines.push(`${isBlocking(item.finding, gate) ? "✖" : "•"} [${item.finding.severity}/${item.finding.confidence}] ${item.finding.message}${reaLabel(item, ran)}`);
38
+ if (first?.kind === "file")
39
+ lines.push(` at ${first.line === undefined ? first.path : `${first.path}:${first.line}:${first.column ?? 1}`}`);
40
+ if (item.finding.remediation !== undefined)
41
+ lines.push(` fix: ${item.finding.remediation}`);
42
+ }
43
+ const { map } = result;
44
+ lines.push("", "Map (informational: any tool reconstructs this from shipped code; keep server-side checks on it)", ` hosts: ${list(map.hosts.map((h) => `${h.host} (${h.count})`), 8)}`, ` API paths: ${list(map.apiPaths, 12)}`, ` source maps: ${map.sourceMaps.files} file(s), ${map.sourceMaps.originalSources} original source(s), ${map.sourceMaps.withContent} with full content`, ` public env names: ${list(map.publicEnvNames, 8)}`, "", result.rea.status === "ran"
45
+ ? "REA: ran its static JavaScript analysis on the same target; each value above was looked up in its output (values are never printed)."
46
+ : result.rea.status === "not-run"
47
+ ? "REA: not run (add --rea to check each finding against REA's own output)."
48
+ : `REA: ${result.rea.status}: ${result.rea.detail ?? ""}`, "", blocking.length > 0
49
+ ? `Verdict: ${blocking.length} item(s) worth taking. Anyone running a reverse-engineering tool on this target gets them.`
50
+ : "Verdict: nothing worth taking at this gate. Tools still reconstruct the map above, and nothing more.");
51
+ return `${lines.join("\n")}\n`;
52
+ }
53
+ export function renderReconJson(result, gate) {
54
+ const blocking = result.findings.filter((finding) => isBlocking(finding, gate)).length;
55
+ const { findings: _findings, ...rest } = result;
56
+ return `${JSON.stringify({ ...rest, gate, blocking }, null, 2)}\n`;
57
+ }
58
+ export function renderReconSarif(result, gate) {
59
+ const artifact = {
60
+ version: result.version,
61
+ layer: "artifact",
62
+ target: result.target,
63
+ adapter: result.adapter,
64
+ filesScanned: result.filesScanned,
65
+ ignored: result.ignored,
66
+ startedAt: result.startedAt,
67
+ finishedAt: result.finishedAt,
68
+ findings: result.findings,
69
+ };
70
+ return renderSarif(artifact, gate);
71
+ }
@@ -1,13 +1,24 @@
1
1
  import { lineColumn } from "../scan/text.js";
2
2
  import { helpUri, MAX_FINDINGS_PER_FILE } from "./types.js";
3
+ const OCTET = "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
4
+ const PRIVATE_IP = `(?:10\\.${OCTET}\\.${OCTET}\\.${OCTET}|192\\.168\\.${OCTET}\\.${OCTET}|172\\.(?:1[6-9]|2\\d|3[01])\\.${OCTET}\\.${OCTET})`;
3
5
  const PATTERNS = [
4
6
  {
7
+ // In a URL or with a port, a private IP is an address the code talks to.
5
8
  name: "Private IP address",
6
- pattern: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b/g,
9
+ pattern: new RegExp(`(?<=://|@)${PRIVATE_IP}(?![.\\d])|(?<![\\w.])${PRIVATE_IP}(?=:\\d{2,5}\\b)`, "g"),
7
10
  severity: "medium",
8
11
  confidence: "medium",
9
12
  remediation: "Replace with a public hostname resolved server-side, or remove the reference.",
10
13
  },
14
+ {
15
+ // A bare one is as often an example, a doc comment or a CIDR in a language grammar.
16
+ name: "Private IP literal",
17
+ pattern: new RegExp(`(?<![\\w.:@/])${PRIVATE_IP}(?![.\\d:])`, "g"),
18
+ severity: "low",
19
+ confidence: "low",
20
+ remediation: "If the client connects to this address, route it through a public endpoint instead.",
21
+ },
11
22
  {
12
23
  name: "Internal hostname",
13
24
  pattern: /\bhttps?:\/\/[a-z0-9.-]+\.(?:internal|local|corp|lan|intranet)(?::\d+)?\b/gi,
@@ -9,7 +9,8 @@ const PROVIDERS = [
9
9
  { name: "Slack token", pattern: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g, severity: "critical", confidence: "high", remediation: ROTATE },
10
10
  { name: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{32,}\b/g, severity: "critical", confidence: "high", remediation: ROTATE },
11
11
  { name: "OpenAI API key", pattern: /\bsk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{32,}\b/g, severity: "critical", confidence: "high", remediation: ROTATE },
12
- { name: "Private key block", pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/g, severity: "critical", confidence: "high", remediation: "Remove the key from the build and rotate it." },
12
+ // The header alone is how PEM-parsing code (jose, auth0-spa-js) checks its input; a real key has a base64 body.
13
+ { name: "Private key block", pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----(?:\\[rn]|\s)*[A-Za-z0-9+/=]{64,}/g, severity: "critical", confidence: "high", remediation: "Remove the key from the build and rotate it." },
13
14
  {
14
15
  name: "Google API key",
15
16
  pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
@@ -34,7 +35,9 @@ function isPublicName(name, context) {
34
35
  function looksLikeCredentialValue(value) {
35
36
  if (value.startsWith("--") || value.startsWith("./") || value.startsWith("/"))
36
37
  return false;
37
- if (/^[a-z0-9]+(?:[.\-_/][a-z0-9]+)+$/i.test(value) && !/[0-9]/.test(value))
38
+ // Dotted or dashed identifiers made of words ("auth0.lock.getTokenSilently", "spacing.large.default")
39
+ // are keys and paths. Each segment is letters with at most trailing digits; tokens mix digits inside.
40
+ if (/^[A-Za-z]+\d*(?:[.\-_/:][A-Za-z]+\d*)+$/.test(value))
38
41
  return false;
39
42
  const classes = [/[a-z]/, /[A-Z]/, /[0-9]/].filter((re) => re.test(value)).length;
40
43
  return classes >= 2 && shannonEntropy(value) >= MIN_NAMED_ENTROPY;
@@ -46,11 +49,12 @@ export const secretLikeString = {
46
49
  check(file, context) {
47
50
  const findings = [];
48
51
  const seen = new Set();
49
- const push = (finding, key) => {
52
+ const push = (finding, key, raw) => {
50
53
  if (seen.has(key) || findings.length >= MAX_FINDINGS_PER_FILE)
51
54
  return;
52
55
  seen.add(key);
53
56
  findings.push(finding);
57
+ context.onMatch?.(finding, raw);
54
58
  };
55
59
  for (const provider of PROVIDERS) {
56
60
  for (const match of file.text.matchAll(provider.pattern)) {
@@ -65,7 +69,7 @@ export const secretLikeString = {
65
69
  evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: redact(value) }],
66
70
  remediation: provider.remediation,
67
71
  helpUri: helpUri(secretLikeString.id),
68
- }, `${provider.name}:${value}`);
72
+ }, `${provider.name}:${value}`, value);
69
73
  }
70
74
  }
71
75
  for (const match of file.text.matchAll(JWT)) {
@@ -85,7 +89,7 @@ export const secretLikeString = {
85
89
  evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: redact(token) }],
86
90
  remediation: "Rotate the service role key in Supabase and use the anon key in the browser.",
87
91
  helpUri: helpUri(secretLikeString.id),
88
- }, `jwt:${token}`);
92
+ }, `jwt:${token}`, token);
89
93
  }
90
94
  else if (role === null) {
91
95
  // Not a Supabase anon/authenticated token; unknown JWTs in a bundle
@@ -99,7 +103,7 @@ export const secretLikeString = {
99
103
  evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: redact(token) }],
100
104
  remediation: "Confirm the token is not a live credential. Tokens belong in responses, not bundles.",
101
105
  helpUri: helpUri(secretLikeString.id),
102
- }, `jwt:${token}`);
106
+ }, `jwt:${token}`, token);
103
107
  }
104
108
  }
105
109
  for (const match of file.text.matchAll(NAMED_VALUE)) {
@@ -121,7 +125,7 @@ export const secretLikeString = {
121
125
  evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: `${name} = ${redact(value)}` }],
122
126
  remediation: "If this is a real credential, rotate it and move the call server-side. If it is public by design, rename it with your framework's public prefix.",
123
127
  helpUri: helpUri(secretLikeString.id),
124
- }, `named:${name}:${value}`);
128
+ }, `named:${name}:${value}`, value);
125
129
  }
126
130
  return findings;
127
131
  },
@@ -18,6 +18,9 @@ export function looksLikeSecretValue(value) {
18
18
  return false;
19
19
  if (/^[A-Z0-9_]+$/.test(value))
20
20
  return false;
21
+ // Protocol identifiers and URLs (urn:ietf:params:oauth:grant-type:…), and anything with spaces, are constants or prose.
22
+ if (/^(?:urn|https?|wss?|mailto|data):/i.test(value) || /\s/.test(value))
23
+ return false;
21
24
  const classes = [/[a-z]/, /[A-Z]/, /[0-9]/].filter((re) => re.test(value)).length;
22
25
  return classes >= 2;
23
26
  }
@@ -42,7 +45,7 @@ export const sensitiveEnvName = {
42
45
  if (seen.has(key))
43
46
  continue;
44
47
  seen.add(key);
45
- findings.push({
48
+ const finding = {
46
49
  ruleId: sensitiveEnvName.id,
47
50
  layer: "artifact",
48
51
  severity: "high",
@@ -51,7 +54,9 @@ export const sensitiveEnvName = {
51
54
  evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: `${name} = ${redact(value)}` }],
52
55
  remediation: "Server-only configuration must not be bundled. Read it on the server and expose only what the client needs.",
53
56
  helpUri: helpUri(sensitiveEnvName.id),
54
- });
57
+ };
58
+ findings.push(finding);
59
+ context.onMatch?.(finding, value);
55
60
  continue;
56
61
  }
57
62
  if (!BARE_NAME_KEYWORDS.has(keyword))
@@ -5,6 +5,12 @@ export interface RuleContext {
5
5
  adapter: Adapter;
6
6
  /** Every shipped file's relative path, for cross-file checks such as source map references. */
7
7
  shippedPaths: ReadonlySet<string>;
8
+ /**
9
+ * Called with the unredacted value behind a finding, for in-process
10
+ * cross-checks such as `evershield recon --rea`. The value must never be
11
+ * stored on the finding or written anywhere.
12
+ */
13
+ onMatch?: (finding: Finding, raw: string) => void;
8
14
  }
9
15
  /**
10
16
  * A rule inspects one file and returns findings with evidence. The doc
@@ -0,0 +1,22 @@
1
+ import type { ScannedFile } from "./files.ts";
2
+ export type ArchiveKind = "android" | "ios" | "electron";
3
+ export declare function archiveKind(file: string, head: Buffer): ArchiveKind | null;
4
+ /** Printable runs of at least `min` characters, ASCII/UTF-8 and UTF-16LE (Android string pools). */
5
+ export declare function extractStrings(buffer: Buffer, min?: number): string[];
6
+ /**
7
+ * Hermes keeps every string literal and identifier in one storage blob with
8
+ * no separators, so a plain strings pass glues neighbours together and a key
9
+ * loses its word boundaries. Read the string table instead. The header is
10
+ * 128 bytes; the counts sit at fixed offsets; the sections that follow are
11
+ * 4-byte aligned. Function headers are 12 bytes in current versions (99,
12
+ * Expo SDK 58) and 16 in older ones, so both layouts are tried and the one
13
+ * whose every entry fits inside the storage wins. Returns null when neither
14
+ * does, and the caller falls back to a plain strings pass.
15
+ */
16
+ export declare function hermesStrings(buffer: Buffer): string[] | null;
17
+ /** Turn one archive entry, or a loose file from an extracted app, into what rules read. */
18
+ export declare function toScanned(name: string, data: Buffer, location: string): ScannedFile | null;
19
+ export declare function collectArchive(file: string): Promise<{
20
+ kind: ArchiveKind;
21
+ files: ScannedFile[];
22
+ }>;
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Packaged apps as artifacts: Android (.apk, .aab), iOS (.ipa) and Electron
3
+ * (.asar). Everything inside ships to the user's device, so everything a
4
+ * rule can read is scanned. No dependencies: zip is parsed here and inflated
5
+ * with node:zlib; ASAR is a JSON header followed by file bytes.
6
+ *
7
+ * Text entries are decoded as UTF-8. Binary entries that carry the strings an
8
+ * attacker would read (Hermes bytecode, DEX, compiled Android XML and
9
+ * resources, binary plists, Mach-O executables) are reduced to their
10
+ * printable strings, one per line, so the same rules apply; a finding's
11
+ * line is then the string's index, and its path ends in " (strings)".
12
+ */
13
+ import { readFile, stat } from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { inflateRawSync } from "node:zlib";
16
+ const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024;
17
+ const MAX_ENTRY_BYTES = 100 * 1024 * 1024;
18
+ const TEXT_ENTRY = /\.(?:m?js|cjs|jsx|ts|tsx|json|html?|css|xml|plist|txt|svg|map|bundle|jsbundle|webmanifest|properties|strings)$/i;
19
+ /** Binary formats whose string tables hold what developers embed: keys, URLs, config. */
20
+ const STRINGS_ENTRY = /(?:\.(?:dex|hbc|arsc|bundle|jsbundle|plist)$|(?:^|\/)AndroidManifest\.xml$)/i;
21
+ const SKIP_ENTRY = /\.(?:png|jpe?g|gif|webp|ico|ttf|otf|woff2?|mp3|mp4|ogg|wav|so|dylib|car|nib|storyboardc|RSA|DSA|EC|SF|MF)$|(?:^|\/)(?:META-INF\/|_CodeSignature\/)/i;
22
+ /** Hermes bytecode magic, little-endian 0x1F1903C103BC1FC6. */
23
+ const HERMES_MAGIC = Buffer.from([0xc6, 0x1f, 0xbc, 0x03, 0xc1, 0x03, 0x19, 0x1f]);
24
+ export function archiveKind(file, head) {
25
+ const ext = path.extname(file).toLowerCase();
26
+ if (ext === ".asar")
27
+ return "electron";
28
+ if (head.length < 4 || head.readUInt32LE(0) !== 0x04034b50)
29
+ return null;
30
+ if (ext === ".ipa")
31
+ return "ios";
32
+ if (ext === ".apk" || ext === ".aab" || ext === ".xapk")
33
+ return "android";
34
+ return null;
35
+ }
36
+ function zipEntries(buffer) {
37
+ // End of central directory: last 22..65557 bytes.
38
+ let eocd = -1;
39
+ for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 65557); i -= 1) {
40
+ if (buffer.readUInt32LE(i) === 0x06054b50) {
41
+ eocd = i;
42
+ break;
43
+ }
44
+ }
45
+ if (eocd < 0)
46
+ throw new Error("not a zip archive (no end of central directory)");
47
+ const count = buffer.readUInt16LE(eocd + 10);
48
+ let offset = buffer.readUInt32LE(eocd + 16);
49
+ const entries = [];
50
+ for (let i = 0; i < count; i += 1) {
51
+ if (buffer.readUInt32LE(offset) !== 0x02014b50)
52
+ throw new Error("corrupt zip central directory");
53
+ const method = buffer.readUInt16LE(offset + 10);
54
+ const compressed = buffer.readUInt32LE(offset + 20);
55
+ const size = buffer.readUInt32LE(offset + 24);
56
+ const nameLength = buffer.readUInt16LE(offset + 28);
57
+ const extraLength = buffer.readUInt16LE(offset + 30);
58
+ const commentLength = buffer.readUInt16LE(offset + 32);
59
+ const local = buffer.readUInt32LE(offset + 42);
60
+ const name = buffer.toString("utf8", offset + 46, offset + 46 + nameLength);
61
+ offset += 46 + nameLength + extraLength + commentLength;
62
+ if (name.endsWith("/") || size > MAX_ENTRY_BYTES || compressed === 0xffffffff)
63
+ continue;
64
+ entries.push({
65
+ name,
66
+ read() {
67
+ const start = local + 30 + buffer.readUInt16LE(local + 26) + buffer.readUInt16LE(local + 28);
68
+ const data = buffer.subarray(start, start + compressed);
69
+ if (method === 0)
70
+ return data;
71
+ if (method === 8)
72
+ return inflateRawSync(data, { maxOutputLength: MAX_ENTRY_BYTES });
73
+ throw new Error(`unsupported zip compression method ${method} for ${name}`);
74
+ },
75
+ });
76
+ }
77
+ return entries;
78
+ }
79
+ function asarEntries(buffer) {
80
+ // Pickle: [u32 4][u32 headerPickleSize] then [u32 payload][u32 jsonLength][json…].
81
+ const headerSize = buffer.readUInt32LE(4);
82
+ const jsonLength = buffer.readUInt32LE(12);
83
+ const header = JSON.parse(buffer.toString("utf8", 16, 16 + jsonLength));
84
+ const base = 8 + headerSize;
85
+ const entries = [];
86
+ const walk = (node, prefix) => {
87
+ for (const [name, child] of Object.entries(node.files ?? {})) {
88
+ const full = prefix === "" ? name : `${prefix}/${name}`;
89
+ if (child.files !== undefined)
90
+ walk(child, full);
91
+ else if (child.offset !== undefined && child.size !== undefined && child.unpacked !== true && child.size <= MAX_ENTRY_BYTES) {
92
+ const start = base + Number(child.offset);
93
+ const size = child.size;
94
+ entries.push({ name: full, read: () => buffer.subarray(start, start + size) });
95
+ }
96
+ }
97
+ };
98
+ walk(header, "");
99
+ return entries;
100
+ }
101
+ /** Printable runs of at least `min` characters, ASCII/UTF-8 and UTF-16LE (Android string pools). */
102
+ export function extractStrings(buffer, min = 6) {
103
+ const out = [];
104
+ const seen = new Set();
105
+ const add = (value) => {
106
+ if (value.length >= min && !seen.has(value)) {
107
+ seen.add(value);
108
+ out.push(value);
109
+ }
110
+ };
111
+ let start = -1;
112
+ for (let i = 0; i <= buffer.length; i += 1) {
113
+ const byte = i < buffer.length ? buffer[i] : 0;
114
+ const printable = (byte >= 0x20 && byte < 0x7f) || byte === 0x09;
115
+ if (printable && start < 0)
116
+ start = i;
117
+ if (!printable && start >= 0) {
118
+ add(buffer.toString("latin1", start, i));
119
+ start = -1;
120
+ }
121
+ }
122
+ // UTF-16LE: printable ASCII byte followed by 0x00, at either byte alignment.
123
+ for (const alignment of [0, 1]) {
124
+ let run = "";
125
+ for (let i = alignment; i + 1 < buffer.length; i += 2) {
126
+ const lo = buffer[i];
127
+ if (buffer[i + 1] === 0 && lo >= 0x20 && lo < 0x7f)
128
+ run += String.fromCharCode(lo);
129
+ else {
130
+ add(run);
131
+ run = "";
132
+ }
133
+ }
134
+ add(run);
135
+ }
136
+ return out;
137
+ }
138
+ /**
139
+ * Hermes keeps every string literal and identifier in one storage blob with
140
+ * no separators, so a plain strings pass glues neighbours together and a key
141
+ * loses its word boundaries. Read the string table instead. The header is
142
+ * 128 bytes; the counts sit at fixed offsets; the sections that follow are
143
+ * 4-byte aligned. Function headers are 12 bytes in current versions (99,
144
+ * Expo SDK 58) and 16 in older ones, so both layouts are tried and the one
145
+ * whose every entry fits inside the storage wins. Returns null when neither
146
+ * does, and the caller falls back to a plain strings pass.
147
+ */
148
+ export function hermesStrings(buffer) {
149
+ if (buffer.length < 128)
150
+ return null;
151
+ const u32 = (offset) => buffer.readUInt32LE(offset);
152
+ const align = (value) => Math.ceil(value / 4) * 4;
153
+ const functionCount = u32(40);
154
+ const stringKindCount = u32(44);
155
+ const identifierCount = u32(48);
156
+ const stringCount = u32(52);
157
+ const overflowCount = u32(56);
158
+ const storageSize = u32(60);
159
+ for (const functionHeaderSize of [12, 16]) {
160
+ let offset = 128;
161
+ offset = align(offset + functionCount * functionHeaderSize);
162
+ offset = align(offset + stringKindCount * 4);
163
+ offset = align(offset + identifierCount * 4);
164
+ const small = offset;
165
+ offset = align(offset + stringCount * 4);
166
+ const overflow = offset;
167
+ offset = align(offset + overflowCount * 8);
168
+ const storage = offset;
169
+ if (storage + storageSize > buffer.length)
170
+ continue;
171
+ const strings = [];
172
+ let valid = true;
173
+ for (let i = 0; i < stringCount && valid; i += 1) {
174
+ const entry = u32(small + i * 4);
175
+ const utf16 = (entry & 1) === 1;
176
+ let start = (entry >>> 1) & 0x7fffff;
177
+ let length = entry >>> 24;
178
+ if (length === 255) {
179
+ if (start >= overflowCount) {
180
+ valid = false;
181
+ break;
182
+ }
183
+ const at = overflow + start * 8;
184
+ start = u32(at);
185
+ length = u32(at + 4);
186
+ }
187
+ const bytes = utf16 ? length * 2 : length;
188
+ if (start + bytes > storageSize)
189
+ valid = false;
190
+ else if (length > 0)
191
+ strings.push(buffer.toString(utf16 ? "utf16le" : "latin1", storage + start, storage + start + bytes));
192
+ }
193
+ if (valid)
194
+ return strings;
195
+ }
196
+ return null;
197
+ }
198
+ function looksBinary(buffer) {
199
+ const end = Math.min(buffer.length, 8192);
200
+ for (let i = 0; i < end; i += 1)
201
+ if (buffer[i] === 0)
202
+ return true;
203
+ return false;
204
+ }
205
+ /** Turn one archive entry, or a loose file from an extracted app, into what rules read. */
206
+ export function toScanned(name, data, location) {
207
+ if (data.length === 0 || SKIP_ENTRY.test(name))
208
+ return null;
209
+ const isHermes = data.length >= 8 && data.subarray(0, 8).equals(HERMES_MAGIC);
210
+ if (!isHermes && TEXT_ENTRY.test(name) && !looksBinary(data)) {
211
+ return { absolutePath: location, relativePath: name, text: data.toString("utf8") };
212
+ }
213
+ if (isHermes || STRINGS_ENTRY.test(name) || isMainExecutable(name, data)) {
214
+ const strings = (isHermes ? hermesStrings(data) : null) ?? extractStrings(data);
215
+ // One string per line; a multi-line literal stays readable but cannot merge with its neighbour.
216
+ return { absolutePath: location, relativePath: `${name} (strings)`, text: strings.map((s) => s.replace(/\n/g, " ")).join("\n") };
217
+ }
218
+ return null;
219
+ }
220
+ /** The iOS app executable: Payload/Name.app/Name, a Mach-O file. */
221
+ function isMainExecutable(name, data) {
222
+ const match = /^Payload\/([^/]+)\.app\/([^/]+)$/.exec(name);
223
+ if (match === null || match[1] !== match[2] || data.length < 4)
224
+ return false;
225
+ const magic = data.readUInt32LE(0);
226
+ return magic === 0xfeedfacf || magic === 0xfeedface || magic === 0xcafebabe || magic === 0xbebafeca;
227
+ }
228
+ export async function collectArchive(file) {
229
+ const info = await stat(file);
230
+ if (info.size > MAX_ARCHIVE_BYTES)
231
+ throw new Error(`${file} is larger than ${MAX_ARCHIVE_BYTES / 1024 / 1024} MiB`);
232
+ const buffer = await readFile(file);
233
+ const kind = archiveKind(file, buffer);
234
+ if (kind === null)
235
+ throw new Error(`not a supported app package (.apk, .aab, .ipa, .asar): ${file}`);
236
+ const entries = kind === "electron" ? asarEntries(buffer) : zipEntries(buffer);
237
+ const files = [];
238
+ for (const entry of entries) {
239
+ if (entry.name.split("/").includes("node_modules") && kind !== "electron")
240
+ continue;
241
+ const scanned = toScanned(entry.name, entry.read(), `${file}!/${entry.name}`);
242
+ if (scanned !== null)
243
+ files.push(scanned);
244
+ }
245
+ files.sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
246
+ return { kind, files };
247
+ }
@@ -1,18 +1,34 @@
1
1
  import { type Finding, type ScanSummary } from "../index.ts";
2
+ import type { RuleContext } from "../rules/types.ts";
2
3
  import { type Adapter } from "./adapters.ts";
4
+ import { type ArchiveKind } from "./archive.ts";
5
+ import { type ScannedFile } from "./files.ts";
3
6
  export interface ArtifactScanOptions {
4
7
  /** Force an adapter instead of detecting one from the directory. */
5
8
  adapter?: Adapter["name"];
9
+ /** See RuleContext.onMatch. */
10
+ onMatch?: RuleContext["onMatch"];
6
11
  }
7
12
  export interface ArtifactScanResult extends ScanSummary {
8
13
  layer: "artifact";
9
14
  target: string;
10
- adapter: Adapter["name"];
15
+ /** Directory adapter, or the kind of app package scanned. */
16
+ adapter: Adapter["name"] | ArchiveKind;
11
17
  filesScanned: number;
12
18
  /** Findings removed by config ignore rules; they are not in `findings`. */
13
19
  ignored: number;
14
20
  }
15
21
  /** Worst first, then surest first, then by location so output is stable. */
16
22
  export declare function sortFindings(findings: Finding[]): Finding[];
17
- /** Layer 1: scan a directory of built files for what should not ship. */
23
+ export interface CollectedTarget {
24
+ root: string;
25
+ adapter: Adapter;
26
+ /** Directory adapter, or the kind of app package. */
27
+ adapterName: ArtifactScanResult["adapter"];
28
+ files: ScannedFile[];
29
+ }
30
+ /** Read what a target ships: a build directory through its adapter, or an app package in memory. */
31
+ export declare function collectTarget(target: string, options?: Pick<ArtifactScanOptions, "adapter">): Promise<CollectedTarget>;
32
+ export declare function runArtifactRules(files: ScannedFile[], adapter: Adapter, onMatch?: RuleContext["onMatch"]): Finding[];
33
+ /** Layer 1: scan a directory of built files, or an app package, for what should not ship. */
18
34
  export declare function scanArtifact(target: string, options?: ArtifactScanOptions): Promise<ArtifactScanResult>;
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { VERSION } from "../index.js";
4
4
  import { ARTIFACT_RULES } from "../rules/index.js";
5
5
  import { ADAPTERS, detectAdapter } from "./adapters.js";
6
+ import { collectArchive } from "./archive.js";
6
7
  import { collectFiles } from "./files.js";
7
8
  const SEVERITY_RANK = {
8
9
  critical: 0,
@@ -34,35 +35,47 @@ export function sortFindings(findings) {
34
35
  return aLine - bLine;
35
36
  });
36
37
  }
37
- /** Layer 1: scan a directory of built files for what should not ship. */
38
- export async function scanArtifact(target, options = {}) {
39
- const startedAt = new Date().toISOString();
38
+ /** Read what a target ships: a build directory through its adapter, or an app package in memory. */
39
+ export async function collectTarget(target, options = {}) {
40
40
  const root = path.resolve(target);
41
41
  const info = await stat(root).catch(() => null);
42
- if (info === null || !info.isDirectory()) {
43
- throw new Error(`not a directory: ${target}`);
42
+ if (info === null || (!info.isDirectory() && !info.isFile())) {
43
+ throw new Error(`not a directory or app package: ${target}`);
44
+ }
45
+ if (info.isFile()) {
46
+ // An app package ships everything inside it; there is nothing for a web adapter to filter.
47
+ const archive = await collectArchive(root);
48
+ return { root, adapter: ADAPTERS.plain, adapterName: archive.kind, files: archive.files };
44
49
  }
45
50
  const adapter = options.adapter ? ADAPTERS[options.adapter] : await detectAdapter(root);
46
- const files = await collectFiles(root, adapter);
51
+ return { root, adapter, adapterName: adapter.name, files: await collectFiles(root, adapter) };
52
+ }
53
+ export function runArtifactRules(files, adapter, onMatch) {
47
54
  const context = {
48
55
  adapter,
49
56
  shippedPaths: new Set(files.map((file) => file.relativePath)),
57
+ ...(onMatch === undefined ? {} : { onMatch }),
50
58
  };
51
59
  const findings = [];
52
60
  for (const file of files) {
53
- for (const rule of ARTIFACT_RULES) {
61
+ for (const rule of ARTIFACT_RULES)
54
62
  findings.push(...rule.check(file, context));
55
- }
56
63
  }
64
+ return sortFindings(findings);
65
+ }
66
+ /** Layer 1: scan a directory of built files, or an app package, for what should not ship. */
67
+ export async function scanArtifact(target, options = {}) {
68
+ const startedAt = new Date().toISOString();
69
+ const collected = await collectTarget(target, options);
57
70
  return {
58
71
  version: VERSION,
59
72
  layer: "artifact",
60
- target: root,
61
- adapter: adapter.name,
62
- filesScanned: files.length,
73
+ target: collected.root,
74
+ adapter: collected.adapterName,
75
+ filesScanned: collected.files.length,
63
76
  ignored: 0,
64
77
  startedAt,
65
78
  finishedAt: new Date().toISOString(),
66
- findings: sortFindings(findings),
79
+ findings: runArtifactRules(collected.files, collected.adapter, options.onMatch),
67
80
  };
68
81
  }
@@ -11,6 +11,7 @@ export interface CollectOptions {
11
11
  /** Files larger than this are skipped; bundles rarely exceed it and maps can be huge. */
12
12
  maxBytes?: number;
13
13
  }
14
+ export declare const PACKAGED: RegExp;
14
15
  export declare function toPosix(relative: string): string;
15
16
  /** Walk the root, keep what the adapter says the browser receives, read as UTF-8. */
16
17
  export declare function collectFiles(root: string, adapter: Adapter, options?: CollectOptions): Promise<ScannedFile[]>;
@@ -1,7 +1,9 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { toScanned } from "./archive.js";
3
4
  const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
4
5
  const ALWAYS_SKIP_DIRS = new Set(["node_modules", ".git"]);
6
+ export const PACKAGED = /\.(?:bundle|jsbundle|hbc|dex)$/i;
5
7
  /** Cheap binary check: a NUL byte in the first 8 KiB means not text. */
6
8
  function isProbablyBinary(buffer) {
7
9
  const end = Math.min(buffer.length, 8192);
@@ -26,6 +28,16 @@ export async function collectFiles(root, adapter, options = {}) {
26
28
  const relativePath = toPosix(path.relative(root, absolutePath));
27
29
  if (relativePath.split("/").some((segment) => ALWAYS_SKIP_DIRS.has(segment)))
28
30
  continue;
31
+ // React Native bundles and Android bytecode in an extracted app or a native build directory.
32
+ if (PACKAGED.test(relativePath)) {
33
+ const info = await stat(absolutePath);
34
+ if (info.size === 0 || info.size > maxBytes)
35
+ continue;
36
+ const scanned = toScanned(relativePath, await readFile(absolutePath), absolutePath);
37
+ if (scanned !== null)
38
+ files.push(scanned);
39
+ continue;
40
+ }
29
41
  if (!adapter.includes(relativePath))
30
42
  continue;
31
43
  const info = await stat(absolutePath);
@@ -115,8 +115,12 @@ export const hiddenHigherTierContent = {
115
115
  return findings;
116
116
  },
117
117
  };
118
- /** Text that is obviously a decoy: masks, lorem ipsum, a single repeated character. */
119
- const PLACEHOLDER = /^(?:[\sxX*•●▒█#?·._,\-–—0]+|lorem ipsum.*|(.)\1+)$/i;
118
+ /**
119
+ * Text that is obviously a decoy: masks, lorem ipsum, a single repeated
120
+ * character, or a label next to a mask ("Burstiness score ●●●●"), which is
121
+ * the fix this rule recommends.
122
+ */
123
+ const PLACEHOLDER = /^(?:[\sxX*•●▒█#?·._,\-–—0]+|lorem ipsum.*|(.)\1+)$|[•●▒█*#]{3,}|\b[xX]{4,}\b/i;
120
124
  export const obscuredContent = {
121
125
  id: "surface/obscured-content",
122
126
  defaultSeverity: "high",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evershield",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Shield everything, everywhere: find what your web app exposes to the browser, in bundles, API responses and the rendered page",
5
5
  "license": "MIT",
6
6
  "author": "Lee Dongwook (@inviy)",
@@ -49,10 +49,11 @@
49
49
  "packageManager": "pnpm@10.28.0",
50
50
  "scripts": {
51
51
  "build": "tsc -p tsconfig.build.json",
52
- "typecheck": "tsc -p tsconfig.json --noEmit",
52
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.scripts.json",
53
53
  "test": "node --experimental-strip-types --test \"src/**/*.test.ts\"",
54
54
  "check": "pnpm typecheck && pnpm test && pnpm build",
55
- "prepublishOnly": "pnpm check"
55
+ "prepublishOnly": "pnpm check",
56
+ "corpus": "node --experimental-strip-types scripts/corpus.ts"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@types/node": "^22.15.0",