evershield 0.1.0 → 0.2.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
 
@@ -96,9 +95,50 @@ names mark them as a higher tier's (`premium_*`, `pro_only`, `internal_*`),
96
95
  credential-like fields in any response, and endpoints that answer every
97
96
  identity identically. The audit only ever talks to `baseUrl`.
98
97
 
98
+ ## Find paywalls that only hide
99
+
100
+ Layer 3 opens your pages in a headless browser as each identity and reports
101
+ content that is in the page but hidden: blurred, `display: none`, clipped,
102
+ or tucked into a `data-*` attribute, an inline script or `localStorage`.
103
+
104
+ ```json
105
+ {
106
+ "surface": {
107
+ "baseUrl": "https://staging.example.com",
108
+ "tiers": ["free", "pro"],
109
+ "identities": {
110
+ "free": { "storageState": "${EVERSHIELD_FREE_STATE}" },
111
+ "pro": { "storageState": "${EVERSHIELD_PRO_STATE}" }
112
+ },
113
+ "pages": [{ "path": "/report/demo" }]
114
+ }
115
+ }
116
+ ```
117
+
118
+ ```bash
119
+ npm install --save-dev playwright && npx playwright install chromium
120
+ EVERSHIELD_FREE_STATE=free.json EVERSHIELD_PRO_STATE=pro.json npx evershield surface
121
+ ```
122
+
123
+ The key rule compares tiers: text the Pro page shows that the Free page
124
+ also contains, hidden, is a cosmetic paywall. Tabs and menus hidden for
125
+ everyone never trigger it. See [the surface rules](docs/rules.md#layer-3-surface)
126
+ and [configuration](docs/configuration.md#surface-layer-3).
127
+
99
128
  ## In CI
100
129
 
101
- The GitHub Action scans your build and uploads the result to the Security
130
+ Fastest path: run this in your project root.
131
+
132
+ ```bash
133
+ npx evershield init
134
+ ```
135
+
136
+ It detects the framework (Next.js, Vite, Astro, SvelteKit, Nuxt, CRA), the
137
+ build directory and package manager, and writes `evershield.config.json`
138
+ and `.github/workflows/evershield.yml`. Existing files are left alone
139
+ unless you pass `--force`.
140
+
141
+ Or write the workflow yourself. The GitHub Action scans your build and uploads the result to the Security
102
142
  tab as code-scanning alerts:
103
143
 
104
144
  ```yaml
@@ -123,6 +163,18 @@ Inputs: `path` (required), `fail-on`, `min-confidence`, `config`, `adapter`,
123
163
  `sarif-file`, `upload-sarif`, `version`. The step fails when evershield
124
164
  exits 1 (blocking findings) or 2 (error), after the SARIF upload.
125
165
 
166
+ ## In Claude Code
167
+
168
+ ```
169
+ /plugin marketplace add inviy/evershield
170
+ /plugin install evershield@evershield
171
+ ```
172
+
173
+ The plugin adds an `evershield` skill (Claude checks the server-side
174
+ boundary whenever it touches response fields, plan branches, public env
175
+ vars or `public/`) and an `/evershield` command that builds and scans the
176
+ project.
177
+
126
178
  ## Allowlisting with a reason
127
179
 
128
180
  Known-safe matches go in `evershield.config.json`, each with a reason:
package/dist/cli.js CHANGED
@@ -3,6 +3,8 @@ 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 { runSurface } from "./surface/run.js";
6
8
  import { VERSION } from "./index.js";
7
9
  import { CONFIDENCES, DEFAULT_GATE, isBlocking, renderJson, renderText, SEVERITIES } from "./report/index.js";
8
10
  import { renderSarif } from "./report/sarif.js";
@@ -12,13 +14,18 @@ const HELP = `evershield ${VERSION}
12
14
  Shield everything, everywhere.
13
15
 
14
16
  Usage:
17
+ evershield init [--force] Detect your framework and write evershield.config.json
18
+ and a GitHub workflow that scans every pull request
15
19
  evershield scan <dir> [options] Layer 1: scan a built web artifact directory
16
20
  evershield audit [options] Layer 2: call configured endpoints as each identity
17
21
  and compare what they receive (needs config "audit")
22
+ evershield surface [options] Layer 3: open configured pages as each identity in a
23
+ headless browser and find content that is present but
24
+ hidden (needs config "surface" and Playwright)
18
25
  evershield --version Print version
19
26
  evershield --help Show this help
20
27
 
21
- Options (both commands):
28
+ Options (scan, audit, surface):
22
29
  --format <text|json|sarif> Output format (default: text)
23
30
  --output <file> Write the report to a file; stdout gets a one-line summary
24
31
  --config <file> Config file (default: ./evershield.config.json if present)
@@ -116,7 +123,32 @@ export async function run(argv) {
116
123
  if (command === "--version" || command === "-v" || command === "version") {
117
124
  return { code: 0, stdout: `${VERSION}\n`, stderr: "" };
118
125
  }
119
- if (command !== "scan" && command !== "audit") {
126
+ if (command === "init") {
127
+ const unknown = rest.filter((arg) => arg !== "--force");
128
+ if (unknown.length > 0)
129
+ return usageError(`init: unexpected argument "${unknown[0]}"`);
130
+ try {
131
+ const { detection, written, skipped } = await runInit(process.cwd(), { force: rest.includes("--force") });
132
+ const lines = [
133
+ `evershield init · detected ${detection.framework} · build output ${detection.buildDir} · ${detection.packageManager}`,
134
+ ...written.map((file) => ` created ${file}`),
135
+ ...skipped.map((file) => ` skipped ${file} (exists; use --force to overwrite)`),
136
+ "",
137
+ "Next:",
138
+ ` 1. Build, then try it locally: npx evershield scan ${detection.buildDir}`,
139
+ " 2. Commit the workflow; every pull request is scanned and findings appear in the Security tab.",
140
+ " 3. Known-safe matches go in evershield.config.json under \"ignore\", each with a reason.",
141
+ ];
142
+ if (detection.framework === "unknown") {
143
+ lines.push("", `Could not detect a framework; edit the build step and path in .github/workflows/evershield.yml.`);
144
+ }
145
+ return { code: 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
146
+ }
147
+ catch (error) {
148
+ return failure("init", error);
149
+ }
150
+ }
151
+ if (command !== "scan" && command !== "audit" && command !== "surface") {
120
152
  return usageError(`unknown command "${command}"`);
121
153
  }
122
154
  const options = parseCommonOptions(rest);
@@ -138,7 +170,27 @@ export async function run(argv) {
138
170
  }
139
171
  }
140
172
  if (options.positionals.length > 0)
141
- return usageError(`audit: unexpected argument "${options.positionals[0]}"`);
173
+ return usageError(`${command}: unexpected argument "${options.positionals[0]}"`);
174
+ if (command === "surface") {
175
+ try {
176
+ const { config, source } = await loadConfig(options.configPath);
177
+ if (config.surface === undefined) {
178
+ throw new ConfigError(source === null
179
+ ? "surface needs a config file with a \"surface\" block (see docs/configuration.md)"
180
+ : `${source} has no "surface" block (see docs/configuration.md)`);
181
+ }
182
+ const gate = gateFor(options, config);
183
+ const surfaced = await runSurface(config.surface);
184
+ if (surfaced.snapshotsEvaluated === 0) {
185
+ throw new Error(`no page could be evaluated, so nothing was audited:\n ${surfaced.warnings.join("\n ")}`);
186
+ }
187
+ const { kept, ignored } = applyIgnores(surfaced.findings, config.ignore);
188
+ return await finish({ ...surfaced, findings: kept, ignored: ignored.length }, gate, options);
189
+ }
190
+ catch (error) {
191
+ return failure("surface", error);
192
+ }
193
+ }
142
194
  try {
143
195
  const { config, source } = await loadConfig(options.configPath);
144
196
  if (config.audit === undefined) {
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.2.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.2.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>;
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
+ }
@@ -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
  }
@@ -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,