mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 09:08:32 +00:00
Compare commits
No commits in common. "dde96f212ebbc399a899a0f8651a966fe2fb918c" and "9eb2d68b9232986e9ad53410ac333a497b37a195" have entirely different histories.
dde96f212e
...
9eb2d68b92
4
packages/secubox-dpi/.gitignore
vendored
4
packages/secubox-dpi/.gitignore
vendored
|
|
@ -1,4 +0,0 @@
|
|||
# debian/rules build artifacts (Go collector + module caches)
|
||||
collector/secubox-dpi-collector
|
||||
_gocache/
|
||||
_gopath/
|
||||
|
|
@ -64,23 +64,6 @@ 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")
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
module github.com/cybermind/secubox-deb/packages/secubox-dpi/collector
|
||||
|
||||
go 1.22
|
||||
|
|
@ -1,445 +0,0 @@
|
|||
// 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
|
||||
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
|
||||
if a.Flows >= beaconMinFlows {
|
||||
avg := a.iatAvg / float64(a.Flows)
|
||||
std := a.iatStd / float64(a.Flows)
|
||||
if avg > 0 && std/avg <= beaconCVMax {
|
||||
alerts = append(alerts, base("beaconing",
|
||||
fmt.Sprintf("%d flux périodiques (~%.0f ms)", a.Flows, avg)))
|
||||
}
|
||||
}
|
||||
// 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 "."
|
||||
}
|
||||
|
|
@ -1,19 +1,3 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@ Source: secubox-dpi
|
|||
Section: net
|
||||
Priority: optional
|
||||
Maintainer: Gerald KERMA <devel@cybermind.fr>
|
||||
Build-Depends: debhelper-compat (= 13), golang-go (>= 2:1.22~)
|
||||
Build-Depends: debhelper-compat (= 13)
|
||||
Standards-Version: 4.6.2
|
||||
Homepage: https://cybermind.fr/secubox
|
||||
Rules-Requires-Root: no
|
||||
|
||||
Package: secubox-dpi
|
||||
Architecture: arm64
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0), iproute2, libndpi-bin
|
||||
Architecture: all
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0), iproute2
|
||||
Recommends: netifyd, secubox-netifyd
|
||||
Description: SecuBox DPI Analytics — netifyd-backed app/protocol classification
|
||||
Analytics layer on top of netifyd: top applications, top protocols,
|
||||
|
|
|
|||
|
|
@ -7,10 +7,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,34 +1,8 @@
|
|||
#!/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
|
||||
|
|
@ -38,15 +12,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -346,19 +346,6 @@
|
|||
</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">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>📊 Top Applications</h2></div>
|
||||
|
|
@ -511,99 +498,6 @@
|
|||
`).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)';
|
||||
|
||||
// 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(); }
|
||||
|
|
@ -624,7 +518,6 @@
|
|||
|
||||
function refreshAll() {
|
||||
loadStatus();
|
||||
loadExfil();
|
||||
loadTopApps();
|
||||
loadTopProtocols();
|
||||
loadBandwidthDevices();
|
||||
|
|
|
|||
|
|
@ -129,12 +129,8 @@ func (c *CA) forge(host string) (*tls.Certificate, error) {
|
|||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: host},
|
||||
// #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),
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{host},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user