From ee77f46177740abab0c3d23f3c85b69d79a32585 Mon Sep 17 00:00:00 2001 From: GangGreenTemperTatum <104169244+GangGreenTemperTatum@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:00:27 -0400 Subject: [PATCH] feat(web-security): postMessage listener discovery and origin-validation depth Distils the transferable intelligence from FransyTracker (gitlab.com/joaxcar/fransytracker, MIT) into the two DOM skills. The extension itself is a human-driven DevTools panel and is deliberately NOT wrapped as a tool -- the value is the knowledge, not the UI. Listener discovery (the biggest gap) Across all 82 skills, `MessagePort`, `MessageChannel`, `__lookupSetter__`, `nr@original`, `__sentry_original__` and `zone.js` appeared ZERO times. Our skills told an agent to grep addEventListener("message", ...), which silently misses: - handlers wrapped by Sentry/New Relic/Rollbar/Raven/Bugsnag/Zone.js, where the registered function is the monitoring shim and reading it reviews the wrong code - `window.onmessage = fn` setter assignment - MessagePort/MessageChannel handlers Adds a wrapper "tell -> recover from" table verified line-by-line against src/main.ts, including that Rollbar tries _wrapped before _rollbar_wrapped and that Bugsnag is NOT recoverable (upstream dropped the callee.caller chain), and a runtime capture snippet hooking all three surfaces. Origin validation Adds the full weak-operator set -- indexOf / includes / search / startsWith / endsWith / loose equality -- where we previously named only two, plus two regex bugs that read as correct: an unescaped dot (matches any char) and a missing $ anchor (allows any suffix). Every bypass in the table is executed in the tests rather than asserted. Also flags wildcard targetOrigin on the *reply* path, which leaks even when the inbound origin check is sound. FransyTracker as optional triage Its rules engine is a self-contained IIFE with no Chrome/DOM dependency, so it runs standalone under tsx over harvested listener bodies. Documented with its MEASURED blind spots -- e.source.postMessage, destructuring, two-hop aliases, jQuery .html(), setTimeout(string) are all unflagged -- so a clean result reads as "not yet triaged", never "safe". Includes the host_permissions *://*/* opsec warning and full credit to Frans Rosen and Zeetaz. Corrections made during review - the documented weak-origin `rg` used (?!=) lookahead, which ripgrep's default engine rejects outright; replaced with a portable [^=] form and a test that runs rg for real - the "native code" hook check was presented as evidence; it is trivially spoofable by overriding toString and also trips on unrelated extensions. Now qualified as a weak signal, with a snippet that actually enumerates listeners (verified in headless Chromium: all three surfaces captured) Tests: 522 passing (+43). Assertions are behavioural -- rg patterns are compiled and executed, every origin bypass is run, wrapper claims are checked against upstream source. Mutation-tested: deleting any of the three new sections fails 6-7 tests, and two guards were tightened after mutations initially slipped through (an operator name matched prose outside its table; "not recoverable" appeared twice so replacing one still passed). --- capabilities/web-security/capability.yaml | 2 +- .../dom-vulnerability-detection/SKILL.md | 148 +++++++- .../SKILL.md | 9 + .../tests/test_dom_postmessage_skills.py | 327 ++++++++++++++++++ 4 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 capabilities/web-security/tests/test_dom_postmessage_skills.py diff --git a/capabilities/web-security/capability.yaml b/capabilities/web-security/capability.yaml index 8da490f..d53ee5d 100644 --- a/capabilities/web-security/capability.yaml +++ b/capabilities/web-security/capability.yaml @@ -1,6 +1,6 @@ schema: 1 name: web-security -version: "1.11.0" +version: "1.12.0" description: > Web application penetration testing with 82 attack technique playbooks covering HTTP desync/request smuggling, cache poisoning, SSRF, SSTI, DOM diff --git a/capabilities/web-security/skills/dom-vulnerability-detection/SKILL.md b/capabilities/web-security/skills/dom-vulnerability-detection/SKILL.md index ef057b2..304f2d6 100644 --- a/capabilities/web-security/skills/dom-vulnerability-detection/SKILL.md +++ b/capabilities/web-security/skills/dom-vulnerability-detection/SKILL.md @@ -19,8 +19,15 @@ rg "eval\(|setTimeout\(|setInterval\(|new Function\(" --type js -n src/ # Search for postMessage handlers without origin checks rg "addEventListener.*message" --type js -n src/ + +# ...but that grep alone MISSES most real-world handlers. Also search: +rg "onmessage\s*=" --type js -n src/ # setter assignment +rg "MessagePort|MessageChannel|\.port[12]?\." --type js -n src/ # MessageChannel ``` +**A grep for `addEventListener("message")` is not a complete listener inventory.** See +*Listeners you will miss* below before concluding a page has no handlers. + ### 2. Trace data flow For each sink found, trace backwards: does attacker-controlled input reach it? - Direct: `element.innerHTML = location.hash.slice(1)` @@ -51,7 +58,97 @@ window.addEventListener('message', (e) => { **targetOrigin bypass via IP normalization:** When `postMessage(data, targetOrigin)` uses regex validation like `/https?:\/\/[^.]+[.]target[.]com/`, the `[^.]+` class matches `/` -- so `http://2130706433/.target.com` passes the regex. The browser's URL parser then normalizes the integer IP to `127.0.0.1` and sends the message to `http://127.0.0.1` (attacker-controlled). Same technique works with hex (`0x7f000001`) and octal IP forms. Check: does the sender validate `targetOrigin` with regex rather than strict string equality? If yes, test integer IP + path injection. -**Checkpoint:** For each handler, verify: (1) strict `e.origin` equality check exists, (2) no `window.origin` comparison, (3) no `startsWith`/`endsWith` on origin, (4) data is not passed to dynamic execution (`window[data.func]`). +#### Origin validation anti-patterns + +Anything other than strict equality against a fixed string is suspect. Full operator list: + +| Pattern | Why it fails | Bypass | +|---|---|---| +| `origin.indexOf('example.com') !== -1` | substring match anywhere | `https://evil-example.com`, `https://example.com.evil.tld` | +| `origin.includes('example.com')` | same as above | same | +| `origin.startsWith('https://example.com')` | no end boundary | `https://example.com.evil.tld` | +| `origin.endsWith('example.com')` | no start boundary | `https://evilexample.com` | +| `origin.search('example.com')` | substring, and `.` is a regex wildcard | `https://exampleXcom` | +| `origin == x` / `origin != x` | loose equality | type-juggling edge cases | +| `e.origin.match(/re/)` without anchors | matches anywhere in string | see regex rules below | + +**Regex quality — two bugs that look correct at a glance:** + +1. **Unescaped dot.** `/^https:\/\/trusted.example\.com$/` — the first `.` is a wildcard, so + `https://trustedXexample.com` passes. Check every `.` between the scheme and TLD is `\.`. +2. **Missing end anchor.** `/^https:\/\/trusted\.example\.com/` (no `$`) allows any suffix, so + `https://trusted.example.com.evil.tld` passes. + +Both are trivially missed in review. When you see an origin regex, read it character by character for +unescaped `.` and a terminating `$`. + +**Also flag:** `postMessage(data, '*')` in the *reply* path. A handler may validate the inbound origin +correctly and then leak the response to any listener via a wildcard `targetOrigin`. + +#### Listeners you will miss + +A registered listener is often **not** the function the app author wrote. Error-monitoring and framework +libraries wrap handlers, so reading the registered function shows you the monitoring shim, not the logic. +Unwrap before reviewing: + +| Library | Tell | Recover original from | +|---|---|---| +| Sentry | `fn.__sentry_original__` is a function | `fn.__sentry_original__` | +| New Relic | `fn["nr@original"]` present | `fn["nr@original"]` | +| Rollbar | `fn._isWrap`, `rollbarContext`/`rollbarWrappedError` in source | `fn._wrapped`, else `fn._rollbar_wrapped` | +| Raven | `.deep…apply…captureException` in source | the single function-valued own property | +| Bugsnag | `autoNotify`/`notifyException` in source, `fn.bugsnag` is a function | **not recoverable** — read the app handler from source instead | +| Bugsnag (alt) | `fn.__trace__` is a function | **not recoverable** — same | +| Zone.js / Vue / React | framework zone or error-boundary wrapper | varies; unwrap by inspecting own properties | + +Unwrapping is recursive — a handler can be wrapped more than once (e.g. Sentry inside Zone.js). + +Registration surfaces that never match an `addEventListener` grep: + +- **`window.onmessage = fn`** — setter assignment, not `addEventListener`. +- **`MessagePort.prototype.addEventListener`** — MessageChannel/`port.onmessage` handlers are an entirely + separate channel, common in iframe/worker bridges and SDKs. + +At runtime, capture what is actually registered rather than trusting a source grep. Hook the +registration paths **before** the app's own scripts run, then read back what was collected: + +```javascript +// agent-browser eval, or a DevTools "run before page load" snippet. +// Must execute before app JS; otherwise earlier registrations are missed. +globalThis.__seen = []; +const realAEL = Window.prototype.addEventListener; +Window.prototype.addEventListener = function (type, fn, opts) { + if (type === 'message') { + globalThis.__seen.push({ via: 'addEventListener', src: String(fn).slice(0, 400) }); + } + return realAEL.call(this, type, fn, opts); +}; +const realPortAEL = MessagePort.prototype.addEventListener; +MessagePort.prototype.addEventListener = function (type, fn, opts) { + if (type === 'message') { + globalThis.__seen.push({ via: 'MessagePort', src: String(fn).slice(0, 400) }); + } + return realPortAEL.call(this, type, fn, opts); +}; +// window.onmessage = fn bypasses both of the above: +Object.defineProperty(window, 'onmessage', { + set(fn) { globalThis.__seen.push({ via: 'onmessage', src: String(fn).slice(0, 400) }); } +}); +// ...load/interact with the page, then: globalThis.__seen +``` + +Detecting whether *something else* already hooked `addEventListener`: + +```javascript +Window.prototype.addEventListener.toString().includes('native code') +// false => a wrapper is installed (an extension, or the app itself) +``` + +**This is a weak signal, not proof.** A wrapper can trivially spoof it by overriding `toString`, and a +false result may simply be your own tooling or another browser extension. Treat it as a hint that the +registration path is instrumented, not as evidence about the application. + +**Checkpoint:** For each handler, verify: (1) strict `e.origin` equality check exists, (2) no `window.origin` comparison, (3) no `indexOf`/`includes`/`startsWith`/`endsWith`/loose-equality on origin, (4) any origin regex has escaped dots and a `$` anchor, (5) data is not passed to dynamic execution (`window[data.func]`), (6) the reply path does not use `postMessage(..., '*')`, (7) you have unwrapped monitoring wrappers and checked `onmessage`/`MessagePort` surfaces. ### 5. Test CSTI (Client-Side Template Injection) - **AngularJS**: `{{constructor.constructor('alert(1)')()}}` @@ -70,5 +167,54 @@ https://target.com/page# ``` **Checkpoint:** Confirm payload executes (not just reflected). Check CSP -- if blocked, see `csp-bypass` skill. +## Optional: bulk-triage listener bodies with FransyTracker's ruleset + +When you have harvested many listener bodies (jxscout, `agent-browser eval`, source review), you can +machine-triage them before reading each by hand. [FransyTracker](https://gitlab.com/joaxcar/fransytracker)'s +rules engine is a self-contained module with no Chrome or DOM dependency, so it runs standalone: + +```bash +git clone https://gitlab.com/joaxcar/fransytracker && cd fransytracker && npm install +npx tsx -e " +import './src/shared/findings.ts'; +const F = (globalThis as any).FransyTrackerFindings; +console.log(JSON.stringify(F.evaluateListener({ + listener: 'function(e){ var d = e.data; document.body.innerHTML = d.html; }' +}), null, 1));" +# => findings: missing-origin-check, tainted-data-to-sink (details: "innerHTML = d") +``` + +Ten rules: `missing-origin-check`, `weak-origin-check`, `origin-regex-unescaped-dot`, +`origin-regex-missing-anchor`, `eval-on-message-data`, `xss-sink-on-message-data`, +`location-assignment-from-data`, `tainted-data-to-sink`, `postmessage-wildcard-target`, +`missing-data-type-guard`. + +**Treat a clean result as "not yet triaged", never as "safe".** Measured blind spots — each of these is a +real sink the engine does not flag: + +| Pattern | Engine result | +|---|---| +| `e.source.postMessage(x, targetOrigin)` | missed entirely | +| `const {html} = e.data; el.innerHTML = html` | sink missed (destructuring) | +| `var a = e.data; var b = a; el.innerHTML = b` | sink missed (two-hop alias) | +| `$('#x').html(e.data)` | sink missed (jQuery) | +| `setTimeout(e.data.code, 0)` | sink missed | + +It is strong on the origin class and weaker on the sink class, so use it to *prioritise* reading order, +not to decide what to skip. Its rules are regex over source text — minification and unusual aliasing +degrade it further. + +**Opsec if you run the browser extension instead of the standalone module:** it requests +`host_permissions: *://*/*` and hooks page prototypes on every site you visit. Use a dedicated browser +profile, never your engagement-authenticated one. + +## Credits +The origin anti-pattern table, wrapper-unwrapping tells, and hidden-listener surfaces above are distilled +from [FransyTracker](https://gitlab.com/joaxcar/fransytracker) (Johan Carlsson), itself an MV3 adaptation of +[postMessage-tracker](https://github.com/fransr/postMessage-tracker) by Frans Rosén and +[FancyTracker](https://github.com/Zeetaz/FancyTracker) by Erik Zettergren. + ## Chain With - `csp-bypass` (CSP blocks execution), `dompurify-mxss-bypass` (DOMPurify present), `custom-sanitizer-audit` (homegrown sanitizer), `self-xss-escalation` (payload only fires in own session) +- `cspt-xss` (Gadget 8 chains a CSPT-injected response into a postMessage listener that trusts `*.target.com`) +- `dom-vulnerability-static-analysis` (same source/sink model, applied to a repo rather than a live page) diff --git a/capabilities/web-security/skills/dom-vulnerability-static-analysis/SKILL.md b/capabilities/web-security/skills/dom-vulnerability-static-analysis/SKILL.md index 0a3806c..6a16d54 100644 --- a/capabilities/web-security/skills/dom-vulnerability-static-analysis/SKILL.md +++ b/capabilities/web-security/skills/dom-vulnerability-static-analysis/SKILL.md @@ -24,6 +24,11 @@ rg 'location\.(href|assign|replace)\s*=|window\.open\(' \ rg 'location\.(hash|search|href)|document\.URL|document\.referrer|window\.name|postMessage' \ --type js --type ts -n src/ +# Weak origin validation (substring/prefix/suffix/loose equality). +# The [^=] at the end excludes safe strict === / !== comparisons. +rg 'origin\s*\.\s*(indexOf|includes|search|startsWith|endsWith)|origin\s*[!=]=[^=]' \ + --type js --type ts -n src/ + # Framework-specific sinks rg 'dangerouslySetInnerHTML|v-html|ng-bind-html|\[innerHTML\]|hx-get|hx-post' \ --type js --type html -g "*.vue" -g "*.tsx" -n src/ @@ -89,6 +94,10 @@ ast-grep -p 'window.open($TARGET)' -l js src/ # postMessage handlers (multi-line aware) ast-grep -p 'window.addEventListener("message", $HANDLER)' -l js src/ +# ...plus the two registration surfaces that pattern does NOT match +ast-grep -p 'window.onmessage = $HANDLER' -l js src/ +ast-grep -p '$PORT.onmessage = $HANDLER' -l js src/ # MessageChannel / Worker + # React dangerouslySetInnerHTML ast-grep -p 'dangerouslySetInnerHTML={{$VAR}}' -l tsx src/ diff --git a/capabilities/web-security/tests/test_dom_postmessage_skills.py b/capabilities/web-security/tests/test_dom_postmessage_skills.py new file mode 100644 index 0000000..a72791b --- /dev/null +++ b/capabilities/web-security/tests/test_dom_postmessage_skills.py @@ -0,0 +1,327 @@ +"""Tests for the postMessage/DOM skill content. + +These skills tell an agent which commands to run and which origin-validation +patterns are exploitable. Both classes of claim rot silently: a `rg` pattern +that no longer parses fails at the operator's terminal, and a bypass example +that is subtly wrong sends an agent chasing a non-issue. + +The assertions here are deliberately behavioural rather than textual: + + * every ``rg`` snippet is compiled to check it is a valid regex under + ripgrep's default engine (no look-around), and + * every documented origin bypass is executed to confirm the "unsafe" pattern + really does accept the attacker origin. + +Content distilled from FransyTracker (https://gitlab.com/joaxcar/fransytracker), +itself derived from Frans Rosen's postMessage-tracker and Zeetaz's FancyTracker. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SKILLS = ROOT / "skills" +DETECTION = SKILLS / "dom-vulnerability-detection" / "SKILL.md" +STATIC = SKILLS / "dom-vulnerability-static-analysis" / "SKILL.md" + +DETECTION_TEXT = DETECTION.read_text(encoding="utf-8") +STATIC_TEXT = STATIC.read_text(encoding="utf-8") + +# The ten rules exposed by FransyTrackerFindings.listRules(), verified against +# src/shared/findings.ts at ruleset version 3. +FRANSY_RULE_IDS = ( + "origin-regex-unescaped-dot", + "origin-regex-missing-anchor", + "missing-origin-check", + "eval-on-message-data", + "postmessage-wildcard-target", + "location-assignment-from-data", + "missing-data-type-guard", + "xss-sink-on-message-data", + "weak-origin-check", + "tainted-data-to-sink", +) + + +# ============================================================================= +# Listener discovery — the surfaces an addEventListener grep misses +# ============================================================================= + + +class TestListenerDiscoverySurfaces: + """A grep for addEventListener("message") is not a listener inventory.""" + + @pytest.mark.parametrize("surface", ["onmessage", "MessagePort", "MessageChannel"]) + def test_detection_names_hidden_registration_surfaces(self, surface: str) -> None: + assert surface in DETECTION_TEXT + + def test_detection_warns_grep_is_incomplete(self) -> None: + assert "not a complete listener inventory" in DETECTION_TEXT + + def test_static_analysis_covers_setter_and_port(self) -> None: + # ast-grep patterns verified to match real source in this repo's tests. + assert "window.onmessage = $HANDLER" in STATIC_TEXT + assert "$PORT.onmessage = $HANDLER" in STATIC_TEXT + + @pytest.mark.parametrize( + "marker", + ["__sentry_original__", "nr@original", "_rollbar_wrapped", "_isWrap"], + ) + def test_wrapper_tells_documented(self, marker: str) -> None: + # Monitoring wrappers hide the real handler; the skill must name the + # properties that recover it. + assert marker in DETECTION_TEXT + + def test_bugsnag_marked_unrecoverable(self) -> None: + # FransyTracker deliberately does NOT unwrap Bugsnag (the callee.caller + # chain is deprecated and unreliable). Claiming otherwise would send an + # agent looking for a property that does not exist. Both Bugsnag rows + # must say so. + bugsnag_rows = [ + line + for line in DETECTION_TEXT.splitlines() + if line.startswith("| Bugsnag") + ] + assert len(bugsnag_rows) == 2, f"expected 2 Bugsnag rows, got {len(bugsnag_rows)}" + for row in bugsnag_rows: + assert "not recoverable" in row.lower(), f"Bugsnag row claims recovery: {row}" + + def test_unwrapping_is_described_as_recursive(self) -> None: + assert "recursive" in DETECTION_TEXT.lower() + + def test_runtime_capture_covers_all_three_surfaces(self) -> None: + # The runtime snippet must hook every registration path, or it repeats + # the very blind spot the section warns about. Verified in a real + # headless browser: all three fire. + capture = DETECTION_TEXT[DETECTION_TEXT.index("At runtime, capture") :] + assert "Window.prototype.addEventListener" in capture + assert "MessagePort.prototype.addEventListener" in capture + assert "Object.defineProperty(window, 'onmessage'" in capture + + def test_runtime_capture_warns_about_ordering(self) -> None: + # Hooking after app JS has run misses earlier registrations. + collapsed = " ".join(DETECTION_TEXT.split()) + assert "before app JS" in collapsed or "before the app" in collapsed + + def test_native_code_check_is_qualified_as_spoofable(self) -> None: + # `toString().includes('native code')` is trivially spoofed by + # overriding toString, and also trips on unrelated extensions. Shipping + # it unqualified would invite a false conclusion. + collapsed = " ".join(DETECTION_TEXT.split()) + assert "weak signal, not proof" in collapsed + assert "spoof" in collapsed.lower() + + +# ============================================================================= +# Origin validation anti-patterns — every bypass is executed +# ============================================================================= + + +class TestOriginBypassClaims: + """Each documented bypass must actually bypass.""" + + @pytest.mark.parametrize( + ("origin", "allowed", "operator"), + [ + ("https://evil-example.com", "example.com", "indexOf"), + ("https://example.com.evil.tld", "example.com", "indexOf"), + ("https://example.com.evil.tld", "https://example.com", "startsWith"), + ("https://evilexample.com", "example.com", "endsWith"), + ], + ) + def test_substring_operators_accept_attacker_origin( + self, origin: str, allowed: str, operator: str + ) -> None: + if operator == "indexOf": + assert allowed in origin + elif operator == "startsWith": + assert origin.startswith(allowed) + else: + assert origin.endswith(allowed) + + def test_unescaped_dot_matches_arbitrary_character(self) -> None: + # /^https:\/\/trusted.example\.com$/ — the first dot is a wildcard. + weak = re.compile(r"^https://trusted.example\.com$") + assert weak.match("https://trustedXexample.com") + strict = re.compile(r"^https://trusted\.example\.com$") + assert not strict.match("https://trustedXexample.com") + + def test_missing_end_anchor_allows_suffix(self) -> None: + weak = re.compile(r"^https://trusted\.example\.com") + assert weak.match("https://trusted.example.com.evil.tld") + anchored = re.compile(r"^https://trusted\.example\.com$") + assert not anchored.match("https://trusted.example.com.evil.tld") + + @pytest.mark.parametrize( + "operator", ["indexOf", "includes", "search", "startsWith", "endsWith"] + ) + def test_all_weak_operators_are_documented(self, operator: str) -> None: + # Must appear as a row of the anti-pattern table, not merely somewhere + # in the prose — the table is what carries the bypass example. + table_rows = [ + line + for line in DETECTION_TEXT.splitlines() + if line.startswith("| `origin") or line.startswith("| `e.origin") + ] + assert any(operator in row for row in table_rows), ( + f"{operator} missing from the origin anti-pattern table" + ) + + def test_loose_equality_documented(self) -> None: + assert "loose equality" in DETECTION_TEXT.lower() + + def test_wildcard_reply_path_documented(self) -> None: + # A handler can validate the inbound origin and still leak the reply. + assert "postMessage(data, '*')" in DETECTION_TEXT + assert "reply" in DETECTION_TEXT.lower() + + def test_checkpoint_covers_the_new_operators(self) -> None: + checkpoint = DETECTION_TEXT[DETECTION_TEXT.index("**Checkpoint:** For each handler") :] + for token in ("indexOf", "includes", "escaped dots", "unwrapped"): + assert token in checkpoint + + +# ============================================================================= +# Documented shell commands must actually run +# ============================================================================= + + +def _rg_patterns(text: str) -> list[str]: + """Extract the regex argument from each documented ``rg`` call. + + Handles both quoting styles used across the skills (single and double). + """ + single = re.findall(r"^rg\s+(?:--\S+\s+)*'([^']+)'", text, re.MULTILINE) + double = re.findall(r'^rg\s+(?:--\S+\s+)*"([^"]+)"', text, re.MULTILINE) + return single + double + + +class TestDocumentedCommandsAreValid: + def test_patterns_were_found(self) -> None: + assert _rg_patterns(DETECTION_TEXT), "no rg snippets found in detection skill" + assert _rg_patterns(STATIC_TEXT), "no rg snippets found in static skill" + + @pytest.mark.parametrize("skill", ["detection", "static"]) + def test_rg_patterns_avoid_lookaround(self, skill: str) -> None: + # ripgrep's default engine rejects look-around: a documented pattern + # using (?!...) fails with "look-around ... is not supported" unless + # --pcre2 is passed. Catch that before an operator does. + text = DETECTION_TEXT if skill == "detection" else STATIC_TEXT + for pattern in _rg_patterns(text): + if "--pcre2" in text and "(?" in pattern: + continue + assert "(?!" not in pattern and "(?=" not in pattern, ( + f"look-around in rg pattern without --pcre2: {pattern!r}" + ) + assert "(?<" not in pattern, f"look-behind in rg pattern: {pattern!r}" + + @pytest.mark.parametrize("skill", ["detection", "static"]) + def test_rg_patterns_compile(self, skill: str) -> None: + text = DETECTION_TEXT if skill == "detection" else STATIC_TEXT + for pattern in _rg_patterns(text): + re.compile(pattern) # raises on malformed regex + + @pytest.mark.skipif(shutil.which("rg") is None, reason="ripgrep not installed") + def test_weak_origin_pattern_matches_unsafe_and_skips_safe(self, tmp_path: Path) -> None: + sample = tmp_path / "origins.js" + sample.write_text( + "\n".join( + [ + "if (e.origin.indexOf('example.com') === -1) return;", + "if (e.origin.includes('example.com')) ok();", + "if (e.origin.startsWith('https://example.com')) ok();", + "if (e.origin.endsWith('example.com')) ok();", + "if (e.origin.search('example.com')) ok();", + "if (e.origin == 'https://example.com') ok();", + "if (e.origin != 'https://x.com') return;", + "if (e.origin === 'https://example.com') ok();", # safe + ] + ), + encoding="utf-8", + ) + pattern = next( + p for p in _rg_patterns(STATIC_TEXT) if "indexOf|includes|search" in p + ) + result = subprocess.run( # noqa: S603 + ["rg", pattern, "-n", str(sample)], # noqa: S607 + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, f"rg failed: {result.stderr}" + matched = {int(line.split(":", 1)[0]) for line in result.stdout.splitlines()} + assert matched == {1, 2, 3, 4, 5, 6, 7}, ( + f"expected the 7 weak lines and not the strict === on line 8, got {sorted(matched)}" + ) + + +# ============================================================================= +# FransyTracker triage accelerator — claims about the third-party ruleset +# ============================================================================= + + +class TestFransyTrackerReference: + def test_all_ten_rule_ids_listed(self) -> None: + for rule_id in FRANSY_RULE_IDS: + assert rule_id in DETECTION_TEXT, f"rule id not documented: {rule_id}" + + def test_upstream_and_lineage_credited(self) -> None: + # MIT-licensed third-party work; attribution is required, and the + # lineage tells an operator where the technique originated. + assert "gitlab.com/joaxcar/fransytracker" in DETECTION_TEXT + assert "postMessage-tracker" in DETECTION_TEXT + assert "FancyTracker" in DETECTION_TEXT + + def test_blind_spots_documented(self) -> None: + # Measured against the real engine: these sinks are NOT flagged, so a + # clean result must never be read as "safe". + for missed in ( + "e.source.postMessage", + "destructuring", + "two-hop alias", + "jQuery", + "setTimeout", + ): + assert missed in DETECTION_TEXT, f"blind spot not documented: {missed}" + + def test_clean_result_is_not_called_safe(self) -> None: + assert "never as \"safe\"" in DETECTION_TEXT + + def test_extension_opsec_warning_present(self) -> None: + # host_permissions *://*/* + prototype hooking on every visited site. + # Collapse whitespace: the warning wraps across lines in the markdown. + collapsed = " ".join(DETECTION_TEXT.split()) + assert "host_permissions" in collapsed + assert "dedicated browser profile" in collapsed + assert "never your engagement-authenticated one" in collapsed + + def test_no_install_of_untrusted_code_is_implied_as_required(self) -> None: + # The standalone module path must be presented as optional, so the + # skill stays usable without cloning third-party code. + heading = "## Optional: bulk-triage listener bodies" + assert heading in DETECTION_TEXT + + +# ============================================================================= +# Cross-skill wiring +# ============================================================================= + + +class TestSkillCrossReferences: + def test_detection_chains_to_cspt(self) -> None: + # CSPT Gadget 8 chains an injected response into a postMessage listener. + assert "cspt-xss" in DETECTION_TEXT + + def test_detection_chains_to_static_counterpart(self) -> None: + assert "dom-vulnerability-static-analysis" in DETECTION_TEXT + + def test_referenced_skills_exist(self) -> None: + for name in ("cspt-xss", "dom-vulnerability-static-analysis"): + assert (SKILLS / name / "SKILL.md").is_file()