comber 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +732 -0
  3. package/action.yml +279 -0
  4. package/comber.config.example.json +64 -0
  5. package/dist/api/classify.js +99 -0
  6. package/dist/api/classify.js.map +1 -0
  7. package/dist/api/driver.js +235 -0
  8. package/dist/api/driver.js.map +1 -0
  9. package/dist/api/graphql-driver.js +150 -0
  10. package/dist/api/graphql-driver.js.map +1 -0
  11. package/dist/api/graphql.js +115 -0
  12. package/dist/api/graphql.js.map +1 -0
  13. package/dist/api/har-driver.js +167 -0
  14. package/dist/api/har-driver.js.map +1 -0
  15. package/dist/api/har.js +100 -0
  16. package/dist/api/har.js.map +1 -0
  17. package/dist/api/load.js +83 -0
  18. package/dist/api/load.js.map +1 -0
  19. package/dist/api/openapi.js +159 -0
  20. package/dist/api/openapi.js.map +1 -0
  21. package/dist/api/request.js +141 -0
  22. package/dist/api/request.js.map +1 -0
  23. package/dist/api/schema.js +126 -0
  24. package/dist/api/schema.js.map +1 -0
  25. package/dist/cli.js +352 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/comment.js +164 -0
  28. package/dist/comment.js.map +1 -0
  29. package/dist/config.js +664 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/connectors/builtin/discord.js +55 -0
  32. package/dist/connectors/builtin/github-checks.js +53 -0
  33. package/dist/connectors/builtin/junit.js +70 -0
  34. package/dist/connectors/builtin/linear.js +58 -0
  35. package/dist/connectors/builtin/slack.js +60 -0
  36. package/dist/connectors/builtin/webhook.js +39 -0
  37. package/dist/connectors/index.js +3 -0
  38. package/dist/connectors/loader.js +72 -0
  39. package/dist/connectors/registry.js +28 -0
  40. package/dist/connectors/types.js +23 -0
  41. package/dist/core/action-gate.js +48 -0
  42. package/dist/core/baseline.js +85 -0
  43. package/dist/core/baseline.js.map +1 -0
  44. package/dist/core/blind-spots.js +22 -0
  45. package/dist/core/blind-spots.js.map +1 -0
  46. package/dist/core/contract-extras.js +1 -0
  47. package/dist/core/contract-extras.js.map +1 -0
  48. package/dist/core/contract.js +1 -0
  49. package/dist/core/contract.js.map +1 -0
  50. package/dist/core/coverage.js +70 -0
  51. package/dist/core/coverage.js.map +1 -0
  52. package/dist/core/crawl.js +229 -0
  53. package/dist/core/crawl.js.map +1 -0
  54. package/dist/core/egress.js +128 -0
  55. package/dist/core/egress.js.map +1 -0
  56. package/dist/core/ledger.js +41 -0
  57. package/dist/core/ledger.js.map +1 -0
  58. package/dist/core/net.js +39 -0
  59. package/dist/core/pipeline.js +191 -0
  60. package/dist/core/pipeline.js.map +1 -0
  61. package/dist/core/plan.js +121 -0
  62. package/dist/core/plan.js.map +1 -0
  63. package/dist/core/redact.js +240 -0
  64. package/dist/core/redact.js.map +1 -0
  65. package/dist/core/replay.js +263 -0
  66. package/dist/core/replay.js.map +1 -0
  67. package/dist/core/report.js +253 -0
  68. package/dist/core/report.js.map +1 -0
  69. package/dist/core/safety-judge.js +116 -0
  70. package/dist/core/safety.js +92 -0
  71. package/dist/core/safety.js.map +1 -0
  72. package/dist/core/signal-normalize.js +190 -0
  73. package/dist/core/signal-normalize.js.map +1 -0
  74. package/dist/core/signals.js +5 -0
  75. package/dist/core/signals.js.map +1 -0
  76. package/dist/core/triage.js +104 -0
  77. package/dist/core/triage.js.map +1 -0
  78. package/dist/core/types.js +1 -0
  79. package/dist/core/types.js.map +1 -0
  80. package/dist/desktop/desktop-profile.js +6 -0
  81. package/dist/desktop/desktop-profile.js.map +1 -0
  82. package/dist/desktop/driver.js +433 -0
  83. package/dist/desktop/driver.js.map +1 -0
  84. package/dist/desktop/evidence.js +206 -0
  85. package/dist/desktop/evidence.js.map +1 -0
  86. package/dist/desktop/macos/accessibility-runner.js +118 -0
  87. package/dist/desktop/macos/accessibility-runner.js.map +1 -0
  88. package/dist/desktop/macos/types.js +1 -0
  89. package/dist/desktop/macos/types.js.map +1 -0
  90. package/dist/desktop/preflight.js +107 -0
  91. package/dist/desktop/preflight.js.map +1 -0
  92. package/dist/desktop/proxy-proof.js +185 -0
  93. package/dist/desktop/proxy-proof.js.map +1 -0
  94. package/dist/desktop/view.js +348 -0
  95. package/dist/desktop/view.js.map +1 -0
  96. package/dist/desktop/windows/types.js +1 -0
  97. package/dist/desktop/windows/types.js.map +1 -0
  98. package/dist/desktop/windows/uia-runner.js +127 -0
  99. package/dist/desktop/windows/uia-runner.js.map +1 -0
  100. package/dist/native/apple.js +14 -0
  101. package/dist/native/apple.js.map +1 -0
  102. package/dist/native/device-profile.js +6 -0
  103. package/dist/native/device-profile.js.map +1 -0
  104. package/dist/native/driver.js +416 -0
  105. package/dist/native/driver.js.map +1 -0
  106. package/dist/native/evidence.js +164 -0
  107. package/dist/native/evidence.js.map +1 -0
  108. package/dist/native/load.js +9 -0
  109. package/dist/native/load.js.map +1 -0
  110. package/dist/native/maestro.js +153 -0
  111. package/dist/native/maestro.js.map +1 -0
  112. package/dist/native/preflight.js +72 -0
  113. package/dist/native/preflight.js.map +1 -0
  114. package/dist/native/proxy-proof.js +182 -0
  115. package/dist/native/proxy-proof.js.map +1 -0
  116. package/dist/native/samsung.js +34 -0
  117. package/dist/native/samsung.js.map +1 -0
  118. package/dist/native/signals.js +50 -0
  119. package/dist/native/signals.js.map +1 -0
  120. package/dist/native/view.js +139 -0
  121. package/dist/native/view.js.map +1 -0
  122. package/dist/profile.js +237 -0
  123. package/dist/profile.js.map +1 -0
  124. package/dist/sink.js +134 -0
  125. package/dist/sink.js.map +1 -0
  126. package/dist/sinks/file.js +49 -0
  127. package/dist/sinks/file.js.map +1 -0
  128. package/dist/sinks/http.js +38 -0
  129. package/dist/sinks/http.js.map +1 -0
  130. package/dist/sinks/index.js +5 -0
  131. package/dist/sinks/index.js.map +1 -0
  132. package/dist/sinks/multi.js +40 -0
  133. package/dist/sinks/multi.js.map +1 -0
  134. package/dist/sinks/paths.js +32 -0
  135. package/dist/sinks/paths.js.map +1 -0
  136. package/dist/sinks/record.js +58 -0
  137. package/dist/sinks/record.js.map +1 -0
  138. package/dist/sinks/types.js +1 -0
  139. package/dist/sinks/types.js.map +1 -0
  140. package/dist/web/breakage.js +210 -0
  141. package/dist/web/breakage.js.map +1 -0
  142. package/dist/web/controls.js +102 -0
  143. package/dist/web/controls.js.map +1 -0
  144. package/dist/web/driver.js +803 -0
  145. package/dist/web/driver.js.map +1 -0
  146. package/dist/web/elements.js +151 -0
  147. package/dist/web/elements.js.map +1 -0
  148. package/dist/web/fingerprint.js +13 -0
  149. package/dist/web/fingerprint.js.map +1 -0
  150. package/dist/web/forms.js +74 -0
  151. package/dist/web/forms.js.map +1 -0
  152. package/dist/web/links.js +194 -0
  153. package/dist/web/links.js.map +1 -0
  154. package/dist/web/types.js +1 -0
  155. package/dist/web/types.js.map +1 -0
  156. package/package.json +80 -0
  157. package/runtime/macos-accessibility-runner.js +275 -0
  158. package/runtime/windows-uia-runner.ps1 +539 -0
@@ -0,0 +1,803 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chromium, } from "playwright";
3
+ import { attachBreakageListeners, attachInitScripts, auditDom, runAxe, sessionLivenessProbe } from "./breakage.js";
4
+ import { classify, orderForExhaustion, synthFor, traversalIntentForKind } from "./elements.js";
5
+ import { exerciseControl, isControl } from "./controls.js";
6
+ import { FormTester } from "./forms.js";
7
+ import { LinkChecker } from "./links.js";
8
+ import { EgressGuard } from "../core/egress.js";
9
+ import { restoreFingerprint } from "./fingerprint.js";
10
+ /**
11
+ * Browser-side DOM extraction, as a STRING expression (tsx/esbuild keep-names injects
12
+ * a `__name` helper into serialized closures that doesn't exist in the browser). Widened
13
+ * selector set + classification fields + a structural domPath (stable across re-snapshots)
14
+ * + recursive OPEN-shadow-DOM piercing. Returns { els, blindSpots } where blindSpots counts
15
+ * likely-closed shadow roots this frame couldn't see into. Runs per-frame (snapshot() merges).
16
+ */
17
+ const EXTRACT_INTERACTIVE = `(() => {
18
+ const SEL = [
19
+ "a[href]","area[href]","button","[role=button]","input","select","textarea","[onclick]",
20
+ "[role=tab]","[role=menuitem]","[role=menuitemcheckbox]","[role=menuitemradio]",
21
+ "[role=switch]","[role=checkbox]","[role=radio]","[role=option]","[role=link]",
22
+ "[role=combobox]","[role=slider]","[role=treeitem]","[role=gridcell]","summary",
23
+ "[aria-haspopup]","[aria-expanded]","[contenteditable='true']",
24
+ "[tabindex]:not([tabindex='-1'])"
25
+ ].join(",");
26
+ const isVisible = (el) => {
27
+ if (!el.getBoundingClientRect) return false;
28
+ const r = el.getBoundingClientRect();
29
+ const s = window.getComputedStyle(el);
30
+ return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none" && s.opacity !== "0";
31
+ };
32
+ const domPath = (el) => {
33
+ const segs = []; let node = el, hops = 0;
34
+ while (node && node.nodeType === 1 && hops < 6) {
35
+ const tag = node.tagName.toLowerCase();
36
+ let n = 1, sib = node;
37
+ while ((sib = sib.previousElementSibling)) { if (sib.tagName === node.tagName) n++; }
38
+ segs.unshift(tag + ":nth-of-type(" + n + ")");
39
+ node = node.parentElement; hops++;
40
+ }
41
+ return segs.join(">");
42
+ };
43
+ const out = []; let i = 0; let blindSpots = 0;
44
+ const ORIGIN = location.origin;
45
+ const linkClassOf = (el) => {
46
+ const raw = el.getAttribute("href"); if (raw == null) return undefined;
47
+ if (raw.startsWith("#")) return "hash";
48
+ if (/^(mailto:|tel:|javascript:)/i.test(raw)) return "nonhttp";
49
+ try { return new URL(el.href).origin === ORIGIN ? "internal" : "external"; } catch { return undefined; }
50
+ };
51
+ const process = (el) => {
52
+ if (!isVisible(el)) return;
53
+ const ref = "c" + (i++);
54
+ try { el.setAttribute("data-comber-ref", ref); } catch (e) {}
55
+ const tag = el.tagName.toLowerCase();
56
+ const aLabel = el.getAttribute("aria-label");
57
+ const txt = (el.innerText || el.textContent || "").trim().replace(/\\s+/g, " ");
58
+ const name = (aLabel || txt || el.getAttribute("placeholder") || el.value ||
59
+ el.getAttribute("title") || el.getAttribute("alt") || el.getAttribute("name") || "").slice(0, 120);
60
+ const aExpanded = el.getAttribute("aria-expanded");
61
+ const aPressed = el.getAttribute("aria-pressed");
62
+ // Action-effect inputs: for a submit-capable form control, capture the effective wire
63
+ // method + resolved action URL (the formaction/formmethod override wins over the form's).
64
+ const etype = el.type ? String(el.type).toLowerCase() : "";
65
+ const submitCapable = !!el.form && (etype === "submit" || etype === "image");
66
+ let formMethod, formAction;
67
+ if (submitCapable) {
68
+ formMethod = (el.getAttribute("formmethod") || el.form.getAttribute("method") || "get").toLowerCase();
69
+ const a = el.getAttribute("formaction") || el.form.getAttribute("action") || "";
70
+ try { formAction = a ? new URL(a, location.href).href : location.href; } catch (e) { formAction = location.href; }
71
+ }
72
+ const onclickAttr = (el.getAttribute("onclick") || "").slice(0, 200);
73
+ out.push({
74
+ ref, role: el.getAttribute("role") || tag, name: String(name), href: el.href || undefined,
75
+ tag, inputType: el.type ? String(el.type).toLowerCase() : undefined, domPath: domPath(el),
76
+ ariaExpanded: aExpanded == null ? undefined : aExpanded === "true",
77
+ ariaControls: el.getAttribute("aria-controls") || undefined,
78
+ ariaPressed: aPressed == null ? undefined : aPressed === "true",
79
+ ariaChecked: el.getAttribute("aria-checked") || undefined,
80
+ checkedState: el.checked != null ? String(el.checked) : el.getAttribute("aria-checked") || undefined,
81
+ ariaSelected: el.getAttribute("aria-selected") || undefined,
82
+ valueNow: el.value != null ? String(el.value) : el.getAttribute("aria-valuenow") || undefined,
83
+ optionCount: el.tagName === "SELECT" ? el.options.length : (el.getAttribute("role") === "listbox" || el.getAttribute("role") === "combobox") ? el.querySelectorAll("[role=option]").length : undefined,
84
+ ariaHasPopup: el.getAttribute("aria-haspopup") || undefined,
85
+ group: el.getAttribute("name") || el.getAttribute("aria-owns") || undefined,
86
+ disabled: !!(el.disabled || el.getAttribute("aria-disabled") === "true"),
87
+ contentEditable: el.isContentEditable === true,
88
+ hasOnClick: el.hasAttribute("onclick") || typeof el.onclick === "function",
89
+ tabbable: el.hasAttribute("tabindex") && el.getAttribute("tabindex") !== "-1",
90
+ linkClass: linkClassOf(el),
91
+ formMethod, formAction, onclickText: onclickAttr || undefined,
92
+ });
93
+ };
94
+ const collect = (root) => {
95
+ try { root.querySelectorAll(SEL).forEach(process); } catch (e) {}
96
+ try {
97
+ root.querySelectorAll("*").forEach((el) => {
98
+ if (el.shadowRoot) { collect(el.shadowRoot); }
99
+ else if (el.tagName && el.tagName.includes("-") && el.childElementCount === 0) { blindSpots++; }
100
+ });
101
+ } catch (e) {}
102
+ };
103
+ collect(document);
104
+ return { els: out, blindSpots };
105
+ })()`;
106
+ /** Browser-side link + anchor-id collection (string body — see EXTRACT note). */
107
+ const COLLECT_LINKS = `(() => {
108
+ const links = [];
109
+ document.querySelectorAll("a[href], area[href]").forEach((el) => {
110
+ const rawHref = el.getAttribute("href");
111
+ if (rawHref == null) return;
112
+ links.push({
113
+ rawHref,
114
+ resolvedHref: el.href || rawHref,
115
+ text: (el.innerText || el.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 80),
116
+ isDownload: el.hasAttribute("download"),
117
+ foundOnUrl: location.href,
118
+ });
119
+ });
120
+ const anchorIds = [];
121
+ document.querySelectorAll("[id]").forEach((el) => { if (el.id) anchorIds.push(el.id); });
122
+ document.querySelectorAll("[name]").forEach((el) => { const n = el.getAttribute("name"); if (n) anchorIds.push(n); });
123
+ return { links, anchorIds };
124
+ })()`;
125
+ /** Browser-side form discovery (string body — tags forms with data-comber-form-ref). */
126
+ const DISCOVER_FORMS = `(() => {
127
+ const isVis = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.display !== "none" && s.visibility !== "hidden"; };
128
+ const forms = []; let fi = 0;
129
+ document.querySelectorAll("form, [role=form]").forEach((f) => {
130
+ if (!isVis(f)) return;
131
+ const fref = "f" + (fi++);
132
+ try { f.setAttribute("data-comber-form-ref", fref); } catch (e) {}
133
+ const fields = [];
134
+ f.querySelectorAll("input,select,textarea").forEach((el) => {
135
+ const type = (el.type || el.tagName).toLowerCase();
136
+ if (["hidden","submit","button","reset","image"].includes(type)) return;
137
+ let label = "";
138
+ if (el.id) { const lab = document.querySelector("label[for='" + (window.CSS && CSS.escape ? CSS.escape(el.id) : el.id) + "']"); if (lab) label = lab.innerText || ""; }
139
+ label = label || el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.getAttribute("name") || "";
140
+ fields.push({ name: el.getAttribute("name") || el.id || "", type, label: String(label).trim().slice(0, 60), required: !!(el.required || el.getAttribute("aria-required") === "true"), pattern: el.getAttribute("pattern") || undefined });
141
+ });
142
+ const submitEl = f.querySelector("[type=submit], button:not([type=button])");
143
+ forms.push({
144
+ formRef: fref, name: f.getAttribute("name") || f.getAttribute("aria-label") || "",
145
+ action: f.getAttribute("action") || undefined, method: (f.getAttribute("method") || "get").toLowerCase(),
146
+ submitText: ((submitEl && (submitEl.innerText || submitEl.value)) || "").trim().slice(0, 40),
147
+ fieldCount: fields.length, requiredCount: fields.filter((x) => x.required).length, fields,
148
+ });
149
+ });
150
+ return forms;
151
+ })()`;
152
+ const WEBACTION_PREFIX = "webaction:";
153
+ const WEBACTION_BASE_SEP = "::";
154
+ const WEBACTION_STEP_SEP = ">>";
155
+ const NOOP_COVERAGE = { skip() { }, bumpExtra() { } };
156
+ function normalizeUrl(url) {
157
+ try {
158
+ const u = new URL(url);
159
+ u.hash = "";
160
+ return u.toString();
161
+ }
162
+ catch {
163
+ return url;
164
+ }
165
+ }
166
+ function pathOf(url) {
167
+ try {
168
+ return new URL(url).pathname;
169
+ }
170
+ catch {
171
+ return url;
172
+ }
173
+ }
174
+ function egressSignalSignature(method, url) {
175
+ const key = (() => {
176
+ try {
177
+ const parsed = new URL(url);
178
+ parsed.hash = "";
179
+ parsed.search = "";
180
+ parsed.hostname = parsed.hostname.toLowerCase();
181
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
182
+ }
183
+ catch {
184
+ return url;
185
+ }
186
+ })();
187
+ return `egress-${method}-${createHash("sha1").update(key).digest("hex").slice(0, 12)}`;
188
+ }
189
+ function sameOrigin(a, b) {
190
+ try {
191
+ return new URL(a).origin === new URL(b).origin;
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ }
197
+ /** scheme+host+port of a URL, or undefined if unparseable. (No shared util existed.) */
198
+ function originOf(url) {
199
+ try {
200
+ return new URL(url).origin;
201
+ }
202
+ catch {
203
+ return undefined;
204
+ }
205
+ }
206
+ function encodeWebActionPath(baseUrl, domPaths) {
207
+ return `${WEBACTION_PREFIX}${encodeURIComponent(baseUrl)}${WEBACTION_BASE_SEP}${domPaths
208
+ .map((domPath) => encodeURIComponent(domPath))
209
+ .join(WEBACTION_STEP_SEP)}`;
210
+ }
211
+ function parseWebActionPath(id) {
212
+ const body = id.slice(WEBACTION_PREFIX.length);
213
+ const sep = body.indexOf(WEBACTION_BASE_SEP);
214
+ const rawBase = sep < 0 ? body : body.slice(0, sep);
215
+ const rawSteps = sep < 0 ? "" : body.slice(sep + WEBACTION_BASE_SEP.length);
216
+ const domPaths = rawSteps
217
+ ? rawSteps.split(WEBACTION_STEP_SEP).filter(Boolean).map((step) => decodeURIComponent(step))
218
+ : [];
219
+ return { baseUrl: decodeURIComponent(rawBase), domPaths };
220
+ }
221
+ /**
222
+ * The first Driver implementation — Playwright over Chromium. It keeps ALL web/DOM/ARIA
223
+ * semantics (the ElKind taxonomy, link/form subsystems, axe, the egress gates) behind the
224
+ * core contract: the core only ever sees an `Address`, a `Surface` of `SurfaceNode`s, and
225
+ * `BreakageSignal`s. Web-internal control/form/link drivers run via `exercise`/`probe`.
226
+ */
227
+ export class WebDriver {
228
+ opts;
229
+ name = "web";
230
+ // Probes registered with their lifecycle slot. The order reproduces the pre-v2 hard-coded
231
+ // crawl ordering EXACTLY: after-goto → session; after-snapshot → axe, dom, links (in that
232
+ // order); after-exercise → forms. No `finalize`-slot probe (link validation is the
233
+ // separate DriverExtras.finalize). Byte-identical web findings + plan ordering.
234
+ capabilities = {
235
+ screenshot: true,
236
+ probes: [
237
+ { name: "session", slot: "after-goto" },
238
+ { name: "axe", slot: "after-snapshot" },
239
+ { name: "dom", slot: "after-snapshot" },
240
+ { name: "links", slot: "after-snapshot" },
241
+ { name: "forms", slot: "after-exercise" },
242
+ ],
243
+ };
244
+ browser;
245
+ ctx;
246
+ page;
247
+ signals = [];
248
+ egressGuard = new EgressGuard();
249
+ cov = NOOP_COVERAGE;
250
+ linkChecker;
251
+ formTester;
252
+ crawlReached = new Set();
253
+ lastSnapshot;
254
+ linkAudit = [];
255
+ axeRuns = 0;
256
+ actionBaseUrl = "";
257
+ actionPath = [];
258
+ constructor(opts) {
259
+ this.opts = opts;
260
+ }
261
+ // -- DriverExtras: core injects its coverage sink so web sub-metrics + form skips land
262
+ // in the same honest ledger the orchestrator owns. --
263
+ attachCoverage(sink) {
264
+ this.cov = sink;
265
+ }
266
+ async start() {
267
+ try {
268
+ await this.startInner();
269
+ }
270
+ catch (err) {
271
+ // A failure AFTER launch (newContext/route/newPage can all throw) would otherwise
272
+ // orphan a headless Chromium process — one leaked browser per failed start, compounding
273
+ // in CI. Tear down what we opened before propagating.
274
+ await this.close();
275
+ throw err;
276
+ }
277
+ }
278
+ async startInner() {
279
+ this.browser = await chromium.launch({ headless: this.opts.headless });
280
+ this.ctx = await this.browser.newContext({
281
+ ...(this.opts.storageStatePath ? { storageState: this.opts.storageStatePath } : {}),
282
+ // Lets a synthetic-aware app reject the bot server-side too.
283
+ extraHTTPHeaders: { "X-Comber-Synthetic": "1" },
284
+ });
285
+ // EGRESS GATE — registered before any navigation. Aborts destructive-endpoint URLs
286
+ // always; aborts all mutating methods when --block-writes. Every block is observable.
287
+ const egress = this.opts.egressUrlDenylist;
288
+ const blockWrites = this.opts.blockWrites === true;
289
+ await this.ctx.route("**/*", async (route) => {
290
+ const req = route.request();
291
+ const method = req.method().toUpperCase();
292
+ const url = req.url();
293
+ const mutating = method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
294
+ // DELETE is an inherently destructive verb — there is no read-DELETE — so it is blocked
295
+ // ALWAYS, independent of --allow-writes, closing the RESTful `DELETE /api/items/5` bypass
296
+ // the accessible-name denylist can't see (an icon-only delete has no name to match).
297
+ const destructiveMethod = method === "DELETE";
298
+ const urlDenied = egress ? egress.test(url) : false;
299
+ const localFixtureAllowed = this.opts.allowPrivateNetworkForTesting === true && sameOrigin(url, this.opts.startUrl);
300
+ const networkDecision = localFixtureAllowed ? { allowed: true } : await this.egressGuard.check(url);
301
+ if (!networkDecision.allowed || urlDenied || destructiveMethod || (blockWrites && mutating)) {
302
+ const reason = !networkDecision.allowed
303
+ ? networkDecision.reason ?? "blocked by network egress policy"
304
+ : urlDenied
305
+ ? "blocked by destructive URL policy"
306
+ : destructiveMethod
307
+ ? "blocked destructive DELETE method"
308
+ : "blocked by write policy";
309
+ this.signals.push({
310
+ kind: "egress-blocked",
311
+ severity: "warn",
312
+ message: `blocked ${method} ${url.slice(0, 140)} (${reason})`,
313
+ url: this.page?.url() ?? url,
314
+ signature: egressSignalSignature(method, url),
315
+ });
316
+ return route.abort("blockedbyclient");
317
+ }
318
+ return route.continue();
319
+ });
320
+ // WebSocket egress gate — ws:// / wss:// connections bypass the HTTP route
321
+ // above, so gate them separately with the same EgressGuard (private/metadata
322
+ // targets blocked). Held until we decide: connect-through if allowed, close if not.
323
+ await this.ctx.routeWebSocket("**/*", async (ws) => {
324
+ const wsUrl = ws.url();
325
+ const localFixtureAllowed = this.opts.allowPrivateNetworkForTesting === true && sameOrigin(wsUrl, this.opts.startUrl);
326
+ const decision = localFixtureAllowed ? { allowed: true } : await this.egressGuard.check(wsUrl);
327
+ if (!decision.allowed) {
328
+ this.signals.push({
329
+ kind: "egress-blocked",
330
+ severity: "warn",
331
+ message: `blocked WS ${wsUrl.slice(0, 140)} (${decision.reason ?? "blocked by network egress policy"})`,
332
+ url: this.page?.url() ?? wsUrl,
333
+ signature: egressSignalSignature("WS", wsUrl),
334
+ });
335
+ ws.close();
336
+ return;
337
+ }
338
+ ws.connectToServer();
339
+ });
340
+ this.page = await this.ctx.newPage();
341
+ attachBreakageListeners(this.page, (s) => this.signals.push(s));
342
+ await attachInitScripts(this.page);
343
+ this.linkChecker = new LinkChecker({
344
+ startOrigin: (() => {
345
+ try {
346
+ return new URL(this.opts.startUrl).origin;
347
+ }
348
+ catch {
349
+ return this.opts.startUrl;
350
+ }
351
+ })(),
352
+ globalConcurrency: this.opts.linkConcurrency,
353
+ timeoutMs: this.opts.linkTimeoutMs,
354
+ externalEnabled: this.opts.checkExternalLinks,
355
+ maxExternalLinks: this.opts.maxExternalLinks,
356
+ crawlReachedUrls: this.crawlReached,
357
+ });
358
+ }
359
+ get p() {
360
+ if (!this.page)
361
+ throw new Error("WebDriver not started");
362
+ return this.page;
363
+ }
364
+ // -- addressing --------------------------------------------------------------
365
+ addressOf(url) {
366
+ return { id: normalizeUrl(url), scope: pathOf(url), label: url };
367
+ }
368
+ entryAddress() {
369
+ return this.addressOf(this.opts.startUrl);
370
+ }
371
+ currentAddress() {
372
+ if (this.actionPath.length > 0 && this.lastSnapshot) {
373
+ return this.actionAddressForSnapshot(this.lastSnapshot, this.actionPath);
374
+ }
375
+ return this.addressOf(this.currentUrl());
376
+ }
377
+ inScope(addr) {
378
+ if (addr.id.startsWith(WEBACTION_PREFIX)) {
379
+ return this.isAllowedOrigin(parseWebActionPath(addr.id).baseUrl);
380
+ }
381
+ return this.isAllowedOrigin(addr.id);
382
+ }
383
+ isAllowedOrigin(url) {
384
+ if (!this.opts.sameOriginOnly)
385
+ return true;
386
+ try {
387
+ return this.opts.allowedOrigins.includes(new URL(url).origin);
388
+ }
389
+ catch {
390
+ return sameOrigin(url, this.opts.startUrl);
391
+ }
392
+ }
393
+ /**
394
+ * Derive the core `ActionEffect` from a submit-capable control's form metadata, so the core
395
+ * action-floor gates on what the action DOES (wire method / destination), not its
396
+ * author/attacker-controlled label. Links are the follow-frontier (never exercised) → no
397
+ * effect. A non-form JS button carries no method → no effect here; its writes are still caught
398
+ * at the wire by the egress denylist / `--block-writes`. Returns undefined when nothing is
399
+ * known (so unknown-effect nodes stay allow, keeping this additive).
400
+ */
401
+ effectOf(el) {
402
+ if (el.kind === "link")
403
+ return undefined;
404
+ const effect = {};
405
+ const method = el.formMethod ? el.formMethod.toUpperCase() : undefined;
406
+ if (method) {
407
+ effect.method = method;
408
+ effect.mutates = /^(POST|PUT|PATCH|DELETE)$/.test(method);
409
+ if (method === "DELETE")
410
+ effect.irreversible = "delete";
411
+ }
412
+ if (el.formAction) {
413
+ const origin = originOf(el.formAction);
414
+ if (origin) {
415
+ effect.destinationOrigin = origin;
416
+ // offOrigin = outside the operator's allowed set (suppressed when --cross-origin is on).
417
+ if (!this.isAllowedOrigin(el.formAction))
418
+ effect.offOrigin = true;
419
+ // crossOrigin = destination ≠ the CURRENT page origin, INDEPENDENT of the allowed set —
420
+ // so a cross-origin WRITE is hard-denied (action-gate rule 1) even when cross-origin
421
+ // crawling is enabled, closing the exfil-POST bypass.
422
+ if (origin !== originOf(this.currentUrl()))
423
+ effect.crossOrigin = true;
424
+ }
425
+ }
426
+ if (el.onclickText && /\bconfirm\s*\(/i.test(el.onclickText))
427
+ effect.requiresConfirm = true;
428
+ return Object.keys(effect).length > 0 ? effect : undefined;
429
+ }
430
+ async goto(addr) {
431
+ if (addr.id.startsWith(WEBACTION_PREFIX)) {
432
+ const parsed = parseWebActionPath(addr.id);
433
+ await this.gotoUrl(parsed.baseUrl);
434
+ this.actionBaseUrl = parsed.baseUrl;
435
+ this.actionPath = [];
436
+ for (const domPath of parsed.domPaths) {
437
+ await this.clickDomPath(domPath);
438
+ this.actionPath.push(domPath);
439
+ }
440
+ return;
441
+ }
442
+ this.actionBaseUrl = addr.id;
443
+ this.actionPath = [];
444
+ await this.gotoUrl(addr.id);
445
+ }
446
+ async gotoUrl(url) {
447
+ await this.p.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 }).catch((e) => {
448
+ this.signals.push({
449
+ kind: "navigation-failed",
450
+ severity: "fail",
451
+ message: `goto ${url} failed: ${String(e).slice(0, 200)}`,
452
+ url,
453
+ signature: `nav-${url}`,
454
+ });
455
+ });
456
+ await this.settle();
457
+ }
458
+ /** Best-effort "page is done reacting": network-idle with a short ceiling. */
459
+ async settle() {
460
+ await this.p.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => { });
461
+ }
462
+ /** Web-internal DOM-first snapshot (full InteractiveEl). The core sees only the Surface
463
+ * produced by `snapshot()`; control/form handlers use this richer view. */
464
+ async webSnapshot() {
465
+ const interactive = [];
466
+ let frameBlindSpots = 0;
467
+ const blindSpotDetails = [];
468
+ for (const frame of this.p.frames()) {
469
+ const isMain = frame === this.p.mainFrame();
470
+ try {
471
+ const res = (await frame.evaluate(EXTRACT_INTERACTIVE));
472
+ frameBlindSpots += res.blindSpots;
473
+ const scope = pathOf(frame.url() || this.p.url());
474
+ for (let i = 0; i < res.blindSpots; i++) {
475
+ blindSpotDetails.push({
476
+ kind: "closed-shadow-root",
477
+ severity: "blocker-for-exhaustive",
478
+ scope,
479
+ message: "closed shadow root or opaque custom element could not be inspected",
480
+ });
481
+ }
482
+ for (const raw of res.els) {
483
+ interactive.push({
484
+ ...raw,
485
+ kind: classify(raw),
486
+ selector: `[data-comber-ref="${raw.ref}"]`,
487
+ framePath: isMain ? undefined : [frame.url()],
488
+ inShadow: undefined,
489
+ });
490
+ }
491
+ }
492
+ catch {
493
+ // cross-origin frame — can't evaluate into it. Honest blind spot.
494
+ if (!isMain) {
495
+ frameBlindSpots += 1;
496
+ blindSpotDetails.push({
497
+ kind: "cross-origin-frame",
498
+ severity: "blocker-for-exhaustive",
499
+ scope: pathOf(frame.url() || this.p.url()),
500
+ message: "cross-origin frame could not be inspected",
501
+ });
502
+ }
503
+ }
504
+ }
505
+ return {
506
+ url: this.p.url(),
507
+ title: await this.p.title().catch(() => ""),
508
+ interactive,
509
+ frameBlindSpots,
510
+ blindSpotDetails,
511
+ };
512
+ }
513
+ /** DISCOVER — the contract surface. Wraps webSnapshot into target-agnostic nodes:
514
+ * the full InteractiveEl rides in `handle`; file-inputs are surfaced as disabled
515
+ * (never drive a native file picker); links are `navigates` (followed, not clicked). */
516
+ async snapshot() {
517
+ const snap = await this.webSnapshot();
518
+ this.lastSnapshot = snap;
519
+ const nodes = orderForExhaustion(snap.interactive).map((el) => ({
520
+ id: el.domPath,
521
+ kind: el.kind,
522
+ label: el.name,
523
+ handle: el,
524
+ group: el.group,
525
+ disabled: Boolean(el.disabled) || el.kind === "file-input",
526
+ intent: traversalIntentForKind(el.kind),
527
+ navigates: el.kind === "link",
528
+ effect: this.effectOf(el),
529
+ }));
530
+ return {
531
+ address: this.surfaceAddress(snap),
532
+ title: snap.title,
533
+ nodes,
534
+ blindSpots: snap.frameBlindSpots,
535
+ blindSpotDetails: snap.blindSpotDetails,
536
+ };
537
+ }
538
+ /** EXERCISE — type-correct dispatch over the ElKind in the node's handle. Stateful
539
+ * controls go through the assert-changed handlers; control/dead-* signals ride the
540
+ * ExerciseResult, listener signals drain via drainSignals(). */
541
+ async exercise(node) {
542
+ const el = node.handle;
543
+ const signals = [];
544
+ if (node.intent === "branch") {
545
+ return this.exerciseBranch(el);
546
+ }
547
+ if (isControl(el.kind)) {
548
+ const r = await exerciseControl(this, el);
549
+ signals.push(...r.signals);
550
+ this.cov.bumpExtra("controlsExercised");
551
+ if (r.modalOpened)
552
+ this.cov.bumpExtra("modalsOpened");
553
+ return { signals };
554
+ }
555
+ switch (el.kind) {
556
+ case "text-input":
557
+ await this.fill(el, synthFor(el));
558
+ break;
559
+ case "contenteditable":
560
+ await this.fill(el, "comber-probe");
561
+ break;
562
+ default:
563
+ // button / generic-clickable / other
564
+ await this.act(el);
565
+ }
566
+ return { signals };
567
+ }
568
+ async exerciseBranch(el) {
569
+ const before = this.lastSnapshot ?? (await this.webSnapshot());
570
+ const beforeUrl = before.url;
571
+ const beforeFp = restoreFingerprint(before);
572
+ await this.act(el);
573
+ const after = await this.webSnapshot();
574
+ this.lastSnapshot = after;
575
+ if (after.url !== beforeUrl) {
576
+ return { discovered: [this.addressOf(after.url)] };
577
+ }
578
+ if (restoreFingerprint(after) === beforeFp) {
579
+ return {};
580
+ }
581
+ const nextPath = [...this.actionPath, el.domPath];
582
+ return { discovered: [this.actionAddressForSnapshot(after, nextPath)] };
583
+ }
584
+ // -- web-internal action primitives (ControlActions) -------------------------
585
+ /** Resolve a Locator for an element, honoring iframe context, with a domPath fallback. */
586
+ async resolveLocator(el) {
587
+ let root = this.p;
588
+ if (el.framePath && el.framePath.length) {
589
+ const f = this.p.frames().find((fr) => fr.url() === el.framePath[0]);
590
+ if (f)
591
+ root = f;
592
+ }
593
+ const primary = root.locator(el.selector).first();
594
+ if ((await primary.count().catch(() => 0)) > 0)
595
+ return primary;
596
+ // Stale ref after a re-snapshot/restore: fall back to the structural domPath.
597
+ if (el.domPath) {
598
+ const byPath = root.locator(el.domPath).first();
599
+ if ((await byPath.count().catch(() => 0)) > 0)
600
+ return byPath;
601
+ }
602
+ return primary; // let the action surface the real failure
603
+ }
604
+ async clickDomPath(domPath) {
605
+ await this.p.locator(domPath).first().click({ timeout: 5_000 }).catch((e) => {
606
+ this.signals.push({
607
+ kind: "dead-end-click",
608
+ severity: "warn",
609
+ message: `replay click "${domPath}" had no effect or errored: ${String(e).slice(0, 120)}`,
610
+ url: this.p.url(),
611
+ signature: `dead-replay-${domPath}`.slice(0, 80),
612
+ });
613
+ });
614
+ await this.settle();
615
+ }
616
+ async act(el) {
617
+ const target = await this.resolveLocator(el);
618
+ await target.click({ timeout: 5_000 }).catch((e) => {
619
+ this.signals.push({
620
+ kind: "dead-end-click",
621
+ severity: "warn",
622
+ message: `click "${el.name}" (${el.kind}) had no effect or errored: ${String(e).slice(0, 120)}`,
623
+ url: this.p.url(),
624
+ signature: `dead-${el.kind}-${el.domPath}`.slice(0, 80),
625
+ });
626
+ });
627
+ await this.settle();
628
+ }
629
+ async fill(el, value) {
630
+ const target = await this.resolveLocator(el);
631
+ await target.fill(value, { timeout: 5_000 }).catch(() => { });
632
+ await this.settle();
633
+ }
634
+ async selectOption(el, value) {
635
+ const target = await this.resolveLocator(el);
636
+ await target.selectOption(value !== undefined ? value : { index: 1 }, { timeout: 5_000 }).catch(() => { });
637
+ await this.settle();
638
+ }
639
+ async pressKey(key) {
640
+ await this.p.keyboard.press(key).catch(() => { });
641
+ await this.settle();
642
+ }
643
+ async nudge(el) {
644
+ const target = await this.resolveLocator(el);
645
+ await target.press("ArrowRight", { timeout: 5_000 }).catch(() => { });
646
+ await this.settle();
647
+ }
648
+ /** DriverExtras.softReset — the cheap middle restore tier (dismiss a leftover popover). */
649
+ async softReset() {
650
+ await this.pressKey("Escape");
651
+ }
652
+ // -- web-internal link + form primitives -------------------------------------
653
+ async collectLinks() {
654
+ return (await this.p.evaluate(COLLECT_LINKS).catch(() => ({ links: [], anchorIds: [] })));
655
+ }
656
+ async discoverForms() {
657
+ return (await this.p.evaluate(DISCOVER_FORMS).catch(() => []));
658
+ }
659
+ // Refs are controlled "fN" tokens, so building eval strings via template literal is
660
+ // safe AND sidesteps the esbuild __name trap (string contents aren't transpiled).
661
+ async formEmptyProbe(ref) {
662
+ const js = `(() => {
663
+ const f = document.querySelector('[data-comber-form-ref="${ref}"]'); if (!f) return null;
664
+ const els = f.querySelectorAll("input,select,textarea");
665
+ const saved = []; els.forEach((el, i) => saved.push([i, el.value == null ? "" : el.value]));
666
+ els.forEach((el) => {
667
+ const t = (el.type || "").toLowerCase();
668
+ if (el.tagName === "TEXTAREA" || ["text","email","password","search","tel","url","number","date","","time","month"].includes(t)) {
669
+ el.value = ""; el.dispatchEvent(new Event("input", { bubbles: true }));
670
+ }
671
+ });
672
+ return { savedJson: JSON.stringify(saved), checkValidity: f.checkValidity ? f.checkValidity() : true, requiredCount: f.querySelectorAll("[required],[aria-required='true']").length };
673
+ })()`;
674
+ return (await this.p.evaluate(js).catch(() => null));
675
+ }
676
+ async submitForm(ref) {
677
+ const js = `(() => { const f = document.querySelector('[data-comber-form-ref="${ref}"]'); if (f) { if (f.requestSubmit) f.requestSubmit(); else if (f.submit) f.submit(); } })()`;
678
+ await this.p.evaluate(js).catch(() => { });
679
+ await this.settle();
680
+ }
681
+ async formFeedback() {
682
+ const js = `(() => !!document.querySelector('[aria-invalid="true"], [role=alert]:not(:empty), input:invalid, textarea:invalid, select:invalid'))()`;
683
+ return (await this.p.evaluate(js).catch(() => false));
684
+ }
685
+ async formExists(ref) {
686
+ const js = `(() => !!document.querySelector('[data-comber-form-ref="${ref}"]'))()`;
687
+ return (await this.p.evaluate(js).catch(() => false));
688
+ }
689
+ async restoreForm(ref, savedJson) {
690
+ const js = `(() => {
691
+ const f = document.querySelector('[data-comber-form-ref="${ref}"]'); if (!f) return;
692
+ const saved = ${savedJson}; const els = f.querySelectorAll("input,select,textarea");
693
+ saved.forEach((pair) => { const el = els[pair[0]]; if (el) { el.value = pair[1]; el.dispatchEvent(new Event("input", { bubbles: true })); } });
694
+ })()`;
695
+ await this.p.evaluate(js).catch(() => { });
696
+ }
697
+ /** DriverExtras.outboundAddresses — in-scope, non-denylisted link destinations from the
698
+ * most recent surface, so the frontier expands by FOLLOWING links (never clicking them).
699
+ * The web destructive-href + name denylists are enforced here (where the href lives). */
700
+ outboundAddresses() {
701
+ const snap = this.lastSnapshot;
702
+ if (!snap)
703
+ return [];
704
+ // domPath order matches the pre-split frontier (links sort last in orderForExhaustion,
705
+ // then by domPath), so traversal order under a tight --max-states cap is preserved.
706
+ const links = snap.interactive
707
+ .filter((el) => el.kind === "link")
708
+ .sort((a, b) => (a.domPath < b.domPath ? -1 : a.domPath > b.domPath ? 1 : 0));
709
+ const out = [];
710
+ for (const el of links) {
711
+ if (!el.href)
712
+ continue;
713
+ if (this.opts.denylist.test(el.name || ""))
714
+ continue;
715
+ if (this.opts.destructiveHrefDenylist.test(el.href))
716
+ continue;
717
+ if (el.linkClass === "internal" || (!el.linkClass && /^https?:/i.test(el.href))) {
718
+ const addr = this.addressOf(el.href);
719
+ if (this.isAllowedOrigin(addr.id))
720
+ out.push(addr);
721
+ }
722
+ }
723
+ return out;
724
+ }
725
+ /** A named, capability-gated discovery pass. Collect-during for links; the validate-after
726
+ * post-pass is DriverExtras.finalize. axe self-limits to the first 8 states (bounded cost). */
727
+ async probe(phase) {
728
+ switch (phase) {
729
+ case "session": {
730
+ const s = await sessionLivenessProbe(this.p);
731
+ return s ? [s] : [];
732
+ }
733
+ case "dom":
734
+ return auditDom(this.p);
735
+ case "axe": {
736
+ if (this.axeRuns >= 8)
737
+ return [];
738
+ this.axeRuns++;
739
+ return runAxe(this.p);
740
+ }
741
+ case "links": {
742
+ // collect-during (every new state); validation happens in finalize().
743
+ this.crawlReached.add(normalizeUrl(this.currentUrl()));
744
+ if (this.linkChecker)
745
+ this.linkChecker.ingest(await this.collectLinks(), this.currentUrl());
746
+ return [];
747
+ }
748
+ case "forms": {
749
+ this.formTester ??= new FormTester(this, this.cov);
750
+ const out = [];
751
+ await this.formTester.testState(this.currentUrl(), (s) => out.push(s));
752
+ return out;
753
+ }
754
+ default:
755
+ return [];
756
+ }
757
+ }
758
+ /** DriverExtras.finalize — validate EVERY collected link over the cookie-inheriting
759
+ * request context (server-side, so CORS is a non-issue). Must run before close(). */
760
+ async finalize() {
761
+ if (!this.linkChecker || !this.ctx)
762
+ return [];
763
+ this.linkAudit = await this.linkChecker.validateAll(this.ctx.request).catch(() => []);
764
+ this.cov.bumpExtra("linksChecked", this.linkAudit.filter((r) => r.method !== "skip").length);
765
+ this.cov.bumpExtra("linksBroken", this.linkAudit.filter((r) => !r.ok).length);
766
+ return this.linkChecker.toFindings(this.linkAudit);
767
+ }
768
+ /** DriverExtras.auditExtra — structured per-driver data for the report/result.json. */
769
+ auditExtra() {
770
+ return { linkAudit: this.linkAudit };
771
+ }
772
+ async screenshot() {
773
+ return this.p.screenshot({ fullPage: false }).catch(() => Buffer.alloc(0));
774
+ }
775
+ currentUrl() {
776
+ return this.page?.url() ?? "";
777
+ }
778
+ surfaceAddress(snap) {
779
+ if (this.actionPath.length === 0)
780
+ return this.addressOf(snap.url);
781
+ return this.actionAddressForSnapshot(snap, this.actionPath);
782
+ }
783
+ actionAddressForSnapshot(snap, domPaths) {
784
+ const baseUrl = this.actionBaseUrl || snap.url;
785
+ const fingerprint = restoreFingerprint(snap);
786
+ const scope = `${pathOf(baseUrl)}#branch-${fingerprint.slice(0, 8)}`;
787
+ const label = `${snap.url} :: ${snap.title || "branch"}`;
788
+ return {
789
+ id: encodeWebActionPath(baseUrl, domPaths),
790
+ scope,
791
+ dedupeKey: `webbranch:${fingerprint}`,
792
+ label,
793
+ };
794
+ }
795
+ drainSignals() {
796
+ const out = this.signals;
797
+ this.signals = [];
798
+ return out;
799
+ }
800
+ async close() {
801
+ await this.browser?.close().catch(() => { });
802
+ }
803
+ }