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.
- package/LICENSE +21 -0
- package/README.md +215 -2
- package/dist/audit/http.d.ts +25 -0
- package/dist/audit/http.js +98 -0
- package/dist/audit/json-paths.d.ts +20 -0
- package/dist/audit/json-paths.js +60 -0
- package/dist/audit/rules.d.ts +31 -0
- package/dist/audit/rules.js +158 -0
- package/dist/audit/run.d.ts +25 -0
- package/dist/audit/run.js +62 -0
- package/dist/audit/types.d.ts +57 -0
- package/dist/audit/types.js +10 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +8 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +213 -0
- package/dist/config.d.ts +42 -0
- package/dist/config.js +352 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +8 -0
- package/dist/init.d.ts +21 -0
- package/dist/init.js +136 -0
- package/dist/report/index.d.ts +19 -0
- package/dist/report/index.js +82 -0
- package/dist/report/sarif.d.ts +2 -0
- package/dist/report/sarif.js +129 -0
- package/dist/rules/index.d.ts +4 -0
- package/dist/rules/index.js +11 -0
- package/dist/rules/internal-endpoint.d.ts +2 -0
- package/dist/rules/internal-endpoint.js +61 -0
- package/dist/rules/secret-like-string.d.ts +2 -0
- package/dist/rules/secret-like-string.js +128 -0
- package/dist/rules/sensitive-env-name.d.ts +4 -0
- package/dist/rules/sensitive-env-name.js +76 -0
- package/dist/rules/source-map-published.d.ts +2 -0
- package/dist/rules/source-map-published.js +39 -0
- package/dist/rules/types.d.ts +23 -0
- package/dist/rules/types.js +6 -0
- package/dist/scan/adapters.d.ts +24 -0
- package/dist/scan/adapters.js +65 -0
- package/dist/scan/artifact.d.ts +18 -0
- package/dist/scan/artifact.js +68 -0
- package/dist/scan/files.d.ts +16 -0
- package/dist/scan/files.js +41 -0
- package/dist/scan/text.d.ts +18 -0
- package/dist/scan/text.js +58 -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 +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 +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
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/assets/banner.svg" alt="evershield: shield everything, everywhere" width="100%" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
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>;
|