Compare commits

...

6 Commits

Author SHA1 Message Date
257fc95182 fix(toolbox): loader.js no-store so SPA/loader updates propagate (was max-age=3600 → stale loaders pinned 1h) (ref #662)
Some checks are pending
License Headers / check (push) Waiting to run
2026-06-19 09:38:24 +02:00
CyberMind
591106ec65
Merge pull request #677 from CyberMind-FR/feat/662-cumulative-live
feat(#662): cumulative-stats reads live module mitm_events (un-freeze kbin page)
2026-06-19 09:32:00 +02:00
CyberMind
15a668829b
Merge pull request #676 from CyberMind-FR/feat/662-analysis-relay
feat(#662): relay per-flow telemetry to dpi/cookies/ja4 analysis sidecars
2026-06-19 09:31:52 +02:00
73b8ad36b1 fix(toolbox): cumulative-stats reads LIVE module sockets, not frozen toolbox.db (ref #662)
The kbin 'Qui te piste?' page (/cumulative-stats.json) read event counts +
top-hosts from toolbox.db's events table, which froze at the #662 Phase-7
cutover. Pull live counts/hosts from the analysis modules over their unix
sockets (dpi/cookies/threat-analyst), with graceful fallback to the legacy
toolbox.db query if every module call fails. sessions/risk/level
distributions read the clients table and are unchanged.
2026-06-19 09:29:54 +02:00
d0db3e87fd chore: changelog 0.1.9 — analysis relay (ref #662) 2026-06-19 09:23:59 +02:00
05c659b4ca feat(toolbox-ng): relay per-flow dpi/cookies/ja4 telemetry to analysis sidecars (ref #662)
Restores the dpi/cookies/ja4 events feeding the kbin "Qui te piste?"
cumulative-stats page, frozen since the Phase-7 cutover decommissioned
the Python mitmproxy relay addons. The Go engine now re-emits EXACTLY
what those addons did, via the existing fire-and-forget emit() helper.

- relay.go: pure payload builders (dpiEvent/cookiesEvent/ja4Event) +
  gated emit wrappers. NAMES ONLY for cookies (never values, CSPN);
  caps ≤30 set / ≤50 sent names, name[:32], url[:300]; user_agent null
  when absent; ja4 extensions always null (stdlib doesn't expose them);
  alpn/ciphers always JSON arrays.
- main.go: --analysis-relay flag (default true) → Proxy.analysisRelay;
  dpi emit in mitmPipeline allow/mitm branch (before anonymize, original
  UA); cookies emit after resp; serverTLSConfigCapture hook relaying ja4
  with the client conn peer IP; peerIP helper.
- transparent.go: ja4 capture wired with the real transparent peer IP.

Fire-and-forget: a dead/slow sidecar socket never blocks or delays the
proxy flow (emit detaches with its own 2s timeout). Block/splice paths
never relay dpi/cookies; ja4 fires per handshake (blocked/allowed alike,
matching the Python tls_clienthello addon).

TDD: relay_test.go covers payload shapes, names-only parsing, caps,
url truncation, the gate at call sites, and live unix-socket delivery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:23:14 +02:00
7 changed files with 863 additions and 25 deletions

View File

@ -205,6 +205,11 @@ type Proxy struct {
portal string // portal base URL for /__toolbox/* reverse-proxy (banner assets)
ads *adStats // #662 — ad-block metrics aggregator (flushed to the portal)
cspDemo bool // #662 CONSENTED-DEMONSTRATION: relax a page's CSP so the injected loader runs, and flag the bypass (data-csp=1 → 🔓). Default on.
// analysisRelay gates the per-flow telemetry relay to the dpi/cookies/ja4
// analysis sidecar sockets (#662 — restoring the "Qui te piste?" events the
// decommissioned Python addons fed). Default on; relay.go is the transport.
analysisRelay bool
}
// recordAdBlock forwards a 204'd ad/tracker block to the engine's metrics
@ -217,11 +222,24 @@ func (px *Proxy) recordAdBlock(adHost, site, macHash string) {
}
func (px *Proxy) serverTLSConfig() *tls.Config {
return px.serverTLSConfigCapture(nil)
}
// serverTLSConfigCapture is serverTLSConfig with an extra per-handshake hook:
// capture, if non-nil, is invoked inside GetCertificate with the live
// *tls.ClientHelloInfo (SNI, SupportedProtos, CipherSuites). The accept-path
// handlers use it to relay the ja4 ClientHello payload (relay.go) WITH the
// client conn's peer IP — which is known at the handler, not inside the TLS
// config. Passing nil yields the plain forging config (CONNECT PoC, tests).
func (px *Proxy) serverTLSConfigCapture(capture func(*tls.ClientHelloInfo)) *tls.Config {
return &tls.Config{
GetCertificate: func(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
if px.jaSink != nil {
px.jaSink(ja4ish(h)) // capture handshake fingerprint
}
if capture != nil {
capture(h) // ja4 relay material (peer IP threaded in by the handler)
}
name := h.ServerName
if name == "" {
name = "unknown.local"
@ -231,6 +249,38 @@ func (px *Proxy) serverTLSConfig() *tls.Config {
}
}
// peerIP returns the remote IP (no port) of a client conn, the same basis as
// clientHashFromConn. Used as the client_ip field of every relay payload.
func peerIP(conn net.Conn) string {
if conn == nil {
return ""
}
host, _, err := net.SplitHostPort(conn.RemoteAddr().String())
if err != nil {
return conn.RemoteAddr().String()
}
return host
}
// captureAndEmitJA4 returns a GetCertificate capture hook that relays the ja4
// ClientHello payload for THIS handshake (once), tagged with the given client
// conn's peer IP + mac-hash-aware clientHash. Gated by analysisRelay (emitJA4
// checks). The hook copies the ClientHelloInfo fields it needs immediately
// (the struct is only valid during the callback). Returns nil when the relay is
// off so the plain config is used (no per-handshake allocation).
func (px *Proxy) captureAndEmitJA4(rawClient net.Conn) func(*tls.ClientHelloInfo) {
if !px.relayEnabled() {
return nil
}
ip := peerIP(rawClient)
hash := clientHashFromConn(rawClient)
return func(h *tls.ClientHelloInfo) {
alpn := append([]string(nil), h.SupportedProtos...)
ciphers := append([]uint16(nil), h.CipherSuites...)
px.emitJA4(ip, hash, h.ServerName, alpn, ciphers)
}
}
func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
host := r.URL.Hostname()
hj, ok := w.(http.Hijacker)
@ -262,7 +312,9 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
}
// MITM: TLS-terminate the client with a forged cert (+ ClientHello capture).
tconn := tls.Server(client, px.serverTLSConfig())
// The capture hook relays the ja4 ClientHello payload for this handshake,
// tagged with the client's peer IP (#662). nil when the relay gate is off.
tconn := tls.Server(client, px.serverTLSConfigCapture(px.captureAndEmitJA4(client)))
if err := tconn.Handshake(); err != nil {
return
}
@ -339,6 +391,15 @@ func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict
// allow — stripping operator headers + asserting opt-out is universally
// safe and never touches own-infra correctness).
clientHash := clientHashFromConn(rawClient) // mac_hash-aware (WG persona)
// #662 — relay the DPI classification hint for this MITM'd request (allow|mitm
// only; never the block 204 / splice paths). Fire-and-forget BEFORE anonymize
// mutates headers, so we relay the client's original User-Agent (the Python
// DPIRelay ran on the unmodified request). Gated by --analysis-relay; a
// dead/slow dpi.sock can never block or delay the proxy flow.
relayIP := peerIP(rawClient)
px.emitDPI(relayIP, clientHash, host, req)
anonymizeRequest(req.Header)
// #662 — do NOT touch Accept-Encoding. We FORWARD the client's original
@ -379,6 +440,13 @@ func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict
}
defer resp.Body.Close()
// #662 — relay the cookie metadata for this MITM'd response (allow|mitm only).
// NAMES ONLY (never values — privacy/CSPN); no-op unless ≥1 Set-Cookie OR ≥1
// request Cookie is present. Emitted before poison rewrites Set-Cookie VALUES,
// which is irrelevant here (names are unchanged by poison) but keeps the
// relayed names byte-for-byte the origin's. Fire-and-forget, gated.
px.emitCookies(relayIP, clientHash, req, resp)
// Poison: only on MITM'd tracker flows (never on allow/own-infra), and only
// when the jar key is loaded. Replaces tracking-id Set-Cookie values with a
// stable fabricated persona; benign cookies pass through untouched.
@ -445,6 +513,8 @@ func main() {
"portal base URL; /__toolbox/loader.js + /__toolbox/bundle are reverse-proxied here (banner assets, served for any MITM'd origin)")
cspDemo := flag.Bool("csp-bypass-demo", true,
"CONSENTED DEMONSTRATION: relax a page's CSP so the injected transparency-banner loader runs even on strict-CSP sites, and flag the bypass (banner shows 🔓). Only on injected 2xx text/html R3 responses; never on non-injected responses. Set false to never touch CSP.")
analysisRelay := flag.Bool("analysis-relay", true,
"relay per-flow telemetry (dpi/cookies/ja4) to the analysis sidecar sockets so the kbin \"Qui te piste?\" events refill (#662; replaces the decommissioned Python relay addons). Fire-and-forget; a dead/slow sidecar never affects the proxy. Set false to emit nothing.")
flag.Parse()
ca, err := loadCA(*caCert, *caKey)
if err != nil {
@ -473,6 +543,8 @@ func main() {
portal: *portal,
ads: newAdStats(),
cspDemo: *cspDemo,
analysisRelay: *analysisRelay,
}
// #662 — start the ad-block metrics flusher: the block path tallies every
// 204 into px.ads, drained every 10s to the portal's /__toolbox/ad-event

View File

@ -0,0 +1,291 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// SecuBox-Deb :: toolbox-ng :: per-flow analysis relay (#662)
//
// Restores the dpi / cookies / ja4 EVENTS that feed the kbin "Qui te piste?"
// cumulative-stats page, frozen since the #662 Phase-7 cutover decommissioned
// the Python mitmproxy relay addons (packages/secubox-toolbox/mitmproxy_addons/
// {dpi,cookies,ja4}.py). The Go engine is now the live R3 MITM core; this file
// re-implements EXACTLY what those addons did — extract privacy-safe flow
// metadata and fire-and-forget it to the analysis sidecar sockets, which
// enrich + write toolbox.db.events keyed by client_mac_hash.
//
// Transport is the existing emit() helper (sidecar.go): a detached goroutine
// with its own 2s timeout — a dead/slow analysis socket can NEVER block, delay,
// or break a client flow. The payload builders here are pure (no I/O), O(1)-ish
// per flow, and emit NAMES ONLY for cookies (never values — privacy / CSPN).
//
// Pure standard library — no external modules.
package main
import (
"encoding/json"
"net/http"
"strings"
"time"
)
// Stable socket paths — verbatim from the Python addons' TARGET constants
// (the http+unix:///run/secubox/<x>.sock/<route> URLs), split into path+route.
const (
dpiSocket = "/run/secubox/dpi.sock"
cookiesSocket = "/run/secubox/cookies.sock"
ja4Socket = "/run/secubox/threat-analyst.sock"
dpiRoute = "/classify"
cookiesRoute = "/inject"
ja4Route = "/ja4"
)
// Caps + truncation limits, matching the Python addons exactly.
const (
maxSetCookieNames = 30 // cookies.py _names_only(set_cookies, cap=30)
maxCookieNames = 50 // cookies.py sent_names[:50]
maxCookieNameLen = 32 // cookies.py name[:32]
maxCookieURL = 300 // cookies.py pretty_url[:300]
)
// nowMS returns the current time as unix milliseconds (ts_ms in every payload).
func nowMS() int64 { return time.Now().UnixMilli() }
// ── gate ─────────────────────────────────────────────────────────────────────
// relayEnabled reports whether per-flow analysis relaying is on (the
// --analysis-relay flag → Proxy.analysisRelay). When false, nothing is emitted.
// Nil-safe so tests / the CONNECT PoC that build a bare Proxy can call it.
func (px *Proxy) relayEnabled() bool {
return px != nil && px.analysisRelay
}
// relayEmit is the gated, fire-and-forget emit used by every relay call site.
// It NEVER blocks (delegates to emit() which detaches a goroutine with its own
// timeout) and emits nothing when the relay gate is off.
func (px *Proxy) relayEmit(socketPath, route string, payload []byte) {
if !px.relayEnabled() || len(payload) == 0 {
return
}
emit(socketPath, route, payload)
}
// ── dpi payload ──────────────────────────────────────────────────────────────
// dpiEvent mirrors the JSON the Python DPIRelay.request() emitted. user_agent is
// a *string so an absent UA serialises to JSON null (not ""), matching
// headers.get("user-agent") → None. scheme + sni are constant "https" / host on
// the MITM'd path (we only relay terminated TLS flows).
type dpiEvent struct {
TSMs int64 `json:"ts_ms"`
ClientIP string `json:"client_ip"`
MacHash string `json:"client_mac_hash"`
Host string `json:"host"`
Scheme string `json:"scheme"`
Method string `json:"method"`
UserAgent *string `json:"user_agent"`
SNI string `json:"sni"`
}
// buildDPIPayload builds the /classify payload for one MITM'd request.
func buildDPIPayload(clientIP, macHash, host string, req *http.Request) []byte {
var ua *string
if v := req.Header.Get("User-Agent"); v != "" {
ua = &v
}
ev := dpiEvent{
TSMs: nowMS(),
ClientIP: clientIP,
MacHash: macHash,
Host: host,
Scheme: "https",
Method: req.Method,
UserAgent: ua,
SNI: host,
}
b, _ := json.Marshal(ev)
return b
}
// emitDPI relays the DPI classification hint for a MITM'd request (gated).
func (px *Proxy) emitDPI(clientIP, macHash, host string, req *http.Request) {
if !px.relayEnabled() {
return
}
px.relayEmit(dpiSocket, dpiRoute, buildDPIPayload(clientIP, macHash, host, req))
}
// ── cookies payload ──────────────────────────────────────────────────────────
// cookiesEvent mirrors the JSON the Python CookiesRelay.response() emitted.
// NAMES ONLY — never cookie values (privacy / CSPN).
type cookiesEvent struct {
TSMs int64 `json:"ts_ms"`
ClientIP string `json:"client_ip"`
MacHash string `json:"client_mac_hash"`
URL string `json:"url"`
Method string `json:"method"`
SetCookieNames []string `json:"set_cookie_names"`
CookieNames []string `json:"cookie_names"`
SetCookieCount int `json:"set_cookie_count"`
CookieCount int `json:"cookie_count"`
Status int `json:"status"`
}
// cookiesRelevant reports whether a flow carries any cookie signal worth
// relaying: ≥1 Set-Cookie in the response OR ≥1 Cookie in the request. Mirrors
// the Python `if not (set_cookies or req_cookies): return`.
func cookiesRelevant(req *http.Request, resp *http.Response) bool {
if resp != nil && len(resp.Header.Values("Set-Cookie")) > 0 {
return true
}
return req != nil && len(req.Header.Values("Cookie")) > 0
}
// setCookieName extracts the cookie NAME from a Set-Cookie header line: the text
// before the first '=' of the first ';'-delimited field, trimmed and capped.
// Returns "" for attribute-only / malformed / empty-name lines (skipped).
func setCookieName(sc string) string {
head := sc
if i := strings.IndexByte(sc, ';'); i >= 0 {
head = sc[:i]
}
eq := strings.IndexByte(head, '=')
if eq < 0 {
return ""
}
n := strings.TrimSpace(head[:eq])
if len(n) > maxCookieNameLen {
n = n[:maxCookieNameLen]
}
return n
}
// parseCookieHeaderNames splits a single "Cookie:" header value into its
// individual cookie NAMES (text before each '=' across ';'-separated pairs),
// trimmed + capped. Mirrors cookies.py _parse_cookie_header.
func parseCookieHeaderNames(value string) []string {
var names []string
for _, part := range strings.Split(value, ";") {
eq := strings.IndexByte(part, '=')
if eq < 0 {
continue
}
n := strings.TrimSpace(part[:eq])
if len(n) > maxCookieNameLen {
n = n[:maxCookieNameLen]
}
if n != "" {
names = append(names, n)
}
}
return names
}
// setCookieNames returns the NAMES of the response Set-Cookie lines, scanning at
// most the first `cap` header lines (Python _names_only(headers[:cap])).
func setCookieNames(setCookies []string, cap int) []string {
out := make([]string, 0, len(setCookies))
for i, sc := range setCookies {
if i >= cap {
break
}
if n := setCookieName(sc); n != "" {
out = append(out, n)
}
}
return out
}
// buildCookiesPayload builds the /inject payload for one MITM'd response that
// carries a cookie signal. The caller is expected to have checked
// cookiesRelevant; building on an empty flow yields empty name lists.
func buildCookiesPayload(clientIP, macHash string, req *http.Request, resp *http.Response) []byte {
setCookies := resp.Header.Values("Set-Cookie")
reqCookies := req.Header.Values("Cookie")
// Sent cookie names: flatten every Cookie header line, then cap to 50 total.
var sent []string
for _, ch := range reqCookies {
sent = append(sent, parseCookieHeaderNames(ch)...)
}
if len(sent) > maxCookieNames {
sent = sent[:maxCookieNames]
}
u := req.URL.String()
if len(u) > maxCookieURL {
u = u[:maxCookieURL]
}
ev := cookiesEvent{
TSMs: nowMS(),
ClientIP: clientIP,
MacHash: macHash,
URL: u,
Method: req.Method,
SetCookieNames: setCookieNames(setCookies, maxSetCookieNames),
CookieNames: sent,
SetCookieCount: len(setCookies),
CookieCount: len(reqCookies),
Status: resp.StatusCode,
}
b, _ := json.Marshal(ev)
return b
}
// emitCookies relays the cookie metadata for a MITM'd response (gated). No-op
// when neither a Set-Cookie nor a request Cookie is present.
func (px *Proxy) emitCookies(clientIP, macHash string, req *http.Request, resp *http.Response) {
if !px.relayEnabled() || !cookiesRelevant(req, resp) {
return
}
px.relayEmit(cookiesSocket, cookiesRoute, buildCookiesPayload(clientIP, macHash, req, resp))
}
// ── ja4 payload ──────────────────────────────────────────────────────────────
// ja4Event mirrors the JSON the Python JA4Relay.tls_clienthello() emitted.
// alpn_protocols / cipher_suites are always JSON arrays (never null) — matching
// list(ch.alpn_protocols or []). extensions is always null: crypto/tls'
// ClientHelloInfo does not expose the raw extension list, exactly the Python
// `if hasattr(ch, "extensions") else None` fallback (the service tolerates it).
type ja4Event struct {
TSMs int64 `json:"ts_ms"`
ClientIP string `json:"client_ip"`
MacHash string `json:"client_mac_hash"`
SNI string `json:"sni"`
ALPN []string `json:"alpn_protocols"`
Ciphers []uint16 `json:"cipher_suites"`
Extensions *[]int `json:"extensions"` // always nil → JSON null
}
// buildJA4Payload builds the /ja4 payload for one MITM'd TLS ClientHello.
func buildJA4Payload(clientIP, macHash, sni string, alpn []string, ciphers []uint16) []byte {
if alpn == nil {
alpn = []string{}
}
if ciphers == nil {
ciphers = []uint16{}
}
ev := ja4Event{
TSMs: nowMS(),
ClientIP: clientIP,
MacHash: macHash,
SNI: sni,
ALPN: alpn,
Ciphers: ciphers,
Extensions: nil,
}
b, _ := json.Marshal(ev)
return b
}
// emitJA4 relays the captured ClientHello fingerprint material for a MITM'd
// handshake (gated). Called once per handshake, before Decide — so blocked and
// allowed flows alike are relayed, matching the Python addon which ran on every
// tls_clienthello.
func (px *Proxy) emitJA4(clientIP, macHash, sni string, alpn []string, ciphers []uint16) {
if !px.relayEnabled() {
return
}
px.relayEmit(ja4Socket, ja4Route, buildJA4Payload(clientIP, macHash, sni, alpn, ciphers))
}

View File

@ -0,0 +1,355 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// Unit tests for the per-flow analysis relay payload builders + emit wiring
// (#662 — restoring the dpi/cookies/ja4 events that feed "Qui te piste?").
package main
import (
"encoding/json"
"net"
"net/http"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
)
// ── dpi payload ──────────────────────────────────────────────────────────────
func TestBuildDPIPayload(t *testing.T) {
req, _ := http.NewRequest("GET", "https://tracker.example.com/pixel?x=1", nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (X11)")
p := buildDPIPayload("203.0.113.7", "abcd1234", "tracker.example.com", req)
var m map[string]any
if err := json.Unmarshal(p, &m); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, p)
}
if m["client_ip"] != "203.0.113.7" {
t.Errorf("client_ip = %v", m["client_ip"])
}
if m["client_mac_hash"] != "abcd1234" {
t.Errorf("client_mac_hash = %v", m["client_mac_hash"])
}
if m["host"] != "tracker.example.com" {
t.Errorf("host = %v", m["host"])
}
if m["scheme"] != "https" {
t.Errorf("scheme = %v", m["scheme"])
}
if m["method"] != "GET" {
t.Errorf("method = %v", m["method"])
}
if m["user_agent"] != "Mozilla/5.0 (X11)" {
t.Errorf("user_agent = %v", m["user_agent"])
}
if m["sni"] != "tracker.example.com" {
t.Errorf("sni = %v", m["sni"])
}
// ts_ms present and plausible (a recent unix-millis value).
ts, ok := m["ts_ms"].(float64)
if !ok || ts < 1_600_000_000_000 {
t.Errorf("ts_ms = %v (want recent unix millis)", m["ts_ms"])
}
}
// Absent User-Agent → JSON null (not "" and not omitted), mirroring the Python
// addon's headers.get("user-agent") → None.
func TestBuildDPIPayloadNullUserAgent(t *testing.T) {
req, _ := http.NewRequest("GET", "https://h.example/", nil)
p := buildDPIPayload("1.2.3.4", "h", "h.example", req)
if !strings.Contains(string(p), `"user_agent":null`) {
t.Errorf("expected user_agent null, got: %s", p)
}
}
// ── cookies payload ──────────────────────────────────────────────────────────
func TestBuildCookiesPayloadNamesOnly(t *testing.T) {
req, _ := http.NewRequest("POST", "https://shop.example.com/cart", nil)
req.Header.Add("Cookie", "sessionid=SECRET_VALUE; csrftoken=ANOTHER_SECRET")
req.Header.Add("Cookie", "_ga=GA1.2.deadbeef")
resp := &http.Response{StatusCode: 200, Header: http.Header{}}
resp.Header.Add("Set-Cookie", "_fbp=fb.1.SECRET; Path=/; HttpOnly; SameSite=Lax")
resp.Header.Add("Set-Cookie", "uid=PRIVATE; Domain=.example.com")
p := buildCookiesPayload("10.99.1.5", "wgpersona", req, resp)
var m map[string]any
if err := json.Unmarshal(p, &m); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, p)
}
if m["url"] != "https://shop.example.com/cart" {
t.Errorf("url = %v", m["url"])
}
if m["method"] != "POST" {
t.Errorf("method = %v", m["method"])
}
if int(m["status"].(float64)) != 200 {
t.Errorf("status = %v", m["status"])
}
if int(m["set_cookie_count"].(float64)) != 2 {
t.Errorf("set_cookie_count = %v", m["set_cookie_count"])
}
if int(m["cookie_count"].(float64)) != 2 {
t.Errorf("cookie_count (header lines) = %v", m["cookie_count"])
}
setNames := toStrings(m["set_cookie_names"])
if !equalStrSet(setNames, []string{"_fbp", "uid"}) {
t.Errorf("set_cookie_names = %v", setNames)
}
cookieNames := toStrings(m["cookie_names"])
if !equalStrSet(cookieNames, []string{"sessionid", "csrftoken", "_ga"}) {
t.Errorf("cookie_names = %v", cookieNames)
}
// Hard privacy guarantee: NO value leaked anywhere in the payload.
raw := string(p)
for _, secret := range []string{"SECRET_VALUE", "ANOTHER_SECRET", "deadbeef", "fb.1.SECRET", "PRIVATE", "GA1.2"} {
if strings.Contains(raw, secret) {
t.Errorf("payload leaked cookie value %q: %s", secret, raw)
}
}
}
// Set-Cookie name parse: text before the first '='. Cookie header split on ';'.
func TestCookieNameParsing(t *testing.T) {
if got := setCookieName("name=val; Path=/; Secure"); got != "name" {
t.Errorf("setCookieName = %q", got)
}
if got := setCookieName(" spaced = v"); got != "spaced" {
t.Errorf("setCookieName trim = %q", got)
}
if got := setCookieName("=novalue"); got != "" {
t.Errorf("setCookieName empty name = %q", got)
}
if got := setCookieName("attributeonly"); got != "" {
t.Errorf("setCookieName no eq = %q", got)
}
names := parseCookieHeaderNames("a=1; b=2;c=3")
if !equalStrSet(names, []string{"a", "b", "c"}) {
t.Errorf("parseCookieHeaderNames = %v", names)
}
}
// Caps: ≤30 Set-Cookie names, ≤50 sent cookie names.
func TestCookiesPayloadCaps(t *testing.T) {
req, _ := http.NewRequest("GET", "https://e.example/", nil)
var bigCookie strings.Builder
for i := 0; i < 80; i++ {
if i > 0 {
bigCookie.WriteString("; ")
}
bigCookie.WriteString("c")
bigCookie.WriteByte(byte('0' + i%10))
bigCookie.WriteString("_")
bigCookie.WriteByte(byte('a' + i%26))
bigCookie.WriteString("=v")
}
req.Header.Add("Cookie", bigCookie.String())
resp := &http.Response{StatusCode: 200, Header: http.Header{}}
for i := 0; i < 45; i++ {
resp.Header.Add("Set-Cookie", "sc"+string(rune('A'+i%26))+string(rune('0'+i%10))+"=v")
}
p := buildCookiesPayload("1.1.1.1", "h", req, resp)
var m map[string]any
json.Unmarshal(p, &m)
if n := len(toStrings(m["set_cookie_names"])); n > 30 {
t.Errorf("set_cookie_names not capped at 30: %d", n)
}
if n := len(toStrings(m["cookie_names"])); n > 50 {
t.Errorf("cookie_names not capped at 50: %d", n)
}
// raw counts still reflect the real totals.
if int(m["set_cookie_count"].(float64)) != 45 {
t.Errorf("set_cookie_count = %v", m["set_cookie_count"])
}
}
// URL truncated to ≤300 chars.
func TestCookiesPayloadURLTruncation(t *testing.T) {
long := "https://e.example/" + strings.Repeat("a", 500)
u, _ := url.Parse(long)
req := &http.Request{Method: "GET", URL: u, Header: http.Header{}}
req.Header.Add("Cookie", "x=1")
resp := &http.Response{StatusCode: 200, Header: http.Header{}}
p := buildCookiesPayload("1.1.1.1", "h", req, resp)
var m map[string]any
json.Unmarshal(p, &m)
if len(m["url"].(string)) > 300 {
t.Errorf("url not truncated: %d chars", len(m["url"].(string)))
}
}
// cookiesRelevant gates emission: only when ≥1 Set-Cookie OR ≥1 Cookie.
func TestCookiesRelevant(t *testing.T) {
mk := func(setC, reqC bool) (*http.Request, *http.Response) {
req, _ := http.NewRequest("GET", "https://e/", nil)
if reqC {
req.Header.Add("Cookie", "a=1")
}
resp := &http.Response{StatusCode: 200, Header: http.Header{}}
if setC {
resp.Header.Add("Set-Cookie", "x=1")
}
return req, resp
}
if r, p := mk(false, false); cookiesRelevant(r, p) {
t.Error("no cookies → should not be relevant")
}
if r, p := mk(true, false); !cookiesRelevant(r, p) {
t.Error("set-cookie present → relevant")
}
if r, p := mk(false, true); !cookiesRelevant(r, p) {
t.Error("request cookie present → relevant")
}
}
// ── ja4 payload ──────────────────────────────────────────────────────────────
func TestBuildJA4Payload(t *testing.T) {
p := buildJA4Payload("198.51.100.9", "tlspersona", "secure.example.com",
[]string{"h2", "http/1.1"}, []uint16{4865, 4866, 49195})
var m map[string]any
if err := json.Unmarshal(p, &m); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, p)
}
if m["sni"] != "secure.example.com" {
t.Errorf("sni = %v", m["sni"])
}
if m["client_ip"] != "198.51.100.9" {
t.Errorf("client_ip = %v", m["client_ip"])
}
if m["client_mac_hash"] != "tlspersona" {
t.Errorf("client_mac_hash = %v", m["client_mac_hash"])
}
alpn := toStrings(m["alpn_protocols"])
if !equalStrSet(alpn, []string{"h2", "http/1.1"}) {
t.Errorf("alpn = %v", alpn)
}
cs := m["cipher_suites"].([]any)
if len(cs) != 3 || int(cs[0].(float64)) != 4865 {
t.Errorf("cipher_suites = %v", cs)
}
// extensions: always null (stdlib doesn't expose them).
if !strings.Contains(string(p), `"extensions":null`) {
t.Errorf("expected extensions null, got: %s", p)
}
}
// Empty ALPN / ciphers → JSON empty arrays (mirrors list(... or [])), not null.
func TestBuildJA4PayloadEmptySlices(t *testing.T) {
p := buildJA4Payload("1.1.1.1", "h", "", nil, nil)
raw := string(p)
if !strings.Contains(raw, `"alpn_protocols":[]`) {
t.Errorf("alpn should be [] not null: %s", raw)
}
if !strings.Contains(raw, `"cipher_suites":[]`) {
t.Errorf("cipher_suites should be [] not null: %s", raw)
}
}
// ── gate wiring ──────────────────────────────────────────────────────────────
// The flag wires into Proxy.analysisRelay and gates emission.
func TestAnalysisRelayGate(t *testing.T) {
on := &Proxy{analysisRelay: true}
off := &Proxy{analysisRelay: false}
if !on.relayEnabled() {
t.Error("analysisRelay=true → relayEnabled() should be true")
}
if off.relayEnabled() {
t.Error("analysisRelay=false → relayEnabled() should be false")
}
}
// emitDPI/emitCookies/emitJA4 respect the gate: with analysisRelay=false they
// deliver nothing to a live socket; with it true they deliver.
func TestEmitGateRespected(t *testing.T) {
sock := filepath.Join(t.TempDir(), "dpi.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
hits := make(chan struct{}, 4)
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 1024)
c.Read(buf)
c.Write([]byte("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"))
c.Close()
hits <- struct{}{}
}
}()
// Gate off → nothing delivered.
off := &Proxy{analysisRelay: false}
off.relayEmit(sock, "/classify", []byte(`{"k":"v"}`))
select {
case <-hits:
t.Fatal("gate off but a payload was delivered")
case <-time.After(300 * time.Millisecond):
}
// Gate on → delivered.
on := &Proxy{analysisRelay: true}
on.relayEmit(sock, "/classify", []byte(`{"k":"v"}`))
select {
case <-hits:
case <-time.After(2 * time.Second):
t.Fatal("gate on but nothing delivered")
}
}
// ── socket-path consts ─────────────────────────────────────────────────────
func TestRelaySocketPaths(t *testing.T) {
if dpiSocket != "/run/secubox/dpi.sock" {
t.Errorf("dpiSocket = %q", dpiSocket)
}
if cookiesSocket != "/run/secubox/cookies.sock" {
t.Errorf("cookiesSocket = %q", cookiesSocket)
}
if ja4Socket != "/run/secubox/threat-analyst.sock" {
t.Errorf("ja4Socket = %q", ja4Socket)
}
}
// ── test helpers ───────────────────────────────────────────────────────────
func toStrings(v any) []string {
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
out = append(out, e.(string))
}
return out
}
func equalStrSet(got, want []string) bool {
if len(got) != len(want) {
return false
}
seen := map[string]int{}
for _, g := range got {
seen[g]++
}
for _, w := range want {
seen[w]--
}
for _, n := range seen {
if n != 0 {
return false
}
}
return true
}

View File

@ -389,7 +389,11 @@ func (px *Proxy) handleTransparent(client net.Conn) {
// over a replayable conn, then run the shared pipeline dialling the captured
// original-dst (NOT the SNI).
replay := &prefixConn{prefix: hello, Conn: client}
tconn := tls.Server(replay, px.serverTLSConfig())
// The capture hook relays the ja4 ClientHello payload for this handshake,
// tagged with the REAL transparent peer IP from the raw client conn (#662).
// nil when the relay gate is off. Emitted around Decide → blocked/allowed
// alike, matching the Python addon's per-tls_clienthello behaviour.
tconn := tls.Server(replay, px.serverTLSConfigCapture(px.captureAndEmitJA4(client)))
if err := tconn.Handshake(); err != nil {
return
}

View File

@ -1,3 +1,13 @@
secubox-toolbox-ng (0.1.9-1~bookworm1) bookworm; urgency=medium
* telemetry: relay per-flow metadata to the analysis sidecars (dpi /classify,
cookies /inject, threat-analyst /ja4) — restoring the kbin "Qui te piste?"
events frozen since the Phase-7 cutover. Fire-and-forget, names-only cookies,
gated --analysis-relay (default on). The sidecars enrich + write toolbox
events → cumulative-stats live again with real host classification. (ref #662)
-- Gerald KERMA <devel@cybermind.fr> Thu, 19 Jun 2026 10:40:00 +0000
secubox-toolbox-ng (0.1.8-1~bookworm1) bookworm; urgency=medium
* demo/csp: only relax + flag 🔓 when the page's effective script directive

View File

@ -57,10 +57,14 @@ router = APIRouter(tags=["toolbox"])
@router.get("/__toolbox/loader.js")
async def toolbox_loader_js() -> Response:
"""Static cosmetic loader (applies the banner client-side from the bundle)."""
# no-store: the loader is the banner entry point and evolves (SPA re-assert,
# CSP proof, …). A long cache (was max-age=3600) pins stale loaders in clients
# for up to an hour — so loader changes never reach already-visited sites. It's
# 4 KB; serve it fresh every load so updates propagate immediately.
return Response(
content=bundlemod.LOADER_JS,
media_type="application/javascript",
headers={"Cache-Control": "public, max-age=3600"},
headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"},
)

View File

@ -32,11 +32,95 @@ DB = Path("/var/lib/secubox/toolbox/toolbox.db")
CACHE_FILE = Path("/var/lib/secubox/toolbox/cumulative-cache.json")
CACHE_TTL_SECONDS = 60 # refresh every minute
# Live analysis-module event stores (post-#662 Phase-7 cutover). The legacy
# toolbox.db `events` table froze at the cutover; the live counts + hosts now
# live in each analysis module, exposed over its own unix socket.
# GET /mitm-events/stats?since_seconds=N -> {"kind":..,"count":n,...}
# GET /mitm-events?limit=N -> {"events":[{...payload...}],"count":n}
_MITM_MODULES = [
("dpi", "/run/secubox/dpi.sock"),
("cookies", "/run/secubox/cookies.sock"),
("ja4", "/run/secubox/threat-analyst.sock"),
]
# dpi socket is the one carrying host/sni payloads for top-hosts aggregation.
_DPI_SOCK = "/run/secubox/dpi.sock"
def _now() -> int:
return int(time.time())
def _uds_get_json(sock_path: str, path: str, timeout: int = 2) -> dict | None:
"""GET a JSON document over a unix socket. Returns the parsed dict, or
None on any error (never raises). Mirrors api._pull_mitm_module_events's
UDSConnection pattern."""
import socket as _sock
import http.client as _hc
try:
class UDSConnection(_hc.HTTPConnection):
def connect(self):
self.sock = _sock.socket(_sock.AF_UNIX, _sock.SOCK_STREAM)
self.sock.settimeout(self.timeout)
self.sock.connect(sock_path)
conn = UDSConnection("localhost", timeout=timeout)
try:
conn.request("GET", path)
resp = conn.getresponse()
if resp.status != 200:
return None
raw = resp.read().decode("utf-8", errors="ignore")[:1000000]
return json.loads(raw)
finally:
conn.close()
except Exception as e:
log.debug("uds get %s%s failed: %s", sock_path, path, e)
return None
def _live_event_counts(window_seconds: int) -> dict | None:
"""Query each analysis module's GET /mitm-events/stats for its event count
in the window. Returns {"dpi":n,"cookies":n,"ja4":n} (missing/error module
omitted). Returns None only if EVERY module call failed (caller falls back
to the legacy toolbox.db query)."""
counts: dict[str, int] = {}
any_ok = False
for kind, sock_path in _MITM_MODULES:
data = _uds_get_json(
sock_path, f"/mitm-events/stats?since_seconds={int(window_seconds)}"
)
if data is None:
continue
any_ok = True
# Prefer the module's self-reported kind; fall back to our tag.
k = data.get("kind") or kind
try:
counts[k] = int(data.get("count", 0))
except (TypeError, ValueError):
counts[k] = 0
return counts if any_ok else None
def _live_top_hosts(limit: int = 5000, top: int = 25) -> list | None:
"""Aggregate top hosts from the dpi module's recent events. Returns a list
of {"host":..,"count":..} (same shape as the legacy top_hosts_7d), or None
if the dpi module call failed."""
data = _uds_get_json(_DPI_SOCK, f"/mitm-events?limit={int(limit)}")
if data is None:
return None
host_counter: Counter = Counter()
for ev in data.get("events", []) or []:
try:
p = ev.get("payload") or {}
h = p.get("host") or p.get("sni")
if h:
host_counter[h] += 1
except Exception:
pass
return [{"host": h, "count": n} for h, n in host_counter.most_common(top)]
def _safe_query(db, sql: str, params: tuple = ()) -> list:
try:
cur = db.execute(sql, params)
@ -74,14 +158,30 @@ def compute() -> dict:
out["sessions"]["all_time"] = (_safe_query(c,
"SELECT COUNT(DISTINCT mac_hash) FROM clients") or [(0,)])[0][0]
# Event counts by source (last 7 days for relevance)
# Event counts by source (last 7 days for relevance).
# Post-#662 Phase-7: the live counts live in the analysis modules'
# own stores (queried over unix sockets). The legacy toolbox.db
# `events` table froze at the cutover, so prefer the live path and
# only fall back to the frozen table if EVERY module call fails.
live_counts = _live_event_counts(86400 * 7)
if live_counts is not None:
out["events"].update(live_counts)
else:
for row in _safe_query(c,
"SELECT source, COUNT(*) as n FROM events WHERE ts > ? GROUP BY source",
(d7d,)):
out["events"][row["source"]] = row["n"]
out["events"]["total_7d"] = sum(out["events"].values())
out["events"]["total_7d"] = sum(
v for v in out["events"].values() if isinstance(v, int)
)
# Top hosts (anonymized — just hostnames, no mac_hash)
# Top hosts (anonymized — just hostnames, no mac_hash).
# Live path: aggregate the dpi module's recent events; fall back to
# the frozen toolbox.db `events` table only if the dpi call fails.
live_hosts = _live_top_hosts()
if live_hosts is not None:
out["top_hosts_7d"] = live_hosts
else:
host_counter = Counter()
for row in _safe_query(c,
"SELECT payload FROM events WHERE source='dpi' AND ts > ? LIMIT 5000",
@ -98,6 +198,8 @@ def compute() -> dict:
for h, n in host_counter.most_common(15)
]
# Risk score / level distributions read the `clients` table (not
# the frozen `events` table), so they stay on toolbox.db for now.
# Risk score distribution (last 7d)
score_buckets = {"low": 0, "medium": 0, "high": 0}
for row in _safe_query(c,