evershield 0.1.0 → 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.
@@ -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];
@@ -0,0 +1,23 @@
1
+ import { type ScanSummary } from "../index.ts";
2
+ import { type SurfaceDriver } from "./driver.ts";
3
+ import type { SurfaceConfig } from "./types.ts";
4
+ export interface SurfaceResult extends ScanSummary {
5
+ layer: "surface";
6
+ target: string;
7
+ identities: string[];
8
+ pagesAudited: number;
9
+ /** Page loads that rendered and were evaluated by the rules. */
10
+ snapshotsEvaluated: number;
11
+ ignored: number;
12
+ warnings: string[];
13
+ }
14
+ export interface SurfaceOptions {
15
+ env?: NodeJS.ProcessEnv;
16
+ /** Injected in tests; defaults to a Playwright Chromium. */
17
+ driver?: SurfaceDriver;
18
+ }
19
+ /**
20
+ * Layer 3: open each page as each identity, least privileged first, one at a
21
+ * time, and run the surface rules over what each identity's browser held.
22
+ */
23
+ export declare function runSurface(config: SurfaceConfig, options?: SurfaceOptions): Promise<SurfaceResult>;
@@ -0,0 +1,80 @@
1
+ import { VERSION } from "../index.js";
2
+ import { sortFindings } from "../scan/artifact.js";
3
+ import { launchPlaywrightDriver } from "./driver.js";
4
+ import { SURFACE_RULES } from "./rules.js";
5
+ const DEFAULT_TIMEOUT_MS = 30_000;
6
+ const DEFAULT_MIN_TEXT = 12;
7
+ /**
8
+ * Layer 3: open each page as each identity, least privileged first, one at a
9
+ * time, and run the surface rules over what each identity's browser held.
10
+ */
11
+ export async function runSurface(config, options = {}) {
12
+ const startedAt = new Date().toISOString();
13
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
14
+ const minTextLength = config.minTextLength ?? DEFAULT_MIN_TEXT;
15
+ const findings = [];
16
+ const warnings = [];
17
+ let snapshotsEvaluated = 0;
18
+ const driver = options.driver ?? (await launchPlaywrightDriver(options.env));
19
+ try {
20
+ for (const page of config.pages) {
21
+ const url = new URL(page.path, config.baseUrl).toString();
22
+ const observation = { page, url, tiers: config.tiers, snapshots: new Map(), minTextLength };
23
+ for (const identityName of config.tiers) {
24
+ const identity = config.identities[identityName];
25
+ if (identity === undefined) {
26
+ warnings.push(`${page.path}: identity "${identityName}" is not defined`);
27
+ continue;
28
+ }
29
+ const snapshot = await driver.capture({
30
+ baseUrl: config.baseUrl,
31
+ url,
32
+ identityName,
33
+ identity,
34
+ absentSelectors: page.expect?.[identityName]?.absentSelectors ?? [],
35
+ timeoutMs,
36
+ ...(page.waitFor === undefined ? {} : { waitFor: page.waitFor }),
37
+ ...(options.env === undefined ? {} : { env: options.env }),
38
+ });
39
+ if (snapshot.error === undefined && new URL(snapshot.finalUrl).origin !== new URL(config.baseUrl).origin) {
40
+ snapshot.error = `redirected off the audited origin to ${new URL(snapshot.finalUrl).origin}`;
41
+ }
42
+ observation.snapshots.set(identityName, snapshot);
43
+ for (const match of snapshot.selectorMatches) {
44
+ if (match.text === "(invalid selector)")
45
+ warnings.push(`${page.path}: absentSelectors entry "${match.selector}" is not a valid CSS selector`);
46
+ }
47
+ if (snapshot.error !== undefined) {
48
+ warnings.push(`${page.path} as ${identityName}: ${snapshot.error}, not evaluated`);
49
+ }
50
+ else if (snapshot.status >= 400) {
51
+ warnings.push(`${page.path} as ${identityName}: HTTP ${snapshot.status}, not evaluated`);
52
+ }
53
+ else {
54
+ snapshotsEvaluated += 1;
55
+ if (snapshot.finalUrl !== url)
56
+ warnings.push(`${page.path} as ${identityName}: landed on ${snapshot.finalUrl}`);
57
+ }
58
+ }
59
+ for (const rule of SURFACE_RULES)
60
+ findings.push(...rule.check(observation));
61
+ }
62
+ }
63
+ finally {
64
+ if (options.driver === undefined)
65
+ await driver.close();
66
+ }
67
+ return {
68
+ version: VERSION,
69
+ layer: "surface",
70
+ target: config.baseUrl,
71
+ identities: [...config.tiers],
72
+ pagesAudited: config.pages.length,
73
+ snapshotsEvaluated,
74
+ ignored: 0,
75
+ warnings,
76
+ startedAt,
77
+ finishedAt: new Date().toISOString(),
78
+ findings: sortFindings(findings),
79
+ };
80
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Layer 3: rendered-surface audit.
3
+ *
4
+ * The operator lists pages and identities. evershield opens each page in a
5
+ * headless browser as each identity, waits for it to settle, and records
6
+ * every text-bearing element with whether a person can actually see it,
7
+ * plus `data-*` attributes, inline scripts and web storage. The rules then
8
+ * look for data that is present but only visually hidden.
9
+ */
10
+ export interface SurfaceIdentityConfig {
11
+ /** Sent only to requests for `baseUrl`'s origin. Credential headers must reference env vars. */
12
+ headers?: Record<string, string>;
13
+ /** Cookie name → value, set for `baseUrl`. Values must reference env vars. */
14
+ cookies?: Record<string, string>;
15
+ /** Path to a Playwright storage-state file (cookies + localStorage), e.g. from `playwright codegen --save-storage`. May reference env vars. */
16
+ storageState?: string;
17
+ }
18
+ export interface PageExpectation {
19
+ /** Strings that must not appear anywhere for this identity: DOM text (hidden or not), attributes, inline scripts, storage. */
20
+ absentText?: string[];
21
+ /** CSS selectors that must match nothing for this identity. */
22
+ absentSelectors?: string[];
23
+ }
24
+ export interface PageConfig {
25
+ /** Path relative to `baseUrl`, starting with `/`. */
26
+ path: string;
27
+ /** CSS selector to wait for before capturing, for pages that render after data loads. */
28
+ waitFor?: string;
29
+ expect?: Record<string, PageExpectation>;
30
+ }
31
+ export interface SurfaceConfig {
32
+ /** Origin the browser is allowed to be credentialed for. Pages are relative to it. */
33
+ baseUrl: string;
34
+ /** Identity names ordered from least to most privileged. */
35
+ tiers: string[];
36
+ identities: Record<string, SurfaceIdentityConfig>;
37
+ pages: PageConfig[];
38
+ timeoutMs?: number;
39
+ /** Shortest text, in characters, the differential rule compares. Default 12. */
40
+ minTextLength?: number;
41
+ }
42
+ /** Why an element cannot be seen. `null` means visible. */
43
+ export type HiddenBy = "display" | "visibility" | "opacity" | "blur" | "transparent-text" | "clipped" | "zero-size";
44
+ export interface TextBlock {
45
+ selector: string;
46
+ /** Element's own text (direct text nodes), whitespace collapsed. */
47
+ text: string;
48
+ hiddenBy: HiddenBy | null;
49
+ }
50
+ export interface AttributeValue {
51
+ selector: string;
52
+ name: string;
53
+ value: string;
54
+ }
55
+ export interface ScriptText {
56
+ selector: string;
57
+ text: string;
58
+ }
59
+ export interface StorageEntry {
60
+ area: "localStorage" | "sessionStorage";
61
+ key: string;
62
+ value: string;
63
+ }
64
+ export interface SurfaceSnapshot {
65
+ identity: string;
66
+ /** URL requested. */
67
+ url: string;
68
+ /** URL after redirects. */
69
+ finalUrl: string;
70
+ status: number;
71
+ blocks: TextBlock[];
72
+ attributes: AttributeValue[];
73
+ scripts: ScriptText[];
74
+ storage: StorageEntry[];
75
+ /** Selectors from the page's `absentSelectors` expectations that matched, with a text sample. */
76
+ selectorMatches: Array<{
77
+ selector: string;
78
+ text: string;
79
+ }>;
80
+ /** Navigation or capture failure. */
81
+ error?: string;
82
+ }
83
+ export interface PageObservation {
84
+ page: PageConfig;
85
+ /** Absolute URL of the page. */
86
+ url: string;
87
+ tiers: string[];
88
+ snapshots: Map<string, SurfaceSnapshot>;
89
+ minTextLength: number;
90
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Layer 3: rendered-surface audit.
3
+ *
4
+ * The operator lists pages and identities. evershield opens each page in a
5
+ * headless browser as each identity, waits for it to settle, and records
6
+ * every text-bearing element with whether a person can actually see it,
7
+ * plus `data-*` attributes, inline scripts and web storage. The rules then
8
+ * look for data that is present but only visually hidden.
9
+ */
10
+ export {};