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,68 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { VERSION } from "../index.js";
4
+ import { ARTIFACT_RULES } from "../rules/index.js";
5
+ import { ADAPTERS, detectAdapter } from "./adapters.js";
6
+ import { collectFiles } from "./files.js";
7
+ const SEVERITY_RANK = {
8
+ critical: 0,
9
+ high: 1,
10
+ medium: 2,
11
+ low: 3,
12
+ info: 4,
13
+ };
14
+ const CONFIDENCE_RANK = {
15
+ high: 0,
16
+ medium: 1,
17
+ low: 2,
18
+ };
19
+ /** Worst first, then surest first, then by location so output is stable. */
20
+ export function sortFindings(findings) {
21
+ return [...findings].sort((a, b) => {
22
+ const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
23
+ if (bySeverity !== 0)
24
+ return bySeverity;
25
+ const byConfidence = CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence];
26
+ if (byConfidence !== 0)
27
+ return byConfidence;
28
+ const aPath = a.evidence[0]?.kind === "file" ? a.evidence[0].path : "";
29
+ const bPath = b.evidence[0]?.kind === "file" ? b.evidence[0].path : "";
30
+ if (aPath !== bPath)
31
+ return aPath < bPath ? -1 : 1;
32
+ const aLine = a.evidence[0]?.kind === "file" ? (a.evidence[0].line ?? 0) : 0;
33
+ const bLine = b.evidence[0]?.kind === "file" ? (b.evidence[0].line ?? 0) : 0;
34
+ return aLine - bLine;
35
+ });
36
+ }
37
+ /** Layer 1: scan a directory of built files for what should not ship. */
38
+ export async function scanArtifact(target, options = {}) {
39
+ const startedAt = new Date().toISOString();
40
+ const root = path.resolve(target);
41
+ const info = await stat(root).catch(() => null);
42
+ if (info === null || !info.isDirectory()) {
43
+ throw new Error(`not a directory: ${target}`);
44
+ }
45
+ const adapter = options.adapter ? ADAPTERS[options.adapter] : await detectAdapter(root);
46
+ const files = await collectFiles(root, adapter);
47
+ const context = {
48
+ adapter,
49
+ shippedPaths: new Set(files.map((file) => file.relativePath)),
50
+ };
51
+ const findings = [];
52
+ for (const file of files) {
53
+ for (const rule of ARTIFACT_RULES) {
54
+ findings.push(...rule.check(file, context));
55
+ }
56
+ }
57
+ return {
58
+ version: VERSION,
59
+ layer: "artifact",
60
+ target: root,
61
+ adapter: adapter.name,
62
+ filesScanned: files.length,
63
+ ignored: 0,
64
+ startedAt,
65
+ finishedAt: new Date().toISOString(),
66
+ findings: sortFindings(findings),
67
+ };
68
+ }
@@ -0,0 +1,16 @@
1
+ import type { Adapter } from "./adapters.ts";
2
+ /** One text file inside the artifact, as rules see it. */
3
+ export interface ScannedFile {
4
+ /** Absolute path on disk. */
5
+ absolutePath: string;
6
+ /** Path relative to the scan root, with forward slashes. Used in reports. */
7
+ relativePath: string;
8
+ text: string;
9
+ }
10
+ export interface CollectOptions {
11
+ /** Files larger than this are skipped; bundles rarely exceed it and maps can be huge. */
12
+ maxBytes?: number;
13
+ }
14
+ export declare function toPosix(relative: string): string;
15
+ /** Walk the root, keep what the adapter says the browser receives, read as UTF-8. */
16
+ export declare function collectFiles(root: string, adapter: Adapter, options?: CollectOptions): Promise<ScannedFile[]>;
@@ -0,0 +1,41 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
4
+ const ALWAYS_SKIP_DIRS = new Set(["node_modules", ".git"]);
5
+ /** Cheap binary check: a NUL byte in the first 8 KiB means not text. */
6
+ function isProbablyBinary(buffer) {
7
+ const end = Math.min(buffer.length, 8192);
8
+ for (let i = 0; i < end; i += 1) {
9
+ if (buffer[i] === 0)
10
+ return true;
11
+ }
12
+ return false;
13
+ }
14
+ export function toPosix(relative) {
15
+ return relative.split(path.sep).join("/");
16
+ }
17
+ /** Walk the root, keep what the adapter says the browser receives, read as UTF-8. */
18
+ export async function collectFiles(root, adapter, options = {}) {
19
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
20
+ const entries = await readdir(root, { recursive: true, withFileTypes: true });
21
+ const files = [];
22
+ for (const entry of entries) {
23
+ if (!entry.isFile())
24
+ continue;
25
+ const absolutePath = path.join(entry.parentPath, entry.name);
26
+ const relativePath = toPosix(path.relative(root, absolutePath));
27
+ if (relativePath.split("/").some((segment) => ALWAYS_SKIP_DIRS.has(segment)))
28
+ continue;
29
+ if (!adapter.includes(relativePath))
30
+ continue;
31
+ const info = await stat(absolutePath);
32
+ if (info.size === 0 || info.size > maxBytes)
33
+ continue;
34
+ const buffer = await readFile(absolutePath);
35
+ if (isProbablyBinary(buffer))
36
+ continue;
37
+ files.push({ absolutePath, relativePath, text: buffer.toString("utf8") });
38
+ }
39
+ files.sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
40
+ return files;
41
+ }
@@ -0,0 +1,18 @@
1
+ /** Text helpers shared by rules: positions, redaction and entropy. */
2
+ export interface LineColumn {
3
+ line: number;
4
+ column: number;
5
+ }
6
+ /** 1-based line and column for a character offset. */
7
+ export declare function lineColumn(text: string, offset: number): LineColumn;
8
+ /**
9
+ * Redact a matched value so reports never reproduce the secret they found.
10
+ * Keeps enough of each end to recognise the value in the source.
11
+ */
12
+ export declare function redact(value: string, keep?: number): string;
13
+ /** Shannon entropy in bits per character. ~4.5 is random base64, ~3 is prose. */
14
+ export declare function shannonEntropy(value: string): number;
15
+ /** Values humans type as stand-ins; never worth a finding on their own. */
16
+ export declare function looksLikePlaceholder(value: string): boolean;
17
+ /** Decode a JWT payload without verifying it. Returns null when it is not a JWT. */
18
+ export declare function decodeJwtPayload(token: string): Record<string, unknown> | null;
@@ -0,0 +1,58 @@
1
+ /** Text helpers shared by rules: positions, redaction and entropy. */
2
+ /** 1-based line and column for a character offset. */
3
+ export function lineColumn(text, offset) {
4
+ let line = 1;
5
+ let lastBreak = -1;
6
+ for (let i = 0; i < offset && i < text.length; i += 1) {
7
+ if (text.charCodeAt(i) === 10) {
8
+ line += 1;
9
+ lastBreak = i;
10
+ }
11
+ }
12
+ return { line, column: offset - lastBreak };
13
+ }
14
+ /**
15
+ * Redact a matched value so reports never reproduce the secret they found.
16
+ * Keeps enough of each end to recognise the value in the source.
17
+ */
18
+ export function redact(value, keep = 4) {
19
+ if (value.length <= keep * 2 + 1)
20
+ return "*".repeat(value.length);
21
+ return `${value.slice(0, keep)}…${value.slice(-keep)} (${value.length} chars)`;
22
+ }
23
+ /** Shannon entropy in bits per character. ~4.5 is random base64, ~3 is prose. */
24
+ export function shannonEntropy(value) {
25
+ if (value.length === 0)
26
+ return 0;
27
+ const counts = new Map();
28
+ for (const ch of value)
29
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
30
+ let entropy = 0;
31
+ for (const count of counts.values()) {
32
+ const p = count / value.length;
33
+ entropy -= p * Math.log2(p);
34
+ }
35
+ return entropy;
36
+ }
37
+ const PLACEHOLDER = /^(x+|0+|\*+|your[_-]|changeme|placeholder|example|dummy|test|sample|<)/i;
38
+ /** Values humans type as stand-ins; never worth a finding on their own. */
39
+ export function looksLikePlaceholder(value) {
40
+ return PLACEHOLDER.test(value) || /^(.)\1+$/.test(value);
41
+ }
42
+ /** Decode a JWT payload without verifying it. Returns null when it is not a JWT. */
43
+ export function decodeJwtPayload(token) {
44
+ const parts = token.split(".");
45
+ if (parts.length !== 3)
46
+ return null;
47
+ const payload = parts[1];
48
+ if (payload === undefined)
49
+ return null;
50
+ try {
51
+ const json = Buffer.from(payload, "base64url").toString("utf8");
52
+ const parsed = JSON.parse(json);
53
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The function that runs inside the page. It is serialized by the browser
3
+ * driver, so it must be self-contained: no imports, no closures, no helpers
4
+ * defined outside its body.
5
+ */
6
+ import type { SurfaceSnapshot } from "./types.ts";
7
+ export type CollectedSurface = Pick<SurfaceSnapshot, "blocks" | "attributes" | "scripts" | "storage" | "selectorMatches">;
8
+ export interface CollectArgs {
9
+ absentSelectors: string[];
10
+ maxBlocks: number;
11
+ maxText: number;
12
+ }
13
+ export declare function collectSurface(args: CollectArgs): CollectedSurface;
@@ -0,0 +1,130 @@
1
+ export function collectSurface(args) {
2
+ const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "TITLE", "META", "LINK"]);
3
+ const clip = (text) => (text.length > args.maxText ? `${text.slice(0, args.maxText)}…` : text);
4
+ const norm = (text) => text.replace(/\s+/g, " ").trim();
5
+ const selectorOf = (element) => {
6
+ const parts = [];
7
+ let node = element;
8
+ while (node !== null && parts.length < 5) {
9
+ if (node.id !== "" && /^[A-Za-z][\w-]*$/.test(node.id)) {
10
+ parts.unshift(`#${node.id}`);
11
+ break;
12
+ }
13
+ let part = node.tagName.toLowerCase();
14
+ const parent = node.parentElement;
15
+ if (parent !== null) {
16
+ const same = Array.from(parent.children).filter((child) => child.tagName === node.tagName);
17
+ if (same.length > 1)
18
+ part += `:nth-of-type(${same.indexOf(node) + 1})`;
19
+ }
20
+ parts.unshift(part);
21
+ node = parent;
22
+ }
23
+ return parts.join(" > ");
24
+ };
25
+ const blurPx = (filter) => {
26
+ const match = /blur\(([\d.]+)px\)/.exec(filter);
27
+ return match === null ? 0 : Number(match[1]);
28
+ };
29
+ const alphaOf = (color) => {
30
+ if (color === "transparent")
31
+ return 0;
32
+ const match = /rgba?\(([^)]+)\)/.exec(color);
33
+ if (match === null)
34
+ return 1;
35
+ const parts = match[1].split(/[ ,/]+/).filter((part) => part.length > 0);
36
+ return parts.length >= 4 ? Number(parts[3]) : 1;
37
+ };
38
+ const hiddenBy = (element) => {
39
+ const checkable = element;
40
+ const rect = element.getBoundingClientRect();
41
+ for (let node = element; node !== null; node = node.parentElement) {
42
+ const style = getComputedStyle(node);
43
+ if (style.display === "none")
44
+ return "display";
45
+ if (blurPx(style.filter) >= 2)
46
+ return "blur";
47
+ if (Number(style.opacity) < 0.1)
48
+ return "opacity";
49
+ if (node !== element && style.overflow !== "visible" && style.overflow !== "") {
50
+ const box = node.getBoundingClientRect();
51
+ const outside = rect.bottom <= box.top || rect.top >= box.bottom || rect.right <= box.left || rect.left >= box.right;
52
+ if (outside || box.width < 2 || box.height < 2)
53
+ return "clipped";
54
+ }
55
+ }
56
+ if (checkable.checkVisibility !== undefined && !checkable.checkVisibility({ checkVisibilityCSS: true, visibilityProperty: true, contentVisibilityAuto: true })) {
57
+ return "visibility";
58
+ }
59
+ const style = getComputedStyle(element);
60
+ if (style.visibility !== "visible")
61
+ return "visibility";
62
+ if (alphaOf(style.color) < 0.1)
63
+ return style.textShadow !== "none" ? "blur" : "transparent-text";
64
+ if (rect.width === 0 || rect.height === 0)
65
+ return "zero-size";
66
+ if (style.clip !== "auto" && style.clip !== "" && (rect.width <= 1 || rect.height <= 1))
67
+ return "clipped";
68
+ return null;
69
+ };
70
+ const blocks = [];
71
+ const attributes = [];
72
+ const scripts = [];
73
+ for (const element of Array.from(document.querySelectorAll("body *"))) {
74
+ if (SKIP.has(element.tagName)) {
75
+ if (element.tagName === "SCRIPT" && !element.src) {
76
+ const text = norm(element.textContent ?? "");
77
+ if (text.length > 0)
78
+ scripts.push({ selector: selectorOf(element), text });
79
+ }
80
+ continue;
81
+ }
82
+ for (const attribute of Array.from(element.attributes)) {
83
+ const isValue = attribute.name === "value" && element.tagName === "INPUT";
84
+ if ((attribute.name.startsWith("data-") || isValue) && attribute.value.trim().length > 0) {
85
+ attributes.push({ selector: selectorOf(element), name: attribute.name, value: clip(norm(attribute.value)) });
86
+ }
87
+ }
88
+ if (blocks.length >= args.maxBlocks)
89
+ continue;
90
+ let own = "";
91
+ for (const child of Array.from(element.childNodes))
92
+ if (child.nodeType === 3)
93
+ own += ` ${child.textContent ?? ""}`;
94
+ own = norm(own);
95
+ if (own.length === 0)
96
+ continue;
97
+ blocks.push({ selector: selectorOf(element), text: clip(own), hiddenBy: hiddenBy(element) });
98
+ }
99
+ for (const script of Array.from(document.querySelectorAll("head script:not([src])"))) {
100
+ const text = norm(script.textContent ?? "");
101
+ if (text.length > 0)
102
+ scripts.push({ selector: selectorOf(script), text });
103
+ }
104
+ const storage = [];
105
+ for (const area of ["localStorage", "sessionStorage"]) {
106
+ try {
107
+ const store = window[area];
108
+ for (let i = 0; i < store.length; i += 1) {
109
+ const key = store.key(i);
110
+ if (key !== null)
111
+ storage.push({ area, key, value: clip(store.getItem(key) ?? "") });
112
+ }
113
+ }
114
+ catch {
115
+ // Storage can be blocked for the origin; nothing to record.
116
+ }
117
+ }
118
+ const selectorMatches = [];
119
+ for (const selector of args.absentSelectors) {
120
+ try {
121
+ const found = document.querySelector(selector);
122
+ if (found !== null)
123
+ selectorMatches.push({ selector, text: clip(norm(found.textContent ?? "")) });
124
+ }
125
+ catch {
126
+ selectorMatches.push({ selector, text: "(invalid selector)" });
127
+ }
128
+ }
129
+ return { blocks, attributes, scripts, storage, selectorMatches };
130
+ }
@@ -0,0 +1,17 @@
1
+ import type { SurfaceIdentityConfig, SurfaceSnapshot } from "./types.ts";
2
+ export interface CaptureRequest {
3
+ baseUrl: string;
4
+ url: string;
5
+ identityName: string;
6
+ identity: SurfaceIdentityConfig;
7
+ waitFor?: string;
8
+ absentSelectors: string[];
9
+ timeoutMs: number;
10
+ env?: NodeJS.ProcessEnv;
11
+ }
12
+ export interface SurfaceDriver {
13
+ capture(request: CaptureRequest): Promise<SurfaceSnapshot>;
14
+ close(): Promise<void>;
15
+ }
16
+ export declare const PLAYWRIGHT_MISSING = "evershield surface drives a headless browser and needs Playwright: npm install --save-dev playwright && npx playwright install chromium";
17
+ export declare function launchPlaywrightDriver(env?: NodeJS.ProcessEnv): Promise<SurfaceDriver>;
@@ -0,0 +1,114 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ /**
10
+ * Headless browser driver. Playwright is an optional peer dependency: the
11
+ * layer 1 and 2 commands never load it, and `evershield surface` explains
12
+ * how to install it when it is missing.
13
+ *
14
+ * Only the small slice of Playwright's API evershield uses is typed here,
15
+ * so the published types do not depend on Playwright being installed.
16
+ */
17
+ import { readFile } from "node:fs/promises";
18
+ import path from "node:path";
19
+ import { interpolateEnv } from "../audit/http.js";
20
+ import { ConfigError } from "../config.js";
21
+ import { collectSurface } from "./collect.js";
22
+ export const PLAYWRIGHT_MISSING = "evershield surface drives a headless browser and needs Playwright: npm install --save-dev playwright && npx playwright install chromium";
23
+ async function loadPlaywright() {
24
+ for (const name of ["playwright", "playwright-core"]) {
25
+ try {
26
+ return (await import(__rewriteRelativeImportExtension(name)));
27
+ }
28
+ catch {
29
+ // Try the next package name.
30
+ }
31
+ }
32
+ throw new Error(PLAYWRIGHT_MISSING);
33
+ }
34
+ const COLLECT_LIMITS = { maxBlocks: 5000, maxText: 500 };
35
+ function emptySnapshot(request, error) {
36
+ return {
37
+ identity: request.identityName,
38
+ url: request.url,
39
+ finalUrl: request.url,
40
+ status: 0,
41
+ blocks: [],
42
+ attributes: [],
43
+ scripts: [],
44
+ storage: [],
45
+ selectorMatches: [],
46
+ error,
47
+ };
48
+ }
49
+ async function readStorageState(file, env) {
50
+ const resolved = path.resolve(interpolateEnv(file, env));
51
+ try {
52
+ return JSON.parse(await readFile(resolved, "utf8"));
53
+ }
54
+ catch (error) {
55
+ throw new ConfigError(`cannot read storageState ${resolved}: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ }
58
+ export async function launchPlaywrightDriver(env = process.env) {
59
+ const playwright = await loadPlaywright();
60
+ const executablePath = env.EVERSHIELD_CHROMIUM_PATH;
61
+ const browser = await playwright.chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) });
62
+ return {
63
+ async capture(request) {
64
+ const env = request.env ?? process.env;
65
+ const origin = new URL(request.baseUrl).origin;
66
+ const headers = Object.fromEntries(Object.entries(request.identity.headers ?? {}).map(([name, value]) => [name.toLowerCase(), interpolateEnv(value, env)]));
67
+ const cookies = Object.entries(request.identity.cookies ?? {}).map(([name, value]) => ({ name, value: interpolateEnv(value, env), url: origin }));
68
+ const storageState = request.identity.storageState === undefined ? undefined : await readStorageState(request.identity.storageState, env);
69
+ // Service workers are blocked so every request goes through the route
70
+ // below, which adds credentials only for the audited origin.
71
+ const context = await browser.newContext({ serviceWorkers: "block", ...(storageState === undefined ? {} : { storageState }) });
72
+ try {
73
+ if (Object.keys(headers).length > 0) {
74
+ await context.route("**/*", async (route) => {
75
+ const target = route.request();
76
+ if (new URL(target.url()).origin === origin)
77
+ await route.continue({ headers: { ...target.headers(), ...headers } });
78
+ else
79
+ await route.continue();
80
+ });
81
+ }
82
+ if (cookies.length > 0)
83
+ await context.addCookies(cookies);
84
+ const page = await context.newPage();
85
+ let response;
86
+ try {
87
+ response = await page.goto(request.url, { waitUntil: "networkidle", timeout: request.timeoutMs });
88
+ }
89
+ catch (error) {
90
+ return emptySnapshot(request, error instanceof Error ? error.message.split("\n")[0] : String(error));
91
+ }
92
+ if (request.waitFor !== undefined) {
93
+ try {
94
+ await page.waitForSelector(request.waitFor, { timeout: request.timeoutMs, state: "attached" });
95
+ }
96
+ catch {
97
+ return { ...emptySnapshot(request, `waitFor selector "${request.waitFor}" never appeared`), finalUrl: page.url(), status: response?.status() ?? 0 };
98
+ }
99
+ }
100
+ // Let late effects (entitlement checks, transitions) settle.
101
+ await page.waitForTimeout(300);
102
+ const args = { absentSelectors: request.absentSelectors, ...COLLECT_LIMITS };
103
+ const collected = await page.evaluate(collectSurface, args);
104
+ return { identity: request.identityName, url: request.url, finalUrl: page.url(), status: response?.status() ?? 0, ...collected };
105
+ }
106
+ finally {
107
+ await context.close();
108
+ }
109
+ },
110
+ async close() {
111
+ await browser.close();
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Surface rules. Each inspects one page observed as every identity.
3
+ *
4
+ * - surface/expected-absent-present: the operator said identity X must not
5
+ * receive text T (or selector S) on this page, and it is somewhere in X's
6
+ * DOM, attributes, inline scripts or storage. High/high: the operator's
7
+ * own contract.
8
+ * - surface/hidden-higher-tier-content: text the top tier can see is present
9
+ * for a lower tier but hidden from it (display:none, blur, clipped, in a
10
+ * data attribute, inline script or storage). The paywall is cosmetic.
11
+ * High/high: the same text, visible to one tier and hidden in another's
12
+ * page, is not a coincidence.
13
+ * - surface/obscured-content: a lower tier's page blurs or makes transparent
14
+ * real-looking text. Blur is how UIs fake a paywall; legitimate hidden UI
15
+ * (menus, tabs) uses display or visibility instead. High/medium: the text
16
+ * may be a decoy, which the rule cannot always tell.
17
+ *
18
+ * Tabs, menus and dialogs hidden for every tier never fire the differential
19
+ * rule, because it requires the top tier to see the text.
20
+ */
21
+ import type { Confidence, Finding, Severity } from "../index.ts";
22
+ import type { PageObservation } from "./types.ts";
23
+ export interface SurfaceRule {
24
+ id: string;
25
+ defaultSeverity: Severity;
26
+ defaultConfidence: Confidence;
27
+ check(observation: PageObservation): Finding[];
28
+ }
29
+ export declare const expectedAbsentPresent: SurfaceRule;
30
+ export declare const hiddenHigherTierContent: SurfaceRule;
31
+ export declare const obscuredContent: SurfaceRule;
32
+ export declare const SURFACE_RULES: readonly SurfaceRule[];
@@ -0,0 +1,150 @@
1
+ const RULES_DOC = "https://github.com/inviy/evershield/blob/main/docs/rules.md";
2
+ const help = (id) => `${RULES_DOC}#${id.replace("/", "")}`;
3
+ function excerpt(text, max = 80) {
4
+ return text.length > max ? `${text.slice(0, max)}…` : text;
5
+ }
6
+ function usable(snapshot) {
7
+ return snapshot !== undefined && snapshot.error === undefined && snapshot.status < 400;
8
+ }
9
+ /** Find `needle` in everything the identity received, hidden or not. */
10
+ function locate(snapshot, needle, options) {
11
+ for (const block of snapshot.blocks) {
12
+ if (options.hiddenOnly && block.hiddenBy === null)
13
+ continue;
14
+ if (block.text.includes(needle)) {
15
+ return { selector: block.selector, where: block.hiddenBy === null ? "visible text" : `text hidden by ${block.hiddenBy}`, text: block.text };
16
+ }
17
+ }
18
+ for (const attribute of snapshot.attributes) {
19
+ if (attribute.value.includes(needle))
20
+ return { selector: `${attribute.selector}[${attribute.name}]`, where: `attribute ${attribute.name}`, text: attribute.value };
21
+ }
22
+ for (const script of snapshot.scripts) {
23
+ if (script.text.includes(needle))
24
+ return { selector: script.selector, where: "inline script", text: needle };
25
+ }
26
+ for (const entry of snapshot.storage) {
27
+ if (entry.value.includes(needle) || entry.key.includes(needle)) {
28
+ return { selector: `${entry.area}[${JSON.stringify(entry.key)}]`, where: entry.area, text: needle };
29
+ }
30
+ }
31
+ return undefined;
32
+ }
33
+ export const expectedAbsentPresent = {
34
+ id: "surface/expected-absent-present",
35
+ defaultSeverity: "high",
36
+ defaultConfidence: "high",
37
+ check(observation) {
38
+ const findings = [];
39
+ for (const [identity, expectation] of Object.entries(observation.page.expect ?? {})) {
40
+ const snapshot = observation.snapshots.get(identity);
41
+ if (!usable(snapshot))
42
+ continue;
43
+ for (const text of expectation.absentText ?? []) {
44
+ const hit = locate(snapshot, text, { hiddenOnly: false });
45
+ if (hit === undefined)
46
+ continue;
47
+ findings.push({
48
+ ruleId: expectedAbsentPresent.id,
49
+ layer: "surface",
50
+ severity: "high",
51
+ confidence: "high",
52
+ message: `${observation.page.path} gives ${identity} "${excerpt(text, 40)}" (${hit.where}), which the config says it must not receive`,
53
+ evidence: [{ kind: "dom", url: observation.url, selector: hit.selector, identity, excerpt: excerpt(hit.text) }],
54
+ remediation: "Do not send this content to this tier at all: render it on the server only for entitled users. Hiding it in the page is not a boundary.",
55
+ helpUri: help(expectedAbsentPresent.id),
56
+ });
57
+ }
58
+ for (const match of snapshot.selectorMatches) {
59
+ if (match.text === "(invalid selector)")
60
+ continue;
61
+ findings.push({
62
+ ruleId: expectedAbsentPresent.id,
63
+ layer: "surface",
64
+ severity: "high",
65
+ confidence: "high",
66
+ message: `${observation.page.path} renders "${match.selector}" for ${identity}, which the config says must match nothing`,
67
+ evidence: [{ kind: "dom", url: observation.url, selector: match.selector, identity, excerpt: excerpt(match.text) }],
68
+ remediation: "Do not render this element for this tier; if it needs a placeholder, render placeholder content, not the real value.",
69
+ helpUri: help(expectedAbsentPresent.id),
70
+ });
71
+ }
72
+ }
73
+ return findings;
74
+ },
75
+ };
76
+ export const hiddenHigherTierContent = {
77
+ id: "surface/hidden-higher-tier-content",
78
+ defaultSeverity: "high",
79
+ defaultConfidence: "high",
80
+ check(observation) {
81
+ const findings = [];
82
+ const topName = observation.tiers[observation.tiers.length - 1];
83
+ const top = topName === undefined ? undefined : observation.snapshots.get(topName);
84
+ if (!usable(top) || observation.tiers.length < 2)
85
+ return findings;
86
+ const topVisible = [...new Set(top.blocks.filter((block) => block.hiddenBy === null && block.text.length >= observation.minTextLength).map((block) => block.text))];
87
+ for (const identity of observation.tiers.slice(0, -1)) {
88
+ const snapshot = observation.snapshots.get(identity);
89
+ if (!usable(snapshot))
90
+ continue;
91
+ const visibleHere = snapshot.blocks.filter((block) => block.hiddenBy === null).map((block) => block.text);
92
+ let reported = 0;
93
+ for (const text of topVisible) {
94
+ // Visible to this tier too: shared content, not a leak.
95
+ if (visibleHere.some((visible) => visible.includes(text)))
96
+ continue;
97
+ const hit = locate(snapshot, text, { hiddenOnly: true });
98
+ if (hit === undefined)
99
+ continue;
100
+ reported += 1;
101
+ if (reported > 20)
102
+ break;
103
+ findings.push({
104
+ ruleId: hiddenHigherTierContent.id,
105
+ layer: "surface",
106
+ severity: "high",
107
+ confidence: "high",
108
+ message: `${observation.page.path}: "${excerpt(text, 40)}" is visible to ${topName} and present but hidden for ${identity} (${hit.where})`,
109
+ evidence: [{ kind: "dom", url: observation.url, selector: hit.selector, identity, excerpt: excerpt(text) }],
110
+ remediation: `Anyone on the ${identity} tier can read this in DevTools. Leave it out of the ${identity} page (server-render a placeholder) instead of hiding it.`,
111
+ helpUri: help(hiddenHigherTierContent.id),
112
+ });
113
+ }
114
+ }
115
+ return findings;
116
+ },
117
+ };
118
+ /** Text that is obviously a decoy: masks, lorem ipsum, a single repeated character. */
119
+ const PLACEHOLDER = /^(?:[\sxX*•●▒█#?·._,\-–—0]+|lorem ipsum.*|(.)\1+)$/i;
120
+ export const obscuredContent = {
121
+ id: "surface/obscured-content",
122
+ defaultSeverity: "high",
123
+ defaultConfidence: "medium",
124
+ check(observation) {
125
+ const findings = [];
126
+ // With one identity there is nothing more privileged to protect; still
127
+ // check it, since blur on any audited page is the classic fake paywall.
128
+ const lower = observation.tiers.length > 1 ? observation.tiers.slice(0, -1) : observation.tiers;
129
+ for (const identity of lower) {
130
+ const snapshot = observation.snapshots.get(identity);
131
+ if (!usable(snapshot))
132
+ continue;
133
+ const obscured = snapshot.blocks.filter((block) => (block.hiddenBy === "blur" || block.hiddenBy === "transparent-text") && block.text.length >= 3 && !PLACEHOLDER.test(block.text));
134
+ if (obscured.length === 0)
135
+ continue;
136
+ findings.push({
137
+ ruleId: obscuredContent.id,
138
+ layer: "surface",
139
+ severity: "high",
140
+ confidence: "medium",
141
+ message: `${observation.page.path} shows ${identity} ${obscured.length} blurred or transparent text element(s); the text is readable in the DOM`,
142
+ evidence: obscured.slice(0, 10).map((block) => ({ kind: "dom", url: observation.url, selector: block.selector, identity, excerpt: excerpt(block.text) })),
143
+ remediation: `Render placeholder text (e.g. "●●●") behind the blur for ${identity}, or nothing; send the real values only to tiers that may see them.`,
144
+ helpUri: help(obscuredContent.id),
145
+ });
146
+ }
147
+ return findings;
148
+ },
149
+ };
150
+ export const SURFACE_RULES = [expectedAbsentPresent, hiddenHigherTierContent, obscuredContent];