evershield 0.2.0 → 0.3.2
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 +61 -0
- package/dist/cli.js +58 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/recon.d.ts +53 -0
- package/dist/recon.js +160 -0
- package/dist/report/recon.d.ts +5 -0
- package/dist/report/recon.js +71 -0
- package/dist/rules/internal-endpoint.js +12 -1
- package/dist/rules/secret-like-string.js +11 -7
- package/dist/rules/sensitive-env-name.js +7 -2
- package/dist/rules/types.d.ts +6 -0
- package/dist/scan/archive.d.ts +22 -0
- package/dist/scan/archive.js +247 -0
- package/dist/scan/artifact.d.ts +18 -2
- package/dist/scan/artifact.js +25 -12
- package/dist/scan/files.d.ts +1 -0
- package/dist/scan/files.js +12 -0
- package/dist/surface/rules.js +6 -2
- package/package.json +4 -3
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]
|
|
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
|
|
8
|
+
export declare const VERSION = "0.3.2";
|
|
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
package/dist/recon.d.ts
ADDED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
package/dist/rules/types.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/scan/artifact.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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>;
|
package/dist/scan/artifact.js
CHANGED
|
@@ -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
|
-
/**
|
|
38
|
-
export async function
|
|
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
|
-
|
|
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:
|
|
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:
|
|
79
|
+
findings: runArtifactRules(collected.files, collected.adapter, options.onMatch),
|
|
67
80
|
};
|
|
68
81
|
}
|
package/dist/scan/files.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/scan/files.js
CHANGED
|
@@ -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);
|
package/dist/surface/rules.js
CHANGED
|
@@ -115,8 +115,12 @@ export const hiddenHigherTierContent = {
|
|
|
115
115
|
return findings;
|
|
116
116
|
},
|
|
117
117
|
};
|
|
118
|
-
/**
|
|
119
|
-
|
|
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
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|