evershield 0.1.0 → 0.3.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.
@@ -5,6 +5,12 @@ export interface RuleContext {
5
5
  adapter: Adapter;
6
6
  /** Every shipped file's relative path, for cross-file checks such as source map references. */
7
7
  shippedPaths: ReadonlySet<string>;
8
+ /**
9
+ * Called with the unredacted value behind a finding, for in-process
10
+ * cross-checks such as `evershield recon --rea`. The value must never be
11
+ * stored on the finding or written anywhere.
12
+ */
13
+ onMatch?: (finding: Finding, raw: string) => void;
8
14
  }
9
15
  /**
10
16
  * A rule inspects one file and returns findings with evidence. The doc
@@ -0,0 +1,22 @@
1
+ import type { ScannedFile } from "./files.ts";
2
+ export type ArchiveKind = "android" | "ios" | "electron";
3
+ export declare function archiveKind(file: string, head: Buffer): ArchiveKind | null;
4
+ /** Printable runs of at least `min` characters, ASCII/UTF-8 and UTF-16LE (Android string pools). */
5
+ export declare function extractStrings(buffer: Buffer, min?: number): string[];
6
+ /**
7
+ * Hermes keeps every string literal and identifier in one storage blob with
8
+ * no separators, so a plain strings pass glues neighbours together and a key
9
+ * loses its word boundaries. Read the string table instead. The header is
10
+ * 128 bytes; the counts sit at fixed offsets; the sections that follow are
11
+ * 4-byte aligned. Function headers are 12 bytes in current versions (99,
12
+ * Expo SDK 58) and 16 in older ones, so both layouts are tried and the one
13
+ * whose every entry fits inside the storage wins. Returns null when neither
14
+ * does, and the caller falls back to a plain strings pass.
15
+ */
16
+ export declare function hermesStrings(buffer: Buffer): string[] | null;
17
+ /** Turn one archive entry, or a loose file from an extracted app, into what rules read. */
18
+ export declare function toScanned(name: string, data: Buffer, location: string): ScannedFile | null;
19
+ export declare function collectArchive(file: string): Promise<{
20
+ kind: ArchiveKind;
21
+ files: ScannedFile[];
22
+ }>;
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Packaged apps as artifacts: Android (.apk, .aab), iOS (.ipa) and Electron
3
+ * (.asar). Everything inside ships to the user's device, so everything a
4
+ * rule can read is scanned. No dependencies: zip is parsed here and inflated
5
+ * with node:zlib; ASAR is a JSON header followed by file bytes.
6
+ *
7
+ * Text entries are decoded as UTF-8. Binary entries that carry the strings an
8
+ * attacker would read (Hermes bytecode, DEX, compiled Android XML and
9
+ * resources, binary plists, Mach-O executables) are reduced to their
10
+ * printable strings, one per line, so the same rules apply; a finding's
11
+ * line is then the string's index, and its path ends in " (strings)".
12
+ */
13
+ import { readFile, stat } from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { inflateRawSync } from "node:zlib";
16
+ const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024;
17
+ const MAX_ENTRY_BYTES = 100 * 1024 * 1024;
18
+ const TEXT_ENTRY = /\.(?:m?js|cjs|jsx|ts|tsx|json|html?|css|xml|plist|txt|svg|map|bundle|jsbundle|webmanifest|properties|strings)$/i;
19
+ /** Binary formats whose string tables hold what developers embed: keys, URLs, config. */
20
+ const STRINGS_ENTRY = /(?:\.(?:dex|hbc|arsc|bundle|jsbundle|plist)$|(?:^|\/)AndroidManifest\.xml$)/i;
21
+ const SKIP_ENTRY = /\.(?:png|jpe?g|gif|webp|ico|ttf|otf|woff2?|mp3|mp4|ogg|wav|so|dylib|car|nib|storyboardc|RSA|DSA|EC|SF|MF)$|(?:^|\/)(?:META-INF\/|_CodeSignature\/)/i;
22
+ /** Hermes bytecode magic, little-endian 0x1F1903C103BC1FC6. */
23
+ const HERMES_MAGIC = Buffer.from([0xc6, 0x1f, 0xbc, 0x03, 0xc1, 0x03, 0x19, 0x1f]);
24
+ export function archiveKind(file, head) {
25
+ const ext = path.extname(file).toLowerCase();
26
+ if (ext === ".asar")
27
+ return "electron";
28
+ if (head.length < 4 || head.readUInt32LE(0) !== 0x04034b50)
29
+ return null;
30
+ if (ext === ".ipa")
31
+ return "ios";
32
+ if (ext === ".apk" || ext === ".aab" || ext === ".xapk")
33
+ return "android";
34
+ return null;
35
+ }
36
+ function zipEntries(buffer) {
37
+ // End of central directory: last 22..65557 bytes.
38
+ let eocd = -1;
39
+ for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 65557); i -= 1) {
40
+ if (buffer.readUInt32LE(i) === 0x06054b50) {
41
+ eocd = i;
42
+ break;
43
+ }
44
+ }
45
+ if (eocd < 0)
46
+ throw new Error("not a zip archive (no end of central directory)");
47
+ const count = buffer.readUInt16LE(eocd + 10);
48
+ let offset = buffer.readUInt32LE(eocd + 16);
49
+ const entries = [];
50
+ for (let i = 0; i < count; i += 1) {
51
+ if (buffer.readUInt32LE(offset) !== 0x02014b50)
52
+ throw new Error("corrupt zip central directory");
53
+ const method = buffer.readUInt16LE(offset + 10);
54
+ const compressed = buffer.readUInt32LE(offset + 20);
55
+ const size = buffer.readUInt32LE(offset + 24);
56
+ const nameLength = buffer.readUInt16LE(offset + 28);
57
+ const extraLength = buffer.readUInt16LE(offset + 30);
58
+ const commentLength = buffer.readUInt16LE(offset + 32);
59
+ const local = buffer.readUInt32LE(offset + 42);
60
+ const name = buffer.toString("utf8", offset + 46, offset + 46 + nameLength);
61
+ offset += 46 + nameLength + extraLength + commentLength;
62
+ if (name.endsWith("/") || size > MAX_ENTRY_BYTES || compressed === 0xffffffff)
63
+ continue;
64
+ entries.push({
65
+ name,
66
+ read() {
67
+ const start = local + 30 + buffer.readUInt16LE(local + 26) + buffer.readUInt16LE(local + 28);
68
+ const data = buffer.subarray(start, start + compressed);
69
+ if (method === 0)
70
+ return data;
71
+ if (method === 8)
72
+ return inflateRawSync(data, { maxOutputLength: MAX_ENTRY_BYTES });
73
+ throw new Error(`unsupported zip compression method ${method} for ${name}`);
74
+ },
75
+ });
76
+ }
77
+ return entries;
78
+ }
79
+ function asarEntries(buffer) {
80
+ // Pickle: [u32 4][u32 headerPickleSize] then [u32 payload][u32 jsonLength][json…].
81
+ const headerSize = buffer.readUInt32LE(4);
82
+ const jsonLength = buffer.readUInt32LE(12);
83
+ const header = JSON.parse(buffer.toString("utf8", 16, 16 + jsonLength));
84
+ const base = 8 + headerSize;
85
+ const entries = [];
86
+ const walk = (node, prefix) => {
87
+ for (const [name, child] of Object.entries(node.files ?? {})) {
88
+ const full = prefix === "" ? name : `${prefix}/${name}`;
89
+ if (child.files !== undefined)
90
+ walk(child, full);
91
+ else if (child.offset !== undefined && child.size !== undefined && child.unpacked !== true && child.size <= MAX_ENTRY_BYTES) {
92
+ const start = base + Number(child.offset);
93
+ const size = child.size;
94
+ entries.push({ name: full, read: () => buffer.subarray(start, start + size) });
95
+ }
96
+ }
97
+ };
98
+ walk(header, "");
99
+ return entries;
100
+ }
101
+ /** Printable runs of at least `min` characters, ASCII/UTF-8 and UTF-16LE (Android string pools). */
102
+ export function extractStrings(buffer, min = 6) {
103
+ const out = [];
104
+ const seen = new Set();
105
+ const add = (value) => {
106
+ if (value.length >= min && !seen.has(value)) {
107
+ seen.add(value);
108
+ out.push(value);
109
+ }
110
+ };
111
+ let start = -1;
112
+ for (let i = 0; i <= buffer.length; i += 1) {
113
+ const byte = i < buffer.length ? buffer[i] : 0;
114
+ const printable = (byte >= 0x20 && byte < 0x7f) || byte === 0x09;
115
+ if (printable && start < 0)
116
+ start = i;
117
+ if (!printable && start >= 0) {
118
+ add(buffer.toString("latin1", start, i));
119
+ start = -1;
120
+ }
121
+ }
122
+ // UTF-16LE: printable ASCII byte followed by 0x00, at either byte alignment.
123
+ for (const alignment of [0, 1]) {
124
+ let run = "";
125
+ for (let i = alignment; i + 1 < buffer.length; i += 2) {
126
+ const lo = buffer[i];
127
+ if (buffer[i + 1] === 0 && lo >= 0x20 && lo < 0x7f)
128
+ run += String.fromCharCode(lo);
129
+ else {
130
+ add(run);
131
+ run = "";
132
+ }
133
+ }
134
+ add(run);
135
+ }
136
+ return out;
137
+ }
138
+ /**
139
+ * Hermes keeps every string literal and identifier in one storage blob with
140
+ * no separators, so a plain strings pass glues neighbours together and a key
141
+ * loses its word boundaries. Read the string table instead. The header is
142
+ * 128 bytes; the counts sit at fixed offsets; the sections that follow are
143
+ * 4-byte aligned. Function headers are 12 bytes in current versions (99,
144
+ * Expo SDK 58) and 16 in older ones, so both layouts are tried and the one
145
+ * whose every entry fits inside the storage wins. Returns null when neither
146
+ * does, and the caller falls back to a plain strings pass.
147
+ */
148
+ export function hermesStrings(buffer) {
149
+ if (buffer.length < 128)
150
+ return null;
151
+ const u32 = (offset) => buffer.readUInt32LE(offset);
152
+ const align = (value) => Math.ceil(value / 4) * 4;
153
+ const functionCount = u32(40);
154
+ const stringKindCount = u32(44);
155
+ const identifierCount = u32(48);
156
+ const stringCount = u32(52);
157
+ const overflowCount = u32(56);
158
+ const storageSize = u32(60);
159
+ for (const functionHeaderSize of [12, 16]) {
160
+ let offset = 128;
161
+ offset = align(offset + functionCount * functionHeaderSize);
162
+ offset = align(offset + stringKindCount * 4);
163
+ offset = align(offset + identifierCount * 4);
164
+ const small = offset;
165
+ offset = align(offset + stringCount * 4);
166
+ const overflow = offset;
167
+ offset = align(offset + overflowCount * 8);
168
+ const storage = offset;
169
+ if (storage + storageSize > buffer.length)
170
+ continue;
171
+ const strings = [];
172
+ let valid = true;
173
+ for (let i = 0; i < stringCount && valid; i += 1) {
174
+ const entry = u32(small + i * 4);
175
+ const utf16 = (entry & 1) === 1;
176
+ let start = (entry >>> 1) & 0x7fffff;
177
+ let length = entry >>> 24;
178
+ if (length === 255) {
179
+ if (start >= overflowCount) {
180
+ valid = false;
181
+ break;
182
+ }
183
+ const at = overflow + start * 8;
184
+ start = u32(at);
185
+ length = u32(at + 4);
186
+ }
187
+ const bytes = utf16 ? length * 2 : length;
188
+ if (start + bytes > storageSize)
189
+ valid = false;
190
+ else if (length > 0)
191
+ strings.push(buffer.toString(utf16 ? "utf16le" : "latin1", storage + start, storage + start + bytes));
192
+ }
193
+ if (valid)
194
+ return strings;
195
+ }
196
+ return null;
197
+ }
198
+ function looksBinary(buffer) {
199
+ const end = Math.min(buffer.length, 8192);
200
+ for (let i = 0; i < end; i += 1)
201
+ if (buffer[i] === 0)
202
+ return true;
203
+ return false;
204
+ }
205
+ /** Turn one archive entry, or a loose file from an extracted app, into what rules read. */
206
+ export function toScanned(name, data, location) {
207
+ if (data.length === 0 || SKIP_ENTRY.test(name))
208
+ return null;
209
+ const isHermes = data.length >= 8 && data.subarray(0, 8).equals(HERMES_MAGIC);
210
+ if (!isHermes && TEXT_ENTRY.test(name) && !looksBinary(data)) {
211
+ return { absolutePath: location, relativePath: name, text: data.toString("utf8") };
212
+ }
213
+ if (isHermes || STRINGS_ENTRY.test(name) || isMainExecutable(name, data)) {
214
+ const strings = (isHermes ? hermesStrings(data) : null) ?? extractStrings(data);
215
+ // One string per line; a multi-line literal stays readable but cannot merge with its neighbour.
216
+ return { absolutePath: location, relativePath: `${name} (strings)`, text: strings.map((s) => s.replace(/\n/g, " ")).join("\n") };
217
+ }
218
+ return null;
219
+ }
220
+ /** The iOS app executable: Payload/Name.app/Name, a Mach-O file. */
221
+ function isMainExecutable(name, data) {
222
+ const match = /^Payload\/([^/]+)\.app\/([^/]+)$/.exec(name);
223
+ if (match === null || match[1] !== match[2] || data.length < 4)
224
+ return false;
225
+ const magic = data.readUInt32LE(0);
226
+ return magic === 0xfeedfacf || magic === 0xfeedface || magic === 0xcafebabe || magic === 0xbebafeca;
227
+ }
228
+ export async function collectArchive(file) {
229
+ const info = await stat(file);
230
+ if (info.size > MAX_ARCHIVE_BYTES)
231
+ throw new Error(`${file} is larger than ${MAX_ARCHIVE_BYTES / 1024 / 1024} MiB`);
232
+ const buffer = await readFile(file);
233
+ const kind = archiveKind(file, buffer);
234
+ if (kind === null)
235
+ throw new Error(`not a supported app package (.apk, .aab, .ipa, .asar): ${file}`);
236
+ const entries = kind === "electron" ? asarEntries(buffer) : zipEntries(buffer);
237
+ const files = [];
238
+ for (const entry of entries) {
239
+ if (entry.name.split("/").includes("node_modules") && kind !== "electron")
240
+ continue;
241
+ const scanned = toScanned(entry.name, entry.read(), `${file}!/${entry.name}`);
242
+ if (scanned !== null)
243
+ files.push(scanned);
244
+ }
245
+ files.sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
246
+ return { kind, files };
247
+ }
@@ -1,18 +1,34 @@
1
1
  import { type Finding, type ScanSummary } from "../index.ts";
2
+ import type { RuleContext } from "../rules/types.ts";
2
3
  import { type Adapter } from "./adapters.ts";
4
+ import { type ArchiveKind } from "./archive.ts";
5
+ import { type ScannedFile } from "./files.ts";
3
6
  export interface ArtifactScanOptions {
4
7
  /** Force an adapter instead of detecting one from the directory. */
5
8
  adapter?: Adapter["name"];
9
+ /** See RuleContext.onMatch. */
10
+ onMatch?: RuleContext["onMatch"];
6
11
  }
7
12
  export interface ArtifactScanResult extends ScanSummary {
8
13
  layer: "artifact";
9
14
  target: string;
10
- adapter: Adapter["name"];
15
+ /** Directory adapter, or the kind of app package scanned. */
16
+ adapter: Adapter["name"] | ArchiveKind;
11
17
  filesScanned: number;
12
18
  /** Findings removed by config ignore rules; they are not in `findings`. */
13
19
  ignored: number;
14
20
  }
15
21
  /** Worst first, then surest first, then by location so output is stable. */
16
22
  export declare function sortFindings(findings: Finding[]): Finding[];
17
- /** Layer 1: scan a directory of built files for what should not ship. */
23
+ export interface CollectedTarget {
24
+ root: string;
25
+ adapter: Adapter;
26
+ /** Directory adapter, or the kind of app package. */
27
+ adapterName: ArtifactScanResult["adapter"];
28
+ files: ScannedFile[];
29
+ }
30
+ /** Read what a target ships: a build directory through its adapter, or an app package in memory. */
31
+ export declare function collectTarget(target: string, options?: Pick<ArtifactScanOptions, "adapter">): Promise<CollectedTarget>;
32
+ export declare function runArtifactRules(files: ScannedFile[], adapter: Adapter, onMatch?: RuleContext["onMatch"]): Finding[];
33
+ /** Layer 1: scan a directory of built files, or an app package, for what should not ship. */
18
34
  export declare function scanArtifact(target: string, options?: ArtifactScanOptions): Promise<ArtifactScanResult>;
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { VERSION } from "../index.js";
4
4
  import { ARTIFACT_RULES } from "../rules/index.js";
5
5
  import { ADAPTERS, detectAdapter } from "./adapters.js";
6
+ import { collectArchive } from "./archive.js";
6
7
  import { collectFiles } from "./files.js";
7
8
  const SEVERITY_RANK = {
8
9
  critical: 0,
@@ -34,35 +35,47 @@ export function sortFindings(findings) {
34
35
  return aLine - bLine;
35
36
  });
36
37
  }
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();
38
+ /** Read what a target ships: a build directory through its adapter, or an app package in memory. */
39
+ export async function collectTarget(target, options = {}) {
40
40
  const root = path.resolve(target);
41
41
  const info = await stat(root).catch(() => null);
42
- if (info === null || !info.isDirectory()) {
43
- throw new Error(`not a directory: ${target}`);
42
+ if (info === null || (!info.isDirectory() && !info.isFile())) {
43
+ throw new Error(`not a directory or app package: ${target}`);
44
+ }
45
+ if (info.isFile()) {
46
+ // An app package ships everything inside it; there is nothing for a web adapter to filter.
47
+ const archive = await collectArchive(root);
48
+ return { root, adapter: ADAPTERS.plain, adapterName: archive.kind, files: archive.files };
44
49
  }
45
50
  const adapter = options.adapter ? ADAPTERS[options.adapter] : await detectAdapter(root);
46
- const files = await collectFiles(root, adapter);
51
+ return { root, adapter, adapterName: adapter.name, files: await collectFiles(root, adapter) };
52
+ }
53
+ export function runArtifactRules(files, adapter, onMatch) {
47
54
  const context = {
48
55
  adapter,
49
56
  shippedPaths: new Set(files.map((file) => file.relativePath)),
57
+ ...(onMatch === undefined ? {} : { onMatch }),
50
58
  };
51
59
  const findings = [];
52
60
  for (const file of files) {
53
- for (const rule of ARTIFACT_RULES) {
61
+ for (const rule of ARTIFACT_RULES)
54
62
  findings.push(...rule.check(file, context));
55
- }
56
63
  }
64
+ return sortFindings(findings);
65
+ }
66
+ /** Layer 1: scan a directory of built files, or an app package, for what should not ship. */
67
+ export async function scanArtifact(target, options = {}) {
68
+ const startedAt = new Date().toISOString();
69
+ const collected = await collectTarget(target, options);
57
70
  return {
58
71
  version: VERSION,
59
72
  layer: "artifact",
60
- target: root,
61
- adapter: adapter.name,
62
- filesScanned: files.length,
73
+ target: collected.root,
74
+ adapter: collected.adapterName,
75
+ filesScanned: collected.files.length,
63
76
  ignored: 0,
64
77
  startedAt,
65
78
  finishedAt: new Date().toISOString(),
66
- findings: sortFindings(findings),
79
+ findings: runArtifactRules(collected.files, collected.adapter, options.onMatch),
67
80
  };
68
81
  }
@@ -11,6 +11,7 @@ export interface CollectOptions {
11
11
  /** Files larger than this are skipped; bundles rarely exceed it and maps can be huge. */
12
12
  maxBytes?: number;
13
13
  }
14
+ export declare const PACKAGED: RegExp;
14
15
  export declare function toPosix(relative: string): string;
15
16
  /** Walk the root, keep what the adapter says the browser receives, read as UTF-8. */
16
17
  export declare function collectFiles(root: string, adapter: Adapter, options?: CollectOptions): Promise<ScannedFile[]>;
@@ -1,7 +1,9 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { toScanned } from "./archive.js";
3
4
  const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
4
5
  const ALWAYS_SKIP_DIRS = new Set(["node_modules", ".git"]);
6
+ export const PACKAGED = /\.(?:bundle|jsbundle|hbc|dex)$/i;
5
7
  /** Cheap binary check: a NUL byte in the first 8 KiB means not text. */
6
8
  function isProbablyBinary(buffer) {
7
9
  const end = Math.min(buffer.length, 8192);
@@ -26,6 +28,16 @@ export async function collectFiles(root, adapter, options = {}) {
26
28
  const relativePath = toPosix(path.relative(root, absolutePath));
27
29
  if (relativePath.split("/").some((segment) => ALWAYS_SKIP_DIRS.has(segment)))
28
30
  continue;
31
+ // React Native bundles and Android bytecode in an extracted app or a native build directory.
32
+ if (PACKAGED.test(relativePath)) {
33
+ const info = await stat(absolutePath);
34
+ if (info.size === 0 || info.size > maxBytes)
35
+ continue;
36
+ const scanned = toScanned(relativePath, await readFile(absolutePath), absolutePath);
37
+ if (scanned !== null)
38
+ files.push(scanned);
39
+ continue;
40
+ }
29
41
  if (!adapter.includes(relativePath))
30
42
  continue;
31
43
  const info = await stat(absolutePath);
@@ -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>;