mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 09:08:32 +00:00
Compare commits
6 Commits
ded89934d0
...
257fc95182
| Author | SHA1 | Date | |
|---|---|---|---|
| 257fc95182 | |||
|
|
591106ec65 | ||
|
|
15a668829b | ||
| 73b8ad36b1 | |||
| d0db3e87fd | |||
| 05c659b4ca |
|
|
@ -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
|
||||
|
|
|
|||
291
packages/secubox-toolbox-ng/cmd/sbxmitm/relay.go
Normal file
291
packages/secubox-toolbox-ng/cmd/sbxmitm/relay.go
Normal 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))
|
||||
}
|
||||
355
packages/secubox-toolbox-ng/cmd/sbxmitm/relay_test.go
Normal file
355
packages/secubox-toolbox-ng/cmd/sbxmitm/relay_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,30 +158,48 @@ 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)
|
||||
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())
|
||||
# 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(
|
||||
v for v in out["events"].values() if isinstance(v, int)
|
||||
)
|
||||
|
||||
# Top hosts (anonymized — just hostnames, no mac_hash)
|
||||
host_counter = Counter()
|
||||
for row in _safe_query(c,
|
||||
"SELECT payload FROM events WHERE source='dpi' AND ts > ? LIMIT 5000",
|
||||
(d7d,)):
|
||||
try:
|
||||
p = json.loads(row["payload"])
|
||||
h = p.get("host") or p.get("sni")
|
||||
if h:
|
||||
host_counter[h] += 1
|
||||
except Exception:
|
||||
pass
|
||||
out["top_hosts_7d"] = [
|
||||
{"host": h, "count": n}
|
||||
for h, n in host_counter.most_common(15)
|
||||
]
|
||||
# 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",
|
||||
(d7d,)):
|
||||
try:
|
||||
p = json.loads(row["payload"])
|
||||
h = p.get("host") or p.get("sni")
|
||||
if h:
|
||||
host_counter[h] += 1
|
||||
except Exception:
|
||||
pass
|
||||
out["top_hosts_7d"] = [
|
||||
{"host": h, "count": n}
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user