Compare commits

...

9 Commits

Author SHA1 Message Date
CyberMind
323363e701
Merge pull request #694 from CyberMind-FR/feature/692-dpi-beaconing-rule-fires-on-sub-second-a
Some checks are pending
License Headers / check (push) Waiting to run
dpi 1.1.1: beaconing period-band + dashboard cards on exfil engine (closes #692, #693)
2026-06-22 10:07:40 +02:00
c91931380f fix(dpi): beaconing period-band + dashboard cards on exfil engine (closes #692, closes #693)
#692 — beaconing scenario was firing on sub-second app/media chatter
("~39 ms" false positives). Now requires a C2-plausible cadence: mean IAT in
[1 s, 1 h], CV <= 0.25, >=6 flows, to an EXTERNAL exfil-relevant/unclassified
dest (never known media/game/social CDNs). Detail reads in seconds.

#693 — the DPI dashboard headline stat cards were legacy netifyd widgets,
empty on R3 boards (netifyd inactive). Repointed to the real exfil engine:
R3 Devices / captured Flows / Categories / Exfil Alerts, all from /exfil.
netifyd list cards degrade gracefully.

secubox-dpi 1.1.1. Verified live on gk2: 39 ms synthetic beacon dropped, 5 s
beacon fires; admin.gk2/dpi/ stat cards + exfil panel populated.
2026-06-22 10:07:24 +02:00
CyberMind
dde96f212e
Merge pull request #691 from CyberMind-FR/feature/689-sbxmitm-forged-leaf-certs-expire-after-2
sbxmitm: forged leaves valid 365d not 24h — stop daily cert expiry (closes #689)
2026-06-22 09:46:57 +02:00
CyberMind
8fae0dab54
Merge pull request #690 from CyberMind-FR/feature/687-plan-full-flow-dpi-on-r3-ndpid-netifyd-p
DPI: per-device R3 cloud-exfiltration pipeline + dashboard, packaged (closes #687)
2026-06-22 09:46:53 +02:00
997fa0501d chore(dpi): gitignore Go build artifacts (ref #687) 2026-06-22 09:44:42 +02:00
1567f94184 build(dpi): package the R3 exfil pipeline as a proper .deb (ref #687)
Ship Phase 2/3 instead of scp-deploying it:
- Architecture all -> arm64 (now carries a compiled collector).
- debian/rules builds the pure-stdlib Go collector offline for arm64
  (GOTOOLCHAIN=local, GOPROXY=off, CGO off) and installs:
    /usr/sbin/secubox-dpi-collector
    /usr/sbin/secubox-dpi-flowcap
    /usr/lib/systemd/system/secubox-dpi-flowcap.service (dh_installsystemd
    auto-enables + starts it)
- control: Depends libndpi-bin (ndpiReader); Build-Depends golang-go.
- postinst pre-creates /var/lib/secubox/dpi (0755) so the collector (root,
  0644 state.json) and the dpi API (secubox) interoperate.
- changelog 1.1.0-1~bookworm1.

Validated on gk2: dpkg upgrade 1.0.5 -> 1.1.0; both secubox-dpi and
secubox-dpi-flowcap enabled+active from the packaged units; /api/v1/dpi/exfil
serving live; libndpi-bin dependency satisfied.
2026-06-22 09:44:29 +02:00
7b379a03d6 fix(sbxmitm): forged leaves valid 365d, not 24h — stop daily cert expiry (closes #689)
The Go MITM engine forged leaf certs with only 24h validity while the per-host
cert cache never evicts. A worker running >24h kept serving the same now-expired
leaf, so every client (notably iOS Safari) reported "certificat expiré" for any
forged site — most visibly kbin.gk2.secubox.in. A worker restart masked it for
~24h, then it recurred.

Forge leaves with NotBefore=now-48h (clock-skew) and NotAfter=now+365d (367d
total span, safely under Apple's 398-day server-cert max-validity rule). Full
interception is preserved — no splice/passthrough.

Verified live on gk2: forged kbin leaf now nb=2026-06-20 na=2027-06-22, issuer
still "Gondwana ToolBoX R3 CA".
2026-06-22 09:19:04 +02:00
01b35e7b95 feat(dpi): service categorization + exfil dashboard panel (ref #687)
"dpidify" R3 egress beyond cloud-only: classify every flow's SNI into nDPI-style
categories so the operator sees *what* each device does, not just whether it hit
a cloud.

collector:
- classify(sni) → (category, service); longest-suffix match for determinism.
  Categories: cloud, filehost, messaging, ai, media, game, social, adult.
- exfil scenarios (volume / new-dest) now fire on exfilCat() = the data-leak-
  relevant set {cloud, filehost, messaging, ai}; media/game/social/adult are
  shown but never alerted (browsing, not exfiltration).
- state per device now carries services[] (all categorized egress) +
  by_category{} flow counts, alongside the back-compat clouds[] subset.
  alert/agg gain category+service fields (cloud kept for back-compat).

dashboard (www/dpi/index.html):
- new "🛰️ Cloud Exfiltration Watch" card: severity-first alert feed +
  per-device egress grouped by category with colored chips and per-service
  ↑/↓ byte counts; exfil-relevant rows flagged red. Polls GET /exfil.

Verified live on gk2 against a real ndpiReader capture: device classified
adult (Chaturbate) + cloud (Google APIs) across 23 flows; CSV columns confirmed
matching (#flow_id/src_ip/server_name_sni/c_to_s_bytes/iat_flow_*).
2026-06-22 08:53:05 +02:00
76acf259c2 feat(dpi): per-device cloud-exfiltration pipeline on R3 (ref #687)
Phase 2 — turn the R3 tap (wg-toolbox) into a per-device DPI exfil sensor,
modeled on how CrowdSec feeds the WAF: a lean C producer feeds a Go scorer
that attributes flows to devices and fires cloud-exfiltration scenarios.

- collector/main.go: pure-stdlib Go scorer. Reads ndpiReader CSV, attributes
  each flow to a device via sha256(wg_pubkey)[:16] from wg-peers.json, maps
  SNI/dest to known clouds, and fires scenarios:
    * exfil_volume  — >=5MB upstream to a cloud and up>down
    * new_cloud     — first contact with a cloud dest for this device
    * beaconing     — low-jitter periodic flows (IAT CV<=0.25)
    * unclassified_external — uncategorised egress to non-local dests
  Writes /var/lib/secubox/dpi/{state.json,seen.json}. CGO-free, ~2MB static.
- sbin/secubox-dpi-flowcap: fixed-window capture loop (ndpiReader -C CSV)
  → collector. Idle-safe if libndpi-bin is absent.
- systemd/secubox-dpi-flowcap.service: Nice 15, MemoryMax 256M, CPUWeight 20,
  CAP_NET_RAW/NET_ADMIN only. Light on a saturated board (~1% CPU observed).
- api/main.py: GET /api/v1/dpi/exfil serves state.json (fail-empty).

ndpiReader is the PoC producer; an nDPId JSON-socket daemon is the production
upgrade (Phase 3). Live on gk2: flowcap active, state.json refreshing.
2026-06-22 08:27:43 +02:00
12 changed files with 743 additions and 19 deletions

4
packages/secubox-dpi/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# debian/rules build artifacts (Go collector + module caches)
collector/secubox-dpi-collector
_gocache/
_gopath/

View File

@ -64,6 +64,23 @@ async def health_check():
"""Public health check endpoint for sidebar status."""
return {"status": "ok", "module": "deb"}
@app.get("/exfil")
async def exfil_state():
"""#687 Phase 2 — per-device cloud-exfiltration state produced by the Go
collector (secubox-dpi-flowcap secubox-dpi-collector). Fail-empty so the
dashboard never errors before the first capture window completes."""
import json as _json
from pathlib import Path as _P
p = _P("/var/lib/secubox/dpi/state.json")
try:
if p.exists():
return _json.loads(p.read_text())
except Exception as e: # pragma: no cover
return {"generated_at": 0, "devices": [], "alerts": [], "error": str(e)}
return {"generated_at": 0, "devices": [], "alerts": [], "alert_count": 0,
"note": "no capture window completed yet (or wg-toolbox idle)"}
app.include_router(auth_router, prefix="/auth")
router = APIRouter()
log = get_logger("dpi")

View File

@ -0,0 +1,3 @@
module github.com/cybermind/secubox-deb/packages/secubox-dpi/collector
go 1.22

View File

@ -0,0 +1,453 @@
// 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 :: secubox-dpi :: flow collector (#687, Phase 2)
//
// Per-device cloud-exfiltration detection from nDPI flow records. Reads
// ndpiReader CSV (the flow producer on wg-toolbox), maps each flow's source
// 10.99.1.x to its R3 device identity (sha256(wg_pubkey)[:16] from
// wg-peers.json), detects external clouds (by SNI / dst), runs the exfil
// scenarios, and writes JSON state for the secubox-dpi dashboard.
//
// Pure Go stdlib — no external deps — so it cross-compiles to arm64 with zero
// vendoring. The CSV producer is swappable for an nDPId JSON socket later.
package main
import (
"crypto/sha256"
"encoding/csv"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"os"
"sort"
"strconv"
"strings"
"time"
)
const (
upExfilBytes = 5 << 20 // >=5 MB outbound to a cloud → volume alert
beaconMinFlows = 6 // >=6 flows same dst → candidate beacon
beaconCVMax = 0.25 // iat coefficient-of-variation <= 0.25 → periodic
// #692 — period band (ndpiReader iat is in ms). Real C2 beacons sit at
// seconds-to-minutes; sub-second cadence is app polling / media chunks /
// websocket keepalives, not exfil. Only flag a steady period in this band.
beaconMinIntervalMs = 1000.0 // >=1 s between flows
beaconMaxIntervalMs = 3600000.0 // <=1 h between flows
topN = 12
)
var (
wgPeersPath = env("SECUBOX_WG_PEERS", "/var/lib/secubox/toolbox/wg-peers.json")
statePath = env("SECUBOX_DPI_STATE", "/var/lib/secubox/dpi/state.json")
seenPath = env("SECUBOX_DPI_SEEN", "/var/lib/secubox/dpi/seen.json")
)
// svc = (category, human service name) for a known SNI suffix. Categories are
// nDPI-style buckets so the dashboard shows *what* each device talks to, not
// just whether it's a cloud. Exfil scenarios only fire on categories where an
// uncontrolled upload is a real data-leak concern (see exfilCat()).
type svc struct{ cat, name string }
// SNI suffix → service. Longest matching suffix wins (deterministic), so add
// specific suffixes (s3.amazonaws.com) alongside broad ones (amazonaws.com).
var serviceSuffix = map[string]svc{
// ── cloud storage / compute (exfil-relevant) ──
"amazonaws.com": {"cloud", "AWS"}, "s3.amazonaws.com": {"cloud", "AWS S3"}, "cloudfront.net": {"cloud", "AWS CloudFront"},
"googleapis.com": {"cloud", "Google APIs"}, "googleusercontent.com": {"cloud", "Google"}, "storage.googleapis.com": {"cloud", "Google Storage"},
"blob.core.windows.net": {"cloud", "Azure Blob"}, "core.windows.net": {"cloud", "Azure"}, "azureedge.net": {"cloud", "Azure CDN"},
"digitaloceanspaces.com": {"cloud", "DigitalOcean"}, "backblazeb2.com": {"cloud", "Backblaze B2"}, "wasabisys.com": {"cloud", "Wasabi"},
"oraclecloud.com": {"cloud", "Oracle Cloud"}, "ovh.net": {"cloud", "OVH"}, "scw.cloud": {"cloud", "Scaleway"}, "hetzner.com": {"cloud", "Hetzner"},
"firebaseio.com": {"cloud", "Firebase"}, "supabase.co": {"cloud", "Supabase"},
// ── file-host / paste / drop (classic exfil channels) ──
"dropboxusercontent.com": {"filehost", "Dropbox"}, "dropbox.com": {"filehost", "Dropbox"}, "box.com": {"filehost", "Box"},
"mega.nz": {"filehost", "MEGA"}, "transfer.sh": {"filehost", "transfer.sh"}, "pastebin.com": {"filehost", "Pastebin"},
"wetransfer.com": {"filehost", "WeTransfer"}, "anonfiles.com": {"filehost", "AnonFiles"}, "gofile.io": {"filehost", "Gofile"},
// ── messaging (can tunnel data out) ──
"telegram.org": {"messaging", "Telegram"}, "t.me": {"messaging", "Telegram"}, "discord.com": {"messaging", "Discord"},
"discordapp.com": {"messaging", "Discord"}, "whatsapp.net": {"messaging", "WhatsApp"}, "signal.org": {"messaging", "Signal"},
// ── AI services (uploads = data egress to 3rd-party models) ──
"openai.com": {"ai", "OpenAI"}, "oaistatic.com": {"ai", "OpenAI"}, "anthropic.com": {"ai", "Anthropic"},
"claude.ai": {"ai", "Claude"}, "perplexity.ai": {"ai", "Perplexity"}, "x.ai": {"ai", "xAI"}, "mistral.ai": {"ai", "Mistral"},
// ── media / streaming ──
"nflxvideo.net": {"media", "Netflix"}, "netflix.com": {"media", "Netflix"}, "googlevideo.com": {"media", "YouTube"},
"youtube.com": {"media", "YouTube"}, "ytimg.com": {"media", "YouTube"}, "ttvnw.net": {"media", "Twitch"}, "twitch.tv": {"media", "Twitch"},
"spotify.com": {"media", "Spotify"}, "scdn.co": {"media", "Spotify"}, "dssott.com": {"media", "Disney+"}, "disneyplus.com": {"media", "Disney+"},
"primevideo.com": {"media", "Prime Video"}, "aiv-cdn.net": {"media", "Prime Video"}, "deezer.com": {"media", "Deezer"},
"dailymotion.com": {"media", "Dailymotion"}, "vimeo.com": {"media", "Vimeo"}, "molotov.tv": {"media", "Molotov"},
// ── gaming ──
"steampowered.com": {"game", "Steam"}, "steamcontent.com": {"game", "Steam"}, "steamserver.net": {"game", "Steam"},
"epicgames.com": {"game", "Epic"}, "playstation.net": {"game", "PlayStation"}, "playstation.com": {"game", "PlayStation"},
"xboxlive.com": {"game", "Xbox"}, "riotgames.com": {"game", "Riot"}, "battle.net": {"game", "Battle.net"},
"roblox.com": {"game", "Roblox"}, "nintendo.net": {"game", "Nintendo"}, "ea.com": {"game", "EA"}, "ubisoft.com": {"game", "Ubisoft"},
// ── social ──
"facebook.com": {"social", "Facebook"}, "fbcdn.net": {"social", "Facebook"}, "instagram.com": {"social", "Instagram"},
"tiktokcdn.com": {"social", "TikTok"}, "tiktokv.com": {"social", "TikTok"}, "tiktok.com": {"social", "TikTok"},
"twitter.com": {"social", "X"}, "x.com": {"social", "X"}, "twimg.com": {"social", "X"}, "snapchat.com": {"social", "Snapchat"},
"reddit.com": {"social", "Reddit"}, "redditmedia.com": {"social", "Reddit"}, "pinterest.com": {"social", "Pinterest"},
// ── adult ──
"pornhub.com": {"adult", "Pornhub"}, "phncdn.com": {"adult", "Pornhub"}, "xvideos.com": {"adult", "XVideos"},
"xnxx.com": {"adult", "XNXX"}, "xnxx-cdn.com": {"adult", "XNXX"}, "redtube.com": {"adult", "RedTube"},
"youporn.com": {"adult", "YouPorn"}, "xhamster.com": {"adult", "xHamster"}, "onlyfans.com": {"adult", "OnlyFans"},
"chaturbate.com": {"adult", "Chaturbate"}, "stripchat.com": {"adult", "Stripchat"},
}
// exfilCat → does an uncontrolled upload to this category constitute data egress?
func exfilCat(cat string) bool {
switch cat {
case "cloud", "filehost", "messaging", "ai":
return true
}
return false
}
func env(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func registrable(host string) string {
host = strings.ToLower(strings.TrimSuffix(strings.Split(host, ":")[0], "."))
p := strings.Split(host, ".")
if len(p) <= 2 {
return host
}
return strings.Join(p[len(p)-2:], ".")
}
// classify → (category, service) for a flow's SNI. Longest matching suffix
// wins so specific entries (s3.amazonaws.com) beat broad ones (amazonaws.com).
// Returns ("","") when the SNI is unknown.
func classify(sni, dstIP string) (string, string) {
s := strings.ToLower(strings.TrimSpace(sni))
if s == "" {
return "", ""
}
bestLen := -1
var bestCat, bestName string
for suf, v := range serviceSuffix {
if s == suf || strings.HasSuffix(s, "."+suf) {
if len(suf) > bestLen {
bestLen, bestCat, bestName = len(suf), v.cat, v.name
}
}
}
return bestCat, bestName
}
func isPrivate(ip string) bool {
p := net.ParseIP(ip)
if p == nil {
return false
}
return p.IsPrivate() || p.IsLoopback() || p.IsLinkLocalUnicast()
}
// ── wg-peers.json : { "peers": { "<pubkey>": { "ip": "10.99.1.X", ... } } } ──
func loadDeviceMap() map[string]string {
m := map[string]string{}
b, err := os.ReadFile(wgPeersPath)
if err != nil {
return m
}
var doc struct {
Peers map[string]struct {
IP string `json:"ip"`
} `json:"peers"`
}
if json.Unmarshal(b, &doc) != nil {
return m
}
for pk, meta := range doc.Peers {
if meta.IP != "" {
sum := sha256.Sum256([]byte(pk))
m[meta.IP] = hex.EncodeToString(sum[:])[:16]
}
}
return m
}
type agg struct {
Device string `json:"device"`
Dst string `json:"dst"`
Category string `json:"category,omitempty"` // cloud|filehost|messaging|ai|media|game|social|adult
Service string `json:"service,omitempty"` // human service name (Netflix, AWS S3, …)
Cloud string `json:"cloud,omitempty"` // back-compat: = Service when exfilCat(Category)
Proto string `json:"proto"`
Up int64 `json:"up_bytes"`
Down int64 `json:"down_bytes"`
Flows int `json:"flows"`
iatAvg float64 // accumulators
iatStd float64
external bool
}
type alert struct {
Device string `json:"device"`
Kind string `json:"kind"`
Dst string `json:"dst"`
Category string `json:"category,omitempty"`
Service string `json:"service,omitempty"`
Cloud string `json:"cloud,omitempty"` // back-compat
Up int64 `json:"up_bytes"`
Down int64 `json:"down_bytes"`
Detail string `json:"detail"`
TS int64 `json:"ts"`
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: secubox-dpi-collector <flows.csv> [now_epoch]")
os.Exit(2)
}
now := time.Now().Unix()
if len(os.Args) >= 3 {
if v, err := strconv.ParseInt(os.Args[2], 10, 64); err == nil {
now = v
}
}
devmap := loadDeviceMap()
seen := loadSeen()
f, err := os.Open(os.Args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "collector: open %s: %v\n", os.Args[1], err)
os.Exit(1)
}
defer f.Close()
r := csv.NewReader(f)
r.FieldsPerRecord = -1
rows, err := r.ReadAll()
if err != nil || len(rows) < 2 {
// no flows this batch — still refresh state timestamp, exit clean.
writeState(map[string]*agg{}, nil, now)
return
}
col := indexCols(rows[0])
get := func(rec []string, name string) string {
if i, ok := col[name]; ok && i < len(rec) {
return rec[i]
}
return ""
}
atoi := func(s string) int64 { v, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64); return v }
atof := func(s string) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64); return v }
aggs := map[string]*agg{}
for _, rec := range rows[1:] {
src := get(rec, "src_ip")
dev, ok := devmap[src]
if !ok || !strings.HasPrefix(src, "10.99.1.") {
continue // only attributed R3 devices
}
dstIP := get(rec, "dst_ip")
sni := get(rec, "server_name_sni")
proto := get(rec, "ndpi_proto")
cat, service := classify(sni, dstIP)
cloud := ""
if exfilCat(cat) {
cloud = service // back-compat field, only set for exfil-relevant dests
}
dst := sni
if dst == "" {
dst = dstIP
}
key := dev + "|" + dst
a := aggs[key]
if a == nil {
a = &agg{Device: dev, Dst: dst, Category: cat, Service: service, Cloud: cloud,
Proto: proto, external: !isPrivate(dstIP)}
aggs[key] = a
}
a.Up += atoi(get(rec, "c_to_s_bytes"))
a.Down += atoi(get(rec, "s_to_c_bytes"))
a.Flows++
a.iatAvg += atof(get(rec, "iat_flow_avg"))
a.iatStd += atof(get(rec, "iat_flow_stddev"))
}
var alerts []alert
newseen := map[string]bool{}
for _, a := range aggs {
base := func(kind, detail string) alert {
return alert{Device: a.Device, Kind: kind, Dst: a.Dst, Category: a.Category,
Service: a.Service, Cloud: a.Cloud, Up: a.Up, Down: a.Down, Detail: detail, TS: now}
}
exfilDest := exfilCat(a.Category)
label := a.Service
if label == "" {
label = a.Dst
}
// 1) volume exfil: lots OUT to a cloud/filehost/ai/messaging, more out than in
if exfilDest && a.Up >= upExfilBytes && a.Up > a.Down {
alerts = append(alerts, base("exfil_volume",
fmt.Sprintf("%s envoyé vers %s", human(a.Up), label)))
}
// 2) new exfil-relevant destination for this device
if exfilDest {
sk := a.Device + "|" + a.Service
newseen[sk] = true
if !seen[sk] {
alerts = append(alerts, base("new_cloud", "première sortie vers "+label))
}
}
// 3) beaconing: many flows, low inter-arrival variance, at a C2-plausible
// cadence (1 s1 h), to an external dest that is exfil-relevant or
// unclassified. Excludes sub-second app chatter and periodic fetches
// to known media/game/social CDNs (#692).
if a.Flows >= beaconMinFlows && a.external && (exfilDest || a.Category == "") {
avg := a.iatAvg / float64(a.Flows)
std := a.iatStd / float64(a.Flows)
if avg >= beaconMinIntervalMs && avg <= beaconMaxIntervalMs && std/avg <= beaconCVMax {
alerts = append(alerts, base("beaconing",
fmt.Sprintf("%d flux périodiques (~%.1f s)", a.Flows, avg/1000)))
}
}
// 4) unclassified flow to an external host with notable upload
if a.external && a.Category == "" && a.Up >= upExfilBytes &&
(a.Proto == "" || strings.Contains(strings.ToLower(a.Proto), "unknown")) {
alerts = append(alerts, base("unclassified_external", human(a.Up)+" sortie non classifiée"))
}
}
// merge seen (persist union so new_cloud only fires once)
for k := range seen {
newseen[k] = true
}
saveSeen(newseen)
writeState(aggs, alerts, now)
fmt.Printf("collector: %d flows-agg, %d alerts @ %d\n", len(aggs), len(alerts), now)
}
func indexCols(header []string) map[string]int {
m := map[string]int{}
for i, h := range header {
m[strings.TrimPrefix(strings.TrimSpace(h), "#")] = i
}
return m
}
func human(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.1f Go", float64(b)/(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.1f Mo", float64(b)/(1<<20))
case b >= 1<<10:
return fmt.Sprintf("%.0f Ko", float64(b)/(1<<10))
}
return fmt.Sprintf("%d o", b)
}
func loadSeen() map[string]bool {
m := map[string]bool{}
b, err := os.ReadFile(seenPath)
if err != nil {
return m
}
var keys []string
if json.Unmarshal(b, &keys) == nil {
for _, k := range keys {
m[k] = true
}
}
return m
}
func saveSeen(m map[string]bool) {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
writeJSON(seenPath, keys)
}
func writeState(aggs map[string]*agg, alerts []alert, now int64) {
// per-device rollup
type devstat struct {
Device string `json:"device"`
Flows int `json:"flows"`
UpBytes int64 `json:"up_bytes"`
Services []*agg `json:"services"` // all classified egress (any category)
Clouds []*agg `json:"clouds"` // back-compat: exfil-relevant subset
ByCat map[string]int `json:"by_category"` // category → flow count
Alerts []alert `json:"alerts"`
}
devs := map[string]*devstat{}
for _, a := range aggs {
d := devs[a.Device]
if d == nil {
d = &devstat{Device: a.Device, ByCat: map[string]int{}}
devs[a.Device] = d
}
d.Flows += a.Flows
d.UpBytes += a.Up
if a.Category != "" {
d.Services = append(d.Services, a)
d.ByCat[a.Category] += a.Flows
}
if a.Cloud != "" {
d.Clouds = append(d.Clouds, a)
}
}
for _, al := range alerts {
if d := devs[al.Device]; d != nil {
d.Alerts = append(d.Alerts, al)
}
}
list := make([]*devstat, 0, len(devs))
for _, d := range devs {
sort.Slice(d.Services, func(i, j int) bool { return d.Services[i].Up > d.Services[j].Up })
if len(d.Services) > topN {
d.Services = d.Services[:topN]
}
sort.Slice(d.Clouds, func(i, j int) bool { return d.Clouds[i].Up > d.Clouds[j].Up })
if len(d.Clouds) > topN {
d.Clouds = d.Clouds[:topN]
}
list = append(list, d)
}
sort.Slice(list, func(i, j int) bool { return list[i].UpBytes > list[j].UpBytes })
out := map[string]any{
"generated_at": now,
"devices": list,
"alerts": alerts,
"alert_count": len(alerts),
}
writeJSON(statePath, out)
}
func writeJSON(path string, v any) {
if err := os.MkdirAll(dir(path), 0o755); err != nil {
fmt.Fprintf(os.Stderr, "collector: mkdir %s: %v\n", dir(path), err)
return
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return
}
tmp := path + ".tmp"
if os.WriteFile(tmp, b, 0o644) == nil {
os.Rename(tmp, path)
}
}
func dir(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 {
return p[:i]
}
return "."
}

View File

@ -1,3 +1,31 @@
secubox-dpi (1.1.1-1~bookworm1) bookworm; urgency=low
* #692 collector: beaconing scenario now requires a C2-plausible cadence
(1 s1 h mean interval) to an external exfil-relevant/unclassified dest —
drops sub-second app/media chatter that previously raised false positives
(e.g. "~39 ms" alerts). Detail now reads in seconds.
* #693 dashboard: headline stat cards repointed to the real R3 exfil engine
(R3 devices / captured flows / categories / exfil alerts) instead of the
legacy netifyd widgets (netifyd is not used on the R3 boards).
-- Gerald KERMA <devel@cybermind.fr> Mon, 22 Jun 2026 10:15:00 +0000
secubox-dpi (1.1.0-1~bookworm1) bookworm; urgency=low
* #687 Phase 2/3: ship the per-device R3 cloud-exfiltration pipeline as a
proper package — no more manual scp deploys.
- Build the pure-stdlib Go collector (secubox-dpi-collector) offline for
arm64 in debian/rules (GOTOOLCHAIN=local, GOPROXY=off).
- Ship sbin/secubox-dpi-flowcap (ndpiReader capture loop) +
secubox-dpi-flowcap.service (auto-enabled), Nice 15 / MemoryMax 256M.
- GET /api/v1/dpi/exfil serves the collector state; dashboard gains the
"Cloud Exfiltration Watch" panel with per-device service categorization
(cloud/filehost/messaging/ai/media/game/social/adult).
* Architecture: all -> arm64 (now ships a compiled collector).
* Depends: libndpi-bin (provides ndpiReader); Build-Depends: golang-go.
-- Gerald KERMA <devel@cybermind.fr> Mon, 22 Jun 2026 09:30:00 +0000
secubox-dpi (1.0.5-1~bookworm1) bookworm; urgency=low
* Clarify Description: this is the netifyd-backed analytics layer

View File

@ -2,14 +2,14 @@ Source: secubox-dpi
Section: net
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper-compat (= 13)
Build-Depends: debhelper-compat (= 13), golang-go (>= 2:1.22~)
Standards-Version: 4.6.2
Homepage: https://cybermind.fr/secubox
Rules-Requires-Root: no
Package: secubox-dpi
Architecture: all
Depends: ${misc:Depends}, secubox-core (>= 1.0), iproute2
Architecture: arm64
Depends: ${misc:Depends}, secubox-core (>= 1.0), iproute2, libndpi-bin
Recommends: netifyd, secubox-netifyd
Description: SecuBox DPI Analytics — netifyd-backed app/protocol classification
Analytics layer on top of netifyd: top applications, top protocols,

View File

@ -7,6 +7,10 @@ case "$1" in
--home /var/lib/secubox --shell /usr/sbin/nologin secubox
install -d -o root -g root -m 1777 /run/secubox
install -d -o secubox -g secubox -m 755 /var/lib/secubox
# #687 exfil collector state dir — collector (root) writes state.json 0644,
# dpi API (secubox) reads it; keep 0755 so secubox can traverse.
install -d -o root -g root -m 0755 /var/lib/secubox/dpi
install -d -o root -g root -m 0755 /run/secubox/dpi
systemctl daemon-reload
systemctl enable secubox-dpi.service
systemctl start secubox-dpi.service || true

View File

@ -1,8 +1,34 @@
#!/usr/bin/make -f
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# SecuBox-Deb :: secubox-dpi — DPI dashboard (Python) + R3 exfil collector (Go)
#
# The per-device cloud-exfiltration collector (#687) is a pure-stdlib Go binary
# cross-built for arm64, fully offline (no module download): the collector has
# no external deps so no vendor tree is needed. GOTOOLCHAIN=local pins the build
# to the distro Go; GOPROXY=off forbids any network. CI cross-builds the same.
export DH_VERBOSE = 1
export GOOS = linux
export GOARCH = arm64
export CGO_ENABLED = 0
export GOPROXY = off
export GOTOOLCHAIN = local
# Keep the Go build/module cache inside the build tree (sandbox-friendly).
export GOCACHE = $(CURDIR)/_gocache
export GOPATH = $(CURDIR)/_gopath
%:
dh $@
override_dh_auto_build:
cd collector && go build -trimpath -ldflags=-s -o secubox-dpi-collector .
# The arm64 cross-binary cannot run its tests on the build host; CI runs Go
# unit tests on the host arch instead.
override_dh_auto_test:
override_dh_auto_install:
# Python API + dashboard (arch-independent payload, shipped in the arm64 deb)
install -d debian/secubox-dpi/usr/lib/secubox/dpi/
cp -r api debian/secubox-dpi/usr/lib/secubox/dpi/
install -d debian/secubox-dpi/usr/share/secubox/www
@ -12,3 +38,15 @@ override_dh_auto_install:
# Modular nginx config
install -d debian/secubox-dpi/etc/nginx/secubox.d
[ -f nginx/dpi.conf ] && cp nginx/dpi.conf debian/secubox-dpi/etc/nginx/secubox.d/ || true
# #687 R3 exfil pipeline: Go collector + capture loop
install -d debian/secubox-dpi/usr/sbin
install -m 0755 collector/secubox-dpi-collector debian/secubox-dpi/usr/sbin/secubox-dpi-collector
install -m 0755 sbin/secubox-dpi-flowcap debian/secubox-dpi/usr/sbin/secubox-dpi-flowcap
# flowcap unit — installed into the tree so dh_installsystemd auto-enables it
install -d debian/secubox-dpi/usr/lib/systemd/system
install -m 0644 systemd/secubox-dpi-flowcap.service \
debian/secubox-dpi/usr/lib/systemd/system/secubox-dpi-flowcap.service
override_dh_auto_clean:
rm -f collector/secubox-dpi-collector
rm -rf _gocache _gopath

View File

@ -0,0 +1,36 @@
#!/usr/bin/env bash
# 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 :: secubox-dpi :: flow capture loop (#687, Phase 2)
#
# Captures nDPI flow records off the R3 tap (wg-toolbox) in fixed windows via
# ndpiReader (-C CSV), then hands each batch to the Go collector for device
# attribution + cloud-exfiltration scenarios. ndpiReader = the lean C nDPI
# engine (PoC producer; an nDPId JSON-socket daemon is the production upgrade).
set -euo pipefail
readonly MODULE="secubox-dpi-flowcap"
IFACE="${SECUBOX_DPI_IFACE:-wg-toolbox}"
WINDOW="${SECUBOX_DPI_WINDOW:-60}"
CSV="${SECUBOX_DPI_CSV:-/run/secubox/dpi/flows.csv}"
COLLECTOR="${SECUBOX_DPI_COLLECTOR:-/usr/sbin/secubox-dpi-collector}"
mkdir -p "$(dirname "$CSV")" /var/lib/secubox/dpi
if ! command -v ndpiReader >/dev/null 2>&1; then
echo "[$MODULE] ndpiReader missing (apt install libndpi-bin) — idle" >&2
sleep 3600; exit 0
fi
echo "[$MODULE] capturing $IFACE in ${WINDOW}s windows → $COLLECTOR"
while true; do
# one capture window → CSV (niced; nDPI is ~1% CPU but the board is busy)
nice -n 15 ndpiReader -i "$IFACE" -s "$WINDOW" -C "$CSV" >/dev/null 2>&1 || true
if [ -s "$CSV" ]; then
nice -n 15 "$COLLECTOR" "$CSV" >/dev/null 2>&1 || \
echo "[$MODULE] collector run failed" >&2
fi
done

View File

@ -0,0 +1,23 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Per-device flow-DPI on the R3 tap (#687): ndpiReader → Go collector →
# cloud-exfiltration scenarios → /var/lib/secubox/dpi/state.json (served by the
# secubox-dpi dashboard at /api/v1/dpi/exfil).
[Unit]
Description=SecuBox-Deb DPI flow capture + exfil collector (#687)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/sbin/secubox-dpi-flowcap
Restart=always
RestartSec=10
# ndpiReader needs raw packet capture on wg-toolbox; nothing else.
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
# Light on a saturated board (~1% CPU observed); bound memory + low priority.
Nice=15
CPUWeight=20
MemoryMax=256M
[Install]
WantedBy=multi-user.target

View File

@ -327,23 +327,37 @@
</div>
</div>
<!-- #693 headline metrics driven by the R3 exfil engine (/exfil) -->
<div class="stats-row">
<div class="stat-card yellow">
<div class="value" id="exfil-devices-count">0</div>
<div class="label">📟 R3 Devices</div>
</div>
<div class="stat-card cyan">
<div class="value" id="flow-count">0</div>
<div class="label">Active Flows</div>
<div class="value" id="exfil-flows-count">0</div>
<div class="label">🌐 Flows (60s)</div>
</div>
<div class="stat-card green">
<div class="value" id="apps-count">0</div>
<div class="label">Applications</div>
<div class="value" id="exfil-cats-count">0</div>
<div class="label">🏷️ Categories</div>
</div>
<div class="stat-card purple">
<div class="value" id="protocols-count">0</div>
<div class="label">Protocols</div>
<div class="stat-card red">
<div class="value" id="exfil-alert-count">0</div>
<div class="label">🛰️ Exfil Alerts</div>
</div>
<div class="stat-card yellow">
<div class="value" id="devices-count">0</div>
<div class="label">Devices</div>
</div>
<!-- #687 — per-device cloud exfiltration watch (R3 DPI) -->
<div class="card" id="exfil-card" style="border-color: var(--p31-decay);">
<div class="card-header">
<h2>🛰️ Cloud Exfiltration Watch <span style="color: var(--text-dim); text-transform:none; letter-spacing:0; font-size:0.7rem;">— per-device egress on R3 (wg-toolbox)</span></h2>
<span class="badge" id="exfil-badge">-</span>
</div>
<div id="exfil-alerts" style="margin-bottom: 1rem;"></div>
<div id="exfil-devices">
<p class="empty">Loading…</p>
</div>
<p id="exfil-foot" style="color: var(--text-dim); font-size: 0.7rem; margin-top: 0.75rem;"></p>
</div>
<div class="grid-2">
@ -433,7 +447,6 @@
const data = await api('/top_apps?limit=10');
const container = document.getElementById('top-apps');
const apps = Array.isArray(data) ? data : [];
document.getElementById('apps-count').textContent = apps.length;
container.innerHTML = apps.length > 0 ? apps.map(app => `
<div class="app-item">
@ -447,7 +460,6 @@
const data = await api('/top_protocols?limit=10');
const container = document.getElementById('top-protocols');
const protocols = Array.isArray(data) ? data : [];
document.getElementById('protocols-count').textContent = protocols.length;
container.innerHTML = protocols.length > 0 ? protocols.map(proto => `
<div class="app-item">
@ -461,7 +473,6 @@
const data = await api('/bandwidth_by_device');
const container = document.getElementById('bandwidth-devices');
const devices = Array.isArray(data) ? data : [];
document.getElementById('devices-count').textContent = devices.length;
container.innerHTML = devices.length > 0 ? devices.map(dev => `
<div class="app-item">
@ -475,7 +486,6 @@
const data = await api('/active_flows');
const container = document.getElementById('active-flows');
const flows = data.flows || [];
document.getElementById('flow-count').textContent = flows.length;
container.innerHTML = flows.length > 0 ? flows.slice(0, 15).map(f => `
<div class="app-item">
@ -498,6 +508,109 @@
`).join('') : '<p style="color: var(--text-dim);">No rules</p>';
}
const EXFIL_KINDS = {
exfil_volume: { label: 'BULK UPLOAD', cls: 'badge-red', icon: '⬆️' },
new_cloud: { label: 'NEW DEST', cls: 'badge-yellow', icon: '☁️' },
beaconing: { label: 'BEACONING', cls: 'badge-purple', icon: '📡' },
unclassified_external: { label: 'UNCLASSIFIED', cls: 'badge-amber', icon: '❔' },
};
// service category → icon + badge class (exfil-relevant cats are warm/red)
const CATEGORY = {
cloud: { icon: '☁️', cls: 'badge-red', exfil: true },
filehost: { icon: '📦', cls: 'badge-red', exfil: true },
messaging: { icon: '💬', cls: 'badge-amber', exfil: true },
ai: { icon: '🤖', cls: 'badge-purple', exfil: true },
media: { icon: '🎬', cls: 'badge-cyan', exfil: false },
game: { icon: '🎮', cls: 'badge-green', exfil: false },
social: { icon: '👥', cls: 'badge-blue', exfil: false },
adult: { icon: '🔞', cls: 'badge-purple', exfil: false },
};
function catMeta(c) { return CATEGORY[c] || { icon: '🌐', cls: 'badge-cyan', exfil: false }; }
function shortDev(d) { return d ? d.slice(0, 8) + '…' : 'unknown'; }
function agoStr(ts) {
if (!ts) return '';
const s = Math.max(0, Math.floor(Date.now()/1000 - ts));
if (s < 90) return s + 's ago';
if (s < 5400) return Math.round(s/60) + 'm ago';
return Math.round(s/3600) + 'h ago';
}
async function loadExfil() {
const data = await api('/exfil');
const alertsBox = document.getElementById('exfil-alerts');
const devBox = document.getElementById('exfil-devices');
const badge = document.getElementById('exfil-badge');
const foot = document.getElementById('exfil-foot');
const card = document.getElementById('exfil-card');
const alerts = Array.isArray(data.alerts) ? data.alerts : [];
const devices = Array.isArray(data.devices) ? data.devices : [];
const count = data.alert_count || alerts.length;
badge.textContent = count > 0 ? (count + ' ALERT' + (count > 1 ? 'S' : '')) : 'CLEAR';
badge.className = 'badge ' + (count > 0 ? 'badge-red' : 'badge-green');
card.style.borderColor = count > 0 ? '#ff4466' : 'var(--p31-decay)';
// #693 headline stat cards from the real DPI engine
const totalFlows = devices.reduce((n, d) => n + (d.flows || 0), 0);
const cats = new Set();
devices.forEach(d => Object.keys(d.by_category || {}).forEach(c => cats.add(c)));
const setCard = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
setCard('exfil-devices-count', devices.length);
setCard('exfil-flows-count', totalFlows);
setCard('exfil-cats-count', cats.size);
setCard('exfil-alert-count', count);
// alert feed (severity-first)
alertsBox.innerHTML = alerts.length ? alerts.map(a => {
const k = EXFIL_KINDS[a.kind] || { label: (a.kind || '?').toUpperCase(), cls: 'badge-amber', icon: '⚠️' };
const svc = a.service || a.cloud;
const cm = catMeta(a.category);
const dest = svc ? `${cm.icon} ${svc} (${a.dst})` : a.dst;
return `<div class="app-item" style="border-left:3px solid #ff4466; padding-left:0.6rem;">
<span><span class="badge ${k.cls}" style="margin-right:0.5rem;">${k.icon} ${k.label}</span>
<span style="font-family:monospace;">${shortDev(a.device)}</span>
→ ${dest}
<span style="color: var(--text-dim);"> · ${a.detail || ''}</span></span>
<span class="bytes">↑${formatBytes(a.up_bytes)} ${agoStr(a.ts)}</span>
</div>`;
}).join('') : '<p class="empty" style="color: var(--green);">✓ No exfiltration detected</p>';
// per-device egress rollup — grouped by service category
devBox.innerHTML = devices.length ? devices.map(d => {
const svcs = d.services || d.clouds || [];
const rows = svcs.map(c => {
const m = catMeta(c.category);
const name = c.service || c.cloud || c.dst;
return `<div class="app-item" style="padding:0.35rem 0.6rem; font-size:0.85rem;${m.exfil ? 'border-left:2px solid #ff4466; padding-left:0.5rem;' : ''}">
<span><span title="${c.category || 'other'}">${m.icon}</span> ${name}
<span style="color:var(--text-dim);">${c.proto || ''}</span></span>
<span class="bytes">↑${formatBytes(c.up_bytes)} ↓${formatBytes(c.down_bytes)}</span>
</div>`;
}).join('') || '<p class="empty" style="padding:0.35rem 0.6rem;">no classified egress</p>';
// category summary chips
const cats = d.by_category || {};
const chips = Object.keys(cats).sort((a,b)=>cats[b]-cats[a]).map(c => {
const m = catMeta(c);
return `<span class="badge ${m.cls}" style="margin-left:0.3rem; font-size:0.65rem;">${m.icon} ${c} ${cats[c]}</span>`;
}).join('');
const hot = (d.alerts && d.alerts.length) ? `<span class="badge badge-red" style="margin-left:0.5rem;">${d.alerts.length}⚠️</span>` : '';
return `<div style="margin-bottom:0.75rem; border:1px solid var(--tube-soft); border-radius:6px;">
<div class="app-item" style="background:rgba(255,179,71,0.06); flex-wrap:wrap;">
<span style="font-family:monospace; color:var(--p31-decay);">📟 ${shortDev(d.device)}${hot} ${chips}</span>
<span class="bytes">${d.flows} flows · ↑${formatBytes(d.up_bytes)}</span>
</div>
${rows}
</div>`;
}).join('') : '<p class="empty">No devices observed on R3 yet (tunnel idle, or first capture window pending).</p>';
foot.textContent = data.generated_at
? `Last capture: ${agoStr(data.generated_at)} · ${devices.length} device(s) · ${alerts.length} alert(s)`
: (data.note || 'no capture window completed yet');
}
async function setupMirred() { const r = await api('/setup_mirred', 'POST'); alert(r.error ? 'Error: ' + r.error : 'Mirred configured'); loadStatus(); }
async function removeMirred() { await api('/remove_mirred', 'POST'); alert('Mirred removed'); loadStatus(); }
async function restartNetifyd() { await api('/restart', 'POST'); alert('netifyd restarted'); loadStatus(); }
@ -518,6 +631,7 @@
function refreshAll() {
loadStatus();
loadExfil();
loadTopApps();
loadTopProtocols();
loadBandwidthDevices();

View File

@ -129,8 +129,12 @@ func (c *CA) forge(host string) (*tls.Certificate, error) {
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: host},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
// #689 — forged leaves must outlive the (non-evicting) cert cache, else a
// long-running worker keeps serving an expired leaf and every client
// reports "certificat expiré". 365d forward + 48h back-skew = 367d span,
// safely under Apple's 398-day max-validity rule for server certs.
NotBefore: time.Now().Add(-48 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{host},