Skip to content

feat: research-fed detection⇄evasion loop, coordination/QUIC/FP-gate rungs, grounding harness#32

Merged
datascry merged 34 commits into
mainfrom
feat/research-loop-grounding
Jun 22, 2026
Merged

feat: research-fed detection⇄evasion loop, coordination/QUIC/FP-gate rungs, grounding harness#32
datascry merged 34 commits into
mainfrom
feat/research-loop-grounding

Conversation

@datascry

Copy link
Copy Markdown
Owner

A full session of the red⇄blue arms-race loop, now research-fed and capped by a turnkey grounding harness. 12 commits, all CI-green (detector 260 · harness 234 · edge vet+test · collector tsc/eslint/vitest · headers).

Detection / evasion rungs (grounded)

  • fix(harness) honest FP gate — exclude internally-incoherent browserforge fingerprints (Win-UA+Linux-platform, UA/CH version skew, HeadlessChrome brands) from the legit corpus; hard bot-FP rate 1%→~0%, no rule weakened.
  • feat(harness) coordination — catch JA4-rotating fleets via collision clustering (cluster by cloned fp_hash / replayed trace / shared WebRTC origin across distinct IPs, not just JA4). Closes a real evasion; scenario gate stays 100/100.
  • feat(edge) ADR-0005 QUIC per-connection (DCID) attributionInitialDCID + DCID-keyed tee + an http3.Server that links a captured Initial to its connection's ks_sid. Plumbing built + unit-tested; the rule stays unshipped pending out-of-sandbox grounding (trusted-cert host + QUIC rotation evader).
  • feat(detector) br.mobile_no_touch — phone/tablet UA with maxTouchPoints == 0 (device-DB-free spatial coherence from FP-Inconsistent, ACM IMC 2025). Grounded both ways, 0 FP, mirrored across demo.py + calibration mapper + livepage collector.

The loop itself

  • docs ADR-0005 (QUIC attribution design) + research radar (docs/research-radar.md) — the loop's cited intake queue, groundable-vs-external split.
  • feat(harness) grounding harnessdocs/grounding.md runbook + task grounding -- <dir> (kitsune_harness.grounding): one command runs per-session FP/recall + coordination + prevalence-prior rebuild over real captures. The turnkey path for when operator real-data (proxy egress / real-device / real traffic / trusted-cert QUIC) arrives.

State of play

In-sandbox detection is saturated; every remaining frontier is external-data-bound and routed to the grounding harness. Net new grounded detection this session: br.mobile_no_touch + the coordination JA4-rotation fix.

datascry added 30 commits June 21, 2026 06:19
… from the FP corpus

browserforge cross-samples userAgent / navigator.platform / userAgentData semi-independently
from its Bayesian network, so a fraction of its output is internally incoherent — a Windows UA
with navigator.platform "Linux x86_64" (navplatform_vs_ua), a UA Chrome major that disagrees with
the UA-CH major (ch_he_version_vs_ua), or a HeadlessChrome brand set (ch_he_headless). A real
browser's UA/platform/CH all come from one binary and cannot disagree, so the convicting rules flag
these correctly — counting them as false positives over-reported the FP rate (the residual ~1% bot
FPs the docs attributed to "data artifacts") and risked pressure to weaken a correct convicting
rule to chase a corpus artifact.

generate_profiles now drops internally-incoherent fingerprints from the legit corpus via the new
calibration.fingerprint_coherence (mirrors the mapper's own derivations, so Android's Linux-kernel
platform under an Android UA stays coherent) and reports the excluded count by reason — never a
silent drop. Measured: hard bot FP rate 1% -> ~0% over the coherent denominator, no rule touched.

Fonts are deliberately excluded from the filter: cross-OS font installs are real, so a UA-vs-font
mismatch is not a binary-bound incoherence — br.font_os_vs_ua is left to be evaluated on its merits.

harness: ruff + mypy clean, 223 passed, coverage 96.36%.
…hin-session coherence

QUIC is the last gap in the within-session coherence axis. The edge attributes QUIC fingerprints
per source IP (the elicit-and-close capturer + FingerprintByIP), which is NAT-confounded,
single-shot, and ungroundable — the reason net.quic_grease_vs_ua / net.quic_pq_keyshare_vs_ua were
retired and a net.quic_unstable_within_session rotation rule cannot ship.

Records the decision to serve H3 and key the captured Initial by QUIC connection ID (DCID) instead
of source address: ks_sid-attributed, migration-safe, and reusing the already-multi-packet parser
(ParseQUICInitials) and the TCP path's session-minting. Explicitly does NOT authorise shipping a
QUIC rule — promotion stays gated on out-of-sandbox GROUNDED-LIVE evidence (a browser-trusted cert
for a real-Chromium h3 negative + a uTLS/quic-go rotation evader positive). The plumbing is
buildable/unit-testable in-sandbox (Docker golang:1.26); the rule is not groundable here.

Status: Proposed.
…itive (ADR-0005)

First in-sandbox increment of the ADR-0005 QUIC rework. The capturer attributed Initials by source
address (FingerprintByIP), which is NAT-shared and breaks on path migration — the root of the QUIC
cross-attribution FPs. This adds attribution by the client-chosen Destination Connection ID, which
is unique to one handshake:

- fingerprint.InitialDCID(pkt) extracts the DCID from the Initial's cleartext long header (no
  decryption); decryptInitialCrypto now reuses it (one header parse, not two).
- teeEntry records the DCID (all Initials of a handshake share it); take()'s predicate now receives
  the entry, and QUICCapturer.FingerprintByDCID(dcid) retrieves a hello per-connection.

The live per-IP bridge is untouched (FingerprintByIP retained) — this is additive, with no detector
/registry/scoring change, so zero FP risk. It is the foundation the H3-serving step consumes to link
a captured Initial to its connection's ks_sid (the next increment); the within-session QUIC rule
stays unbuilt until that lands and can be GROUNDED-LIVE on a trusted-cert host (per ADR-0005).

Tests: TestInitialDCID (pure extraction + rejections), TestFingerprintByDCID (real quic-go handshake
→ retrieve by recorded DCID, and an unknown DCID does not mis-attribute). Full edge vet + tests green
via Docker golang:1.26.
…(ADR-0005)

Second ADR-0005 increment: a QUICServer that serves H3 over the tee'd UDP socket instead of
eliciting-and-closing, so the ks_sid arrives ON the QUIC connection (the H3 request cookie) and the
captured Initial is attributed to it by DCID — removing the source-IP match across the TCP/QUIC
boundary that made the QUIC tells NAT-confounded.

- QUICServer runs http3.Server.Serve(tee): the tee is a net.PacketConn, so one socket both serves H3
  and records each connection's Initial + DCID. ConnContext binds the connection's DCID (resolved via
  tee.dcidForAddr at connection start, where the remote addr still equals the Initial's source addr)
  into the request context, so the handler attributes by DCID and survives later path migration.
- The handler reuses prepare() for ks_sid + header signals, appends quicTells looked up by
  fingerprintByDCID, forwards, and reverse-proxies to the backend.
- tee gains fingerprintByDCID + dcidForAddr; QUICCapturer.FingerprintByDCID now delegates (DRY).
- go.mod: quic-go/qpack pulled in (indirect) by the http3 import.

NOT yet wired into ListenAndServe (the live edge still runs the elicit-and-close QUICCapturer) — the
swap, and the net.quic_unstable_within_session rule, stay gated on out-of-sandbox GROUNDED-LIVE
evidence (a browser-trusted cert + a QUIC rotation evader), per ADR-0005.

Test: TestQUICServerAttributesFingerprintByDCID drives a real http3 client through the server to a stub
backend and asserts quic_observed is emitted under the request's ks_sid (proving the Initial→DCID→
ks_sid path end-to-end over real QUIC). Full edge vet + tests green via Docker golang:1.26.
… clustering

Red⇄blue rung on the cross-session coordination seam. RED: the fleet detector clustered ONLY by JA4
prefix (score_corpus / FleetTracker), and every collision check ran within a single JA4 cluster — so a
fleet that rotates its JA4 per node (uTLS egress / mixed builds) lands in singleton clusters, is never
graded, and its convicting tells are never computed. Grounded: a cloned-fp / replayed-trace /
shared-origin fleet with a distinct JA4 per node scored 0 clusters, 0 convictions (EVADES) — including
the unambiguous trace-replay and shared-origin tells.

BLUE: cluster by ANY cross-instance binding, not just JA4. _collision_clusters groups sessions by a
cloned fp_hash / replayed trace_hash / shared webrtc_public_ip spanning >=2 distinct observed IPs;
_clusters unifies JA4-prefix + collision clusters (a collision subset of a JA4 cluster is deduped, so a
fleet sharing both is reported once). score_corpus and FleetTracker (online) both use it; score_cluster
gains a `basis` label and is reused unchanged, so the conviction gate — and its FP-safety — is identical.

Grounded both directions: the three JA4-rotating fleets now convict (offline + a single online alert on
the 2nd arrival); FP-safety locked — distinct-profile cohorts form no collision cluster, and a corporate
one-image fp-collision still caps at candidate (ambiguous, uncorroborated) on the new path too. All 42
prior coordination tests pass, the scenario gate holds at 100%/100% precision/recall, +6 grounding tests.
harness: ruff + mypy clean, 229 passed, coverage 96.45%.
…ration 1)

Seeds the research-fed red⇄blue loop from a deep-research pass (5 angles, 23 sources, 25 claims
3-vote-verified, 22 confirmed). docs/research-radar.md is the durable queue: external detection/evasion
techniques mapped to Kitsune seams, each tagged groundable-in-sandbox vs external-data-bound (which IS
the real-world-testability evaluation), tracked lead→grounded.

Iteration 1 leads:
- Groundable: G1 cross-attribute (spatial) fingerprint inconsistency (FP-Inconsistent, ACM IMC 2025 —
  the strongest external validation of Kitsune's incoherence thesis: coherence rules cut DataDome
  evasion 48%); G2 GAN/diffusion mouse-synthesis evader to pressure-test the coalesced terminus;
  G3 keystroke dynamics; G4 JA4+ suite coverage audit.
- External queue: X1 encapsulated-TLS proxy/tunnel fingerprinting (USENIX Sec 2024); X2 RESIP
  relayed/tunnel classifier; X3 CGNAT detection; X4 real-traffic prevalence prior.
- Validations (do not rebuild): temporal inconsistency == the within-session axis; session-replay ==
  trace_replay/trace_collision; engine-level spoof defeated by coherence not property probes.

Methodology guardrails recorded: grounding discipline, lab-classifier≠production deltas, and Rosetta's
packet-length-sequence non-transfer warning.
The edge pump needs local Docker + go-task, so the loop runs on demand in a local session rather than
as a cloud routine. Drive a cycle by asking Claude to "run a research-loop cycle" or via /loop. A cloud
routine can be added later via /schedule once GitHub is connected for datascry/kitsune.
…nts 0 (research-radar G1)

First research-fed pump rung (docs/research-radar.md G1, from FP-Inconsistent, ACM IMC 2025 — the
strongest external validation of Kitsune's incoherence thesis). The device-database-FREE form of its
spatial inconsistency check ("an iPhone with an impossible geometry").

RED (gap): a desktop bot wearing a mobile UA (CDP setUserAgentOverride to a phone UA without touch
emulation) reports navigator.maxTouchPoints === 0. The existing touch rules miss it — maxtouch_desktop
is the inverse, and pointer_touch_incoherent sees a coherent desktop (CSS coarse=false AND maxTouch=0).
So the mobile-spoof produced zero convicting tells: EVADES.

BLUE: a phone/tablet is a touchscreen device — every real one reports maxTouchPoints > 0 (iOS Safari = 5).
A Mobile/iPhone/iPad-token UA with maxTouchPoints === 0 is a desktop wearing a mobile UA. FP-safe with no
device DB: scoped to the Mobile/iPhone/iPad tokens, NOT bare "Android" (which also matches touch-less
Android TV/Auto). Emitted by demo.py (authoritative collector) + the calibration mapper.

GROUNDED both ways: convicts the iPhone/Android mobile-spoof as the SOLE convicting tell (bot); real
iOS/Android (maxTouchPoints 5/>0) and desktop UAs (out of scope) do NOT fire it. 0 FP on browserforge
N=600 (real mobile fps carry touch>0). detector 260 passed (99.32%), harness 230 passed (96.50%);
catalog/README/matrix regenerated; new regression test in test_calibration_methodology.

The screen-geometry-DB half of G1 split to X5 (external — needs a real device→resolution map). Follow-up:
mirror the emission into the production collector (collector/src/livepage). Loop RECORD step: G1 → done.
…roundable, G4 covered

Iteration 3 of the research-fed loop ran the rest of the groundable column:
- G2 (DMTG/diffusion mouse synthesis): VALIDATED — the convicting behavioral tell is structural
  (synthetic_no_coalesced / coalesced_untrusted key on getCoalescedEvents length + isTrusted), so
  trajectory-shape realism is orthogonal; a DMTG path via CDP still has coalescedMax<=1 → caught.
  Already grounded (KS_BEHAVE); escape is privilege-gated XTEST. No new rule.
- G3 (keystroke dynamics / cGAN): NOT GROUNDABLE — keystroke timing is jitter-unsound across instances,
  no structural channel analogous to mouse coalesced sampling; entropy floor stays corroborating.
- G4 (JA4+ suite): COVERED — JA4 + JA4H (h2_header_order_vs_ua); JA4T detection-value = TCP-OS coherence
  (tcp_kernel + tcp_os_vs_ua); JA4L marginal/latency-external; JA4S server-side (N/A for client detection).

Groundable column is now DRY; remaining leads (X1-X5) are all external-data-bound. Net research-fed-loop
result: 1 new grounded rule (br.mobile_no_touch, G1) + the rest validated/routed. Re-run SCAN periodically.
Completes the G1 rung across collector surfaces (the mirror-across-collectors discipline): the production
livepage collector now emits browser.mobile_no_touch (phone/tablet UA + maxTouchPoints === 0) alongside
the authoritative demo.py emission, so live-page traffic is scored on it too. Same scope (Mobile/iPhone/
iPad tokens, not bare Android) and FP-safety as demo.py + the calibration mapper.

collector: typecheck + eslint + prettier + vitest all green (Docker node:22).
…ata grounding

The recurring unlock, made turnkey. The in-sandbox seams are saturated and every remaining frontier is
external-data-bound (real proxy egress / real-device diversity / real traffic / real QUIC); the ingest
paths existed but were scattered. This adds the single consumer:

- kitsune_harness.grounding (`task grounding -- <dir> [--expect legit|bot] [--build-prior OUT]`): over a
  directory of REAL session captures (collector/edge shape) it runs per-session FP/recall (against real
  traffic, not synthetic browserforge), coordination grading (a per-session mimic caught at the cluster
  layer is not counted as a miss), and an optional prevalence-prior rebuild. Pure evaluate()/render() +
  thin IO CLI; 4 tests (legit-clean, legit-FP-flagged, bot-per-session, bot-via-fleet).
- docs/grounding.md: the runbook mapping each blocked frontier (radar X1-X5 + Tier-3 prevalence + QUIC) to
  the exact capture + command + what it unlocks, plus the existing capture tooling and the no-shortcut
  discipline.

Validated over the committed captures: `task grounding -- corpus/sessions --expect bot` surfaces the 10
known EVADES mimics as residual per-session misses and catches the 64-member stealth fleet via coordination.
harness: ruff + mypy clean, 234 passed, coverage 95.83%.
…le_no_touch

NAIVE=1 on the mobile-emulation evader does the header-only mobile spoof: a phone UA via newContext({
userAgent }) with NO device emulation (no hasTouch/isMobile/viewport) — the common scraper shortcut. This
leaves navigator.maxTouchPoints at the desktop default 0, so a phone UA on a touch-less client trips
br.mobile_no_touch (the rule shipped this session, previously synthetic-test-only).

GROUNDED LIVE against the running edge->detector stack: NAIVE=1 → label bot WITH br.mobile_no_touch fired;
the full Pixel-5 emulation (hasTouch:true → maxTouchPoints>0) → br.mobile_no_touch correctly does NOT fire.
Lights an active-but-unexercised rule via a faithful red-team mode (validation; no version bump).
…e mobile escalation ladder

Two more faithful mobile-spoof modes that complete the desktop-faking-mobile ladder, each catching the
spoofer who patched the previous tell (Tier-1 mobile uplevel):

- IOS=1: an iPhone-Safari UA on a Chromium host (no touch). GROUNDED LIVE → br.apple_ua_nonwebkit +
  br.safari_ua_no_webkit_api + br.mobile_no_touch all fire (the iOS naive-spoof path, previously
  unexercised — we'd only grounded the Android naive spoof).
- FIXED_TOUCH=1: the escalation past mobile_no_touch — addInitScript fakes navigator.maxTouchPoints=5 but
  leaves the CSS pointer surface desktop. GROUNDED LIVE → br.pointer_touch_incoherent fires and
  br.mobile_no_touch correctly stays quiet (maxTouchPoints>0). Lights pointer_touch_incoherent, an active
  rule no prior evader exercised (every desktop evader is touch-coherent; the Pixel-5 emulation sets both).

Ladder now grounded end-to-end: naive-UA-spoof → mobile_no_touch; iOS-naive → apple/safari/mobile_no_touch;
fixed-touch → pointer_touch_incoherent; full device-emulation → OS-leak (fingerprint_improbable/font_os).
Validation (lighting unexercised active rules via faithful modes); no version bump.
…7 + killed sensor lead

Tier-2 of the mobile uplevel: a mobile-targeted deep-research pass (6 angles, 26 sources, 25 claims
3-vote-verified, 18 confirmed). New radar rows:
- G6 (top groundable): mobile GPU-family ↔ OS coherence — Adreno/Mali⟹Android, "Apple GPU"⟹Apple; an
  Android UA + "Apple GPU" (or iPhone + Adreno) is a clean incoherence, the mobile extension of
  webgl_os_vs_ua. FP-safe, locally groundable via a mismatched-mobile-renderer evader.
- G5 (weak): WebView surface (wv token / X-Requested-With) — NOT convicting (WebView is the dominant LEGIT
  mobile surface + UA-spoofable); only a non-UA-vs-UA mismatch is a tell, and that's niche → X7.
- X6 (external): mobile touch biometrics (Touchalytics/BeCAPTCHA) — needs real mobile traffic; adversarially
  synthesizable (random-vector attacks raise FAR 22-27%), so corroborating-only by nature.
- X7 (external/open): iOS WKWebView client-side discriminator — none survived verification.

KILLED (do not build): device-motion-sensor fingerprinting/coherence (refuted 1-2/0-3 — would be FP-prone;
the research saved the build), iOS WebView Version-token/messageHandlers signals (refuted 0-3), WebView
"259x uniqueness" (refuted). Next groundable rung: G6.
…GAP-1)

Holistic-review finding (HIGH/security). ParseQUICInitials reassembled CRYPTO fragments with
need=int(f.off)+len(f.data) then make([]byte, need-len(buf)) and NO upper bound on the offset. The CRYPTO
offset is an attacker-controllable varint (up to 2^62), and the Initial AEAD keys derive from the PUBLIC
DCID — so a crafted, validly-authenticated Initial with a huge CRYPTO offset could force a multi-GB
allocation on the NET_RAW edge (remote OOM).

Extracted the stitch into reassembleCrypto() and bounded it at maxCryptoReassembly (64 KiB): a real
ClientHello (even with PQ key shares across several Initials) is a few KB, so an oversized offset is a
forged/garbage Initial — skip the fragment (the rest still stitch; a truncated buffer just never parses as
a hello). New unit test TestReassembleCryptoBoundsOffset drives adversarial offsets directly (the
multi-packet reassembly path was previously untested + unreachable by the AEAD-gated fuzzer). edge vet +
fingerprint tests green via Docker.
Holistic-review finding (HIGH/concurrency). countingConn.Read calls scanner.Feed() (writes the frame
counters) on the http2 server's per-connection read goroutine, while request handlers call
RapidReset()/ContinuationFlood()/ControlFrameFlood() (read those counters) from concurrent per-stream
handler goroutines — with no synchronisation. Single-goroutine tests kept -race green and hid it.

Converted the five counters (Headers/RSTStreams/Continuations/Settings/Pings) to atomic.Uint64 — Feed uses
.Add(1), the detectors use .Load(). The parser state (prefaceSkipped/hdr/hdrFilled/payloadLeft) is
single-writer (only Feed, one read loop per connection) so it needs no synchronisation. Atomics beat a
mutex on this per-Read hot path.

New TestH2FrameScannerConcurrentFeedAndReadIsRaceFree drives Feed concurrently with all three detectors;
`CGO_ENABLED=1 go test -race` is clean on internal/fingerprint + internal/proxy. go vet + gofmt clean.
…alog (GAP-3)

Holistic-review finding (HIGH/doc-gen). _MAIN_CANDIDATES matched no script in evaders/pow/ (entrypoint is
under cmd/; the technique file is pow.go) or evaders/xtest-coalesce/ (probe.py), so _evader_main returned
None and both vanished from docs/evasion-catalog.md — the doc claimed "20 evader tools" while 22 dirs exist,
and --check passed because generation was self-consistent.

Added "pow.go" and "probe.py" to _MAIN_CANDIDATES (both carry a descriptive 2-line header) and regenerated:
the catalog now lists 22 evader tools including `pow` (multi-class PoW primitive) and `xtest-coalesce` (the
XTEST-motion coalesced-tell terminus probe). evasion/readme drift checks green; ruff clean.
…av.language) (GAP-4)

Holistic-review finding (HIGH). The mapper emits language_list_incoherent (calibration.py) but the kind was
absent from DERIVABLE_KINDS, so br.language_vs_languages (active, coherence, convicting) was marked
calibrated=False and silently EXCLUDED from the browserforge FP gate — an active convicting rule whose
real-traffic FP rate was never measured.

Two parts: (1) add "language_list_incoherent" to DERIVABLE_KINDS; (2) _fingerprint_to_dict now derives
navigator.language (= languages[0], the HTML-standard invariant — browserforge carries only the list), so
the coherence check actually runs on a coherent input instead of short-circuiting on an empty value.

Result: calibrated browser rules 30→32 of 88; br.language_vs_languages is now exercised and shows 0% FP on
the coherent corpus (FP-safe by construction — a real browser's language always equals languages[0]).
test_calibration (read ⊇ DERIVABLE_KINDS) + test_mapper_coverage scope pin both green; ruff/mypy clean.
Holistic-review finding (HIGH/CI). The docs:check drift gate ran only in local `task ci`, not GitHub CI —
so a PR that changed registry.yaml/corpus without `task docs` left the committed catalogs/README/matrix
stale and GitHub CI passed green. And ci.yml's own header claimed a "contracts" job that did not exist
(contract validation only rode transitively inside detector pytest); `task ci` also omitted the contracts
task.

Added two jobs mirroring the existing pinned-action pattern: `docs` (readme_stats/readme_redteam/
rule_catalog/evasion_catalog --check + the matrix git-diff gate, in harness) and `contracts` (load + validate
the rule registry, in detector). Added `- task: contracts` to the local `task ci` pipeline (docs:check was
already there). Verified: ci.yml parses (jobs now include contracts + docs), the contracts command prints
"contracts ok", and the matrix git-diff gate is clean against the committed docs.
…P-6)

Holistic-review finding (MED/provenance). Two rules had two `source:` lines each; PyYAML silently keeps the
LAST, so the wrong provenance won and the correct note was dropped — and source/last_validated feed the
signal-decay honesty matrix:
- br.nav_property_spoofed kept a "native plugins" note (wrong subject); restored the pdfViewerEnabled/
  mimeTypes own-property + FLOOR_SPOOF note.
- net.ch_ua_no_grease_brand kept a Sec-Fetch note that belongs to net.sec_fetch_vs_ua; restored the
  Sec-CH-UA GREASE note, and gave net.sec_fetch_vs_ua (which had NO source) the Sec-Fetch provenance.

Added test_registry_has_no_duplicate_yaml_keys: a SafeLoader subclass that rejects duplicate mapping keys
(deep), self-checked against a synthetic dup, then run over the real registry — so a repeated key in any rule
block fails CI instead of silently dropping a value. detector 261 passed (99.32%); catalog/readme fresh.
Holistic-review finding (MED). tcpfp.Store.Get treated an expired entry as a miss but never deleted it, and
there was no sweep — so every distinct source IP the SYN sniffer saw was retained forever. On the NET_RAW
edge (explicitly meant to face floods/scans with many distinct/spoofed source IPs) the map grew without
bound.

Added an amortised sweep in Put (at most once per TTL, drop expired entries — mirrors the QUIC tee's expiry
sweep; O(n)-once-per-TTL, not per-Put, so a flood can't make it O(n^2)) plus delete-on-read in Get. Bounds
the map to ~one TTL window of SYNs. New TestStoreSweepsExpiredOnPut: 1000 IPs + a TTL gap + one Put leaves
exactly the fresh entry. edge vet + tcpfp tests green via Docker; existing TTL/overwrite tests unaffected.
…y (GAP-8)

Holistic-review finding (MED/dead-code). fleet.py (fleet_signature/detect_fleets/render_fleets) was
JA4-prefix clustering fully reimplemented and superseded by coordination.py (_clusters/score_corpus with
the JS-divergence paradox + collision conviction gate). Nothing imported it — only its own test_fleet.py
and an unwired CLI (no Taskfile target). It padded coverage with a non-current implementation.

Deleted fleet.py + test_fleet.py; fixed the now-dangling [[fleet]] reference in coordination.py's docstring
(it describes coordination's own JA4 grouping). harness ruff/mypy clean, 229 passed, coverage 95.68% (>95%).
Holistic-review finding (MED/doc-drift). The hand-maintained architecture.md had drifted from reality:
- ruleset_version 0.70.0 → made version-agnostic ("carries its own ruleset_version") so it can't restale.
- "~114" rules → "~130" + a pointer to the generated detection-catalog.md (the authoritative table).
- §1/§3 evader enumerations (named ~14 of 22 tools) → added the missing ones (mobile-emulation, pow,
  azuretls, playwright-extra, apify-fp-inject, webkit-ua-spoof, firefox-os-spoof, xtest-coalesce) and a
  pointer to the generated evasion-catalog.md so the fleet list can't restale.
- §12 CI claims → matched to reality: the `contracts` job now exists (added in GAP-5), the e2e job is the
  in-process spine smoke (not docker-compose), there is no CI calibrate gate (it's local), and the new
  `docs` drift gate is documented. The end-to-end testing-table row corrected likewise.

Doc-only (architecture.md is hand-written, not generated); headers clean.
…mo.py lag (GAP-10)

Holistic-review finding (MED/drift). The livepage self-test page lagged the authoritative demo.py by ~15
signals, including ones backing convicting/within-session rules. demo.py stays authoritative (the detector
serves it; rules validate against it), but the self-test page was under-reporting.

Ported the cheap, high-value static probes into probes.ts: webgl_renderer + webgl_vendor (raw GPU identity —
feed the prevalence model + the within-session br.fp_unstable convicting rule), maxtouch_desktop (the inverse
of mobile_no_touch), and nav_language_primary (accept-language coherence). The remaining lag is now bounded
to the heavy/async probes the lightweight page intentionally omits (WebRTC STUN, composite fp_hash, WebGPU
family, the biomech stream, honeypot/adblock) — documented as an explicit, conscious scope in architecture
§3 with a "add new convicting probes to livepage or list them here" guard, so the lag can't silently widen.

collector typecheck + eslint + prettier + vitest green via Docker node:22; headers clean.
…rules_json guard, evader docs)

Holistic-review LOW batch — the last items in the gap queue:
- (a) prevalence.features_from_session dropped the vestigial "color" feature (the colour factor was removed
  v0.74.20; the key was computed but never read by _FACTORS/_SCORED). Test expectation updated.
- (b) test_rule_firing: a parametrized test that every active convicting present-predicate single-read rule
  (65 of them) FIRES when its read signal is present — engine-level, so a typo'd `reads` kind (an unfireable
  dead rule) is caught instead of silently passing.
- (c) rules_json._client_evaluable now guards the vacuous-true (empty reads => not client-evaluable, was an
  empty all()==True); new test_rules_json covers _client_evaluable + build() (was 0% covered).
- (d) architecture §12 documents that the bare-run.mjs Playwright evaders (mobile-emulation/firefox-os-spoof/
  webkit-ua-spoof) run via the shared kitsune-stealth image rather than duplicating a Dockerfile.

detector 326 passed (99.32%), harness 231 passed (96.30%); mypy/ruff/headers clean.
… both exhausted

Probed G6 (mobile GPU↔OS coherence) confirm-EVADES-first: the Apple-GPU half is ALREADY caught (_webgl_os
maps Apple→macOS, so Android UA + "Apple GPU" already fires webgl_os_vs_ua); the Adreno/Mali→Android half is
FP-UNSAFE (real Windows-on-ARM ships Adreno, ChromeOS ships Mali — both verified to correctly not fire today,
and would FP if mapped to Android) → external (device-class disambiguator, X5-class); the FP-safe sliver
(Adreno/Mali under an Apple UA) is redundant with the iOS-spoof tells. No new rule — avoids a real-device FP,
mirroring the font_os_vs_ua / vendor_vs_ua discipline.

With G6 resolved, every groundable lead (G1–G6) is done/covered/validated/external and the entire gap queue
(GAP-1–11) is cleared. Both queues exhausted; the loop stops. Remaining radar items (X1–X7) are all
external-data-bound and route to the grounding harness. Re-arm the loop after an external-sourcing pass.
The proxy_exit seed was Tor-only (a thin slice of real proxy egress) and the
datacenter seed was AWS/GCP-only. Add the MIT-licensed X4BNet/lists_vpn feeds
(~11k VPN CIDRs → proxy_exit, ~42k hosting CIDRs → datacenter) to the deploy-time
refresh, with a generic one-CIDR-per-line parser and per-source floor guards
(x4b_vpn/x4b_datacenter >= 1000) so a drifted source fails loud. Output stays
uncommitted per the existing deploy-not-commit rule; only the parser, offline
tests, and source URLs are committed.

Licence verified at source: GitHub's detector reports X4BNet null, but its README
carries full MIT text covering "the list itself (source files and generated
output)" — confirmed before wiring (don't-trust-single-source, applied to a licence).

Also adds the "Real-data sources -> grounding input" shopping list to the research
radar (each vetted dataset mapped to its X-item + access + licence + grounding
command) and flips X4's IP-rep half to partially-unblocked. FireHOL stays a
licence-gated candidate (GPLv2 mixed-upstream aggregate); the Berke prevalence
corpus is the next unlock (request research-use terms).
Resolves the 3 high Dependabot alerts (one advisory, GHSA-7mvr-c777-76hp,
vulnerable < 1.55.1) across the playwright-extra, apify-fp-inject, and stealth
evader manifests. Applied locally as datascry per the single-author rule (not a
GitHub bot merge). Manifest-only; rebrowser-playwright/patchright are unaffected
(separate packages, not flagged). Evaders run in Docker images, no committed lockfile.
A real refresh (in-memory, output uncommitted) with the deploy-time floor guard
active passes and grows the feeds from a thin seed to proxy_exit 12,231 CIDRs
(~10x, was Tor-only ~1.2k) and datacenter 53,297 CIDRs (~4.6x, was AWS/GCP-only
~11.6k); real AWS/Google IPs classify as datacenter, private LAN stays clean.
datascry added 4 commits June 22, 2026 00:05
The grounding harness is the documented unlock for every external-data-bound
frontier (X1-X7), but its CLI entry point was pragma:no-cover and had an
order-dependent bug: the index-based scan only excluded --expect's value, so
`grounding --build-prior out.json --expect bot caps` mistook out.json for the
corpus directory. Extract a pure, tested parse_args() that consumes BOTH option
values (and skips unknown flags), and slim main() to IO. Add tests for arg
parsing and for the operator-facing NEEDS-ATTENTION render path (false-positive
+ fleet output). Verified end-to-end: the turnkey command grounds the local
96-session corpus clean (86 bot + 10 caught-via-fleet, 2 fleets).

grounding.py 83% -> 99%; harness suite 234 green at 97.09%.
The X4 prevalence-prior half: a real-traffic prior is the unlock for promoting
br.fingerprint_improbable (the shipped browserforge prior is same-source-blind).
Berke et al. published 8,400 consented real fingerprints covering Kitsune's exact
prevalence attributes — but the browser-attributes file is on Harvard Dataverse
under research-use terms (no re-identification, no resharing) and isn't in the
repo, so it can't be fetched in-sandbox.

Build the turnkey consumer instead: berke_corpus.py maps each CSV row into the
detector's own features_from_fingerprint (no bucketing duplication → the prior
stays in sync with the runtime scorer) and emits ONLY the aggregate prior
(frequency tables, never rows → honours the no-resharing term). Column names
pinned from the published data dictionary + the repo's preprocessing notebook;
offline-tested against synthetic rows in that schema (7 tests, 100% module cov,
harness 240 green). When an operator accepts the terms and downloads the CSV:
`uv run python -m kitsune_harness.berke_corpus <csv>` builds the real prior.

Both halves of X4 now have turnkey consumers (IP-rep via X4BNet last iteration,
prevalence via this); only the operator-gated Dataverse download remains. Docs:
radar real-data table + iteration log + grounding runbook updated.
Reverts ef056f5. The 1.52.0 pin is a documented decision, not a stale version:
the evader Dockerfiles explicitly `npm install playwright@1.52.0` because
rebrowser-playwright caps at 1.52.0, so all three drivers (playwright/patchright/
rebrowser-playwright) must share ONE Chromium revision. GHSA-7mvr-c777-76hp is an
ACCEPTED build-time-only risk on an evader that runs only against Kitsune's own
detector + the allow-list, so the dependabot alerts are intentionally left open.
My package.json bump was also ineffective (the Dockerfiles override it) and
repeated exactly the mistake commit 94fa3b9 already reverted. Pins back to 1.52.0.
gitleaks-action v2 made GITHUB_TOKEN required to scan PRs; without it the secrets
job errors out ("GITHUB_TOKEN is now required") and the secret scan silently does
not run on any PR. Wire the read-only default token into the step's env so the
scan actually executes. No real leak was present — the job was failing on this
config gap, not a finding.
@datascry datascry merged commit f57fad3 into main Jun 22, 2026
18 of 19 checks passed
datascry added a commit that referenced this pull request Jul 8, 2026
…ounded)

Dependabot #32/#33: golang.org/x/crypto <0.45.0 in os-spoof (indirect, v0.36.0) — the ssh/agent
panic and ssh unbounded-memory advisories. Bumped to v0.53.0 (matching the other Go modules); go mod
tidy, build green. os-spoof does not use x/crypto/ssh, but the bump clears the transitive alert.
datascry added a commit that referenced this pull request Jul 8, 2026
…ounded)

Dependabot #32/#33: golang.org/x/crypto <0.45.0 in os-spoof (indirect, v0.36.0) — the ssh/agent
panic and ssh unbounded-memory advisories. Bumped to v0.53.0 (matching the other Go modules); go mod
tidy, build green. os-spoof does not use x/crypto/ssh, but the bump clears the transitive alert.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant