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/dist/config.js ADDED
@@ -0,0 +1,352 @@
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
+ if (raw.surface !== undefined)
76
+ config.surface = parseSurfaceConfig(raw.surface, `${source}: surface`);
77
+ return config;
78
+ }
79
+ const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
80
+ const CREDENTIAL_HEADERS = new Set(["authorization", "cookie", "x-api-key", "api-key", "x-auth-token"]);
81
+ function parseHeaders(raw, where) {
82
+ if (!isRecord(raw))
83
+ throw new ConfigError(`${where} must be an object of header name → value`);
84
+ const headers = {};
85
+ for (const [name, value] of Object.entries(raw)) {
86
+ if (typeof value !== "string")
87
+ throw new ConfigError(`${where}.${name} must be a string`);
88
+ if (CREDENTIAL_HEADERS.has(name.toLowerCase()) && !/\$\{[A-Z_][A-Z0-9_]*\}/.test(value)) {
89
+ throw new ConfigError(`${where}.${name} must reference an environment variable such as "Bearer \${EVERSHIELD_PRO_TOKEN}"; literal credentials are not allowed in config`);
90
+ }
91
+ headers[name] = value;
92
+ }
93
+ return headers;
94
+ }
95
+ function parseBaseUrl(raw, where) {
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
+ return base;
108
+ }
109
+ function parseTiers(raw, where, identities) {
110
+ if (!Array.isArray(raw.tiers) || raw.tiers.length === 0 || !raw.tiers.every((tier) => typeof tier === "string")) {
111
+ throw new ConfigError(`${where}.tiers must list identity names from least to most privileged`);
112
+ }
113
+ const tiers = raw.tiers;
114
+ for (const tier of tiers) {
115
+ if (identities[tier] === undefined)
116
+ throw new ConfigError(`${where}.tiers names "${tier}" which is not in identities`);
117
+ }
118
+ return tiers;
119
+ }
120
+ function parseTimeout(raw, where) {
121
+ if (raw.timeoutMs === undefined)
122
+ return undefined;
123
+ if (typeof raw.timeoutMs !== "number" || raw.timeoutMs <= 0)
124
+ throw new ConfigError(`${where}.timeoutMs must be a positive number`);
125
+ return raw.timeoutMs;
126
+ }
127
+ function parseAuditConfig(raw, where) {
128
+ if (!isRecord(raw))
129
+ throw new ConfigError(`${where} must be an object`);
130
+ const base = parseBaseUrl(raw, where);
131
+ if (!isRecord(raw.identities) || Object.keys(raw.identities).length === 0) {
132
+ throw new ConfigError(`${where}.identities must define at least one identity`);
133
+ }
134
+ const identities = {};
135
+ for (const [name, value] of Object.entries(raw.identities)) {
136
+ if (!isRecord(value))
137
+ throw new ConfigError(`${where}.identities.${name} must be an object`);
138
+ const identity = {};
139
+ if (value.headers !== undefined)
140
+ identity.headers = parseHeaders(value.headers, `${where}.identities.${name}.headers`);
141
+ identities[name] = identity;
142
+ }
143
+ const tiers = parseTiers(raw, where, identities);
144
+ if (!Array.isArray(raw.endpoints) || raw.endpoints.length === 0)
145
+ throw new ConfigError(`${where}.endpoints must list at least one endpoint`);
146
+ const endpoints = raw.endpoints.map((entry, index) => {
147
+ const at = `${where}.endpoints[${index}]`;
148
+ if (!isRecord(entry))
149
+ throw new ConfigError(`${at} must be an object`);
150
+ const method = (typeof entry.method === "string" ? entry.method : "GET").toUpperCase();
151
+ if (!METHODS.includes(method))
152
+ throw new ConfigError(`${at}.method must be one of ${METHODS.join(", ")}`);
153
+ if (typeof entry.path !== "string" || !entry.path.startsWith("/"))
154
+ throw new ConfigError(`${at}.path must start with "/" (relative to baseUrl)`);
155
+ const endpoint = { method: method, path: entry.path };
156
+ if (entry.headers !== undefined)
157
+ endpoint.headers = parseHeaders(entry.headers, `${at}.headers`);
158
+ if (entry.body !== undefined)
159
+ endpoint.body = entry.body;
160
+ if (entry.tiered !== undefined) {
161
+ if (typeof entry.tiered !== "boolean")
162
+ throw new ConfigError(`${at}.tiered must be a boolean`);
163
+ endpoint.tiered = entry.tiered;
164
+ }
165
+ if (entry.expect !== undefined) {
166
+ if (!isRecord(entry.expect))
167
+ throw new ConfigError(`${at}.expect must be an object keyed by identity`);
168
+ endpoint.expect = {};
169
+ for (const [identity, expectation] of Object.entries(entry.expect)) {
170
+ if (identities[identity] === undefined)
171
+ throw new ConfigError(`${at}.expect.${identity} is not a defined identity`);
172
+ if (!isRecord(expectation))
173
+ throw new ConfigError(`${at}.expect.${identity} must be an object`);
174
+ const absent = expectation.absent;
175
+ if (absent !== undefined && !(Array.isArray(absent) && absent.every((path) => typeof path === "string" && path.length > 0))) {
176
+ throw new ConfigError(`${at}.expect.${identity}.absent must be an array of JSON paths`);
177
+ }
178
+ endpoint.expect[identity] = absent === undefined ? {} : { absent: absent };
179
+ }
180
+ }
181
+ return endpoint;
182
+ });
183
+ const audit = { baseUrl: base.origin, tiers, identities, endpoints };
184
+ const timeoutMs = parseTimeout(raw, where);
185
+ if (timeoutMs !== undefined)
186
+ audit.timeoutMs = timeoutMs;
187
+ return audit;
188
+ }
189
+ const ENV_REF_PRESENT = /\$\{[A-Z_][A-Z0-9_]*\}/;
190
+ function parseStringList(value, where) {
191
+ if (!(Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0))) {
192
+ throw new ConfigError(`${where} must be an array of non-empty strings`);
193
+ }
194
+ return value;
195
+ }
196
+ function parseSurfaceConfig(raw, where) {
197
+ if (!isRecord(raw))
198
+ throw new ConfigError(`${where} must be an object`);
199
+ const base = parseBaseUrl(raw, where);
200
+ if (!isRecord(raw.identities) || Object.keys(raw.identities).length === 0) {
201
+ throw new ConfigError(`${where}.identities must define at least one identity`);
202
+ }
203
+ const identities = {};
204
+ for (const [name, value] of Object.entries(raw.identities)) {
205
+ const at = `${where}.identities.${name}`;
206
+ if (!isRecord(value))
207
+ throw new ConfigError(`${at} must be an object`);
208
+ const identity = {};
209
+ if (value.headers !== undefined)
210
+ identity.headers = parseHeaders(value.headers, `${at}.headers`);
211
+ if (value.cookies !== undefined) {
212
+ if (!isRecord(value.cookies))
213
+ throw new ConfigError(`${at}.cookies must be an object of cookie name → value`);
214
+ identity.cookies = {};
215
+ for (const [cookie, cookieValue] of Object.entries(value.cookies)) {
216
+ if (typeof cookieValue !== "string" || !ENV_REF_PRESENT.test(cookieValue)) {
217
+ throw new ConfigError(`${at}.cookies.${cookie} must reference an environment variable such as "\${EVERSHIELD_PRO_SESSION}"; literal credentials are not allowed in config`);
218
+ }
219
+ identity.cookies[cookie] = cookieValue;
220
+ }
221
+ }
222
+ if (value.storageState !== undefined) {
223
+ if (typeof value.storageState !== "string" || value.storageState.length === 0)
224
+ throw new ConfigError(`${at}.storageState must be a file path`);
225
+ identity.storageState = value.storageState;
226
+ }
227
+ identities[name] = identity;
228
+ }
229
+ const tiers = parseTiers(raw, where, identities);
230
+ if (!Array.isArray(raw.pages) || raw.pages.length === 0)
231
+ throw new ConfigError(`${where}.pages must list at least one page`);
232
+ const pages = raw.pages.map((entry, index) => {
233
+ const at = `${where}.pages[${index}]`;
234
+ if (!isRecord(entry))
235
+ throw new ConfigError(`${at} must be an object`);
236
+ if (typeof entry.path !== "string" || !entry.path.startsWith("/"))
237
+ throw new ConfigError(`${at}.path must start with "/" (relative to baseUrl)`);
238
+ const page = { path: entry.path };
239
+ if (entry.waitFor !== undefined) {
240
+ if (typeof entry.waitFor !== "string" || entry.waitFor.length === 0)
241
+ throw new ConfigError(`${at}.waitFor must be a CSS selector`);
242
+ page.waitFor = entry.waitFor;
243
+ }
244
+ if (entry.expect !== undefined) {
245
+ if (!isRecord(entry.expect))
246
+ throw new ConfigError(`${at}.expect must be an object keyed by identity`);
247
+ page.expect = {};
248
+ for (const [identity, expectation] of Object.entries(entry.expect)) {
249
+ if (identities[identity] === undefined)
250
+ throw new ConfigError(`${at}.expect.${identity} is not a defined identity`);
251
+ if (!isRecord(expectation))
252
+ throw new ConfigError(`${at}.expect.${identity} must be an object`);
253
+ const parsed = {};
254
+ if (expectation.absentText !== undefined)
255
+ parsed.absentText = parseStringList(expectation.absentText, `${at}.expect.${identity}.absentText`);
256
+ if (expectation.absentSelectors !== undefined) {
257
+ parsed.absentSelectors = parseStringList(expectation.absentSelectors, `${at}.expect.${identity}.absentSelectors`);
258
+ }
259
+ page.expect[identity] = parsed;
260
+ }
261
+ }
262
+ return page;
263
+ });
264
+ const surface = { baseUrl: base.origin, tiers, identities, pages };
265
+ const timeoutMs = parseTimeout(raw, where);
266
+ if (timeoutMs !== undefined)
267
+ surface.timeoutMs = timeoutMs;
268
+ if (raw.minTextLength !== undefined) {
269
+ if (typeof raw.minTextLength !== "number" || !Number.isInteger(raw.minTextLength) || raw.minTextLength < 4) {
270
+ throw new ConfigError(`${where}.minTextLength must be an integer of at least 4`);
271
+ }
272
+ surface.minTextLength = raw.minTextLength;
273
+ }
274
+ return surface;
275
+ }
276
+ /**
277
+ * Load the config file. An explicit path must exist; the default file is
278
+ * optional. Returns the source path alongside, for messages.
279
+ */
280
+ export async function loadConfig(explicitPath) {
281
+ const filePath = path.resolve(explicitPath ?? DEFAULT_CONFIG_FILE);
282
+ let text;
283
+ try {
284
+ text = await readFile(filePath, "utf8");
285
+ }
286
+ catch (error) {
287
+ const code = error.code;
288
+ if (code === "ENOENT" && explicitPath === undefined)
289
+ return { config: { ignore: [] }, source: null };
290
+ throw new ConfigError(`cannot read config ${filePath}: ${code ?? String(error)}`);
291
+ }
292
+ let raw;
293
+ try {
294
+ raw = JSON.parse(text);
295
+ }
296
+ catch (error) {
297
+ throw new ConfigError(`${filePath}: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
298
+ }
299
+ return { config: parseConfig(raw, filePath), source: filePath };
300
+ }
301
+ /** Minimal glob: `**` spans directories, `*` and `?` stay within one segment. */
302
+ export function globToRegExp(pattern) {
303
+ let out = "";
304
+ for (let i = 0; i < pattern.length; i += 1) {
305
+ const ch = pattern[i];
306
+ if (ch === "*") {
307
+ if (pattern[i + 1] === "*") {
308
+ const slashFollows = pattern[i + 2] === "/";
309
+ out += slashFollows ? "(?:.*/)?" : ".*";
310
+ i += slashFollows ? 2 : 1;
311
+ }
312
+ else {
313
+ out += "[^/]*";
314
+ }
315
+ }
316
+ else if (ch === "?") {
317
+ out += "[^/]";
318
+ }
319
+ else {
320
+ out += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
321
+ }
322
+ }
323
+ return new RegExp(`^${out}$`);
324
+ }
325
+ function matches(rule, finding) {
326
+ if (rule.ruleId !== "*" && rule.ruleId !== finding.ruleId)
327
+ return false;
328
+ const first = finding.evidence[0];
329
+ if (rule.path !== undefined) {
330
+ // For file evidence the glob matches the file path; for response
331
+ // evidence it matches "METHOD /path" so one entry can cover an endpoint;
332
+ // DOM evidence matches the page path.
333
+ const subject = first?.kind === "file" ? first.path : first?.kind === "response" ? first.endpoint : first?.kind === "dom" ? new URL(first.url).pathname : undefined;
334
+ if (subject === undefined || !globToRegExp(rule.path).test(subject))
335
+ return false;
336
+ }
337
+ if (rule.match !== undefined) {
338
+ const haystack = `${finding.message}\n${first?.excerpt ?? ""}`;
339
+ if (!haystack.includes(rule.match))
340
+ return false;
341
+ }
342
+ return true;
343
+ }
344
+ /** Split findings into those kept and those an ignore rule accounts for. */
345
+ export function applyIgnores(findings, ignore) {
346
+ const kept = [];
347
+ const ignored = [];
348
+ for (const finding of findings) {
349
+ (ignore.some((rule) => matches(rule, finding)) ? ignored : kept).push(finding);
350
+ }
351
+ return { kept, ignored };
352
+ }
@@ -0,0 +1,54 @@
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.2.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
+ identity?: string;
33
+ excerpt?: string;
34
+ };
35
+ export interface Finding {
36
+ /** Stable rule identifier, e.g. "artifact/secret-like-string". */
37
+ ruleId: string;
38
+ layer: Layer;
39
+ severity: Severity;
40
+ confidence: Confidence;
41
+ /** One sentence a developer can act on without opening docs. */
42
+ message: string;
43
+ evidence: Evidence[];
44
+ /** Concrete fix, phrased for the person who will apply it. */
45
+ remediation?: string;
46
+ /** Rule documentation URL. */
47
+ helpUri?: string;
48
+ }
49
+ export interface ScanSummary {
50
+ version: string;
51
+ startedAt: string;
52
+ finishedAt: string;
53
+ findings: Finding[];
54
+ }
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.2.0";
package/dist/init.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ export interface Detection {
2
+ framework: "nextjs" | "vite" | "astro" | "create-react-app" | "nuxt" | "sveltekit" | "unknown";
3
+ /** Build output directory relative to the project root. */
4
+ buildDir: string;
5
+ packageManager: "pnpm" | "yarn" | "bun" | "npm";
6
+ /** Shell command that produces the build directory. */
7
+ buildCommand: string;
8
+ /** package.json pins a version in "packageManager"; pnpm/action-setup fails without it unless told one. */
9
+ packageManagerPinned?: boolean;
10
+ }
11
+ export declare function detectProject(root: string): Promise<Detection>;
12
+ export declare function renderWorkflow(detection: Detection): string;
13
+ export declare function renderConfig(): string;
14
+ export interface InitResult {
15
+ detection: Detection;
16
+ written: string[];
17
+ skipped: string[];
18
+ }
19
+ export declare function runInit(root: string, options?: {
20
+ force?: boolean;
21
+ }): Promise<InitResult>;
package/dist/init.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `evershield init`: turn a one-off scan into a check that runs on every
3
+ * pull request. Detects the framework and build directory, then writes
4
+ * `evershield.config.json` and `.github/workflows/evershield.yml`.
5
+ *
6
+ * Existing files are never overwritten unless `--force` is given; init
7
+ * reports what it skipped instead.
8
+ */
9
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import path from "node:path";
11
+ async function exists(filePath) {
12
+ try {
13
+ await access(filePath);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ async function anyExists(root, names) {
21
+ for (const name of names)
22
+ if (await exists(path.join(root, name)))
23
+ return true;
24
+ return false;
25
+ }
26
+ export async function detectProject(root) {
27
+ let pkg = {};
28
+ try {
29
+ pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
30
+ }
31
+ catch {
32
+ // Not a Node project, or no package.json: fall through to defaults.
33
+ }
34
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
35
+ const has = (name) => deps[name] !== undefined;
36
+ let framework = "unknown";
37
+ let buildDir = "dist";
38
+ if (has("next") || (await anyExists(root, ["next.config.js", "next.config.mjs", "next.config.ts"]))) {
39
+ framework = "nextjs";
40
+ buildDir = ".next";
41
+ }
42
+ else if (has("nuxt")) {
43
+ framework = "nuxt";
44
+ buildDir = ".output/public";
45
+ }
46
+ else if (has("@sveltejs/kit")) {
47
+ framework = "sveltekit";
48
+ buildDir = "build";
49
+ }
50
+ else if (has("astro")) {
51
+ framework = "astro";
52
+ buildDir = "dist";
53
+ }
54
+ else if (has("react-scripts")) {
55
+ framework = "create-react-app";
56
+ buildDir = "build";
57
+ }
58
+ else if (has("vite")) {
59
+ framework = "vite";
60
+ buildDir = "dist";
61
+ }
62
+ let packageManager = "npm";
63
+ if (await exists(path.join(root, "pnpm-lock.yaml")))
64
+ packageManager = "pnpm";
65
+ else if (await exists(path.join(root, "yarn.lock")))
66
+ packageManager = "yarn";
67
+ else if (await anyExists(root, ["bun.lockb", "bun.lock"]))
68
+ packageManager = "bun";
69
+ const run = packageManager === "npm" ? "npm run" : packageManager;
70
+ const buildCommand = pkg.scripts?.build !== undefined ? `${run} build` : "echo 'set your build command here' && exit 1";
71
+ return { framework, buildDir, packageManager, buildCommand, packageManagerPinned: typeof pkg.packageManager === "string" };
72
+ }
73
+ const INSTALL = {
74
+ npm: "npm ci",
75
+ pnpm: "pnpm install --frozen-lockfile",
76
+ yarn: "yarn install --frozen-lockfile",
77
+ bun: "bun install --frozen-lockfile",
78
+ };
79
+ export function renderWorkflow(detection) {
80
+ const setup = detection.packageManager === "pnpm"
81
+ ? ` - uses: pnpm/action-setup@v4\n${detection.packageManagerPinned === true ? "" : " with:\n version: 10\n"} - uses: actions/setup-node@v4\n with:\n node-version: 22\n cache: pnpm\n`
82
+ : detection.packageManager === "bun"
83
+ ? " - uses: oven-sh/setup-bun@v2\n"
84
+ : ` - uses: actions/setup-node@v4\n with:\n node-version: 22\n cache: ${detection.packageManager}\n`;
85
+ return `name: evershield
86
+
87
+ # Scans what your app ships to the browser on every pull request and
88
+ # reports findings in the Security tab. Generated by \`evershield init\`.
89
+
90
+ on:
91
+ pull_request:
92
+ push:
93
+ branches: [main]
94
+
95
+ permissions:
96
+ contents: read
97
+ security-events: write
98
+
99
+ jobs:
100
+ scan:
101
+ runs-on: ubuntu-latest
102
+ steps:
103
+ - uses: actions/checkout@v4
104
+ ${setup} - run: ${INSTALL[detection.packageManager]}
105
+ - run: ${detection.buildCommand}
106
+ - uses: inviy/evershield@v0
107
+ with:
108
+ path: ${detection.buildDir}
109
+ `;
110
+ }
111
+ export function renderConfig() {
112
+ return `${JSON.stringify({
113
+ gate: { failOn: "high", minConfidence: "medium" },
114
+ ignore: [],
115
+ }, null, 2)}\n`;
116
+ }
117
+ export async function runInit(root, options = {}) {
118
+ const detection = await detectProject(root);
119
+ const files = [
120
+ ["evershield.config.json", renderConfig()],
121
+ [".github/workflows/evershield.yml", renderWorkflow(detection)],
122
+ ];
123
+ const written = [];
124
+ const skipped = [];
125
+ for (const [relative, content] of files) {
126
+ const target = path.join(root, relative);
127
+ if (!options.force && (await exists(target))) {
128
+ skipped.push(relative);
129
+ continue;
130
+ }
131
+ await mkdir(path.dirname(target), { recursive: true });
132
+ await writeFile(target, content);
133
+ written.push(relative);
134
+ }
135
+ return { detection, written, skipped };
136
+ }
@@ -0,0 +1,19 @@
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
+ import type { SurfaceResult } from "../surface/run.ts";
5
+ export type Format = "text" | "json" | "sarif";
6
+ /** Any layer's result. Renderers switch on `layer` for the header only. */
7
+ export type Report = ArtifactScanResult | AuditResult | SurfaceResult;
8
+ export declare const SEVERITIES: readonly Severity[];
9
+ export declare const CONFIDENCES: readonly Confidence[];
10
+ export interface Gate {
11
+ /** Findings at this severity or worse can fail the build. "none" never fails. */
12
+ failOn: Severity | "none";
13
+ /** Findings below this confidence never fail the build, whatever their severity. */
14
+ minConfidence: Confidence;
15
+ }
16
+ export declare const DEFAULT_GATE: Gate;
17
+ export declare function isBlocking(finding: Finding, gate: Gate): boolean;
18
+ export declare function renderText(result: Report, gate: Gate): string;
19
+ export declare function renderJson(result: Report, gate: Gate): string;
@@ -0,0 +1,82 @@
1
+ export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
2
+ export const CONFIDENCES = ["high", "medium", "low"];
3
+ export const DEFAULT_GATE = { failOn: "high", minConfidence: "medium" };
4
+ export function isBlocking(finding, gate) {
5
+ if (gate.failOn === "none")
6
+ return false;
7
+ const severeEnough = SEVERITIES.indexOf(finding.severity) <= SEVERITIES.indexOf(gate.failOn);
8
+ const sureEnough = CONFIDENCES.indexOf(finding.confidence) <= CONFIDENCES.indexOf(gate.minConfidence);
9
+ return severeEnough && sureEnough;
10
+ }
11
+ function location(finding) {
12
+ const first = finding.evidence[0];
13
+ if (first === undefined)
14
+ return "";
15
+ if (first.kind === "file") {
16
+ return first.line === undefined ? first.path : `${first.path}:${first.line}:${first.column ?? 1}`;
17
+ }
18
+ if (first.kind === "response")
19
+ return `${first.endpoint} as ${first.identity} → ${first.jsonPath}`;
20
+ return `${first.url}${first.identity === undefined ? "" : ` as ${first.identity}`} → ${first.selector}`;
21
+ }
22
+ function header(result) {
23
+ if (result.layer === "artifact") {
24
+ return [
25
+ `evershield ${result.version} · artifact scan · ${result.adapter} adapter · ${result.filesScanned} files`,
26
+ `target: ${result.target}`,
27
+ ];
28
+ }
29
+ if (result.layer === "surface") {
30
+ return [
31
+ `evershield ${result.version} · surface audit · ${result.pagesAudited} page(s) × ${result.identities.length} identities (${result.identities.join(" < ")}) · ${result.snapshotsEvaluated} page loads evaluated`,
32
+ `target: ${result.target}`,
33
+ ];
34
+ }
35
+ return [
36
+ `evershield ${result.version} · response audit · ${result.endpointsAudited} endpoint(s) × ${result.identities.length} identities (${result.identities.join(" < ")}) · ${result.responsesEvaluated} responses evaluated`,
37
+ `target: ${result.target}`,
38
+ ];
39
+ }
40
+ export function renderText(result, gate) {
41
+ const lines = [...header(result), ""];
42
+ if (result.layer !== "artifact" && result.warnings.length > 0) {
43
+ for (const warning of result.warnings)
44
+ lines.push(`! ${warning}`);
45
+ lines.push("");
46
+ }
47
+ const ignoredNote = result.ignored > 0 ? ` ${result.ignored} finding(s) ignored by config.` : "";
48
+ if (result.findings.length === 0) {
49
+ const what = result.layer === "artifact"
50
+ ? "Nothing in the shipped files matched a rule."
51
+ : result.layer === "surface"
52
+ ? "No identity's page held content it could not see."
53
+ : "Every identity received only what it should.";
54
+ lines.push(`No findings. ${what}${ignoredNote}`);
55
+ return `${lines.join("\n")}\n`;
56
+ }
57
+ let blocking = 0;
58
+ for (const finding of result.findings) {
59
+ const blocks = isBlocking(finding, gate);
60
+ if (blocks)
61
+ blocking += 1;
62
+ const marker = blocks ? "✖" : "•";
63
+ lines.push(`${marker} [${finding.severity}/${finding.confidence}] ${finding.message}`);
64
+ lines.push(` at ${location(finding)}`);
65
+ const first = finding.evidence[0];
66
+ const excerpt = first?.excerpt;
67
+ if (excerpt !== undefined && first !== undefined && (first.kind !== "file" || first.line !== undefined)) {
68
+ lines.push(` evidence: ${excerpt}`);
69
+ }
70
+ if (finding.remediation !== undefined)
71
+ lines.push(` fix: ${finding.remediation}`);
72
+ lines.push(` rule: ${finding.ruleId}`);
73
+ lines.push("");
74
+ }
75
+ const gateText = gate.failOn === "none" ? "gate disabled" : `gate: severity ≥ ${gate.failOn}, confidence ≥ ${gate.minConfidence}`;
76
+ lines.push(`${result.findings.length} finding(s), ${blocking} blocking (${gateText}).${ignoredNote}`);
77
+ return `${lines.join("\n")}\n`;
78
+ }
79
+ export function renderJson(result, gate) {
80
+ const blocking = result.findings.filter((finding) => isBlocking(finding, gate)).length;
81
+ return `${JSON.stringify({ ...result, gate, blocking }, null, 2)}\n`;
82
+ }
@@ -0,0 +1,2 @@
1
+ import { type Gate, type Report } from "./index.ts";
2
+ export declare function renderSarif(result: Report, gate: Gate): string;