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 +118 -5
- package/dist/cli.js +113 -4
- package/dist/config.d.ts +3 -0
- package/dist/config.js +121 -18
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/init.d.ts +21 -0
- package/dist/init.js +136 -0
- package/dist/recon.d.ts +53 -0
- package/dist/recon.js +160 -0
- package/dist/report/index.d.ts +2 -1
- package/dist/report/index.js +13 -3
- package/dist/report/recon.d.ts +5 -0
- package/dist/report/recon.js +71 -0
- package/dist/report/sarif.js +16 -2
- 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/collect.d.ts +13 -0
- package/dist/surface/collect.js +130 -0
- package/dist/surface/driver.d.ts +17 -0
- package/dist/surface/driver.js +114 -0
- package/dist/surface/rules.d.ts +32 -0
- package/dist/surface/rules.js +154 -0
- package/dist/surface/run.d.ts +23 -0
- package/dist/surface/run.js +80 -0
- package/dist/surface/types.d.ts +90 -0
- package/dist/surface/types.js +10 -0
- package/package.json +22 -5
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.
|
|
11
|
-
>
|
|
12
|
-
>
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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.
|
|
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
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>;
|