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 +57 -5
- package/dist/cli.js +55 -3
- 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/report/index.d.ts +2 -1
- package/dist/report/index.js +13 -3
- package/dist/report/sarif.js +16 -2
- 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 +150 -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 +19 -3
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
|
|
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
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
|
|
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.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
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
|
+
}
|
package/dist/report/index.d.ts
CHANGED
|
@@ -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 {
|
package/dist/report/index.js
CHANGED
|
@@ -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
|
|
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"
|
|
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
|
}
|
package/dist/report/sarif.js
CHANGED
|
@@ -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
|
-
|
|
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,
|