evershield 0.1.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/dist/init.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `evershield init`: turn a one-off scan into a check that runs on every
3
+ * pull request. Detects the framework and build directory, then writes
4
+ * `evershield.config.json` and `.github/workflows/evershield.yml`.
5
+ *
6
+ * Existing files are never overwritten unless `--force` is given; init
7
+ * reports what it skipped instead.
8
+ */
9
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import path from "node:path";
11
+ async function exists(filePath) {
12
+ try {
13
+ await access(filePath);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ async function anyExists(root, names) {
21
+ for (const name of names)
22
+ if (await exists(path.join(root, name)))
23
+ return true;
24
+ return false;
25
+ }
26
+ export async function detectProject(root) {
27
+ let pkg = {};
28
+ try {
29
+ pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
30
+ }
31
+ catch {
32
+ // Not a Node project, or no package.json: fall through to defaults.
33
+ }
34
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
35
+ const has = (name) => deps[name] !== undefined;
36
+ let framework = "unknown";
37
+ let buildDir = "dist";
38
+ if (has("next") || (await anyExists(root, ["next.config.js", "next.config.mjs", "next.config.ts"]))) {
39
+ framework = "nextjs";
40
+ buildDir = ".next";
41
+ }
42
+ else if (has("nuxt")) {
43
+ framework = "nuxt";
44
+ buildDir = ".output/public";
45
+ }
46
+ else if (has("@sveltejs/kit")) {
47
+ framework = "sveltekit";
48
+ buildDir = "build";
49
+ }
50
+ else if (has("astro")) {
51
+ framework = "astro";
52
+ buildDir = "dist";
53
+ }
54
+ else if (has("react-scripts")) {
55
+ framework = "create-react-app";
56
+ buildDir = "build";
57
+ }
58
+ else if (has("vite")) {
59
+ framework = "vite";
60
+ buildDir = "dist";
61
+ }
62
+ let packageManager = "npm";
63
+ if (await exists(path.join(root, "pnpm-lock.yaml")))
64
+ packageManager = "pnpm";
65
+ else if (await exists(path.join(root, "yarn.lock")))
66
+ packageManager = "yarn";
67
+ else if (await anyExists(root, ["bun.lockb", "bun.lock"]))
68
+ packageManager = "bun";
69
+ const run = packageManager === "npm" ? "npm run" : packageManager;
70
+ const buildCommand = pkg.scripts?.build !== undefined ? `${run} build` : "echo 'set your build command here' && exit 1";
71
+ return { framework, buildDir, packageManager, buildCommand, packageManagerPinned: typeof pkg.packageManager === "string" };
72
+ }
73
+ const INSTALL = {
74
+ npm: "npm ci",
75
+ pnpm: "pnpm install --frozen-lockfile",
76
+ yarn: "yarn install --frozen-lockfile",
77
+ bun: "bun install --frozen-lockfile",
78
+ };
79
+ export function renderWorkflow(detection) {
80
+ const setup = detection.packageManager === "pnpm"
81
+ ? ` - uses: pnpm/action-setup@v4\n${detection.packageManagerPinned === true ? "" : " with:\n version: 10\n"} - uses: actions/setup-node@v4\n with:\n node-version: 22\n cache: pnpm\n`
82
+ : detection.packageManager === "bun"
83
+ ? " - uses: oven-sh/setup-bun@v2\n"
84
+ : ` - uses: actions/setup-node@v4\n with:\n node-version: 22\n cache: ${detection.packageManager}\n`;
85
+ return `name: evershield
86
+
87
+ # Scans what your app ships to the browser on every pull request and
88
+ # reports findings in the Security tab. Generated by \`evershield init\`.
89
+
90
+ on:
91
+ pull_request:
92
+ push:
93
+ branches: [main]
94
+
95
+ permissions:
96
+ contents: read
97
+ security-events: write
98
+
99
+ jobs:
100
+ scan:
101
+ runs-on: ubuntu-latest
102
+ steps:
103
+ - uses: actions/checkout@v4
104
+ ${setup} - run: ${INSTALL[detection.packageManager]}
105
+ - run: ${detection.buildCommand}
106
+ - uses: inviy/evershield@v0
107
+ with:
108
+ path: ${detection.buildDir}
109
+ `;
110
+ }
111
+ export function renderConfig() {
112
+ return `${JSON.stringify({
113
+ gate: { failOn: "high", minConfidence: "medium" },
114
+ ignore: [],
115
+ }, null, 2)}\n`;
116
+ }
117
+ export async function runInit(root, options = {}) {
118
+ const detection = await detectProject(root);
119
+ const files = [
120
+ ["evershield.config.json", renderConfig()],
121
+ [".github/workflows/evershield.yml", renderWorkflow(detection)],
122
+ ];
123
+ const written = [];
124
+ const skipped = [];
125
+ for (const [relative, content] of files) {
126
+ const target = path.join(root, relative);
127
+ if (!options.force && (await exists(target))) {
128
+ skipped.push(relative);
129
+ continue;
130
+ }
131
+ await mkdir(path.dirname(target), { recursive: true });
132
+ await writeFile(target, content);
133
+ written.push(relative);
134
+ }
135
+ return { detection, written, skipped };
136
+ }
@@ -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
+ }
@@ -1,9 +1,10 @@
1
1
  import type { AuditResult } from "../audit/run.ts";
2
2
  import type { Confidence, Finding, Severity } from "../index.ts";
3
3
  import type { ArtifactScanResult } from "../scan/artifact.ts";
4
+ import type { SurfaceResult } from "../surface/run.ts";
4
5
  export type Format = "text" | "json" | "sarif";
5
6
  /** Any layer's result. Renderers switch on `layer` for the header only. */
6
- export type Report = ArtifactScanResult | AuditResult;
7
+ export type Report = ArtifactScanResult | AuditResult | SurfaceResult;
7
8
  export declare const SEVERITIES: readonly Severity[];
8
9
  export declare const CONFIDENCES: readonly Confidence[];
9
10
  export interface Gate {
@@ -17,7 +17,7 @@ function location(finding) {
17
17
  }
18
18
  if (first.kind === "response")
19
19
  return `${first.endpoint} as ${first.identity} → ${first.jsonPath}`;
20
- return `${first.url} ${first.selector}`;
20
+ return `${first.url}${first.identity === undefined ? "" : ` as ${first.identity}`} → ${first.selector}`;
21
21
  }
22
22
  function header(result) {
23
23
  if (result.layer === "artifact") {
@@ -26,6 +26,12 @@ function header(result) {
26
26
  `target: ${result.target}`,
27
27
  ];
28
28
  }
29
+ if (result.layer === "surface") {
30
+ return [
31
+ `evershield ${result.version} · surface audit · ${result.pagesAudited} page(s) × ${result.identities.length} identities (${result.identities.join(" < ")}) · ${result.snapshotsEvaluated} page loads evaluated`,
32
+ `target: ${result.target}`,
33
+ ];
34
+ }
29
35
  return [
30
36
  `evershield ${result.version} · response audit · ${result.endpointsAudited} endpoint(s) × ${result.identities.length} identities (${result.identities.join(" < ")}) · ${result.responsesEvaluated} responses evaluated`,
31
37
  `target: ${result.target}`,
@@ -33,14 +39,18 @@ function header(result) {
33
39
  }
34
40
  export function renderText(result, gate) {
35
41
  const lines = [...header(result), ""];
36
- if (result.layer === "response" && result.warnings.length > 0) {
42
+ if (result.layer !== "artifact" && result.warnings.length > 0) {
37
43
  for (const warning of result.warnings)
38
44
  lines.push(`! ${warning}`);
39
45
  lines.push("");
40
46
  }
41
47
  const ignoredNote = result.ignored > 0 ? ` ${result.ignored} finding(s) ignored by config.` : "";
42
48
  if (result.findings.length === 0) {
43
- const what = result.layer === "artifact" ? "Nothing in the shipped files matched a rule." : "Every identity received only what it should.";
49
+ const what = result.layer === "artifact"
50
+ ? "Nothing in the shipped files matched a rule."
51
+ : result.layer === "surface"
52
+ ? "No identity's page held content it could not see."
53
+ : "Every identity received only what it should.";
44
54
  lines.push(`No findings. ${what}${ignoredNote}`);
45
55
  return `${lines.join("\n")}\n`;
46
56
  }
@@ -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
+ }
@@ -6,11 +6,12 @@
6
6
  * File paths are relative to the scanned directory, not the repository
7
7
  * root, because build output is usually not committed. Response evidence
8
8
  * has no file, so it is reported as a logical location (endpoint, identity
9
- * and JSON path).
9
+ * and JSON path); DOM evidence likewise (page, identity and selector).
10
10
  */
11
11
  import { RESPONSE_RULES } from "../audit/rules.js";
12
12
  import { VERSION } from "../index.js";
13
13
  import { ARTIFACT_RULES } from "../rules/index.js";
14
+ import { SURFACE_RULES } from "../surface/rules.js";
14
15
  import { helpUri } from "../rules/types.js";
15
16
  import { isBlocking } from "./index.js";
16
17
  function levelFor(severity) {
@@ -41,16 +42,29 @@ function locations(finding) {
41
42
  },
42
43
  ];
43
44
  }
44
- return [{ logicalLocations: [{ name: first.selector, fullyQualifiedName: `${first.url} ${first.selector}`, kind: "element" }] }];
45
+ const as = first.identity === undefined ? "" : ` as ${first.identity}`;
46
+ return [{ logicalLocations: [{ name: first.selector, fullyQualifiedName: `${first.url}${as} → ${first.selector}`, kind: "element" }] }];
45
47
  }
46
48
  const KNOWN_RULES = new Map([
47
49
  ...ARTIFACT_RULES.map((rule) => [rule.id, rule]),
48
50
  ...RESPONSE_RULES.map((rule) => [rule.id, rule]),
51
+ ...SURFACE_RULES.map((rule) => [rule.id, rule]),
49
52
  ]);
50
53
  function invocationProperties(result, gate) {
51
54
  if (result.layer === "artifact") {
52
55
  return { layer: result.layer, adapter: result.adapter, filesScanned: result.filesScanned, ignored: result.ignored, gate };
53
56
  }
57
+ if (result.layer === "surface") {
58
+ return {
59
+ layer: result.layer,
60
+ identities: result.identities,
61
+ pagesAudited: result.pagesAudited,
62
+ snapshotsEvaluated: result.snapshotsEvaluated,
63
+ warnings: result.warnings,
64
+ ignored: result.ignored,
65
+ gate,
66
+ };
67
+ }
54
68
  return {
55
69
  layer: result.layer,
56
70
  identities: result.identities,
@@ -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))