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/README.md CHANGED
@@ -7,10 +7,9 @@
7
7
  **Shield everything, everywhere.** Find what your web app exposes to the
8
8
  browser before attackers do.
9
9
 
10
- > Status: pre-release. Layer 1 (built-artifact scan) and layer 2
11
- > (privilege-differential response audit) work today. Layer 3 is next;
12
- > follow the [roadmap](docs/design.md#roadmap) or open an issue to help
13
- > shape it.
10
+ > Status: pre-release. All three layers work: built-artifact scan,
11
+ > privilege-differential response audit, and rendered-surface audit. Follow
12
+ > the [roadmap](docs/design.md#roadmap) or open an issue to help shape it.
14
13
 
15
14
  ## Why
16
15
 
@@ -43,6 +42,12 @@ npx evershield scan ./dist --format json --fail-on medium --min-confidence low
43
42
 
44
43
  # SARIF for code scanning, written to a file
45
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)
46
51
  ```
47
52
 
48
53
  Exit code `0` means nothing blocking, `1` means findings at or above the
@@ -58,6 +63,37 @@ confidence, is in [docs/rules.md](docs/rules.md):
58
63
  - `artifact/sensitive-env-name`: `SERVICE_ROLE`, `*_SECRET`, `DATABASE_URL`, …
59
64
  - `artifact/internal-endpoint`: private IPs, `.internal` hosts, dev fallbacks
60
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
+
61
97
  ## Audit what each plan actually receives
62
98
 
63
99
  Layer 2 calls your own API as each identity you configure and compares the
@@ -96,9 +132,50 @@ names mark them as a higher tier's (`premium_*`, `pro_only`, `internal_*`),
96
132
  credential-like fields in any response, and endpoints that answer every
97
133
  identity identically. The audit only ever talks to `baseUrl`.
98
134
 
135
+ ## Find paywalls that only hide
136
+
137
+ Layer 3 opens your pages in a headless browser as each identity and reports
138
+ content that is in the page but hidden: blurred, `display: none`, clipped,
139
+ or tucked into a `data-*` attribute, an inline script or `localStorage`.
140
+
141
+ ```json
142
+ {
143
+ "surface": {
144
+ "baseUrl": "https://staging.example.com",
145
+ "tiers": ["free", "pro"],
146
+ "identities": {
147
+ "free": { "storageState": "${EVERSHIELD_FREE_STATE}" },
148
+ "pro": { "storageState": "${EVERSHIELD_PRO_STATE}" }
149
+ },
150
+ "pages": [{ "path": "/report/demo" }]
151
+ }
152
+ }
153
+ ```
154
+
155
+ ```bash
156
+ npm install --save-dev playwright && npx playwright install chromium
157
+ EVERSHIELD_FREE_STATE=free.json EVERSHIELD_PRO_STATE=pro.json npx evershield surface
158
+ ```
159
+
160
+ The key rule compares tiers: text the Pro page shows that the Free page
161
+ also contains, hidden, is a cosmetic paywall. Tabs and menus hidden for
162
+ everyone never trigger it. See [the surface rules](docs/rules.md#layer-3-surface)
163
+ and [configuration](docs/configuration.md#surface-layer-3).
164
+
99
165
  ## In CI
100
166
 
101
- The GitHub Action scans your build and uploads the result to the Security
167
+ Fastest path: run this in your project root.
168
+
169
+ ```bash
170
+ npx evershield init
171
+ ```
172
+
173
+ It detects the framework (Next.js, Vite, Astro, SvelteKit, Nuxt, CRA), the
174
+ build directory and package manager, and writes `evershield.config.json`
175
+ and `.github/workflows/evershield.yml`. Existing files are left alone
176
+ unless you pass `--force`.
177
+
178
+ Or write the workflow yourself. The GitHub Action scans your build and uploads the result to the Security
102
179
  tab as code-scanning alerts:
103
180
 
104
181
  ```yaml
@@ -123,6 +200,18 @@ Inputs: `path` (required), `fail-on`, `min-confidence`, `config`, `adapter`,
123
200
  `sarif-file`, `upload-sarif`, `version`. The step fails when evershield
124
201
  exits 1 (blocking findings) or 2 (error), after the SARIF upload.
125
202
 
203
+ ## In Claude Code
204
+
205
+ ```
206
+ /plugin marketplace add inviy/evershield
207
+ /plugin install evershield@evershield
208
+ ```
209
+
210
+ The plugin adds an `evershield` skill (Claude checks the server-side
211
+ boundary whenever it touches response fields, plan branches, public env
212
+ vars or `public/`) and an `/evershield` command that builds and scans the
213
+ project.
214
+
126
215
  ## Allowlisting with a reason
127
216
 
128
217
  Known-safe matches go in `evershield.config.json`, each with a reason:
@@ -143,6 +232,30 @@ evershield refuses a config whose ignores have no `reason`, and reports how
143
232
  many findings the config removed. Details in
144
233
  [docs/configuration.md](docs/configuration.md).
145
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
+
146
259
  ## Principles
147
260
 
148
261
  - **Defensive only.** It audits targets you own, listed in your config.
package/dist/cli.js CHANGED
@@ -3,6 +3,10 @@ import path from "node:path";
3
3
  import { parseArgs } from "node:util";
4
4
  import { runAudit } from "./audit/run.js";
5
5
  import { applyIgnores, ConfigError, loadConfig } from "./config.js";
6
+ import { runInit } from "./init.js";
7
+ import { runRecon } from "./recon.js";
8
+ import { renderReconJson, renderReconSarif, renderReconText } from "./report/recon.js";
9
+ import { runSurface } from "./surface/run.js";
6
10
  import { VERSION } from "./index.js";
7
11
  import { CONFIDENCES, DEFAULT_GATE, isBlocking, renderJson, renderText, SEVERITIES } from "./report/index.js";
8
12
  import { renderSarif } from "./report/sarif.js";
@@ -12,13 +16,25 @@ const HELP = `evershield ${VERSION}
12
16
  Shield everything, everywhere.
13
17
 
14
18
  Usage:
15
- evershield scan <dir> [options] Layer 1: scan a built web artifact directory
19
+ evershield init [--force] Detect your framework and write evershield.config.json
20
+ and a GitHub workflow that scans every pull request
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
16
29
  evershield audit [options] Layer 2: call configured endpoints as each identity
17
30
  and compare what they receive (needs config "audit")
31
+ evershield surface [options] Layer 3: open configured pages as each identity in a
32
+ headless browser and find content that is present but
33
+ hidden (needs config "surface" and Playwright)
18
34
  evershield --version Print version
19
35
  evershield --help Show this help
20
36
 
21
- Options (both commands):
37
+ Options (scan, audit, surface):
22
38
  --format <text|json|sarif> Output format (default: text)
23
39
  --output <file> Write the report to a file; stdout gets a one-line summary
24
40
  --config <file> Config file (default: ./evershield.config.json if present)
@@ -116,7 +132,34 @@ export async function run(argv) {
116
132
  if (command === "--version" || command === "-v" || command === "version") {
117
133
  return { code: 0, stdout: `${VERSION}\n`, stderr: "" };
118
134
  }
119
- if (command !== "scan" && command !== "audit") {
135
+ if (command === "init") {
136
+ const unknown = rest.filter((arg) => arg !== "--force");
137
+ if (unknown.length > 0)
138
+ return usageError(`init: unexpected argument "${unknown[0]}"`);
139
+ try {
140
+ const { detection, written, skipped } = await runInit(process.cwd(), { force: rest.includes("--force") });
141
+ const lines = [
142
+ `evershield init · detected ${detection.framework} · build output ${detection.buildDir} · ${detection.packageManager}`,
143
+ ...written.map((file) => ` created ${file}`),
144
+ ...skipped.map((file) => ` skipped ${file} (exists; use --force to overwrite)`),
145
+ "",
146
+ "Next:",
147
+ ` 1. Build, then try it locally: npx evershield scan ${detection.buildDir}`,
148
+ " 2. Commit the workflow; every pull request is scanned and findings appear in the Security tab.",
149
+ " 3. Known-safe matches go in evershield.config.json under \"ignore\", each with a reason.",
150
+ ];
151
+ if (detection.framework === "unknown") {
152
+ lines.push("", `Could not detect a framework; edit the build step and path in .github/workflows/evershield.yml.`);
153
+ }
154
+ return { code: 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
155
+ }
156
+ catch (error) {
157
+ return failure("init", error);
158
+ }
159
+ }
160
+ if (command === "recon")
161
+ return reconCommand(rest);
162
+ if (command !== "scan" && command !== "audit" && command !== "surface") {
120
163
  return usageError(`unknown command "${command}"`);
121
164
  }
122
165
  const options = parseCommonOptions(rest);
@@ -138,7 +181,27 @@ export async function run(argv) {
138
181
  }
139
182
  }
140
183
  if (options.positionals.length > 0)
141
- return usageError(`audit: unexpected argument "${options.positionals[0]}"`);
184
+ return usageError(`${command}: unexpected argument "${options.positionals[0]}"`);
185
+ if (command === "surface") {
186
+ try {
187
+ const { config, source } = await loadConfig(options.configPath);
188
+ if (config.surface === undefined) {
189
+ throw new ConfigError(source === null
190
+ ? "surface needs a config file with a \"surface\" block (see docs/configuration.md)"
191
+ : `${source} has no "surface" block (see docs/configuration.md)`);
192
+ }
193
+ const gate = gateFor(options, config);
194
+ const surfaced = await runSurface(config.surface);
195
+ if (surfaced.snapshotsEvaluated === 0) {
196
+ throw new Error(`no page could be evaluated, so nothing was audited:\n ${surfaced.warnings.join("\n ")}`);
197
+ }
198
+ const { kept, ignored } = applyIgnores(surfaced.findings, config.ignore);
199
+ return await finish({ ...surfaced, findings: kept, ignored: ignored.length }, gate, options);
200
+ }
201
+ catch (error) {
202
+ return failure("surface", error);
203
+ }
204
+ }
142
205
  try {
143
206
  const { config, source } = await loadConfig(options.configPath);
144
207
  if (config.audit === undefined) {
@@ -159,3 +222,49 @@ export async function run(argv) {
159
222
  return failure("audit", error);
160
223
  }
161
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/config.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AuditConfig } from "./audit/types.ts";
2
2
  import type { Finding } from "./index.ts";
3
+ import type { SurfaceConfig } from "./surface/types.ts";
3
4
  import { type Gate } from "./report/index.ts";
4
5
  export interface IgnoreRule {
5
6
  /** Rule id to ignore, or "*" for any rule. */
@@ -16,6 +17,8 @@ export interface Config {
16
17
  ignore: IgnoreRule[];
17
18
  /** Layer 2 settings; `evershield audit` requires this block. */
18
19
  audit?: AuditConfig;
20
+ /** Layer 3 settings; `evershield surface` requires this block. */
21
+ surface?: SurfaceConfig;
19
22
  }
20
23
  export declare const DEFAULT_CONFIG_FILE = "evershield.config.json";
21
24
  export declare class ConfigError extends Error {
package/dist/config.js CHANGED
@@ -72,6 +72,8 @@ export function parseConfig(raw, source) {
72
72
  }
73
73
  if (raw.audit !== undefined)
74
74
  config.audit = parseAuditConfig(raw.audit, `${source}: audit`);
75
+ if (raw.surface !== undefined)
76
+ config.surface = parseSurfaceConfig(raw.surface, `${source}: surface`);
75
77
  return config;
76
78
  }
77
79
  const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
@@ -90,9 +92,7 @@ function parseHeaders(raw, where) {
90
92
  }
91
93
  return headers;
92
94
  }
93
- function parseAuditConfig(raw, where) {
94
- if (!isRecord(raw))
95
- throw new ConfigError(`${where} must be an object`);
95
+ function parseBaseUrl(raw, where) {
96
96
  if (typeof raw.baseUrl !== "string")
97
97
  throw new ConfigError(`${where}.baseUrl is required`);
98
98
  let base;
@@ -104,6 +104,30 @@ function parseAuditConfig(raw, where) {
104
104
  }
105
105
  if (base.protocol !== "http:" && base.protocol !== "https:")
106
106
  throw new ConfigError(`${where}.baseUrl must be http or https`);
107
+ return base;
108
+ }
109
+ function parseTiers(raw, where, identities) {
110
+ if (!Array.isArray(raw.tiers) || raw.tiers.length === 0 || !raw.tiers.every((tier) => typeof tier === "string")) {
111
+ throw new ConfigError(`${where}.tiers must list identity names from least to most privileged`);
112
+ }
113
+ const tiers = raw.tiers;
114
+ for (const tier of tiers) {
115
+ if (identities[tier] === undefined)
116
+ throw new ConfigError(`${where}.tiers names "${tier}" which is not in identities`);
117
+ }
118
+ return tiers;
119
+ }
120
+ function parseTimeout(raw, where) {
121
+ if (raw.timeoutMs === undefined)
122
+ return undefined;
123
+ if (typeof raw.timeoutMs !== "number" || raw.timeoutMs <= 0)
124
+ throw new ConfigError(`${where}.timeoutMs must be a positive number`);
125
+ return raw.timeoutMs;
126
+ }
127
+ function parseAuditConfig(raw, where) {
128
+ if (!isRecord(raw))
129
+ throw new ConfigError(`${where} must be an object`);
130
+ const base = parseBaseUrl(raw, where);
107
131
  if (!isRecord(raw.identities) || Object.keys(raw.identities).length === 0) {
108
132
  throw new ConfigError(`${where}.identities must define at least one identity`);
109
133
  }
@@ -116,14 +140,7 @@ function parseAuditConfig(raw, where) {
116
140
  identity.headers = parseHeaders(value.headers, `${where}.identities.${name}.headers`);
117
141
  identities[name] = identity;
118
142
  }
119
- if (!Array.isArray(raw.tiers) || raw.tiers.length === 0 || !raw.tiers.every((tier) => typeof tier === "string")) {
120
- throw new ConfigError(`${where}.tiers must list identity names from least to most privileged`);
121
- }
122
- const tiers = raw.tiers;
123
- for (const tier of tiers) {
124
- if (identities[tier] === undefined)
125
- throw new ConfigError(`${where}.tiers names "${tier}" which is not in identities`);
126
- }
143
+ const tiers = parseTiers(raw, where, identities);
127
144
  if (!Array.isArray(raw.endpoints) || raw.endpoints.length === 0)
128
145
  throw new ConfigError(`${where}.endpoints must list at least one endpoint`);
129
146
  const endpoints = raw.endpoints.map((entry, index) => {
@@ -164,13 +181,98 @@ function parseAuditConfig(raw, where) {
164
181
  return endpoint;
165
182
  });
166
183
  const audit = { baseUrl: base.origin, tiers, identities, endpoints };
167
- if (raw.timeoutMs !== undefined) {
168
- if (typeof raw.timeoutMs !== "number" || raw.timeoutMs <= 0)
169
- throw new ConfigError(`${where}.timeoutMs must be a positive number`);
170
- audit.timeoutMs = raw.timeoutMs;
171
- }
184
+ const timeoutMs = parseTimeout(raw, where);
185
+ if (timeoutMs !== undefined)
186
+ audit.timeoutMs = timeoutMs;
172
187
  return audit;
173
188
  }
189
+ const ENV_REF_PRESENT = /\$\{[A-Z_][A-Z0-9_]*\}/;
190
+ function parseStringList(value, where) {
191
+ if (!(Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0))) {
192
+ throw new ConfigError(`${where} must be an array of non-empty strings`);
193
+ }
194
+ return value;
195
+ }
196
+ function parseSurfaceConfig(raw, where) {
197
+ if (!isRecord(raw))
198
+ throw new ConfigError(`${where} must be an object`);
199
+ const base = parseBaseUrl(raw, where);
200
+ if (!isRecord(raw.identities) || Object.keys(raw.identities).length === 0) {
201
+ throw new ConfigError(`${where}.identities must define at least one identity`);
202
+ }
203
+ const identities = {};
204
+ for (const [name, value] of Object.entries(raw.identities)) {
205
+ const at = `${where}.identities.${name}`;
206
+ if (!isRecord(value))
207
+ throw new ConfigError(`${at} must be an object`);
208
+ const identity = {};
209
+ if (value.headers !== undefined)
210
+ identity.headers = parseHeaders(value.headers, `${at}.headers`);
211
+ if (value.cookies !== undefined) {
212
+ if (!isRecord(value.cookies))
213
+ throw new ConfigError(`${at}.cookies must be an object of cookie name → value`);
214
+ identity.cookies = {};
215
+ for (const [cookie, cookieValue] of Object.entries(value.cookies)) {
216
+ if (typeof cookieValue !== "string" || !ENV_REF_PRESENT.test(cookieValue)) {
217
+ throw new ConfigError(`${at}.cookies.${cookie} must reference an environment variable such as "\${EVERSHIELD_PRO_SESSION}"; literal credentials are not allowed in config`);
218
+ }
219
+ identity.cookies[cookie] = cookieValue;
220
+ }
221
+ }
222
+ if (value.storageState !== undefined) {
223
+ if (typeof value.storageState !== "string" || value.storageState.length === 0)
224
+ throw new ConfigError(`${at}.storageState must be a file path`);
225
+ identity.storageState = value.storageState;
226
+ }
227
+ identities[name] = identity;
228
+ }
229
+ const tiers = parseTiers(raw, where, identities);
230
+ if (!Array.isArray(raw.pages) || raw.pages.length === 0)
231
+ throw new ConfigError(`${where}.pages must list at least one page`);
232
+ const pages = raw.pages.map((entry, index) => {
233
+ const at = `${where}.pages[${index}]`;
234
+ if (!isRecord(entry))
235
+ throw new ConfigError(`${at} must be an object`);
236
+ if (typeof entry.path !== "string" || !entry.path.startsWith("/"))
237
+ throw new ConfigError(`${at}.path must start with "/" (relative to baseUrl)`);
238
+ const page = { path: entry.path };
239
+ if (entry.waitFor !== undefined) {
240
+ if (typeof entry.waitFor !== "string" || entry.waitFor.length === 0)
241
+ throw new ConfigError(`${at}.waitFor must be a CSS selector`);
242
+ page.waitFor = entry.waitFor;
243
+ }
244
+ if (entry.expect !== undefined) {
245
+ if (!isRecord(entry.expect))
246
+ throw new ConfigError(`${at}.expect must be an object keyed by identity`);
247
+ page.expect = {};
248
+ for (const [identity, expectation] of Object.entries(entry.expect)) {
249
+ if (identities[identity] === undefined)
250
+ throw new ConfigError(`${at}.expect.${identity} is not a defined identity`);
251
+ if (!isRecord(expectation))
252
+ throw new ConfigError(`${at}.expect.${identity} must be an object`);
253
+ const parsed = {};
254
+ if (expectation.absentText !== undefined)
255
+ parsed.absentText = parseStringList(expectation.absentText, `${at}.expect.${identity}.absentText`);
256
+ if (expectation.absentSelectors !== undefined) {
257
+ parsed.absentSelectors = parseStringList(expectation.absentSelectors, `${at}.expect.${identity}.absentSelectors`);
258
+ }
259
+ page.expect[identity] = parsed;
260
+ }
261
+ }
262
+ return page;
263
+ });
264
+ const surface = { baseUrl: base.origin, tiers, identities, pages };
265
+ const timeoutMs = parseTimeout(raw, where);
266
+ if (timeoutMs !== undefined)
267
+ surface.timeoutMs = timeoutMs;
268
+ if (raw.minTextLength !== undefined) {
269
+ if (typeof raw.minTextLength !== "number" || !Number.isInteger(raw.minTextLength) || raw.minTextLength < 4) {
270
+ throw new ConfigError(`${where}.minTextLength must be an integer of at least 4`);
271
+ }
272
+ surface.minTextLength = raw.minTextLength;
273
+ }
274
+ return surface;
275
+ }
174
276
  /**
175
277
  * Load the config file. An explicit path must exist; the default file is
176
278
  * optional. Returns the source path alongside, for messages.
@@ -226,8 +328,9 @@ function matches(rule, finding) {
226
328
  const first = finding.evidence[0];
227
329
  if (rule.path !== undefined) {
228
330
  // For file evidence the glob matches the file path; for response
229
- // evidence it matches "METHOD /path" so one entry can cover an endpoint.
230
- const subject = first?.kind === "file" ? first.path : first?.kind === "response" ? first.endpoint : undefined;
331
+ // evidence it matches "METHOD /path" so one entry can cover an endpoint;
332
+ // DOM evidence matches the page path.
333
+ const subject = first?.kind === "file" ? first.path : first?.kind === "response" ? first.endpoint : first?.kind === "dom" ? new URL(first.url).pathname : undefined;
231
334
  if (subject === undefined || !globToRegExp(rule.path).test(subject))
232
335
  return false;
233
336
  }
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.1.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. */
@@ -29,6 +29,7 @@ export type Evidence = {
29
29
  kind: "dom";
30
30
  url: string;
31
31
  selector: string;
32
+ identity?: string;
32
33
  excerpt?: string;
33
34
  };
34
35
  export interface Finding {
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.1.0";
8
+ export const VERSION = "0.3.0";
package/dist/init.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ export interface Detection {
2
+ framework: "nextjs" | "vite" | "astro" | "create-react-app" | "nuxt" | "sveltekit" | "unknown";
3
+ /** Build output directory relative to the project root. */
4
+ buildDir: string;
5
+ packageManager: "pnpm" | "yarn" | "bun" | "npm";
6
+ /** Shell command that produces the build directory. */
7
+ buildCommand: string;
8
+ /** package.json pins a version in "packageManager"; pnpm/action-setup fails without it unless told one. */
9
+ packageManagerPinned?: boolean;
10
+ }
11
+ export declare function detectProject(root: string): Promise<Detection>;
12
+ export declare function renderWorkflow(detection: Detection): string;
13
+ export declare function renderConfig(): string;
14
+ export interface InitResult {
15
+ detection: Detection;
16
+ written: string[];
17
+ skipped: string[];
18
+ }
19
+ export declare function runInit(root: string, options?: {
20
+ force?: boolean;
21
+ }): Promise<InitResult>;