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
@@ -0,0 +1,62 @@
1
+ import { VERSION } from "../index.js";
2
+ import { sortFindings } from "../scan/artifact.js";
3
+ import { requestEndpoint } from "./http.js";
4
+ import { RESPONSE_RULES } from "./rules.js";
5
+ const DEFAULT_TIMEOUT_MS = 15_000;
6
+ /**
7
+ * Layer 2: call each endpoint as each identity, least privileged first, and
8
+ * run the response rules over what came back. Requests are sequential: an
9
+ * audit is a handful of calls, and it should never look like load.
10
+ */
11
+ export async function runAudit(config, options = {}) {
12
+ const startedAt = new Date().toISOString();
13
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
14
+ const findings = [];
15
+ const warnings = [];
16
+ let responsesEvaluated = 0;
17
+ for (const endpoint of config.endpoints) {
18
+ const label = `${endpoint.method} ${endpoint.path}`;
19
+ const observation = { endpoint, label, tiers: config.tiers, responses: new Map() };
20
+ for (const identityName of config.tiers) {
21
+ const identity = config.identities[identityName];
22
+ if (identity === undefined) {
23
+ warnings.push(`${label}: identity "${identityName}" is not defined`);
24
+ continue;
25
+ }
26
+ const requestOptions = { baseUrl: config.baseUrl, endpoint, identityName, identity, timeoutMs };
27
+ const response = await requestEndpoint({
28
+ ...requestOptions,
29
+ ...(options.env === undefined ? {} : { env: options.env }),
30
+ ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
31
+ });
32
+ observation.responses.set(identityName, response);
33
+ if (response.error !== undefined) {
34
+ warnings.push(`${label} as ${identityName}: request failed (${response.error})`);
35
+ }
36
+ else if (response.status < 200 || response.status >= 300) {
37
+ warnings.push(`${label} as ${identityName}: HTTP ${response.status}, not evaluated`);
38
+ }
39
+ else if (!response.isJson) {
40
+ warnings.push(`${label} as ${identityName}: response is not JSON (${response.contentType ?? "no content-type"}), not evaluated`);
41
+ }
42
+ else {
43
+ responsesEvaluated += 1;
44
+ }
45
+ }
46
+ for (const rule of RESPONSE_RULES)
47
+ findings.push(...rule.check(observation));
48
+ }
49
+ return {
50
+ version: VERSION,
51
+ layer: "response",
52
+ target: config.baseUrl,
53
+ identities: [...config.tiers],
54
+ endpointsAudited: config.endpoints.length,
55
+ responsesEvaluated,
56
+ ignored: 0,
57
+ warnings,
58
+ startedAt,
59
+ finishedAt: new Date().toISOString(),
60
+ findings: sortFindings(findings),
61
+ };
62
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Layer 2: privilege-differential response audit.
3
+ *
4
+ * The operator declares identities (anonymous, free, pro, admin, …) whose
5
+ * credentials live in environment variables, an ordered list of tiers from
6
+ * least to most privileged, and endpoints to call as each identity. The
7
+ * audit compares what each identity receives and reports fields that a
8
+ * lower tier should not have.
9
+ */
10
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
11
+ export interface IdentityConfig {
12
+ /** Header values may reference env vars as `${NAME}`; credential headers must. */
13
+ headers?: Record<string, string>;
14
+ }
15
+ export interface EndpointExpectation {
16
+ /** JSON paths (dot notation, `[]` for arrays) that must be absent or null for this identity. */
17
+ absent?: string[];
18
+ }
19
+ export interface EndpointConfig {
20
+ method: HttpMethod;
21
+ /** Path relative to `baseUrl`, starting with `/`. */
22
+ path: string;
23
+ headers?: Record<string, string>;
24
+ /** JSON body for POST/PUT/PATCH. String values may reference env vars. */
25
+ body?: unknown;
26
+ /** The endpoint is supposed to answer differently per tier. */
27
+ tiered?: boolean;
28
+ /** Per-identity expectations, keyed by identity name. */
29
+ expect?: Record<string, EndpointExpectation>;
30
+ }
31
+ export interface AuditConfig {
32
+ /** Origin the audit is allowed to talk to. Endpoints are relative to it. */
33
+ baseUrl: string;
34
+ /** Identity names ordered from least to most privileged. */
35
+ tiers: string[];
36
+ identities: Record<string, IdentityConfig>;
37
+ endpoints: EndpointConfig[];
38
+ timeoutMs?: number;
39
+ }
40
+ export interface ResponseObservation {
41
+ identity: string;
42
+ status: number;
43
+ contentType: string | null;
44
+ /** Parsed JSON body, or undefined when the body was not JSON. */
45
+ json: unknown;
46
+ isJson: boolean;
47
+ /** Transport-level failure, when the request did not complete. */
48
+ error?: string;
49
+ }
50
+ export interface EndpointObservation {
51
+ endpoint: EndpointConfig;
52
+ /** "POST /api/analyze" */
53
+ label: string;
54
+ /** Identity names, least privileged first. */
55
+ tiers: string[];
56
+ responses: Map<string, ResponseObservation>;
57
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Layer 2: privilege-differential response audit.
3
+ *
4
+ * The operator declares identities (anonymous, free, pro, admin, …) whose
5
+ * credentials live in environment variables, an ordered list of tiers from
6
+ * least to most privileged, and endpoints to call as each identity. The
7
+ * audit compares what each identity receives and reports fields that a
8
+ * lower tier should not have.
9
+ */
10
+ export {};
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "./cli.js";
3
+ const result = await run(process.argv.slice(2));
4
+ if (result.stdout)
5
+ process.stdout.write(result.stdout);
6
+ if (result.stderr)
7
+ process.stderr.write(result.stderr);
8
+ process.exitCode = result.code;
package/dist/cli.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export interface CliResult {
2
+ /** Process exit code. 0 clean, 1 blocking findings, 2 usage or runtime error. */
3
+ code: number;
4
+ stdout: string;
5
+ stderr: string;
6
+ }
7
+ /**
8
+ * Entry point without process side effects beyond the optional --output
9
+ * file: takes argv (minus node and the script path), returns what to print
10
+ * and the exit code.
11
+ */
12
+ export declare function run(argv: readonly string[]): Promise<CliResult>;
package/dist/cli.js ADDED
@@ -0,0 +1,213 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { runAudit } from "./audit/run.js";
5
+ import { applyIgnores, ConfigError, loadConfig } from "./config.js";
6
+ import { runInit } from "./init.js";
7
+ import { runSurface } from "./surface/run.js";
8
+ import { VERSION } from "./index.js";
9
+ import { CONFIDENCES, DEFAULT_GATE, isBlocking, renderJson, renderText, SEVERITIES } from "./report/index.js";
10
+ import { renderSarif } from "./report/sarif.js";
11
+ import { scanArtifact } from "./scan/artifact.js";
12
+ import { ADAPTERS } from "./scan/adapters.js";
13
+ const HELP = `evershield ${VERSION}
14
+ Shield everything, everywhere.
15
+
16
+ Usage:
17
+ evershield init [--force] Detect your framework and write evershield.config.json
18
+ and a GitHub workflow that scans every pull request
19
+ evershield scan <dir> [options] Layer 1: scan a built web artifact directory
20
+ evershield audit [options] Layer 2: call configured endpoints as each identity
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)
25
+ evershield --version Print version
26
+ evershield --help Show this help
27
+
28
+ Options (scan, audit, surface):
29
+ --format <text|json|sarif> Output format (default: text)
30
+ --output <file> Write the report to a file; stdout gets a one-line summary
31
+ --config <file> Config file (default: ./evershield.config.json if present)
32
+ --fail-on <severity|none> Fail on findings at this severity or worse
33
+ (critical|high|medium|low|info|none; default: high)
34
+ --min-confidence <level> Findings below this confidence never fail the build
35
+ (high|medium|low; default: medium)
36
+
37
+ Scan options:
38
+ --adapter <nextjs|plain> Force an adapter instead of detecting one
39
+
40
+ Exit codes: 0 clean · 1 blocking findings · 2 usage or runtime error
41
+ Documentation: https://github.com/inviy/evershield
42
+ `;
43
+ function usageError(message) {
44
+ return { code: 2, stdout: "", stderr: `evershield: ${message}\n\n${HELP}` };
45
+ }
46
+ function isOneOf(value, allowed) {
47
+ return allowed.includes(value);
48
+ }
49
+ function parseCommonOptions(rest) {
50
+ let parsed;
51
+ try {
52
+ parsed = parseArgs({
53
+ args: [...rest],
54
+ allowPositionals: true,
55
+ options: {
56
+ format: { type: "string", default: "text" },
57
+ output: { type: "string" },
58
+ config: { type: "string" },
59
+ "fail-on": { type: "string" },
60
+ "min-confidence": { type: "string" },
61
+ adapter: { type: "string" },
62
+ },
63
+ });
64
+ }
65
+ catch (error) {
66
+ return usageError(error instanceof Error ? error.message : String(error));
67
+ }
68
+ const format = String(parsed.values.format);
69
+ if (!isOneOf(format, ["text", "json", "sarif"]))
70
+ return usageError(`unknown --format "${format}"`);
71
+ const failOn = parsed.values["fail-on"] === undefined ? undefined : String(parsed.values["fail-on"]);
72
+ if (failOn !== undefined && !isOneOf(failOn, [...SEVERITIES, "none"]))
73
+ return usageError(`unknown --fail-on "${failOn}"`);
74
+ const minConfidence = parsed.values["min-confidence"] === undefined ? undefined : String(parsed.values["min-confidence"]);
75
+ if (minConfidence !== undefined && !isOneOf(minConfidence, CONFIDENCES))
76
+ return usageError(`unknown --min-confidence "${minConfidence}"`);
77
+ const adapter = parsed.values.adapter === undefined ? undefined : String(parsed.values.adapter);
78
+ if (adapter !== undefined && !isOneOf(adapter, Object.keys(ADAPTERS))) {
79
+ return usageError(`unknown --adapter "${adapter}"`);
80
+ }
81
+ return {
82
+ positionals: parsed.positionals,
83
+ format,
84
+ outputPath: parsed.values.output === undefined ? undefined : String(parsed.values.output),
85
+ configPath: parsed.values.config === undefined ? undefined : String(parsed.values.config),
86
+ failOn,
87
+ minConfidence,
88
+ adapter,
89
+ };
90
+ }
91
+ function gateFor(options, config) {
92
+ // Precedence: CLI flag, then config file, then defaults.
93
+ return {
94
+ failOn: options.failOn ?? config.gate?.failOn ?? DEFAULT_GATE.failOn,
95
+ minConfidence: options.minConfidence ?? config.gate?.minConfidence ?? DEFAULT_GATE.minConfidence,
96
+ };
97
+ }
98
+ async function finish(report, gate, options) {
99
+ const blocking = report.findings.filter((finding) => isBlocking(finding, gate)).length;
100
+ const rendered = options.format === "json" ? renderJson(report, gate) : options.format === "sarif" ? renderSarif(report, gate) : renderText(report, gate);
101
+ const code = blocking > 0 ? 1 : 0;
102
+ if (options.outputPath === undefined)
103
+ return { code, stdout: rendered, stderr: "" };
104
+ await writeFile(path.resolve(options.outputPath), rendered);
105
+ const summary = `evershield: ${report.findings.length} finding(s), ${blocking} blocking, ${report.ignored} ignored · ${options.format} report written to ${options.outputPath}\n`;
106
+ return { code, stdout: summary, stderr: "" };
107
+ }
108
+ function failure(command, error) {
109
+ const message = error instanceof Error ? error.message : String(error);
110
+ const prefix = error instanceof ConfigError ? "evershield config" : `evershield ${command}`;
111
+ return { code: 2, stdout: "", stderr: `${prefix}: ${message}\n` };
112
+ }
113
+ /**
114
+ * Entry point without process side effects beyond the optional --output
115
+ * file: takes argv (minus node and the script path), returns what to print
116
+ * and the exit code.
117
+ */
118
+ export async function run(argv) {
119
+ const [command, ...rest] = argv;
120
+ if (command === undefined || command === "--help" || command === "-h" || command === "help") {
121
+ return { code: 0, stdout: HELP, stderr: "" };
122
+ }
123
+ if (command === "--version" || command === "-v" || command === "version") {
124
+ return { code: 0, stdout: `${VERSION}\n`, stderr: "" };
125
+ }
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") {
152
+ return usageError(`unknown command "${command}"`);
153
+ }
154
+ const options = parseCommonOptions(rest);
155
+ if ("code" in options)
156
+ return options;
157
+ if (command === "scan") {
158
+ const target = options.positionals[0];
159
+ if (target === undefined)
160
+ return usageError("scan: missing <dir>");
161
+ try {
162
+ const { config } = await loadConfig(options.configPath);
163
+ const gate = gateFor(options, config);
164
+ const scanned = await scanArtifact(target, options.adapter === undefined ? {} : { adapter: options.adapter });
165
+ const { kept, ignored } = applyIgnores(scanned.findings, config.ignore);
166
+ return await finish({ ...scanned, findings: kept, ignored: ignored.length }, gate, options);
167
+ }
168
+ catch (error) {
169
+ return failure("scan", error);
170
+ }
171
+ }
172
+ if (options.positionals.length > 0)
173
+ return usageError(`${command}: unexpected argument "${options.positionals[0]}"`);
174
+ if (command === "surface") {
175
+ try {
176
+ const { config, source } = await loadConfig(options.configPath);
177
+ if (config.surface === undefined) {
178
+ throw new ConfigError(source === null
179
+ ? "surface needs a config file with a \"surface\" block (see docs/configuration.md)"
180
+ : `${source} has no "surface" block (see docs/configuration.md)`);
181
+ }
182
+ const gate = gateFor(options, config);
183
+ const surfaced = await runSurface(config.surface);
184
+ if (surfaced.snapshotsEvaluated === 0) {
185
+ throw new Error(`no page could be evaluated, so nothing was audited:\n ${surfaced.warnings.join("\n ")}`);
186
+ }
187
+ const { kept, ignored } = applyIgnores(surfaced.findings, config.ignore);
188
+ return await finish({ ...surfaced, findings: kept, ignored: ignored.length }, gate, options);
189
+ }
190
+ catch (error) {
191
+ return failure("surface", error);
192
+ }
193
+ }
194
+ try {
195
+ const { config, source } = await loadConfig(options.configPath);
196
+ if (config.audit === undefined) {
197
+ throw new ConfigError(source === null
198
+ ? "audit needs a config file with an \"audit\" block (see docs/configuration.md)"
199
+ : `${source} has no "audit" block (see docs/configuration.md)`);
200
+ }
201
+ const gate = gateFor(options, config);
202
+ const audited = await runAudit(config.audit);
203
+ if (audited.responsesEvaluated === 0) {
204
+ // "No findings" would be a lie: nothing was looked at. Fail loudly.
205
+ throw new Error(`no response could be evaluated, so nothing was audited:\n ${audited.warnings.join("\n ")}`);
206
+ }
207
+ const { kept, ignored } = applyIgnores(audited.findings, config.ignore);
208
+ return await finish({ ...audited, findings: kept, ignored: ignored.length }, gate, options);
209
+ }
210
+ catch (error) {
211
+ return failure("audit", error);
212
+ }
213
+ }
@@ -0,0 +1,42 @@
1
+ import type { AuditConfig } from "./audit/types.ts";
2
+ import type { Finding } from "./index.ts";
3
+ import type { SurfaceConfig } from "./surface/types.ts";
4
+ import { type Gate } from "./report/index.ts";
5
+ export interface IgnoreRule {
6
+ /** Rule id to ignore, or "*" for any rule. */
7
+ ruleId: string;
8
+ /** Glob over the evidence path, relative to the scan target. */
9
+ path?: string;
10
+ /** Substring that must appear in the finding's message or excerpt. */
11
+ match?: string;
12
+ /** Why this is safe. Required. */
13
+ reason: string;
14
+ }
15
+ export interface Config {
16
+ gate?: Partial<Gate>;
17
+ ignore: IgnoreRule[];
18
+ /** Layer 2 settings; `evershield audit` requires this block. */
19
+ audit?: AuditConfig;
20
+ /** Layer 3 settings; `evershield surface` requires this block. */
21
+ surface?: SurfaceConfig;
22
+ }
23
+ export declare const DEFAULT_CONFIG_FILE = "evershield.config.json";
24
+ export declare class ConfigError extends Error {
25
+ }
26
+ /** Validate a parsed JSON value into a Config, with messages a human can act on. */
27
+ export declare function parseConfig(raw: unknown, source: string): Config;
28
+ /**
29
+ * Load the config file. An explicit path must exist; the default file is
30
+ * optional. Returns the source path alongside, for messages.
31
+ */
32
+ export declare function loadConfig(explicitPath?: string): Promise<{
33
+ config: Config;
34
+ source: string | null;
35
+ }>;
36
+ /** Minimal glob: `**` spans directories, `*` and `?` stay within one segment. */
37
+ export declare function globToRegExp(pattern: string): RegExp;
38
+ /** Split findings into those kept and those an ignore rule accounts for. */
39
+ export declare function applyIgnores(findings: Finding[], ignore: IgnoreRule[]): {
40
+ kept: Finding[];
41
+ ignored: Finding[];
42
+ };