Compare commits

...

8 Commits

Author SHA1 Message Date
c8fe9bb148 fix(toolbox): clarify #ads labels — Trackers & pubs, bytes marked as estimate (ref #735)
Some checks are pending
License Headers / check (push) Waiting to run
The #ads panel mixes ad + tracker + telemetry blocks, and 'bytes saved' is a flat
~45 KB/block estimate (a blocked request is never downloaded, so real bytes cannot
be measured). Relabel 'Pubs bloquées' → 'Trackers & pubs bloqués' and mark the
byte figure as an estimate (~ + tooltip). Pairs with an operator allowlist update
excluding generic AWS API-gateway hosts (execute-api.*) from the ad classifier.
2026-06-26 17:42:31 +02:00
e87d46f6a7 feat(sbxwaf): inject the real SecuBox health banner (not a custom badge) into first-party HTML (#747)
Per operator intent, the WAF injects the SHARED secubox health-banner.js in its
CDN-injected mode (absolute Hub origin for the asset + metrics APIs via the
window.SECUBOX_* overrides) so the SAME health widget the dashboard shows mounts
on first-party content sites (chess.maegia.tv et al.) — NOT a bespoke badge nor
the toolbox/mitm kbin transparency banner. Skips pages that already ship the
banner; --widget-hosts now includes maegia.tv; --health-banner-origin configures
the Hub. CORS on the metrics API (access-control-allow-origin: *) is already set.
2026-06-26 17:33:53 +02:00
efac8cec16 feat(sbxwaf): inject SecuBox health/visit widget into first-party HTML (#747)
On operator-configured first-party host suffixes (--widget-hosts), the WAF injects
a discreet fixed-corner badge into text/html responses showing the live visit
counter + a protected mark. Decompression-aware (gzip/br/zstd), idempotent, strictly
fail-open (missing </body>, oversize, decode error → original bytes untouched).
Wired into both reverse-proxy ModifyResponse paths (cached + fallback).
2026-06-26 17:26:06 +02:00
9561cb4bdb fix(toolbox): Live metrics read cumulative stats (events table is empty under R3) (ref #744)
The toolbox.db  table was fed by the OLD Python mitmproxy addons; the R3
path is Go sbxmitm → relay → sidecars → cumulative, so that table is empty and the
Live-metrics panel showed all zeros. Fall back to the cumulative per-source totals
(cookies/ja4) when the events table is empty, and derive mitm.connections from the
ja4 handshake count (the cumulative has no 'dpi' key, so the old probe was always 0).
2026-06-26 17:18:46 +02:00
344bb0738d fix(crowdsec): nftables health detects custom secubox_blacklist table (firewall reported OK)
The firewall-bouncer uses a CUSTOM nft table (inet secubox_blacklist), not the
upstream default ip crowdsec / ip6 crowdsec6, so the legacy probe always reported
nftables not OK — propagating a false 'nftables firewall: not OK' to the
security-posture scorecard while the firewall (inet filter, default-drop) was
active. Detect the custom + default names; base nftables_ok on the general
SecuBox firewall being loaded, not the IPv6 anchor.
2026-06-26 17:14:56 +02:00
b54b5383cd fix(waf): show fresh engine data — gate CrowdSec overlay + dashboard tabs (ref #744)
The CrowdSec overlay existed because the OLD Python mitmproxy WAF log was usually
empty; the Go sbxwaf engine now writes a rich threat log, so the overlay was
clobbering the engine's fresh categories and pushing a stale '1h ago' entry onto
the live attack banner. Only overlay when the engine produced nothing. Also move
Tracked Attackers + Visits into dashboard tabs (Menaces / Attaquants / Visites).
2026-06-26 17:09:07 +02:00
23788e304b feat(waf-webui): Visits panel — client type / OS / geo / vhost bars (#747) 2026-06-26 16:59:49 +02:00
3b28f84591 feat(sbxwaf+waf-api): non-attacker visit statistics (client type/OS/geo) (ref #744 #747)
sbxwaf aggregates LEGITIMATE (non-blocked) traffic in memory — total, client-type
(browser/mobile-app/bot/crawler via UA), OS, per-vhost, status bucket, top client
IPs — and flushes a JSON snapshot every 30s (double-caching: hot path only bumps
counters). New --visits-stats flag. The WAF API /visits endpoint reads the snapshot
and geo-maps the top IPs (it holds the GeoIP DB) for the dashboard, no per-request
PII stored. A statusRecorder in the handler tallies every served response and
excludes the WAF-block 403 and unmapped 421.
2026-06-26 16:56:21 +02:00
10 changed files with 816 additions and 23 deletions

View File

@ -557,9 +557,15 @@ async def health():
capture_output=True, text=True, timeout=3 capture_output=True, text=True, timeout=3
) )
nft_output = result.stdout if result.returncode == 0 else "" nft_output = result.stdout if result.returncode == 0 else ""
checks["nftables_crowdsec"] = "ip crowdsec" in nft_output # The SecuBox firewall-bouncer uses a CUSTOM table name (inet
checks["nftables_crowdsec6"] = "ip6 crowdsec6" in nft_output # secubox_blacklist), not the upstream default `ip crowdsec` / `ip6
checks["nftables_ok"] = checks["nftables_crowdsec"] and checks["nftables_crowdsec6"] # crowdsec6` — so the legacy probe always missed it and reported the
# firewall "not OK" even though it was active. Detect both the custom and
# the default names, and base nftables_ok on the GENERAL SecuBox firewall
# being loaded (inet filter / secubox_blacklist), not on the IPv6 anchor.
checks["nftables_crowdsec"] = ("ip crowdsec" in nft_output) or ("secubox_blacklist" in nft_output)
checks["nftables_crowdsec6"] = ("ip6 crowdsec6" in nft_output) or ("crowdsec6" in nft_output)
checks["nftables_ok"] = ("inet filter" in nft_output) or ("secubox_blacklist" in nft_output)
except Exception as e: except Exception as e:
log.warning("nftables check failed: %s", e) log.warning("nftables check failed: %s", e)
checks["nftables_ok"] = False checks["nftables_ok"] = False

View File

@ -0,0 +1,185 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
// Source-Disclosed License — All rights reserved except as expressly granted.
// See LICENCE-CMSD-1.0.md for terms.
// SecuBox-Deb :: sbxwaf :: WAF-injected health/visit widget (#747)
//
// On OUR OWN sites (the operator-configured host suffixes), the WAF injects a
// tiny "health widget" footer badge into the HTML it serves — a discreet
// SecuBox-protected mark carrying the live visit counter. It is the WAF analogue
// of the toolbox transparency banner, but for first-party sites: "this site is
// behind the SecuBox WAF, and here is its visit count".
//
// Injection is decompression-aware (gzip/br/zstd via internal/httpcodec),
// idempotent (a guard marker), and STRICTLY fail-open: any decode/encode failure
// or a missing </body> returns the original bytes untouched — a widget is never
// worth breaking a page. Only text/html responses on configured hosts are touched.
package main
import (
"bytes"
"io"
"net/http"
"strconv"
"strings"
"github.com/CyberMind-FR/secubox-deb/secubox-toolbox-ng/internal/httpcodec"
)
// widgetMaxBody caps how large an HTML response we will buffer to inject into.
// Larger HTML pages (rare) are passed through untouched — never worth the memory.
const widgetMaxBody = 4 << 20 // 4 MiB
// applyWidget injects the SecuBox health banner loader into an upstream HTML
// response when (a) injection is enabled (origin + hosts non-empty), (b) the
// request host matches a configured first-party suffix, and (c) the response is
// text/html under the size cap. STRICTLY fail-open: any issue leaves the response
// byte-identical. Called from the reverse-proxy ModifyResponse hook.
func applyWidget(resp *http.Response, host string, origin string, hosts []string) {
if origin == "" || len(hosts) == 0 || resp == nil || resp.Body == nil {
return
}
if !widgetHostMatch(host, hosts) || !isHTMLResponse(resp.Header.Get("Content-Type")) {
return
}
// Don't try to inject into a body we won't fully buffer.
if resp.ContentLength > widgetMaxBody {
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, widgetMaxBody+1))
resp.Body.Close()
if err != nil || int64(len(body)) > widgetMaxBody {
// Restore whatever we read so the client still gets the bytes, fail-open.
resp.Body = io.NopCloser(bytes.NewReader(body))
return
}
out, ok := injectWidgetBody(body, resp.Header.Get("Content-Encoding"), origin)
if !ok {
resp.Body = io.NopCloser(bytes.NewReader(body))
return
}
resp.Body = io.NopCloser(bytes.NewReader(out))
resp.ContentLength = int64(len(out))
resp.Header.Set("Content-Length", strconv.Itoa(len(out)))
}
// widgetGuard marks an already-injected document so a re-proxied response is not
// double-stamped.
const widgetGuard = "sbxwaf-health-banner-loader"
// healthBannerSnippet emits the loader for the SHARED SecuBox health banner
// (shared/health-banner.js) in its CDN-injected mode: it points the banner's APIs
// + asset at the canonical Hub origin (absolute URLs) so the SAME health widget
// the dashboard shows also mounts on first-party content sites. The banner script
// self-guards against double-init (window.__SBX_HEALTH_BANNER__); IS_CDN_INJECTED
// becomes true because window.SECUBOX_HEALTH_API is set.
func healthBannerSnippet(origin string) string {
o := strings.TrimRight(origin, "/")
return `<script id="` + widgetGuard + `">(function(){` +
`if(window.__SBX_HEALTH_BANNER__)return;` +
`var O=` + jsString(o) + `;` +
`window.SECUBOX_HEALTH_API=O+'/api/v1/metrics/health/summary';` +
`window.SECUBOX_VISITOR_ORIGIN_API=O+'/api/v1/metrics/visitor-origin';` +
`window.SECUBOX_LIVE_HOSTS_API=O+'/api/v1/metrics/live-hosts';` +
`window.SECUBOX_CERT_STATUS_API=O+'/api/v1/metrics/cert-status';` +
`window.SECUBOX_COOKIE_AUDIT_SUMMARY=O+'/api/v1/cookie-audit/summary';` +
`var s=document.createElement('script');s.src=O+'/shared/health-banner.js';s.async=true;` +
`document.body.appendChild(s);})();</script>`
}
// jsString returns a safe single-quoted JS string literal for s (escapes the few
// metacharacters that matter inside '...'); origins are operator-config hostnames
// so this is belt-and-braces, not untrusted input.
func jsString(s string) string {
r := strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\n", `\n`, "\r", `\r`, "<", `\x3c`)
return "'" + r.Replace(s) + "'"
}
// injectWidgetHTML inserts the health-banner loader just before the closing
// </body> tag of a decompressed HTML document. Returns the original bytes
// unchanged when there is no </body>, the loader was already injected, OR the
// page ALREADY ships the health banner itself (a dashboard page) — so we never
// double-mount it.
func injectWidgetHTML(plain []byte, origin string) []byte {
if bytes.Contains(plain, []byte(widgetGuard)) ||
bytes.Contains(plain, []byte("health-banner.js")) ||
bytes.Contains(plain, []byte("__SBX_HEALTH_BANNER__")) {
return plain // already has the banner (loader or first-party include)
}
// Case-insensitive search for the LAST </body>.
low := bytes.ToLower(plain)
idx := bytes.LastIndex(low, []byte("</body>"))
if idx < 0 {
return plain // no body close → nothing safe to do
}
snippet := []byte(healthBannerSnippet(origin))
out := make([]byte, 0, len(plain)+len(snippet))
out = append(out, plain[:idx]...)
out = append(out, snippet...)
out = append(out, plain[idx:]...)
return out
}
// injectWidgetBody decompresses (per Content-Encoding), injects the widget, and
// re-encodes in the SAME codec. Fail-open on any error. Returns (out, true) when
// the body was rewritten, (body, false) otherwise — the caller updates
// Content-Length to len(out) only when ok.
func injectWidgetBody(body []byte, encoding string, origin string) (out []byte, ok bool) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "":
inj := injectWidgetHTML(body, origin)
return inj, len(inj) != len(body)
case "gzip", "br", "zstd":
plain, err := httpcodec.Decode(encoding, body)
if err != nil {
return body, false // fail open: serve the original compressed bytes
}
inj := injectWidgetHTML(plain, origin)
if len(inj) == len(plain) {
return body, false // nothing injected → keep original (avoid re-encode churn)
}
reenc, err := httpcodec.Encode(encoding, inj)
if err != nil {
return body, false // never serve a truncated frame
}
return reenc, true
default:
return body, false // unknown encoding we cannot decode → pass through
}
}
// isHTMLResponse reports whether a Content-Type is an HTML document we may inject
// into (text/html, optionally with a charset parameter).
func isHTMLResponse(contentType string) bool {
ct := strings.ToLower(strings.TrimSpace(contentType))
return strings.HasPrefix(ct, "text/html")
}
// splitCSV splits a comma-separated flag value into trimmed, lowercased,
// non-empty entries.
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(strings.ToLower(p)); p != "" {
out = append(out, p)
}
}
return out
}
// widgetHostMatch reports whether host (bare, lowercased) ends with one of the
// configured first-party suffixes the operator opted into widget injection for.
func widgetHostMatch(host string, suffixes []string) bool {
for _, s := range suffixes {
if s == "" {
continue
}
// Exact host, or a dot-boundary subdomain — NOT a bare suffix match
// (which would wrongly match "notsecubox.in" against "secubox.in").
if host == s || strings.HasSuffix(host, "."+s) {
return true
}
}
return false
}

View File

@ -0,0 +1,91 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
package main
import (
"bytes"
"strings"
"testing"
)
const testOrigin = "https://admin.gk2.secubox.in"
func TestInjectWidgetHTMLBeforeBodyClose(t *testing.T) {
in := []byte(`<html><head></head><body><h1>hi</h1></body></html>`)
out := injectWidgetHTML(in, testOrigin)
s := string(out)
if !strings.Contains(s, widgetGuard) {
t.Fatalf("loader guard absent:\n%s", s)
}
// The loader points the banner asset/API at the Hub origin (O+'/path' at runtime).
if !strings.Contains(s, "/shared/health-banner.js") {
t.Fatalf("health-banner asset path absent:\n%s", s)
}
if !strings.Contains(s, testOrigin) {
t.Fatalf("Hub origin absent from loader:\n%s", s)
}
if !strings.Contains(s, "SECUBOX_HEALTH_API") {
t.Fatalf("health API override absent:\n%s", s)
}
// The loader must land BEFORE the closing </body>.
gi := strings.Index(s, widgetGuard)
bi := strings.LastIndex(strings.ToLower(s), "</body>")
if gi < 0 || bi < 0 || gi > bi {
t.Fatalf("loader not before </body> (guard=%d body=%d)", gi, bi)
}
if !strings.Contains(s, "<h1>hi</h1>") {
t.Fatalf("original content displaced:\n%s", s)
}
}
func TestInjectWidgetIdempotent(t *testing.T) {
in := []byte(`<body>x</body>`)
once := injectWidgetHTML(in, testOrigin)
twice := injectWidgetHTML(once, testOrigin)
if !bytes.Equal(once, twice) {
t.Fatalf("second injection must be a no-op (idempotent)")
}
if n := strings.Count(string(twice), widgetGuard); n != 1 {
t.Fatalf("expected exactly 1 loader, got %d", n)
}
}
func TestInjectWidgetSkipsPageThatAlreadyHasBanner(t *testing.T) {
// A dashboard page already including the banner must NOT be double-mounted.
in := []byte(`<body><script src="/shared/health-banner.js"></script></body>`)
out := injectWidgetHTML(in, testOrigin)
if !bytes.Equal(in, out) {
t.Fatalf("page already shipping health-banner.js must be left untouched")
}
}
func TestInjectWidgetNoBodyPassthrough(t *testing.T) {
in := []byte(`{"json":true}`) // no </body>
out := injectWidgetHTML(in, testOrigin)
if !bytes.Equal(in, out) {
t.Fatalf("non-HTML (no </body>) must pass through unchanged")
}
}
func TestInjectWidgetBodyGzipRoundTrip(t *testing.T) {
html := []byte(`<html><body>content</body></html>`)
out, ok := injectWidgetBody(html, "", testOrigin)
if !ok || !bytes.Contains(out, []byte(widgetGuard)) {
t.Fatalf("identity inject must report ok + contain loader")
}
}
func TestWidgetHostMatch(t *testing.T) {
hosts := []string{"gk2.secubox.in", "cybermind.fr"}
for _, h := range []string{"blog.gk2.secubox.in", "gk2.secubox.in", "www.cybermind.fr"} {
if !widgetHostMatch(h, hosts) {
t.Fatalf("%q should match first-party suffixes", h)
}
}
for _, h := range []string{"evil.com", "notsecubox.in"} {
if widgetHostMatch(h, hosts) {
t.Fatalf("%q must NOT match", h)
}
}
}

View File

@ -155,6 +155,22 @@ type Server struct {
// the upstream); cacheable responses on a miss are stored after proxying. // the upstream); cacheable responses on a miss are stored after proxying.
// Nil means caching is disabled (--media-cache-dir=""). // Nil means caching is disabled (--media-cache-dir="").
mediaCache *MediaCache mediaCache *MediaCache
// visits is the #747 non-attacker visit-statistics aggregator. When non-nil,
// every LEGITIMATE (non-blocked, non-misdirected) response is tallied by
// client-type / OS / vhost / status / top-IP and flushed to a JSON snapshot
// the WAF API geo-maps for the dashboard Visits panel. Nil disables it.
visits *VisitStats
// widgetHosts are the first-party host suffixes (gk2.secubox.in, …) into whose
// HTML responses the WAF injects the SecuBox health banner (#747). Empty
// disables injection. Set from --widget-hosts.
widgetHosts []string
// bannerOrigin is the canonical Hub origin (absolute, e.g.
// https://admin.gk2.secubox.in) the injected health-banner loads its asset +
// metrics APIs from (CDN-injected mode). Empty disables injection.
bannerOrigin string
} }
// handler returns an http.Handler that: // handler returns an http.Handler that:
@ -194,6 +210,20 @@ func (s *Server) handler() http.Handler {
} }
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// #747 — wrap the writer so we can tally the visit's final status. The
// defer records every LEGITIMATE response (excludes the WAF-block 403 and
// the unmapped-host 421) into the visit-stats aggregator.
var visitHost string
if s.visits != nil {
sr := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
w = sr
defer func() {
if sr.status != http.StatusForbidden && sr.status != http.StatusMisdirectedRequest {
s.visits.Record(visitHost, r.UserAgent(), clientIP(r), sr.status)
}
}()
}
// Hot-reload check: stat the routes file and swap the map if mtime changed. // Hot-reload check: stat the routes file and swap the map if mtime changed.
// Cheap when nothing changed (throttle=0 means one stat per call, but stat // Cheap when nothing changed (throttle=0 means one stat per call, but stat
// is O(1) and not on the inner response path). // is O(1) and not on the inner response path).
@ -208,6 +238,7 @@ func (s *Server) handler() http.Handler {
host = r.Host host = r.Host
} }
host = strings.ToLower(strings.TrimSpace(host)) host = strings.ToLower(strings.TrimSpace(host))
visitHost = host
ip, port, ok := s.routeLookup(host) ip, port, ok := s.routeLookup(host)
if !ok { if !ok {
@ -238,6 +269,8 @@ func (s *Server) handler() http.Handler {
if ca := s.cookieAudit; ca != nil { if ca := s.cookieAudit; ca != nil {
ca.Record(host, resp.Request, resp) ca.Record(host, resp.Request, resp)
} }
// #747: inject the SecuBox health/visit widget on first-party HTML.
applyWidget(resp, host, s.bannerOrigin, s.widgetHosts)
return nil return nil
} }
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
@ -534,6 +567,15 @@ func main() {
// Task 6.1: response media cache. // Task 6.1: response media cache.
mediaCacheDir := flag.String("media-cache-dir", "/var/cache/secubox/waf/media", mediaCacheDir := flag.String("media-cache-dir", "/var/cache/secubox/waf/media",
"directory for the response media cache (16 MiB/obj, 2 GiB total); empty disables") "directory for the response media cache (16 MiB/obj, 2 GiB total); empty disables")
// #747: non-attacker visit statistics — aggregate legit traffic (client type,
// OS, vhost, status, top IPs) flushed to a JSON snapshot the WAF API geo-maps.
visitsStats := flag.String("visits-stats", "/var/log/secubox/waf/visits-stats.json",
"path for the non-attacker visit-stats JSON snapshot (client type/OS/vhost/geo); empty disables")
// #747: WAF-injected SecuBox health banner on FIRST-PARTY sites (HTML only).
widgetHosts := flag.String("widget-hosts", "gk2.secubox.in,secubox.in,cybermind.fr,maegia.tv",
"comma-separated first-party host suffixes to inject the SecuBox health banner into; empty disables")
bannerOrigin := flag.String("health-banner-origin", "https://admin.gk2.secubox.in",
"absolute Hub origin the injected health banner loads its asset + metrics APIs from (CDN-injected); empty disables")
// Body inspection cap: only the first N bytes of the request body are scanned. // Body inspection cap: only the first N bytes of the request body are scanned.
// Payloads beyond this offset are NOT inspected (documented parity gap vs Python full-body scan). // Payloads beyond this offset are NOT inspected (documented parity gap vs Python full-body scan).
// Raise for stricter coverage; truncation events are always audit-logged regardless of this cap. // Raise for stricter coverage; truncation events are always audit-logged regardless of this cap.
@ -578,6 +620,13 @@ func main() {
log.Printf("sbxwaf: media-cache enabled → %s (maxObj=16MiB, maxTotal=2GiB)", *mediaCacheDir) log.Printf("sbxwaf: media-cache enabled → %s (maxObj=16MiB, maxTotal=2GiB)", *mediaCacheDir)
} }
// #747: non-attacker visit statistics. Disabled when --visits-stats is empty.
var visits *VisitStats
if *visitsStats != "" {
visits = NewVisitStats(*visitsStats)
log.Printf("sbxwaf: visit-stats enabled → %s (flush %s)", *visitsStats, visitFlushInterval)
}
srv := &Server{ srv := &Server{
upstreamTimeout: *upstreamTimeout, upstreamTimeout: *upstreamTimeout,
transport: sharedTransport, transport: sharedTransport,
@ -591,6 +640,11 @@ func main() {
cookieAudit: cookieAudit, cookieAudit: cookieAudit,
// Task 6.1: response media cache. // Task 6.1: response media cache.
mediaCache: mediaCache, mediaCache: mediaCache,
// #747: non-attacker visit statistics.
visits: visits,
// #747: first-party host suffixes + Hub origin for the injected health banner.
widgetHosts: splitCSV(*widgetHosts),
bannerOrigin: strings.TrimSpace(*bannerOrigin),
// Body inspection cap (--max-body-inspect). // Body inspection cap (--max-body-inspect).
maxBodyInspect: *maxBodyInspectFlag, maxBodyInspect: *maxBodyInspectFlag,
// Trusted-host skip (--waf-skip-hosts): mirrors Python whitelist. // Trusted-host skip (--waf-skip-hosts): mirrors Python whitelist.
@ -626,6 +680,8 @@ func main() {
r := LoadRoutes(*routesFile, sharedTransport) r := LoadRoutes(*routesFile, sharedTransport)
// Task 5.1: inject cookie audit so Routes-built proxies also record cookies. // Task 5.1: inject cookie audit so Routes-built proxies also record cookies.
r.cookieAudit = cookieAudit r.cookieAudit = cookieAudit
r.widgetHosts = srv.widgetHosts
r.bannerOrigin = srv.bannerOrigin
srv.routes = r srv.routes = r
srv.routeLookup = r.Lookup srv.routeLookup = r.Lookup
log.Printf("sbxwaf: routes loaded from %s (%d entries)", *routesFile, func() int { log.Printf("sbxwaf: routes loaded from %s (%d entries)", *routesFile, func() int {

View File

@ -77,6 +77,11 @@ type Routes struct {
// LoadRoutes time; never mutated afterwards. // LoadRoutes time; never mutated afterwards.
cookieAudit *CookieAudit cookieAudit *CookieAudit
// #747: first-party host suffixes + Hub origin for the injected SecuBox health
// banner. Read inside ModifyResponse; set in main() after LoadRoutes.
widgetHosts []string
bannerOrigin string
// watcher handles mtime tracking + Apply callbacks (throttle=0 → eager). // watcher handles mtime tracking + Apply callbacks (throttle=0 → eager).
watcher *reload.Watcher watcher *reload.Watcher
} }
@ -167,13 +172,18 @@ func (r *Routes) buildEntries(parsed map[string][2]string) map[string]routeEntry
// visible to both startup-built and hot-reload-built proxies. // visible to both startup-built and hot-reload-built proxies.
p.ModifyResponse = func(resp *http.Response) error { p.ModifyResponse = func(resp *http.Response) error {
resp.Header.Set("X-SecuBox-WAF", "inspected") resp.Header.Set("X-SecuBox-WAF", "inspected")
reqHost := ""
if resp.Request != nil {
reqHost = resp.Request.Host
}
if ca := r.cookieAudit; ca != nil { if ca := r.cookieAudit; ca != nil {
reqHost := ""
if resp.Request != nil {
reqHost = resp.Request.Host
}
ca.Record(reqHost, resp.Request, resp) ca.Record(reqHost, resp.Request, resp)
} }
// #747: inject the SecuBox health/visit widget on first-party HTML.
if bare, _, e := net.SplitHostPort(reqHost); e == nil {
reqHost = bare
}
applyWidget(resp, strings.ToLower(reqHost), r.bannerOrigin, r.widgetHosts)
return nil return nil
} }
p.ErrorHandler = func(w http.ResponseWriter, req *http.Request, err error) { p.ErrorHandler = func(w http.ResponseWriter, req *http.Request, err error) {

View File

@ -0,0 +1,305 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
// Source-Disclosed License — All rights reserved except as expressly granted.
// See LICENCE-CMSD-1.0.md for terms.
// SecuBox-Deb :: sbxwaf :: non-attacker visit statistics (#747)
//
// The WAF sees ALL inbound external traffic, so it is the right place to produce
// real "who actually visited" statistics — NOT just the threats already captured
// in the threat log. This aggregates LEGITIMATE (non-blocked) requests in memory
// (lock-guarded, capped maps) and flushes an aggregate JSON snapshot periodically
// (the double-caching pattern: hot path only touches counters, never disk). The
// WAF API reads the snapshot, geo-maps the top client IPs (it already ships a
// GeoIP reader), and serves the dashboard's Visits panel.
//
// Privacy: the snapshot is AGGREGATE counters (client-type / OS / per-vhost /
// status buckets) plus the top-N busiest client IPs for geo-mapping — no
// per-request PII, no full request log. UA is classified into coarse buckets, not
// stored verbatim. Pure standard library.
package main
import (
"encoding/json"
"net/http"
"os"
"strings"
"sync"
"time"
)
// statusRecorder wraps an http.ResponseWriter to remember the status code that
// was written, so the handler can tally the visit's outcome (2xx/3xx vs the
// blocked 403/421 it filters out) after the proxy has served the response. It
// forwards Flush so progressive responses still stream.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Flush() {
if f, ok := s.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// visitMapCap bounds each per-key map so a flood of distinct vhosts/IPs cannot
// grow memory without bound. NEW keys past the cap are dropped (existing keys
// keep counting) until the next flush clears the buffer.
const visitMapCap = 5000
// visitFlushInterval is how often the in-memory counters are snapshotted to disk.
const visitFlushInterval = 30 * time.Second
// VisitStats aggregates legitimate (non-blocked) request counts. All maps are
// guarded by mu; the hot path takes mu only for the O(1) increment.
type VisitStats struct {
mu sync.Mutex
total int64
byVhost map[string]int64
byClientType map[string]int64 // browser | mobile-app | bot | crawler | other
byOS map[string]int64 // Windows | macOS | Linux | Android | iOS | other
byStatus map[string]int64 // 2xx | 3xx | 4xx | 5xx
topIP map[string]int64 // busiest client IPs (geo-mapped by the API)
statsPath string
}
// NewVisitStats builds the aggregator and starts the background flusher writing
// to statsPath every visitFlushInterval. statsPath == "" disables persistence
// (the aggregator still counts, useful for tests).
func NewVisitStats(statsPath string) *VisitStats {
v := &VisitStats{
byVhost: map[string]int64{},
byClientType: map[string]int64{},
byOS: map[string]int64{},
byStatus: map[string]int64{},
topIP: map[string]int64{},
statsPath: statsPath,
}
if statsPath != "" {
go v.runFlusher()
}
return v
}
// Record tallies one legitimate visit. The caller filters out blocked/misdirected
// responses (403/421) before calling — this counts real traffic to real backends.
func (v *VisitStats) Record(vhost, ua, ip string, status int) {
if v == nil {
return
}
ct, os := classifyUA(ua)
v.mu.Lock()
defer v.mu.Unlock()
v.total++
bump(v.byClientType, ct) // small fixed key-set — no cap needed
bump(v.byOS, os) // small fixed key-set
bump(v.byStatus, statusBucket(status)) // 4 keys
capBump(v.byVhost, vhost)
if ip != "" {
capBump(v.topIP, ip)
}
}
// Total returns the current cumulative visit count (lock-guarded) for the
// WAF-injected health widget.
func (v *VisitStats) Total() int64 {
if v == nil {
return 0
}
v.mu.Lock()
defer v.mu.Unlock()
return v.total
}
// bump increments an unbounded-but-tiny key set (client type / OS / status).
func bump(m map[string]int64, k string) {
if k == "" {
k = "other"
}
m[k]++
}
// capBump increments k, adding a NEW key only while the map is under visitMapCap.
func capBump(m map[string]int64, k string) {
if k == "" {
return
}
if _, ok := m[k]; ok {
m[k]++
} else if len(m) < visitMapCap {
m[k] = 1
}
}
// statusBucket coarsens an HTTP status code into a 2xx/3xx/4xx/5xx bucket.
func statusBucket(code int) string {
switch {
case code >= 200 && code < 300:
return "2xx"
case code >= 300 && code < 400:
return "3xx"
case code >= 400 && code < 500:
return "4xx"
case code >= 500:
return "5xx"
default:
return "other"
}
}
// classifyUA maps a raw User-Agent string into a coarse (clientType, os) pair.
// Order matters: bots/crawlers are checked before browsers because crawler UAs
// also carry "Mozilla". Heuristic, not exhaustive — good enough for a dashboard
// breakdown, and never stores the raw UA.
func classifyUA(ua string) (clientType, os string) {
l := strings.ToLower(ua)
if l == "" {
return "other", "other"
}
// ── OS ──────────────────────────────────────────────────────────────────
switch {
case strings.Contains(l, "android"):
os = "Android"
case strings.Contains(l, "iphone"), strings.Contains(l, "ipad"), strings.Contains(l, "ios "):
os = "iOS"
case strings.Contains(l, "windows"):
os = "Windows"
case strings.Contains(l, "mac os x"), strings.Contains(l, "macintosh"):
os = "macOS"
case strings.Contains(l, "linux"), strings.Contains(l, "x11"):
os = "Linux"
default:
os = "other"
}
// ── client type ─────────────────────────────────────────────────────────
switch {
case strings.Contains(l, "bot"), strings.Contains(l, "spider"),
strings.Contains(l, "slurp"), strings.Contains(l, "crawl"):
clientType = "crawler"
case strings.Contains(l, "curl"), strings.Contains(l, "wget"),
strings.Contains(l, "python"), strings.Contains(l, "go-http"),
strings.Contains(l, "okhttp"), strings.Contains(l, "java/"),
strings.Contains(l, "libwww"), strings.Contains(l, "scanner"):
clientType = "bot"
case (os == "Android" || os == "iOS") &&
!strings.Contains(l, "mobile safari") && !strings.Contains(l, "version/"):
// A mobile OS UA without a browser marker → native app.
clientType = "mobile-app"
case strings.Contains(l, "mozilla"):
clientType = "browser"
default:
clientType = "other"
}
return clientType, os
}
// visitSnapshot is the on-disk JSON shape the WAF API reads.
type visitSnapshot struct {
Total int64 `json:"total"`
ByVhost map[string]int64 `json:"by_vhost"`
ByClientType map[string]int64 `json:"by_client_type"`
ByOS map[string]int64 `json:"by_os"`
ByStatus map[string]int64 `json:"by_status"`
TopIP map[string]int64 `json:"top_ips"`
UpdatedUnix int64 `json:"updated_unix"`
}
// snapshot copies the counters under the lock. The counters are CUMULATIVE (not
// cleared) so the dashboard shows running totals since process start; the flusher
// overwrites the file each tick with the latest cumulative state.
func (v *VisitStats) snapshot(nowUnix int64) visitSnapshot {
v.mu.Lock()
defer v.mu.Unlock()
return visitSnapshot{
Total: v.total,
ByVhost: copyTopN(v.byVhost, 50),
ByClientType: copyMap(v.byClientType),
ByOS: copyMap(v.byOS),
ByStatus: copyMap(v.byStatus),
TopIP: copyTopN(v.topIP, 50),
UpdatedUnix: nowUnix,
}
}
func copyMap(m map[string]int64) map[string]int64 {
out := make(map[string]int64, len(m))
for k, n := range m {
out[k] = n
}
return out
}
// copyTopN returns the n highest-count entries of m (keeps the file small even
// when the in-memory map holds thousands of vhosts/IPs).
func copyTopN(m map[string]int64, n int) map[string]int64 {
if len(m) <= n {
return copyMap(m)
}
type kv struct {
k string
v int64
}
all := make([]kv, 0, len(m))
for k, v := range m {
all = append(all, kv{k, v})
}
// partial selection of the top n by count (simple insertion into a bounded slice)
top := make([]kv, 0, n)
for _, e := range all {
if len(top) < n {
top = append(top, e)
continue
}
// find the current minimum in top and replace if e is larger
minI := 0
for i := 1; i < len(top); i++ {
if top[i].v < top[minI].v {
minI = i
}
}
if e.v > top[minI].v {
top[minI] = e
}
}
out := make(map[string]int64, n)
for _, e := range top {
out[e.k] = e.v
}
return out
}
// writeSnapshot atomically writes the snapshot JSON to statsPath (temp + rename)
// so the API never reads a half-written file. Best-effort: errors are swallowed.
func (v *VisitStats) writeSnapshot(nowUnix int64) {
if v.statsPath == "" {
return
}
snap := v.snapshot(nowUnix)
buf, err := json.Marshal(snap)
if err != nil {
return
}
tmp := v.statsPath + ".tmp"
if err := os.WriteFile(tmp, buf, 0o640); err != nil {
return
}
_ = os.Rename(tmp, v.statsPath)
}
// runFlusher overwrites the stats file every visitFlushInterval for the process
// lifetime. Started once from NewVisitStats.
func (v *VisitStats) runFlusher() {
t := time.NewTicker(visitFlushInterval)
defer t.Stop()
for range t.C {
v.writeSnapshot(time.Now().Unix())
}
}

View File

@ -3775,10 +3775,14 @@ async def admin_metrics() -> dict:
"events_24h_total": 0, "events_24h_total": 0,
"mitm": {"connections": 0, "tls_pinned": 0, "unique_hosts": 0}, "mitm": {"connections": 0, "tls_pinned": 0, "unique_hosts": 0},
} }
# Per-source event counts (last 24h) # Per-source event counts. The legacy toolbox.db `events` table was fed by the
# OLD Python mitmproxy addons; the current R3 path is Go sbxmitm → relay →
# sidecars → cumulative stats, so that table is now empty. Read the cumulative
# per-source totals (cookies/ja4/…) when the events table has nothing, so the
# Live-metrics panel shows real activity instead of zeros.
since = int(time.time()) - 86400
try: try:
with _sq3.connect("/var/lib/secubox/toolbox/toolbox.db", timeout=2) as c: with _sq3.connect("/var/lib/secubox/toolbox/toolbox.db", timeout=2) as c:
since = int(time.time()) - 86400
rows = c.execute( rows = c.execute(
"SELECT source, COUNT(*) FROM events WHERE ts > ? GROUP BY source", "SELECT source, COUNT(*) FROM events WHERE ts > ? GROUP BY source",
(since,), (since,),
@ -3791,6 +3795,17 @@ async def admin_metrics() -> dict:
).fetchone()[0] ).fetchone()[0]
except Exception as e: except Exception as e:
metrics["sqlite_error"] = str(e) metrics["sqlite_error"] = str(e)
if not metrics["events_by_source"]:
try:
ev = (cumulative.get_cached() or {}).get("events", {}) or {}
src = {k: int(v) for k, v in ev.items()
if k != "total_7d" and isinstance(v, (int, float))}
if src:
metrics["events_by_source"] = src
metrics["events_24h_total"] = int(ev.get("total_7d") or sum(src.values()))
metrics["events_window"] = "7d" # cumulative fallback, not strictly 24h
except Exception:
pass
# Live MITM activity. NOTE: the old journal-scrape for "server connect" # Live MITM activity. NOTE: the old journal-scrape for "server connect"
# NEVER worked — the workers run at --log-level warning, so those INFO lines # NEVER worked — the workers run at --log-level warning, so those INFO lines
# are never emitted → the trio was permanently 0. Derive from real data: the # are never emitted → the trio was permanently 0. Derive from real data: the
@ -3799,8 +3814,10 @@ async def admin_metrics() -> dict:
try: try:
cs = cumulative.get_cached() or {} cs = cumulative.get_cached() or {}
ev = cs.get("events", {}) or {} ev = cs.get("events", {}) or {}
# "connections analysées" — DPI classifies one flow per upstream connection. # "connections analysées" — each JA4 observation is one inspected TLS
metrics["mitm"]["connections"] = int(ev.get("dpi", 0) or 0) # handshake/upstream connection. The cumulative stats expose ja4 (and
# cookies), not a `dpi` key, so the old ev.get("dpi") was always 0.
metrics["mitm"]["connections"] = int(ev.get("ja4") or ev.get("dpi") or 0)
metrics["mitm"]["unique_hosts"] = len(cs.get("top_hosts_7d", []) or []) metrics["mitm"]["unique_hosts"] = len(cs.get("top_hosts_7d", []) or [])
except Exception: except Exception:
pass pass

View File

@ -179,7 +179,7 @@
</div> </div>
<div id="ads-section" class="grid"> <div id="ads-section" class="grid">
<div class="card" style="grid-column:1/-1"> <div class="card" style="grid-column:1/-1">
<h2>🛑 Pubs bloquées (24h)</h2> <h2>🛑 Trackers & pubs bloqués (24h)</h2>
<div class="kv" id="ads-kpi"><span class="k">loading…</span><span class="v"></span></div> <div class="kv" id="ads-kpi"><span class="k">loading…</span><span class="v"></span></div>
</div> </div>
<div class="card" style="grid-column:1/-1"> <div class="card" style="grid-column:1/-1">
@ -191,7 +191,7 @@
<div id="ads-sites"><div class="empty">loading…</div></div> <div id="ads-sites"><div class="empty">loading…</div></div>
</div> </div>
<div class="card" style="grid-column:1/-1"> <div class="card" style="grid-column:1/-1">
<h2>👤 Top visiteurs (pubs bloquées)</h2> <h2>👤 Top visiteurs (trackers/pubs bloqués)</h2>
<div id="ads-visitors"><div class="empty">loading…</div></div> <div id="ads-visitors"><div class="empty">loading…</div></div>
<div id="ads-client-detail"></div> <div id="ads-client-detail"></div>
</div> </div>
@ -617,24 +617,24 @@ async function loadAds() {
const kpi = document.getElementById('ads-kpi'); const kpi = document.getElementById('ads-kpi');
const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
if (!d || d.__error) { kpi.innerHTML = `<span class="k">err</span><span class="v">${(d&&d.__error)||'no data'}</span>`; return; } if (!d || d.__error) { kpi.innerHTML = `<span class="k">err</span><span class="v">${(d&&d.__error)||'no data'}</span>`; return; }
kpi.innerHTML = `<span class="k">Pubs bloquées</span> <span class="v">${d.total_blocked||0}</span>` kpi.innerHTML = `<span class="k">Trackers &amp; pubs bloqués</span> <span class="v">${d.total_blocked||0}</span>`
+ ` <span class="k">Ko économisés</span> <span class="v">${Math.round((d.total_bytes||0)/1024)}</span>` + ` <span class="k" title="estimation : un contenu bloqué n'est jamais téléchargé, on ne peut pas mesurer les octets réels — ~45 Ko/blocage">Ko évités <span style="opacity:.6">(est.)</span></span> <span class="v">~${Math.round((d.total_bytes||0)/1024)}</span>`
+ ` <span class="k">Silenced</span> <span class="v">${(d.by_action&&d.by_action.silent)||0}</span>` + ` <span class="k">Silenced</span> <span class="v">${(d.by_action&&d.by_action.silent)||0}</span>`
+ ` <span class="k">Fenêtre</span> <span class="v">${d.window_hours||24}h</span>`; + ` <span class="k">Fenêtre</span> <span class="v">${d.window_hours||24}h</span>`;
const hostRows = (d.top_hosts||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.host)}</code></td><td>${r.hits}</td><td>${Math.round((r.bytes||0)/1024)}</td></tr>`).join(''); const hostRows = (d.top_hosts||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.host)}</code></td><td>${r.hits}</td><td>~${Math.round((r.bytes||0)/1024)}</td></tr>`).join('');
const siteRows = (d.top_sites||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.site)}</code></td><td>${r.hits}</td></tr>`).join(''); const siteRows = (d.top_sites||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.site)}</code></td><td>${r.hits}</td></tr>`).join('');
document.getElementById('ads-hosts').innerHTML = hostRows document.getElementById('ads-hosts').innerHTML = hostRows
? '<table><thead><tr><th>Ad host</th><th>bloqués</th><th>Ko</th></tr></thead><tbody>'+hostRows+'</tbody></table>' ? '<table><thead><tr><th>Tracker / pub host</th><th>bloqués</th><th>Ko (est.)</th></tr></thead><tbody>'+hostRows+'</tbody></table>'
: '<div class="empty">aucune pub bloquée dans la fenêtre</div>'; : '<div class="empty">rien de bloqué dans la fenêtre</div>';
document.getElementById('ads-sites').innerHTML = siteRows document.getElementById('ads-sites').innerHTML = siteRows
? '<table><thead><tr><th>Site</th><th>pubs bloquées</th></tr></thead><tbody>'+siteRows+'</tbody></table>' ? '<table><thead><tr><th>Site</th><th>trackers/pubs bloqués</th></tr></thead><tbody>'+siteRows+'</tbody></table>'
: ''; : '';
const visRows = (d.top_visitors||[]).slice(0, 5).map(r=>{ const visRows = (d.top_visitors||[]).slice(0, 5).map(r=>{
const mh = esc(r.mac_hash); const mh = esc(r.mac_hash);
return `<tr><td><a href="#" onclick="loadAdsClient('${mh}');return false;"><code>${mh}</code></a></td><td>${r.hits}</td></tr>`; return `<tr><td><a href="#" onclick="loadAdsClient('${mh}');return false;"><code>${mh}</code></a></td><td>${r.hits}</td></tr>`;
}).join(''); }).join('');
document.getElementById('ads-visitors').innerHTML = visRows document.getElementById('ads-visitors').innerHTML = visRows
? '<table><thead><tr><th>Visiteur</th><th>pubs bloquées</th></tr></thead><tbody>'+visRows+'</tbody></table>' ? '<table><thead><tr><th>Visiteur</th><th>trackers/pubs</th></tr></thead><tbody>'+visRows+'</tbody></table>'
: '<div class="empty">aucun visiteur dans la fenêtre</div>'; : '<div class="empty">aucun visiteur dans la fenêtre</div>';
} }
@ -645,7 +645,7 @@ async function loadAdsClient(mh) {
const d = await J('/admin/ad-stats/client/'+encodeURIComponent(mh)); const d = await J('/admin/ad-stats/client/'+encodeURIComponent(mh));
if (!d || d.__error) { detail.innerHTML = `<div class="empty">${(d&&d.__error)||'no data'}</div>`; return; } if (!d || d.__error) { detail.innerHTML = `<div class="empty">${(d&&d.__error)||'no data'}</div>`; return; }
const rows = (d.top_hosts||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.host)}</code></td><td>${r.hits}</td><td>${Math.round((r.bytes||0)/1024)}</td></tr>`).join(''); const rows = (d.top_hosts||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.host)}</code></td><td>${r.hits}</td><td>${Math.round((r.bytes||0)/1024)}</td></tr>`).join('');
detail.innerHTML = `<h3>👤 <code>${esc(d.mac_hash)}</code> — ${d.total||0} pubs bloquées</h3>` detail.innerHTML = `<h3>👤 <code>${esc(d.mac_hash)}</code> — ${d.total||0} trackers/pubs bloqués</h3>`
+ (rows + (rows
? '<table><thead><tr><th>Ad host</th><th>bloqués</th><th>Ko</th></tr></thead><tbody>'+rows+'</tbody></table>' ? '<table><thead><tr><th>Ad host</th><th>bloqués</th><th>Ko</th></tr></thead><tbody>'+rows+'</tbody></table>'
: '<div class="empty">aucune pub bloquée pour ce visiteur</div>'); : '<div class="empty">aucune pub bloquée pour ce visiteur</div>');

View File

@ -702,7 +702,13 @@ def _refresh_warm_caches() -> dict:
cs_alerts = _get_crowdsec_alerts() cs_alerts = _get_crowdsec_alerts()
except Exception: except Exception:
cs_alerts = [] cs_alerts = []
if cs_alerts: # The Go sbxwaf engine (#744) now writes a RICH threat log, so the WAF stats
# are real and fresh. Only fall back to the CrowdSec overlay when the engine
# produced NOTHING (engine down / log unreadable) — otherwise the overlay's
# older, coarser CrowdSec scenarios would clobber the engine's fresh
# categories AND push a stale "1h ago" entry to the live attack banner.
engine_has_data = (new_stats.get("total_threats", 0) or 0) > 0 or bool(new_alerts)
if cs_alerts and not engine_has_data:
# Defensive: a CrowdSec-overlay failure must NEVER abort the refresh and # Defensive: a CrowdSec-overlay failure must NEVER abort the refresh and
# leave the warm cache frozen — the WAF top_ips/top_vhosts/tracked-attacker # leave the warm cache frozen — the WAF top_ips/top_vhosts/tracked-attacker
# data (the dashboard's per-IP/vhost panels) lives in new_stats and must # data (the dashboard's per-IP/vhost panels) lives in new_stats and must
@ -869,6 +875,43 @@ def _empty_stats_skeleton() -> dict:
} }
VISITS_STATS = os.environ.get(
"SECUBOX_WAF_VISITS_STATS", "/var/log/secubox/waf/visits-stats.json")
@app.get("/visits")
async def get_visits():
"""Non-attacker visit statistics (#747) — the WAF sees ALL inbound traffic,
so this is real 'who actually visited' data: total requests, client-type /
OS / per-vhost / status breakdowns, plus the busiest client IPs geo-mapped to
countries here (the engine ships only the IPs; the API holds the GeoIP DB).
Public, served straight from the engine's periodic JSON snapshot."""
try:
snap = json.loads(Path(VISITS_STATS).read_text())
except (OSError, ValueError):
return {"available": False, "total": 0, "by_client_type": {}, "by_os": {},
"by_status": {}, "by_vhost": {}, "top_countries": {}, "top_ips": {}}
reader = _get_geoip_reader()
top_ips = snap.get("top_ips") or {}
by_country: Dict[str, int] = defaultdict(int)
for ip, n in top_ips.items():
by_country[_lookup_country(ip, reader)] += int(n or 0)
return {
"available": True,
"total": int(snap.get("total", 0)),
"by_client_type": snap.get("by_client_type") or {},
"by_os": snap.get("by_os") or {},
"by_status": snap.get("by_status") or {},
"by_vhost": dict(sorted((snap.get("by_vhost") or {}).items(),
key=lambda kv: -int(kv[1] or 0))[:10]),
"top_countries": dict(sorted(by_country.items(),
key=lambda kv: -kv[1])[:10]),
"top_ips": dict(sorted(top_ips.items(),
key=lambda kv: -int(kv[1] or 0))[:10]),
"updated_unix": int(snap.get("updated_unix", 0)),
}
@app.get("/stats") @app.get("/stats")
async def get_stats(): async def get_stats():
"""Threat statistics for the dashboard (public). """Threat statistics for the dashboard (public).

View File

@ -561,6 +561,25 @@
<div class="stat-card"><div class="emoji">🛡️</div><div class="value" id="protectedSites">0</div><div class="label">Protected</div></div> <div class="stat-card"><div class="emoji">🛡️</div><div class="value" id="protectedSites">0</div><div class="label">Protected</div></div>
</div> </div>
<!-- #747 — dashboard tabs: Menaces / Attaquants suivis / Visites -->
<div class="waf-tabs" id="wafTabs">
<button class="waf-tab active" data-tab="overview" onclick="switchTab('overview')">🛡️ Menaces</button>
<button class="waf-tab" data-tab="attackers" onclick="switchTab('attackers')">🎯 Attaquants suivis</button>
<button class="waf-tab" data-tab="visits" onclick="switchTab('visits')">👁️ Visites</button>
</div>
<style>
.waf-tabs{display:flex;gap:.4rem;margin:.4rem 0 1rem;flex-wrap:wrap}
.waf-tab{background:rgba(110,64,201,.08);color:#c9b8ff;border:1px solid #2a2a3f;border-radius:.5rem;
padding:.45rem 1rem;cursor:pointer;font-size:.88rem;font-weight:600}
.waf-tab.active{background:linear-gradient(90deg,#00d4ff22,#6e40c944);color:#fff;border-color:#6e40c9}
.vbars{display:flex;flex-direction:column;gap:.35rem}
.vbar{display:grid;grid-template-columns:auto 1fr auto;gap:.6rem;align-items:center;font-size:.82rem}
.vbar .vlabel{white-space:nowrap;max-width:16rem;overflow:hidden;text-overflow:ellipsis}
.vbar .vtrack{height:.6rem;border-radius:.3rem;background:rgba(255,255,255,.07);overflow:hidden}
.vbar .vfill{height:100%;border-radius:.3rem;background:linear-gradient(90deg,#00d4ff,#6e40c9)}
.vbar .vcount{font-variant-numeric:tabular-nums;color:#c9a84c}
</style>
<div class="grid-2"> <div class="grid-2">
<!-- Security Events (combined alerts/bans) --> <!-- Security Events (combined alerts/bans) -->
<div class="glass-card"> <div class="glass-card">
@ -700,6 +719,19 @@
</table> </table>
</div> </div>
</div> </div>
<!-- #747 — non-attacker visit statistics (shown in the Visites tab) -->
<div class="glass-card" id="visitsCard" style="grid-column: 1 / -1;">
<h2>👁️ Visites — trafic légitime <span class="muted" style="font-size:0.8rem;">— total <b id="visitsTotal"></b> requêtes vues par le WAF</span></h2>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin-top:0.6rem">
<div><h3 style="font-size:0.85rem;margin:0 0 .4rem">🧭 Type de client</h3><div id="visitsClientType" class="vbars"><span class="muted"></span></div></div>
<div><h3 style="font-size:0.85rem;margin:0 0 .4rem">💻 Système</h3><div id="visitsOS" class="vbars"><span class="muted"></span></div></div>
<div><h3 style="font-size:0.85rem;margin:0 0 .4rem">🌍 Pays</h3><div id="visitsCountries" class="vbars"><span class="muted"></span></div></div>
<div><h3 style="font-size:0.85rem;margin:0 0 .4rem">📊 Statut</h3><div id="visitsStatus" class="vbars"><span class="muted"></span></div></div>
<div style="grid-column:1/-1"><h3 style="font-size:0.85rem;margin:0 0 .4rem">🎯 Sites les plus visités</h3><div id="visitsVhosts" class="vbars"><span class="muted"></span></div></div>
</div>
</div>
</div> </div>
</main> </main>
@ -1154,7 +1186,8 @@
loadStats(), loadStats(),
loadSecurityEvents(), loadSecurityEvents(),
loadCategories(), loadCategories(),
loadTrackedAttackers() loadTrackedAttackers(),
loadVisits()
]); ]);
btn.innerHTML = '🔄<span class="quick-btn-tooltip">Refresh</span>'; btn.innerHTML = '🔄<span class="quick-btn-tooltip">Refresh</span>';
} }
@ -1195,6 +1228,52 @@
}).join(''); }).join('');
} }
// #747 — non-attacker visit statistics
const _CT_EMOJI = { browser: '🌐', 'mobile-app': '📱', bot: '🤖', crawler: '🕷️', other: '❔' };
const _OS_EMOJI = { Windows: '🪟', macOS: '🍎', Linux: '🐧', Android: '🤖', iOS: '📱', other: '❔' };
function _vbars(elId, obj, emoji) {
const el = document.getElementById(elId);
if (!el) return;
const entries = Object.entries(obj || {}).sort((a, b) => b[1] - a[1]).slice(0, 10);
if (!entries.length) { el.innerHTML = '<span class="muted">aucune donnée</span>'; return; }
const max = entries[0][1] || 1;
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
el.innerHTML = entries.map(([k, v]) => {
const pct = Math.max(3, Math.round((v / max) * 100));
const ic = emoji ? (emoji[k] || '') + ' ' : '';
return '<div class="vbar"><span class="vlabel" title="' + esc(k) + '">' + ic + esc(k) + '</span>'
+ '<span class="vtrack"><span class="vfill" style="width:' + pct + '%"></span></span>'
+ '<span class="vcount">' + v + '</span></div>';
}).join('');
}
async function loadVisits() {
const d = await api('/visits');
const tot = document.getElementById('visitsTotal');
if (tot) tot.textContent = (d && d.total != null) ? d.total.toLocaleString() : '—';
_vbars('visitsClientType', d?.by_client_type, _CT_EMOJI);
_vbars('visitsOS', d?.by_os, _OS_EMOJI);
_vbars('visitsCountries', d?.top_countries, null);
_vbars('visitsStatus', d?.by_status, null);
_vbars('visitsVhosts', d?.by_vhost, null);
}
// #747 — dashboard tabs (Menaces / Attaquants suivis / Visites)
function switchTab(name) {
document.querySelectorAll('#wafTabs .waf-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === name));
const show = (sel, on) => document.querySelectorAll(sel).forEach(el =>
el.style.display = on ? '' : 'none');
const overview = name === 'overview';
show('.grid-2', overview);
show('.eyemote-card', overview);
const tracked = document.getElementById('trackedAttackersCard');
const visits = document.getElementById('visitsCard');
if (tracked) tracked.style.display = (name === 'attackers') ? '' : 'none';
if (visits) visits.style.display = (name === 'visits') ? '' : 'none';
if (name === 'visits') loadVisits();
if (name === 'attackers') loadTrackedAttackers();
}
// WAF Toggle // WAF Toggle
let wafActive = true; let wafActive = true;
async function toggleWaf() { async function toggleWaf() {
@ -1263,6 +1342,7 @@
btn.innerHTML = '📤<span class="quick-btn-tooltip">Export</span>'; btn.innerHTML = '📤<span class="quick-btn-tooltip">Export</span>';
} }
switchTab('overview');
refresh(); refresh();
setInterval(refresh, 30000); setInterval(refresh, 30000);
// More frequent live updates // More frequent live updates