evershield 0.0.0-stage → 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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -2
  3. package/dist/audit/http.d.ts +25 -0
  4. package/dist/audit/http.js +98 -0
  5. package/dist/audit/json-paths.d.ts +20 -0
  6. package/dist/audit/json-paths.js +60 -0
  7. package/dist/audit/rules.d.ts +31 -0
  8. package/dist/audit/rules.js +158 -0
  9. package/dist/audit/run.d.ts +25 -0
  10. package/dist/audit/run.js +62 -0
  11. package/dist/audit/types.d.ts +57 -0
  12. package/dist/audit/types.js +10 -0
  13. package/dist/bin.d.ts +2 -0
  14. package/dist/bin.js +8 -0
  15. package/dist/cli.d.ts +12 -0
  16. package/dist/cli.js +213 -0
  17. package/dist/config.d.ts +42 -0
  18. package/dist/config.js +352 -0
  19. package/dist/index.d.ts +54 -0
  20. package/dist/index.js +8 -0
  21. package/dist/init.d.ts +21 -0
  22. package/dist/init.js +136 -0
  23. package/dist/report/index.d.ts +19 -0
  24. package/dist/report/index.js +82 -0
  25. package/dist/report/sarif.d.ts +2 -0
  26. package/dist/report/sarif.js +129 -0
  27. package/dist/rules/index.d.ts +4 -0
  28. package/dist/rules/index.js +11 -0
  29. package/dist/rules/internal-endpoint.d.ts +2 -0
  30. package/dist/rules/internal-endpoint.js +61 -0
  31. package/dist/rules/secret-like-string.d.ts +2 -0
  32. package/dist/rules/secret-like-string.js +128 -0
  33. package/dist/rules/sensitive-env-name.d.ts +4 -0
  34. package/dist/rules/sensitive-env-name.js +76 -0
  35. package/dist/rules/source-map-published.d.ts +2 -0
  36. package/dist/rules/source-map-published.js +39 -0
  37. package/dist/rules/types.d.ts +23 -0
  38. package/dist/rules/types.js +6 -0
  39. package/dist/scan/adapters.d.ts +24 -0
  40. package/dist/scan/adapters.js +65 -0
  41. package/dist/scan/artifact.d.ts +18 -0
  42. package/dist/scan/artifact.js +68 -0
  43. package/dist/scan/files.d.ts +16 -0
  44. package/dist/scan/files.js +41 -0
  45. package/dist/scan/text.d.ts +18 -0
  46. package/dist/scan/text.js +58 -0
  47. package/dist/surface/collect.d.ts +13 -0
  48. package/dist/surface/collect.js +130 -0
  49. package/dist/surface/driver.d.ts +17 -0
  50. package/dist/surface/driver.js +114 -0
  51. package/dist/surface/rules.d.ts +32 -0
  52. package/dist/surface/rules.js +150 -0
  53. package/dist/surface/run.d.ts +23 -0
  54. package/dist/surface/run.js +80 -0
  55. package/dist/surface/types.d.ts +90 -0
  56. package/dist/surface/types.js +10 -0
  57. package/package.json +73 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dongwook.lee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,216 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="docs/assets/banner.svg" alt="evershield: shield everything, everywhere" width="100%" />
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ # evershield
6
+
7
+ **Shield everything, everywhere.** Find what your web app exposes to the
8
+ browser before attackers do.
9
+
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.
13
+
14
+ ## Why
15
+
16
+ Everything a browser receives is public: bundles, source maps, API responses,
17
+ the rendered DOM. Reverse-engineering tools now read all of it automatically,
18
+ and they look like ordinary visitors, so you cannot block them. The only
19
+ defence that works is to control what you ship.
20
+
21
+ Teams leak in three recurring ways, and evershield checks all three before
22
+ deploy:
23
+
24
+ | Layer | What leaks | Example |
25
+ |---|---|---|
26
+ | **Artifact** | keys, internal URLs, prompts, source maps in the build | an LLM system prompt shipped as a string constant |
27
+ | **Response** | higher-tier data in lower-tier API responses | "Pro only" metrics present in every free user's JSON |
28
+ | **Surface** | data hidden by CSS instead of withheld by the server | a blurred paywall with the real numbers in the DOM |
29
+
30
+ Secret scanners look at your repository. evershield looks at what your users
31
+ actually get.
32
+
33
+ ## Quick start
34
+
35
+ ```bash
36
+ # scan a built artifact directory
37
+ npx evershield scan ./dist # Vite, CRA, Astro, static sites, …
38
+ npx evershield scan ./.next # Next.js: only browser-facing files are scanned
39
+
40
+ # machine-readable output and a stricter gate for CI
41
+ npx evershield scan ./dist --format json --fail-on medium --min-confidence low
42
+
43
+ # SARIF for code scanning, written to a file
44
+ npx evershield scan ./dist --format sarif --output evershield.sarif
45
+ ```
46
+
47
+ Exit code `0` means nothing blocking, `1` means findings at or above the
48
+ gate (default: severity ≥ high **and** confidence ≥ medium), `2` means a
49
+ usage or runtime error. Matched values are never printed in full.
50
+
51
+ What layer 1 catches today, with the reasoning behind each severity and
52
+ confidence, is in [docs/rules.md](docs/rules.md):
53
+
54
+ - `artifact/secret-like-string`: provider keys, private key blocks, Supabase
55
+ `service_role` JWTs, and name-based high-entropy values
56
+ - `artifact/source-map-published`: shipped `.map` files and inline maps
57
+ - `artifact/sensitive-env-name`: `SERVICE_ROLE`, `*_SECRET`, `DATABASE_URL`, …
58
+ - `artifact/internal-endpoint`: private IPs, `.internal` hosts, dev fallbacks
59
+
60
+ ## Audit what each plan actually receives
61
+
62
+ Layer 2 calls your own API as each identity you configure and compares the
63
+ responses. Credentials are referenced from environment variables, never
64
+ written in the config:
65
+
66
+ ```json
67
+ {
68
+ "audit": {
69
+ "baseUrl": "https://api.example.com",
70
+ "tiers": ["anonymous", "free", "pro"],
71
+ "identities": {
72
+ "anonymous": {},
73
+ "free": { "headers": { "Authorization": "Bearer ${EVERSHIELD_FREE_TOKEN}" } },
74
+ "pro": { "headers": { "Authorization": "Bearer ${EVERSHIELD_PRO_TOKEN}" } }
75
+ },
76
+ "endpoints": [
77
+ {
78
+ "method": "POST",
79
+ "path": "/api/analyze",
80
+ "body": { "text": "sample" },
81
+ "tiered": true,
82
+ "expect": { "free": { "absent": ["perplexity", "burstiness"] } }
83
+ }
84
+ ]
85
+ }
86
+ }
87
+ ```
88
+
89
+ ```bash
90
+ EVERSHIELD_FREE_TOKEN=… EVERSHIELD_PRO_TOKEN=… npx evershield audit
91
+ ```
92
+
93
+ It reports fields the config says a tier must not receive, fields whose
94
+ names mark them as a higher tier's (`premium_*`, `pro_only`, `internal_*`),
95
+ credential-like fields in any response, and endpoints that answer every
96
+ identity identically. The audit only ever talks to `baseUrl`.
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
+
128
+ ## In CI
129
+
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
142
+ tab as code-scanning alerts:
143
+
144
+ ```yaml
145
+ # .github/workflows/evershield.yml
146
+ name: evershield
147
+ on: [pull_request, push]
148
+ permissions:
149
+ contents: read
150
+ security-events: write # for the SARIF upload
151
+ jobs:
152
+ scan:
153
+ runs-on: ubuntu-latest
154
+ steps:
155
+ - uses: actions/checkout@v4
156
+ - run: npm ci && npm run build # produce dist/ or .next/
157
+ - uses: inviy/evershield@v0
158
+ with:
159
+ path: ./dist
160
+ ```
161
+
162
+ Inputs: `path` (required), `fail-on`, `min-confidence`, `config`, `adapter`,
163
+ `sarif-file`, `upload-sarif`, `version`. The step fails when evershield
164
+ exits 1 (blocking findings) or 2 (error), after the SARIF upload.
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
+
178
+ ## Allowlisting with a reason
179
+
180
+ Known-safe matches go in `evershield.config.json`, each with a reason:
181
+
182
+ ```json
183
+ {
184
+ "ignore": [
185
+ {
186
+ "ruleId": "artifact/internal-endpoint",
187
+ "match": "localhost:9999",
188
+ "reason": "supabase-js bundles this fallback URL; unreachable in production"
189
+ }
190
+ ]
191
+ }
192
+ ```
193
+
194
+ evershield refuses a config whose ignores have no `reason`, and reports how
195
+ many findings the config removed. Details in
196
+ [docs/configuration.md](docs/configuration.md).
197
+
198
+ ## Principles
199
+
200
+ - **Defensive only.** It audits targets you own, listed in your config.
201
+ - **Local only.** Nothing leaves your machine. No telemetry.
202
+ - **Evidence first.** Every finding has a location and a confidence. Noise is
203
+ a bug.
204
+ - **Framework agnostic.** Files and HTTP responses, whatever produced them.
205
+ - **CI first.** Stable exit codes, JSON and SARIF output.
206
+
207
+ Read the full [design document](docs/design.md).
208
+
209
+ ## Contributing
210
+
211
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Security reports: see
212
+ [SECURITY.md](SECURITY.md).
213
+
214
+ ## License
215
+
216
+ [MIT](LICENSE)
@@ -0,0 +1,25 @@
1
+ import type { EndpointConfig, IdentityConfig, ResponseObservation } from "./types.ts";
2
+ /** Replace `${NAME}` with the environment value; a missing variable is a config error that names the variable, never a value. */
3
+ export declare function interpolateEnv(value: string, env?: NodeJS.ProcessEnv): string;
4
+ export declare function interpolateDeep(value: unknown, env?: NodeJS.ProcessEnv): unknown;
5
+ /**
6
+ * `fetch failed` on its own helps nobody. Surface the underlying cause
7
+ * (DNS, refused connection, TLS) and, since Node's fetch ignores proxy
8
+ * environment variables unless told otherwise, hint at the usual fix when a
9
+ * proxy is configured but not in use.
10
+ */
11
+ export declare function describeFetchError(error: unknown, timeoutMs: number, env?: NodeJS.ProcessEnv): string;
12
+ export interface RequestOptions {
13
+ baseUrl: string;
14
+ endpoint: EndpointConfig;
15
+ identityName: string;
16
+ identity: IdentityConfig;
17
+ timeoutMs: number;
18
+ env?: NodeJS.ProcessEnv;
19
+ fetchImpl?: typeof fetch;
20
+ }
21
+ /**
22
+ * Perform one request as one identity. Header values are interpolated here
23
+ * and never stored on the observation, so a report cannot leak them.
24
+ */
25
+ export declare function requestEndpoint(options: RequestOptions): Promise<ResponseObservation>;
@@ -0,0 +1,98 @@
1
+ import { ConfigError } from "../config.js";
2
+ const ENV_REF = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
3
+ /** Replace `${NAME}` with the environment value; a missing variable is a config error that names the variable, never a value. */
4
+ export function interpolateEnv(value, env = process.env) {
5
+ return value.replace(ENV_REF, (_match, name) => {
6
+ const resolved = env[name];
7
+ if (resolved === undefined || resolved === "") {
8
+ throw new ConfigError(`environment variable ${name} is referenced in the audit config but not set`);
9
+ }
10
+ return resolved;
11
+ });
12
+ }
13
+ export function interpolateDeep(value, env = process.env) {
14
+ if (typeof value === "string")
15
+ return interpolateEnv(value, env);
16
+ if (Array.isArray(value))
17
+ return value.map((item) => interpolateDeep(item, env));
18
+ if (typeof value === "object" && value !== null) {
19
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, interpolateDeep(child, env)]));
20
+ }
21
+ return value;
22
+ }
23
+ /**
24
+ * `fetch failed` on its own helps nobody. Surface the underlying cause
25
+ * (DNS, refused connection, TLS) and, since Node's fetch ignores proxy
26
+ * environment variables unless told otherwise, hint at the usual fix when a
27
+ * proxy is configured but not in use.
28
+ */
29
+ export function describeFetchError(error, timeoutMs, env = process.env) {
30
+ if (!(error instanceof Error))
31
+ return String(error);
32
+ if (error.name === "AbortError")
33
+ return `timed out after ${timeoutMs}ms`;
34
+ // A refused connection over IPv4 and IPv6 arrives as an AggregateError with
35
+ // no code of its own; the first inner error carries it.
36
+ let cause = error.cause;
37
+ if (cause instanceof AggregateError && cause.errors.length > 0)
38
+ cause = cause.errors[0];
39
+ const causeText = cause instanceof Error
40
+ ? [...new Set([cause.code, cause.message])]
41
+ .filter((part) => typeof part === "string" && part.length > 0)
42
+ .join(" ")
43
+ : "";
44
+ let message = causeText.length > 0 ? `${error.message}: ${causeText}` : error.message;
45
+ const proxyConfigured = Boolean(env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy);
46
+ if (proxyConfigured && env.NODE_USE_ENV_PROXY === undefined) {
47
+ message += " (a proxy is configured in the environment but Node's fetch does not use it by default; set NODE_USE_ENV_PROXY=1)";
48
+ }
49
+ return message;
50
+ }
51
+ /**
52
+ * Perform one request as one identity. Header values are interpolated here
53
+ * and never stored on the observation, so a report cannot leak them.
54
+ */
55
+ export async function requestEndpoint(options) {
56
+ const { baseUrl, endpoint, identityName, identity, timeoutMs } = options;
57
+ const env = options.env ?? process.env;
58
+ const fetchImpl = options.fetchImpl ?? fetch;
59
+ const headers = new Headers();
60
+ for (const [name, value] of Object.entries(identity.headers ?? {}))
61
+ headers.set(name, interpolateEnv(value, env));
62
+ for (const [name, value] of Object.entries(endpoint.headers ?? {}))
63
+ headers.set(name, interpolateEnv(value, env));
64
+ let body;
65
+ if (endpoint.body !== undefined && endpoint.method !== "GET") {
66
+ body = JSON.stringify(interpolateDeep(endpoint.body, env));
67
+ if (!headers.has("content-type"))
68
+ headers.set("content-type", "application/json");
69
+ }
70
+ const controller = new AbortController();
71
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
72
+ try {
73
+ const init = { method: endpoint.method, headers, signal: controller.signal };
74
+ if (body !== undefined)
75
+ init.body = body;
76
+ const response = await fetchImpl(new URL(endpoint.path, baseUrl), init);
77
+ const contentType = response.headers.get("content-type");
78
+ const text = await response.text();
79
+ let json;
80
+ let isJson = false;
81
+ if (text.length > 0) {
82
+ try {
83
+ json = JSON.parse(text);
84
+ isJson = true;
85
+ }
86
+ catch {
87
+ isJson = false;
88
+ }
89
+ }
90
+ return { identity: identityName, status: response.status, contentType, json, isJson };
91
+ }
92
+ catch (error) {
93
+ return { identity: identityName, status: 0, contentType: null, json: undefined, isJson: false, error: describeFetchError(error, timeoutMs) };
94
+ }
95
+ finally {
96
+ clearTimeout(timer);
97
+ }
98
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Flatten a JSON value into dot paths. Array indices collapse to `[]` so two
3
+ * responses with different list lengths still compare field by field; the
4
+ * first sample seen for a path is kept for evidence.
5
+ *
6
+ * { a: { b: [ { c: 1 }, { c: 2 } ] } } → "a", "a.b", "a.b[]", "a.b[].c"
7
+ */
8
+ export interface PathSample {
9
+ path: string;
10
+ value: unknown;
11
+ }
12
+ export declare function flattenPaths(value: unknown): Map<string, unknown>;
13
+ /** Paths whose value is neither null nor undefined: what the identity actually received. */
14
+ export declare function presentPaths(value: unknown): Map<string, unknown>;
15
+ /** Last segment of a path, without array markers: "a.b[].c" → "c". */
16
+ export declare function leafName(path: string): string;
17
+ /** Whether `path` is `prefix` itself or nested under it. */
18
+ export declare function isUnder(path: string, prefix: string): boolean;
19
+ /** Structural equality for parsed JSON. */
20
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Flatten a JSON value into dot paths. Array indices collapse to `[]` so two
3
+ * responses with different list lengths still compare field by field; the
4
+ * first sample seen for a path is kept for evidence.
5
+ *
6
+ * { a: { b: [ { c: 1 }, { c: 2 } ] } } → "a", "a.b", "a.b[]", "a.b[].c"
7
+ */
8
+ function isObject(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ export function flattenPaths(value) {
12
+ const out = new Map();
13
+ const visit = (node, path) => {
14
+ if (path !== "" && !out.has(path))
15
+ out.set(path, node);
16
+ if (Array.isArray(node)) {
17
+ for (const item of node)
18
+ visit(item, `${path}[]`);
19
+ }
20
+ else if (isObject(node)) {
21
+ for (const [key, child] of Object.entries(node))
22
+ visit(child, path === "" ? key : `${path}.${key}`);
23
+ }
24
+ };
25
+ visit(value, "");
26
+ return out;
27
+ }
28
+ /** Paths whose value is neither null nor undefined: what the identity actually received. */
29
+ export function presentPaths(value) {
30
+ const present = new Map();
31
+ for (const [path, sample] of flattenPaths(value)) {
32
+ if (sample !== null && sample !== undefined)
33
+ present.set(path, sample);
34
+ }
35
+ return present;
36
+ }
37
+ /** Last segment of a path, without array markers: "a.b[].c" → "c". */
38
+ export function leafName(path) {
39
+ const segments = path.split(".");
40
+ const last = segments[segments.length - 1] ?? path;
41
+ return last.replace(/\[\]$/g, "");
42
+ }
43
+ /** Whether `path` is `prefix` itself or nested under it. */
44
+ export function isUnder(path, prefix) {
45
+ return path === prefix || path.startsWith(`${prefix}.`) || path.startsWith(`${prefix}[]`);
46
+ }
47
+ /** Structural equality for parsed JSON. */
48
+ export function deepEqual(a, b) {
49
+ if (a === b)
50
+ return true;
51
+ if (Array.isArray(a) && Array.isArray(b)) {
52
+ return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
53
+ }
54
+ if (isObject(a) && isObject(b)) {
55
+ const keysA = Object.keys(a).sort();
56
+ const keysB = Object.keys(b).sort();
57
+ return keysA.length === keysB.length && keysA.every((key, index) => key === keysB[index] && deepEqual(a[key], b[key]));
58
+ }
59
+ return false;
60
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Response rules. Each inspects one endpoint observed as every identity.
3
+ *
4
+ * - response/expected-absent-field-present: the operator said identity X
5
+ * must not receive path P, and it did. The operator's own contract makes
6
+ * this high severity and high confidence. This is the rule that catches
7
+ * "Pro-only metric present in every free user's JSON".
8
+ * - response/higher-tier-field-in-lower-tier: a field whose name marks it
9
+ * as privileged (`premium`, `pro_only`, `admin`, `internal`, `cost`, …)
10
+ * is present for a lower tier. Name-based, so medium confidence.
11
+ * - response/sensitive-field: a password, secret, token or key field with a
12
+ * non-boolean value in any response. Over-fetching; high on both.
13
+ * - response/identical-across-tiers: every identity got the same body. On
14
+ * an endpoint declared `tiered`, the gate is missing (high/medium);
15
+ * otherwise a low/low prompt to declare it if it should differ.
16
+ */
17
+ import type { Confidence, Finding, Severity } from "../index.ts";
18
+ import type { EndpointObservation } from "./types.ts";
19
+ export interface ResponseRuleMeta {
20
+ id: string;
21
+ defaultSeverity: Severity;
22
+ defaultConfidence: Confidence;
23
+ }
24
+ export interface ResponseRule extends ResponseRuleMeta {
25
+ check(observation: EndpointObservation): Finding[];
26
+ }
27
+ export declare const expectedAbsentFieldPresent: ResponseRule;
28
+ export declare const higherTierFieldInLowerTier: ResponseRule;
29
+ export declare const sensitiveField: ResponseRule;
30
+ export declare const identicalAcrossTiers: ResponseRule;
31
+ export declare const RESPONSE_RULES: readonly ResponseRule[];
@@ -0,0 +1,158 @@
1
+ import { redact } from "../scan/text.js";
2
+ import { deepEqual, isUnder, leafName, presentPaths } from "./json-paths.js";
3
+ const RULES_DOC = "https://github.com/inviy/evershield/blob/main/docs/rules.md";
4
+ const help = (id) => `${RULES_DOC}#${id.replace("/", "")}`;
5
+ const PRIVILEGED_NAME = /^(?:is_?)?(?:premium|pro|pro_?only|paid|plus|enterprise|admin|internal|private|debug|cost|margin|staff|owner_?only)(?:_|$)|_(?:premium|pro|paid|internal|admin|cost)$/i;
6
+ const SENSITIVE_NAME = /(?:^|_)(?:password(?:_?hash)?|hashed_?password|passwd|secret|api_?key|access_?token|refresh_?token|private_?key|ssn|card_?number|cvv)$|^(?:apiKey|accessToken|refreshToken|privateKey|cardNumber|passwordHash)$/i;
7
+ /** Array element paths ("items[]") share their leaf with the array itself; checking both would double-report. */
8
+ function isElementPath(path) {
9
+ return path.endsWith("[]");
10
+ }
11
+ function excerptOf(value) {
12
+ if (typeof value === "string")
13
+ return redact(value);
14
+ if (typeof value === "number" || typeof value === "boolean")
15
+ return String(value);
16
+ if (Array.isArray(value))
17
+ return `array(${value.length})`;
18
+ if (typeof value === "object" && value !== null)
19
+ return `object{${Object.keys(value).slice(0, 5).join(",")}}`;
20
+ return String(value);
21
+ }
22
+ function okJson(response) {
23
+ return response !== undefined && response.error === undefined && response.status >= 200 && response.status < 300 && response.isJson;
24
+ }
25
+ export const expectedAbsentFieldPresent = {
26
+ id: "response/expected-absent-field-present",
27
+ defaultSeverity: "high",
28
+ defaultConfidence: "high",
29
+ check(observation) {
30
+ const findings = [];
31
+ for (const [identity, expectation] of Object.entries(observation.endpoint.expect ?? {})) {
32
+ const response = observation.responses.get(identity);
33
+ if (!okJson(response))
34
+ continue;
35
+ const present = presentPaths(response.json);
36
+ for (const prefix of expectation.absent ?? []) {
37
+ const hit = [...present.entries()].find(([path]) => isUnder(path, prefix));
38
+ if (hit === undefined)
39
+ continue;
40
+ const [path, value] = hit;
41
+ findings.push({
42
+ ruleId: expectedAbsentFieldPresent.id,
43
+ layer: "response",
44
+ severity: "high",
45
+ confidence: "high",
46
+ message: `${observation.label} returns "${prefix}" to ${identity}, which the config says must not receive it`,
47
+ evidence: [{ kind: "response", endpoint: observation.label, identity, jsonPath: path, excerpt: excerptOf(value) }],
48
+ remediation: "Withhold the field on the server for this tier (omit it or send null). Hiding it in the UI is not a boundary.",
49
+ helpUri: help(expectedAbsentFieldPresent.id),
50
+ });
51
+ }
52
+ }
53
+ return findings;
54
+ },
55
+ };
56
+ export const higherTierFieldInLowerTier = {
57
+ id: "response/higher-tier-field-in-lower-tier",
58
+ defaultSeverity: "medium",
59
+ defaultConfidence: "medium",
60
+ check(observation) {
61
+ const findings = [];
62
+ const top = observation.tiers.length - 1;
63
+ observation.tiers.forEach((identity, index) => {
64
+ if (index >= top)
65
+ return;
66
+ const response = observation.responses.get(identity);
67
+ if (!okJson(response))
68
+ return;
69
+ for (const [path, value] of presentPaths(response.json)) {
70
+ if (isElementPath(path))
71
+ continue;
72
+ const leaf = leafName(path);
73
+ if (!PRIVILEGED_NAME.test(leaf) || typeof value === "boolean")
74
+ continue;
75
+ findings.push({
76
+ ruleId: higherTierFieldInLowerTier.id,
77
+ layer: "response",
78
+ severity: "medium",
79
+ confidence: "medium",
80
+ message: `${observation.label} returns "${path}" to ${identity}; the name suggests a higher tier's data`,
81
+ evidence: [{ kind: "response", endpoint: observation.label, identity, jsonPath: path, excerpt: excerptOf(value) }],
82
+ remediation: "If this belongs to a higher tier, withhold it server-side and add it to that identity's `expect.absent` so the audit enforces it.",
83
+ helpUri: help(higherTierFieldInLowerTier.id),
84
+ });
85
+ }
86
+ });
87
+ return findings;
88
+ },
89
+ };
90
+ export const sensitiveField = {
91
+ id: "response/sensitive-field",
92
+ defaultSeverity: "high",
93
+ defaultConfidence: "high",
94
+ check(observation) {
95
+ const findings = [];
96
+ const seen = new Set();
97
+ for (const identity of observation.tiers) {
98
+ const response = observation.responses.get(identity);
99
+ if (!okJson(response))
100
+ continue;
101
+ for (const [path, value] of presentPaths(response.json)) {
102
+ if (isElementPath(path) || !SENSITIVE_NAME.test(leafName(path)) || typeof value === "boolean" || seen.has(path))
103
+ continue;
104
+ seen.add(path);
105
+ findings.push({
106
+ ruleId: sensitiveField.id,
107
+ layer: "response",
108
+ severity: "high",
109
+ confidence: "high",
110
+ message: `${observation.label} returns a sensitive field "${path}" (first seen for ${identity})`,
111
+ evidence: [{ kind: "response", endpoint: observation.label, identity, jsonPath: path, excerpt: excerptOf(value) }],
112
+ remediation: "Never serialize credentials or hashes. Select the fields the client needs instead of returning the whole record.",
113
+ helpUri: help(sensitiveField.id),
114
+ });
115
+ }
116
+ }
117
+ return findings;
118
+ },
119
+ };
120
+ export const identicalAcrossTiers = {
121
+ id: "response/identical-across-tiers",
122
+ defaultSeverity: "low",
123
+ defaultConfidence: "low",
124
+ check(observation) {
125
+ const bodies = observation.tiers
126
+ .map((identity) => observation.responses.get(identity))
127
+ .filter((response) => okJson(response));
128
+ if (bodies.length < 2)
129
+ return [];
130
+ const first = bodies[0];
131
+ if (!bodies.every((response) => deepEqual(response.json, first.json)))
132
+ return [];
133
+ const identities = bodies.map((response) => response.identity).join(", ");
134
+ const tiered = observation.endpoint.tiered === true;
135
+ return [
136
+ {
137
+ ruleId: identicalAcrossTiers.id,
138
+ layer: "response",
139
+ severity: tiered ? "high" : "low",
140
+ confidence: tiered ? "medium" : "low",
141
+ message: tiered
142
+ ? `${observation.label} is declared tiered but returns the same body to ${identities}`
143
+ : `${observation.label} returns the same body to ${identities}`,
144
+ evidence: [{ kind: "response", endpoint: observation.label, identity: first.identity, jsonPath: "$" }],
145
+ remediation: tiered
146
+ ? "The tier check is not applied on this endpoint. Verify the server reads the caller's entitlement, not a client-supplied plan."
147
+ : "If this endpoint should differ by plan, set `tiered: true` so the audit treats sameness as a failure.",
148
+ helpUri: help(identicalAcrossTiers.id),
149
+ },
150
+ ];
151
+ },
152
+ };
153
+ export const RESPONSE_RULES = [
154
+ expectedAbsentFieldPresent,
155
+ sensitiveField,
156
+ higherTierFieldInLowerTier,
157
+ identicalAcrossTiers,
158
+ ];
@@ -0,0 +1,25 @@
1
+ import { type ScanSummary } from "../index.ts";
2
+ import type { AuditConfig } from "./types.ts";
3
+ export interface AuditResult extends ScanSummary {
4
+ layer: "response";
5
+ /** The base URL audited. */
6
+ target: string;
7
+ identities: string[];
8
+ endpointsAudited: number;
9
+ /** Responses that were 2xx JSON and therefore evaluated by the rules. */
10
+ responsesEvaluated: number;
11
+ /** Findings removed by config ignore rules. */
12
+ ignored: number;
13
+ /** Endpoints or identities that could not be evaluated, and why. */
14
+ warnings: string[];
15
+ }
16
+ export interface AuditOptions {
17
+ env?: NodeJS.ProcessEnv;
18
+ fetchImpl?: typeof fetch;
19
+ }
20
+ /**
21
+ * Layer 2: call each endpoint as each identity, least privileged first, and
22
+ * run the response rules over what came back. Requests are sequential: an
23
+ * audit is a handful of calls, and it should never look like load.
24
+ */
25
+ export declare function runAudit(config: AuditConfig, options?: AuditOptions): Promise<AuditResult>;