evershield 0.0.0-stage → 0.1.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 +163 -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 +161 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +249 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +8 -0
- package/dist/report/index.d.ts +18 -0
- package/dist/report/index.js +72 -0
- package/dist/report/sarif.d.ts +2 -0
- package/dist/report/sarif.js +115 -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/package.json +57 -4
|
@@ -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
package/dist/bin.js
ADDED
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,161 @@
|
|
|
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 { VERSION } from "./index.js";
|
|
7
|
+
import { CONFIDENCES, DEFAULT_GATE, isBlocking, renderJson, renderText, SEVERITIES } from "./report/index.js";
|
|
8
|
+
import { renderSarif } from "./report/sarif.js";
|
|
9
|
+
import { scanArtifact } from "./scan/artifact.js";
|
|
10
|
+
import { ADAPTERS } from "./scan/adapters.js";
|
|
11
|
+
const HELP = `evershield ${VERSION}
|
|
12
|
+
Shield everything, everywhere.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
evershield scan <dir> [options] Layer 1: scan a built web artifact directory
|
|
16
|
+
evershield audit [options] Layer 2: call configured endpoints as each identity
|
|
17
|
+
and compare what they receive (needs config "audit")
|
|
18
|
+
evershield --version Print version
|
|
19
|
+
evershield --help Show this help
|
|
20
|
+
|
|
21
|
+
Options (both commands):
|
|
22
|
+
--format <text|json|sarif> Output format (default: text)
|
|
23
|
+
--output <file> Write the report to a file; stdout gets a one-line summary
|
|
24
|
+
--config <file> Config file (default: ./evershield.config.json if present)
|
|
25
|
+
--fail-on <severity|none> Fail on findings at this severity or worse
|
|
26
|
+
(critical|high|medium|low|info|none; default: high)
|
|
27
|
+
--min-confidence <level> Findings below this confidence never fail the build
|
|
28
|
+
(high|medium|low; default: medium)
|
|
29
|
+
|
|
30
|
+
Scan options:
|
|
31
|
+
--adapter <nextjs|plain> Force an adapter instead of detecting one
|
|
32
|
+
|
|
33
|
+
Exit codes: 0 clean · 1 blocking findings · 2 usage or runtime error
|
|
34
|
+
Documentation: https://github.com/inviy/evershield
|
|
35
|
+
`;
|
|
36
|
+
function usageError(message) {
|
|
37
|
+
return { code: 2, stdout: "", stderr: `evershield: ${message}\n\n${HELP}` };
|
|
38
|
+
}
|
|
39
|
+
function isOneOf(value, allowed) {
|
|
40
|
+
return allowed.includes(value);
|
|
41
|
+
}
|
|
42
|
+
function parseCommonOptions(rest) {
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = parseArgs({
|
|
46
|
+
args: [...rest],
|
|
47
|
+
allowPositionals: true,
|
|
48
|
+
options: {
|
|
49
|
+
format: { type: "string", default: "text" },
|
|
50
|
+
output: { type: "string" },
|
|
51
|
+
config: { type: "string" },
|
|
52
|
+
"fail-on": { type: "string" },
|
|
53
|
+
"min-confidence": { type: "string" },
|
|
54
|
+
adapter: { type: "string" },
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return usageError(error instanceof Error ? error.message : String(error));
|
|
60
|
+
}
|
|
61
|
+
const format = String(parsed.values.format);
|
|
62
|
+
if (!isOneOf(format, ["text", "json", "sarif"]))
|
|
63
|
+
return usageError(`unknown --format "${format}"`);
|
|
64
|
+
const failOn = parsed.values["fail-on"] === undefined ? undefined : String(parsed.values["fail-on"]);
|
|
65
|
+
if (failOn !== undefined && !isOneOf(failOn, [...SEVERITIES, "none"]))
|
|
66
|
+
return usageError(`unknown --fail-on "${failOn}"`);
|
|
67
|
+
const minConfidence = parsed.values["min-confidence"] === undefined ? undefined : String(parsed.values["min-confidence"]);
|
|
68
|
+
if (minConfidence !== undefined && !isOneOf(minConfidence, CONFIDENCES))
|
|
69
|
+
return usageError(`unknown --min-confidence "${minConfidence}"`);
|
|
70
|
+
const adapter = parsed.values.adapter === undefined ? undefined : String(parsed.values.adapter);
|
|
71
|
+
if (adapter !== undefined && !isOneOf(adapter, Object.keys(ADAPTERS))) {
|
|
72
|
+
return usageError(`unknown --adapter "${adapter}"`);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
positionals: parsed.positionals,
|
|
76
|
+
format,
|
|
77
|
+
outputPath: parsed.values.output === undefined ? undefined : String(parsed.values.output),
|
|
78
|
+
configPath: parsed.values.config === undefined ? undefined : String(parsed.values.config),
|
|
79
|
+
failOn,
|
|
80
|
+
minConfidence,
|
|
81
|
+
adapter,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function gateFor(options, config) {
|
|
85
|
+
// Precedence: CLI flag, then config file, then defaults.
|
|
86
|
+
return {
|
|
87
|
+
failOn: options.failOn ?? config.gate?.failOn ?? DEFAULT_GATE.failOn,
|
|
88
|
+
minConfidence: options.minConfidence ?? config.gate?.minConfidence ?? DEFAULT_GATE.minConfidence,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async function finish(report, gate, options) {
|
|
92
|
+
const blocking = report.findings.filter((finding) => isBlocking(finding, gate)).length;
|
|
93
|
+
const rendered = options.format === "json" ? renderJson(report, gate) : options.format === "sarif" ? renderSarif(report, gate) : renderText(report, gate);
|
|
94
|
+
const code = blocking > 0 ? 1 : 0;
|
|
95
|
+
if (options.outputPath === undefined)
|
|
96
|
+
return { code, stdout: rendered, stderr: "" };
|
|
97
|
+
await writeFile(path.resolve(options.outputPath), rendered);
|
|
98
|
+
const summary = `evershield: ${report.findings.length} finding(s), ${blocking} blocking, ${report.ignored} ignored · ${options.format} report written to ${options.outputPath}\n`;
|
|
99
|
+
return { code, stdout: summary, stderr: "" };
|
|
100
|
+
}
|
|
101
|
+
function failure(command, error) {
|
|
102
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
103
|
+
const prefix = error instanceof ConfigError ? "evershield config" : `evershield ${command}`;
|
|
104
|
+
return { code: 2, stdout: "", stderr: `${prefix}: ${message}\n` };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Entry point without process side effects beyond the optional --output
|
|
108
|
+
* file: takes argv (minus node and the script path), returns what to print
|
|
109
|
+
* and the exit code.
|
|
110
|
+
*/
|
|
111
|
+
export async function run(argv) {
|
|
112
|
+
const [command, ...rest] = argv;
|
|
113
|
+
if (command === undefined || command === "--help" || command === "-h" || command === "help") {
|
|
114
|
+
return { code: 0, stdout: HELP, stderr: "" };
|
|
115
|
+
}
|
|
116
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
117
|
+
return { code: 0, stdout: `${VERSION}\n`, stderr: "" };
|
|
118
|
+
}
|
|
119
|
+
if (command !== "scan" && command !== "audit") {
|
|
120
|
+
return usageError(`unknown command "${command}"`);
|
|
121
|
+
}
|
|
122
|
+
const options = parseCommonOptions(rest);
|
|
123
|
+
if ("code" in options)
|
|
124
|
+
return options;
|
|
125
|
+
if (command === "scan") {
|
|
126
|
+
const target = options.positionals[0];
|
|
127
|
+
if (target === undefined)
|
|
128
|
+
return usageError("scan: missing <dir>");
|
|
129
|
+
try {
|
|
130
|
+
const { config } = await loadConfig(options.configPath);
|
|
131
|
+
const gate = gateFor(options, config);
|
|
132
|
+
const scanned = await scanArtifact(target, options.adapter === undefined ? {} : { adapter: options.adapter });
|
|
133
|
+
const { kept, ignored } = applyIgnores(scanned.findings, config.ignore);
|
|
134
|
+
return await finish({ ...scanned, findings: kept, ignored: ignored.length }, gate, options);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
return failure("scan", error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (options.positionals.length > 0)
|
|
141
|
+
return usageError(`audit: unexpected argument "${options.positionals[0]}"`);
|
|
142
|
+
try {
|
|
143
|
+
const { config, source } = await loadConfig(options.configPath);
|
|
144
|
+
if (config.audit === undefined) {
|
|
145
|
+
throw new ConfigError(source === null
|
|
146
|
+
? "audit needs a config file with an \"audit\" block (see docs/configuration.md)"
|
|
147
|
+
: `${source} has no "audit" block (see docs/configuration.md)`);
|
|
148
|
+
}
|
|
149
|
+
const gate = gateFor(options, config);
|
|
150
|
+
const audited = await runAudit(config.audit);
|
|
151
|
+
if (audited.responsesEvaluated === 0) {
|
|
152
|
+
// "No findings" would be a lie: nothing was looked at. Fail loudly.
|
|
153
|
+
throw new Error(`no response could be evaluated, so nothing was audited:\n ${audited.warnings.join("\n ")}`);
|
|
154
|
+
}
|
|
155
|
+
const { kept, ignored } = applyIgnores(audited.findings, config.ignore);
|
|
156
|
+
return await finish({ ...audited, findings: kept, ignored: ignored.length }, gate, options);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
return failure("audit", error);
|
|
160
|
+
}
|
|
161
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AuditConfig } from "./audit/types.ts";
|
|
2
|
+
import type { Finding } from "./index.ts";
|
|
3
|
+
import { type Gate } from "./report/index.ts";
|
|
4
|
+
export interface IgnoreRule {
|
|
5
|
+
/** Rule id to ignore, or "*" for any rule. */
|
|
6
|
+
ruleId: string;
|
|
7
|
+
/** Glob over the evidence path, relative to the scan target. */
|
|
8
|
+
path?: string;
|
|
9
|
+
/** Substring that must appear in the finding's message or excerpt. */
|
|
10
|
+
match?: string;
|
|
11
|
+
/** Why this is safe. Required. */
|
|
12
|
+
reason: string;
|
|
13
|
+
}
|
|
14
|
+
export interface Config {
|
|
15
|
+
gate?: Partial<Gate>;
|
|
16
|
+
ignore: IgnoreRule[];
|
|
17
|
+
/** Layer 2 settings; `evershield audit` requires this block. */
|
|
18
|
+
audit?: AuditConfig;
|
|
19
|
+
}
|
|
20
|
+
export declare const DEFAULT_CONFIG_FILE = "evershield.config.json";
|
|
21
|
+
export declare class ConfigError extends Error {
|
|
22
|
+
}
|
|
23
|
+
/** Validate a parsed JSON value into a Config, with messages a human can act on. */
|
|
24
|
+
export declare function parseConfig(raw: unknown, source: string): Config;
|
|
25
|
+
/**
|
|
26
|
+
* Load the config file. An explicit path must exist; the default file is
|
|
27
|
+
* optional. Returns the source path alongside, for messages.
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadConfig(explicitPath?: string): Promise<{
|
|
30
|
+
config: Config;
|
|
31
|
+
source: string | null;
|
|
32
|
+
}>;
|
|
33
|
+
/** Minimal glob: `**` spans directories, `*` and `?` stay within one segment. */
|
|
34
|
+
export declare function globToRegExp(pattern: string): RegExp;
|
|
35
|
+
/** Split findings into those kept and those an ignore rule accounts for. */
|
|
36
|
+
export declare function applyIgnores(findings: Finding[], ignore: IgnoreRule[]): {
|
|
37
|
+
kept: Finding[];
|
|
38
|
+
ignored: Finding[];
|
|
39
|
+
};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* evershield.config.json
|
|
3
|
+
*
|
|
4
|
+
* {
|
|
5
|
+
* "gate": { "failOn": "high", "minConfidence": "medium" },
|
|
6
|
+
* "ignore": [
|
|
7
|
+
* { "ruleId": "artifact/internal-endpoint", "match": "localhost:9999",
|
|
8
|
+
* "reason": "supabase-js bundles this default; unreachable in production" }
|
|
9
|
+
* ]
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* Every ignore needs a reason. An allowlist without reasons rots into a
|
|
13
|
+
* list nobody dares to delete; with reasons it is documentation.
|
|
14
|
+
*/
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { CONFIDENCES, SEVERITIES } from "./report/index.js";
|
|
18
|
+
export const DEFAULT_CONFIG_FILE = "evershield.config.json";
|
|
19
|
+
export class ConfigError extends Error {
|
|
20
|
+
}
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
/** Validate a parsed JSON value into a Config, with messages a human can act on. */
|
|
25
|
+
export function parseConfig(raw, source) {
|
|
26
|
+
if (!isRecord(raw))
|
|
27
|
+
throw new ConfigError(`${source}: expected a JSON object`);
|
|
28
|
+
const config = { ignore: [] };
|
|
29
|
+
if (raw.gate !== undefined) {
|
|
30
|
+
if (!isRecord(raw.gate))
|
|
31
|
+
throw new ConfigError(`${source}: "gate" must be an object`);
|
|
32
|
+
const gate = {};
|
|
33
|
+
if (raw.gate.failOn !== undefined) {
|
|
34
|
+
const failOn = raw.gate.failOn;
|
|
35
|
+
if (typeof failOn !== "string" || !(failOn === "none" || SEVERITIES.includes(failOn))) {
|
|
36
|
+
throw new ConfigError(`${source}: "gate.failOn" must be one of ${[...SEVERITIES, "none"].join(", ")}`);
|
|
37
|
+
}
|
|
38
|
+
gate.failOn = failOn;
|
|
39
|
+
}
|
|
40
|
+
if (raw.gate.minConfidence !== undefined) {
|
|
41
|
+
const minConfidence = raw.gate.minConfidence;
|
|
42
|
+
if (typeof minConfidence !== "string" || !CONFIDENCES.includes(minConfidence)) {
|
|
43
|
+
throw new ConfigError(`${source}: "gate.minConfidence" must be one of ${CONFIDENCES.join(", ")}`);
|
|
44
|
+
}
|
|
45
|
+
gate.minConfidence = minConfidence;
|
|
46
|
+
}
|
|
47
|
+
config.gate = gate;
|
|
48
|
+
}
|
|
49
|
+
if (raw.ignore !== undefined) {
|
|
50
|
+
if (!Array.isArray(raw.ignore))
|
|
51
|
+
throw new ConfigError(`${source}: "ignore" must be an array`);
|
|
52
|
+
raw.ignore.forEach((entry, index) => {
|
|
53
|
+
const where = `${source}: ignore[${index}]`;
|
|
54
|
+
if (!isRecord(entry))
|
|
55
|
+
throw new ConfigError(`${where} must be an object`);
|
|
56
|
+
if (typeof entry.ruleId !== "string" || entry.ruleId.length === 0)
|
|
57
|
+
throw new ConfigError(`${where}.ruleId is required`);
|
|
58
|
+
if (typeof entry.reason !== "string" || entry.reason.trim().length === 0) {
|
|
59
|
+
throw new ConfigError(`${where}.reason is required: say why this finding is safe to ignore`);
|
|
60
|
+
}
|
|
61
|
+
if (entry.path !== undefined && typeof entry.path !== "string")
|
|
62
|
+
throw new ConfigError(`${where}.path must be a string`);
|
|
63
|
+
if (entry.match !== undefined && typeof entry.match !== "string")
|
|
64
|
+
throw new ConfigError(`${where}.match must be a string`);
|
|
65
|
+
const rule = { ruleId: entry.ruleId, reason: entry.reason };
|
|
66
|
+
if (typeof entry.path === "string")
|
|
67
|
+
rule.path = entry.path;
|
|
68
|
+
if (typeof entry.match === "string")
|
|
69
|
+
rule.match = entry.match;
|
|
70
|
+
config.ignore.push(rule);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (raw.audit !== undefined)
|
|
74
|
+
config.audit = parseAuditConfig(raw.audit, `${source}: audit`);
|
|
75
|
+
return config;
|
|
76
|
+
}
|
|
77
|
+
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
78
|
+
const CREDENTIAL_HEADERS = new Set(["authorization", "cookie", "x-api-key", "api-key", "x-auth-token"]);
|
|
79
|
+
function parseHeaders(raw, where) {
|
|
80
|
+
if (!isRecord(raw))
|
|
81
|
+
throw new ConfigError(`${where} must be an object of header name → value`);
|
|
82
|
+
const headers = {};
|
|
83
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
84
|
+
if (typeof value !== "string")
|
|
85
|
+
throw new ConfigError(`${where}.${name} must be a string`);
|
|
86
|
+
if (CREDENTIAL_HEADERS.has(name.toLowerCase()) && !/\$\{[A-Z_][A-Z0-9_]*\}/.test(value)) {
|
|
87
|
+
throw new ConfigError(`${where}.${name} must reference an environment variable such as "Bearer \${EVERSHIELD_PRO_TOKEN}"; literal credentials are not allowed in config`);
|
|
88
|
+
}
|
|
89
|
+
headers[name] = value;
|
|
90
|
+
}
|
|
91
|
+
return headers;
|
|
92
|
+
}
|
|
93
|
+
function parseAuditConfig(raw, where) {
|
|
94
|
+
if (!isRecord(raw))
|
|
95
|
+
throw new ConfigError(`${where} must be an object`);
|
|
96
|
+
if (typeof raw.baseUrl !== "string")
|
|
97
|
+
throw new ConfigError(`${where}.baseUrl is required`);
|
|
98
|
+
let base;
|
|
99
|
+
try {
|
|
100
|
+
base = new URL(raw.baseUrl);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
throw new ConfigError(`${where}.baseUrl is not a valid URL`);
|
|
104
|
+
}
|
|
105
|
+
if (base.protocol !== "http:" && base.protocol !== "https:")
|
|
106
|
+
throw new ConfigError(`${where}.baseUrl must be http or https`);
|
|
107
|
+
if (!isRecord(raw.identities) || Object.keys(raw.identities).length === 0) {
|
|
108
|
+
throw new ConfigError(`${where}.identities must define at least one identity`);
|
|
109
|
+
}
|
|
110
|
+
const identities = {};
|
|
111
|
+
for (const [name, value] of Object.entries(raw.identities)) {
|
|
112
|
+
if (!isRecord(value))
|
|
113
|
+
throw new ConfigError(`${where}.identities.${name} must be an object`);
|
|
114
|
+
const identity = {};
|
|
115
|
+
if (value.headers !== undefined)
|
|
116
|
+
identity.headers = parseHeaders(value.headers, `${where}.identities.${name}.headers`);
|
|
117
|
+
identities[name] = identity;
|
|
118
|
+
}
|
|
119
|
+
if (!Array.isArray(raw.tiers) || raw.tiers.length === 0 || !raw.tiers.every((tier) => typeof tier === "string")) {
|
|
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
|
+
}
|
|
127
|
+
if (!Array.isArray(raw.endpoints) || raw.endpoints.length === 0)
|
|
128
|
+
throw new ConfigError(`${where}.endpoints must list at least one endpoint`);
|
|
129
|
+
const endpoints = raw.endpoints.map((entry, index) => {
|
|
130
|
+
const at = `${where}.endpoints[${index}]`;
|
|
131
|
+
if (!isRecord(entry))
|
|
132
|
+
throw new ConfigError(`${at} must be an object`);
|
|
133
|
+
const method = (typeof entry.method === "string" ? entry.method : "GET").toUpperCase();
|
|
134
|
+
if (!METHODS.includes(method))
|
|
135
|
+
throw new ConfigError(`${at}.method must be one of ${METHODS.join(", ")}`);
|
|
136
|
+
if (typeof entry.path !== "string" || !entry.path.startsWith("/"))
|
|
137
|
+
throw new ConfigError(`${at}.path must start with "/" (relative to baseUrl)`);
|
|
138
|
+
const endpoint = { method: method, path: entry.path };
|
|
139
|
+
if (entry.headers !== undefined)
|
|
140
|
+
endpoint.headers = parseHeaders(entry.headers, `${at}.headers`);
|
|
141
|
+
if (entry.body !== undefined)
|
|
142
|
+
endpoint.body = entry.body;
|
|
143
|
+
if (entry.tiered !== undefined) {
|
|
144
|
+
if (typeof entry.tiered !== "boolean")
|
|
145
|
+
throw new ConfigError(`${at}.tiered must be a boolean`);
|
|
146
|
+
endpoint.tiered = entry.tiered;
|
|
147
|
+
}
|
|
148
|
+
if (entry.expect !== undefined) {
|
|
149
|
+
if (!isRecord(entry.expect))
|
|
150
|
+
throw new ConfigError(`${at}.expect must be an object keyed by identity`);
|
|
151
|
+
endpoint.expect = {};
|
|
152
|
+
for (const [identity, expectation] of Object.entries(entry.expect)) {
|
|
153
|
+
if (identities[identity] === undefined)
|
|
154
|
+
throw new ConfigError(`${at}.expect.${identity} is not a defined identity`);
|
|
155
|
+
if (!isRecord(expectation))
|
|
156
|
+
throw new ConfigError(`${at}.expect.${identity} must be an object`);
|
|
157
|
+
const absent = expectation.absent;
|
|
158
|
+
if (absent !== undefined && !(Array.isArray(absent) && absent.every((path) => typeof path === "string" && path.length > 0))) {
|
|
159
|
+
throw new ConfigError(`${at}.expect.${identity}.absent must be an array of JSON paths`);
|
|
160
|
+
}
|
|
161
|
+
endpoint.expect[identity] = absent === undefined ? {} : { absent: absent };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return endpoint;
|
|
165
|
+
});
|
|
166
|
+
const audit = { baseUrl: base.origin, tiers, identities, endpoints };
|
|
167
|
+
if (raw.timeoutMs !== undefined) {
|
|
168
|
+
if (typeof raw.timeoutMs !== "number" || raw.timeoutMs <= 0)
|
|
169
|
+
throw new ConfigError(`${where}.timeoutMs must be a positive number`);
|
|
170
|
+
audit.timeoutMs = raw.timeoutMs;
|
|
171
|
+
}
|
|
172
|
+
return audit;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Load the config file. An explicit path must exist; the default file is
|
|
176
|
+
* optional. Returns the source path alongside, for messages.
|
|
177
|
+
*/
|
|
178
|
+
export async function loadConfig(explicitPath) {
|
|
179
|
+
const filePath = path.resolve(explicitPath ?? DEFAULT_CONFIG_FILE);
|
|
180
|
+
let text;
|
|
181
|
+
try {
|
|
182
|
+
text = await readFile(filePath, "utf8");
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
const code = error.code;
|
|
186
|
+
if (code === "ENOENT" && explicitPath === undefined)
|
|
187
|
+
return { config: { ignore: [] }, source: null };
|
|
188
|
+
throw new ConfigError(`cannot read config ${filePath}: ${code ?? String(error)}`);
|
|
189
|
+
}
|
|
190
|
+
let raw;
|
|
191
|
+
try {
|
|
192
|
+
raw = JSON.parse(text);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
throw new ConfigError(`${filePath}: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
196
|
+
}
|
|
197
|
+
return { config: parseConfig(raw, filePath), source: filePath };
|
|
198
|
+
}
|
|
199
|
+
/** Minimal glob: `**` spans directories, `*` and `?` stay within one segment. */
|
|
200
|
+
export function globToRegExp(pattern) {
|
|
201
|
+
let out = "";
|
|
202
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
203
|
+
const ch = pattern[i];
|
|
204
|
+
if (ch === "*") {
|
|
205
|
+
if (pattern[i + 1] === "*") {
|
|
206
|
+
const slashFollows = pattern[i + 2] === "/";
|
|
207
|
+
out += slashFollows ? "(?:.*/)?" : ".*";
|
|
208
|
+
i += slashFollows ? 2 : 1;
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
out += "[^/]*";
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else if (ch === "?") {
|
|
215
|
+
out += "[^/]";
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
out += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return new RegExp(`^${out}$`);
|
|
222
|
+
}
|
|
223
|
+
function matches(rule, finding) {
|
|
224
|
+
if (rule.ruleId !== "*" && rule.ruleId !== finding.ruleId)
|
|
225
|
+
return false;
|
|
226
|
+
const first = finding.evidence[0];
|
|
227
|
+
if (rule.path !== undefined) {
|
|
228
|
+
// 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
|
+
const subject = first?.kind === "file" ? first.path : first?.kind === "response" ? first.endpoint : undefined;
|
|
231
|
+
if (subject === undefined || !globToRegExp(rule.path).test(subject))
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
if (rule.match !== undefined) {
|
|
235
|
+
const haystack = `${finding.message}\n${first?.excerpt ?? ""}`;
|
|
236
|
+
if (!haystack.includes(rule.match))
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
/** Split findings into those kept and those an ignore rule accounts for. */
|
|
242
|
+
export function applyIgnores(findings, ignore) {
|
|
243
|
+
const kept = [];
|
|
244
|
+
const ignored = [];
|
|
245
|
+
for (const finding of findings) {
|
|
246
|
+
(ignore.some((rule) => matches(rule, finding)) ? ignored : kept).push(finding);
|
|
247
|
+
}
|
|
248
|
+
return { kept, ignored };
|
|
249
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* evershield public API.
|
|
3
|
+
*
|
|
4
|
+
* The scanner itself lands in later releases; this module fixes the shapes
|
|
5
|
+
* every layer reports in, so CLI, GitHub Action and MCP surfaces can share
|
|
6
|
+
* one finding model from day one. See docs/design.md for the rationale.
|
|
7
|
+
*/
|
|
8
|
+
export declare const VERSION = "0.1.0";
|
|
9
|
+
/** How sure evershield is that a finding is real. Drives exit codes and CI gating. */
|
|
10
|
+
export type Confidence = "high" | "medium" | "low";
|
|
11
|
+
/** How bad it is if the finding is real. */
|
|
12
|
+
export type Severity = "critical" | "high" | "medium" | "low" | "info";
|
|
13
|
+
/** Which analysis layer produced the finding. */
|
|
14
|
+
export type Layer = "artifact" | "response" | "surface";
|
|
15
|
+
/** Where in the shipped output the evidence was observed. */
|
|
16
|
+
export type Evidence = {
|
|
17
|
+
kind: "file";
|
|
18
|
+
path: string;
|
|
19
|
+
line?: number;
|
|
20
|
+
column?: number;
|
|
21
|
+
excerpt?: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "response";
|
|
24
|
+
endpoint: string;
|
|
25
|
+
identity: string;
|
|
26
|
+
jsonPath: string;
|
|
27
|
+
excerpt?: string;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "dom";
|
|
30
|
+
url: string;
|
|
31
|
+
selector: string;
|
|
32
|
+
excerpt?: string;
|
|
33
|
+
};
|
|
34
|
+
export interface Finding {
|
|
35
|
+
/** Stable rule identifier, e.g. "artifact/secret-like-string". */
|
|
36
|
+
ruleId: string;
|
|
37
|
+
layer: Layer;
|
|
38
|
+
severity: Severity;
|
|
39
|
+
confidence: Confidence;
|
|
40
|
+
/** One sentence a developer can act on without opening docs. */
|
|
41
|
+
message: string;
|
|
42
|
+
evidence: Evidence[];
|
|
43
|
+
/** Concrete fix, phrased for the person who will apply it. */
|
|
44
|
+
remediation?: string;
|
|
45
|
+
/** Rule documentation URL. */
|
|
46
|
+
helpUri?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface ScanSummary {
|
|
49
|
+
version: string;
|
|
50
|
+
startedAt: string;
|
|
51
|
+
finishedAt: string;
|
|
52
|
+
findings: Finding[];
|
|
53
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* evershield public API.
|
|
3
|
+
*
|
|
4
|
+
* The scanner itself lands in later releases; this module fixes the shapes
|
|
5
|
+
* every layer reports in, so CLI, GitHub Action and MCP surfaces can share
|
|
6
|
+
* one finding model from day one. See docs/design.md for the rationale.
|
|
7
|
+
*/
|
|
8
|
+
export const VERSION = "0.1.0";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AuditResult } from "../audit/run.ts";
|
|
2
|
+
import type { Confidence, Finding, Severity } from "../index.ts";
|
|
3
|
+
import type { ArtifactScanResult } from "../scan/artifact.ts";
|
|
4
|
+
export type Format = "text" | "json" | "sarif";
|
|
5
|
+
/** Any layer's result. Renderers switch on `layer` for the header only. */
|
|
6
|
+
export type Report = ArtifactScanResult | AuditResult;
|
|
7
|
+
export declare const SEVERITIES: readonly Severity[];
|
|
8
|
+
export declare const CONFIDENCES: readonly Confidence[];
|
|
9
|
+
export interface Gate {
|
|
10
|
+
/** Findings at this severity or worse can fail the build. "none" never fails. */
|
|
11
|
+
failOn: Severity | "none";
|
|
12
|
+
/** Findings below this confidence never fail the build, whatever their severity. */
|
|
13
|
+
minConfidence: Confidence;
|
|
14
|
+
}
|
|
15
|
+
export declare const DEFAULT_GATE: Gate;
|
|
16
|
+
export declare function isBlocking(finding: Finding, gate: Gate): boolean;
|
|
17
|
+
export declare function renderText(result: Report, gate: Gate): string;
|
|
18
|
+
export declare function renderJson(result: Report, gate: Gate): string;
|