mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-01 17:17:14 +00:00
Compare commits
9 Commits
febf58fd27
...
9a275e2355
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a275e2355 | |||
| 2dab321e36 | |||
|
|
982176209a | ||
| de8e6de23c | |||
|
|
ffa75bbe9d | ||
| 9697ea05a9 | |||
|
|
f34dc633a8 | ||
| 1063c91815 | |||
| a5c07c3d50 |
|
|
@ -3,6 +3,52 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-11 — Phase 12.C + Phase 13 protection enforcement plane COMPLETE (ref #518-#528)
|
||||||
|
|
||||||
|
`secubox-toolbox 2.6.6 → 2.6.11`, tags v2.13.16 → v2.13.19.
|
||||||
|
|
||||||
|
### Phase 12.C — operator-grade / state-adjacent (#518, v2.13.16, 2.6.7)
|
||||||
|
`detect_operator_grade` : telco header-enrichment (MSISDN/x-acr/WAP),
|
||||||
|
operator-consortium (Utiq/TrustPid), data-broker / state-adjacent hosts
|
||||||
|
(LiveRamp/BlueKai/Acxiom/Neustar/Tapad/Experian/Palantir-class). Top
|
||||||
|
severity void-purple lens + double ring + ⛔ banner + PDF evidence
|
||||||
|
section. Detection only.
|
||||||
|
|
||||||
|
### Phase 13 — protection enforcement plane (#519) COMPLETE
|
||||||
|
Made the SecuBox ban plane (Vortex DNS + WAF + CrowdSec) actually enforce
|
||||||
|
on device browsing across every egress path.
|
||||||
|
- **13.A** (#521, v2.13.17, 2.6.8) — `inet secubox_blacklist` nft table,
|
||||||
|
v4/v6 interval+timeout sets, single forward-hook drop chain (covers
|
||||||
|
captive/WG/br-lxc/LAN); `secubox-blacklist-sync` unions CrowdSec bans +
|
||||||
|
threat-intel C2 (2h timeout); /admin/blacklist. **Also fixed the
|
||||||
|
override_dh_strip latent bug** (never runs for arch:all → nft/unbound/
|
||||||
|
nginx/perf drop-ins had stopped shipping; root cause of live-config
|
||||||
|
drift) by moving to execute_after_dh_auto_install. Memory saved.
|
||||||
|
- **13.B** (#522, v2.13.17, 2.6.9) — DNS-guard: resolve blocklisted
|
||||||
|
domains → IPs into the set (closes DoH/hardcoded-IP bypass); count-only
|
||||||
|
DoH/DoT detection chain (15 v4 + 6 v6 providers); SECUBOX_DOH_BLOCK
|
||||||
|
opt-in. create-or-replace idiom → idempotent reloads.
|
||||||
|
- **13.C** (#524, v2.13.18, 2.6.10) — per-device attribution: rate-limited
|
||||||
|
SBX-BL-DROP/SBX-DOH nft logs → journald tailer → device_blocks
|
||||||
|
(anonymous WG/lease hash); quarantine set + /admin/quarantine + one-click
|
||||||
|
operator action.
|
||||||
|
- **13.D** (#527, v2.13.19, 2.6.11) — feedback loop: escalation evaluator
|
||||||
|
reads opgrade/antibot/device-blocks aggregates, escalates over threshold
|
||||||
|
to blacklist IPs / cscli decision / device quarantine. Audit-logged,
|
||||||
|
reversible, **all sources default OFF** (opt-in via SECUBOX_ESCALATE_*).
|
||||||
|
|
||||||
|
**Doctrine** : DEFAULT DROP preserved (policy accept only adds drops); no
|
||||||
|
WAF bypass; anonymous (rotating mac_hash); all escalations TTL'd +
|
||||||
|
reversible + opt-in. Verified live on gk2 (18 C2 IPs enforced, quarantine
|
||||||
|
add/remove, synthetic escalation + audit entry).
|
||||||
|
|
||||||
|
### Future idea captured (#525)
|
||||||
|
Phase 14 deception plane — pseudo-responses from a proxy instead of
|
||||||
|
dropping tracker IPs (indistinguable, pollutes the profile) + neutralizing
|
||||||
|
CDN-preloaded tracking scripts. For later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-06-10 (soir) — Phase 11 COMPLETE + Phase 12.A/B + toolbox tabs — v2.13.15 (ref #502-#516)
|
## 2026-06-10 (soir) — Phase 11 COMPLETE + Phase 12.A/B + toolbox tabs — v2.13.15 (ref #502-#516)
|
||||||
|
|
||||||
Consolidated stack merged via PR #517. `secubox-toolbox 2.5.2 → 2.6.6`,
|
Consolidated stack merged via PR #517. `secubox-toolbox 2.5.2 → 2.6.6`,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,31 @@
|
||||||
# TODO — SecuBox-DEB Backlog
|
# TODO — SecuBox-DEB Backlog
|
||||||
*Mis à jour : 2026-06-10*
|
*Mis à jour : 2026-06-11*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔥 P0 — Immediate (in flight)
|
## 🔥 P0 — Immediate (in flight)
|
||||||
|
|
||||||
|
### Phase 13 — Protection enforcement plane (#519) — ✅ COMPLETE
|
||||||
|
|
||||||
|
- [x] **13.A spine** (#521, `2.6.8`, v2.13.17) — nft blacklist set + forward-drop
|
||||||
|
chain + sync (CrowdSec + threat-intel). + override_dh_strip drift fix.
|
||||||
|
- [x] **13.B DNS-guard** (#522, `2.6.9`, v2.13.17) — résout domaines blocklistés
|
||||||
|
→ IPs ; détection DoH/DoT (block opt-in).
|
||||||
|
- [x] **13.C attribution** (#524, `2.6.10`, v2.13.18) — per-device blocked-attempts
|
||||||
|
+ quarantine + endpoints + tile.
|
||||||
|
- [x] **13.D feedback** (#527, `2.6.11`, v2.13.19) — escalation evaluator
|
||||||
|
(detections→nft/cscli/quarantine), audit-log, **default OFF**.
|
||||||
|
- [ ] **13.x opt-in tuning** — activer `SECUBOX_ESCALATE_*` / `SECUBOX_DOH_BLOCK`
|
||||||
|
selon politique opérateur quand voulu.
|
||||||
|
- [ ] **threatfox feed = 0** — investiguer l'ingestion domain vide (impacte
|
||||||
|
13.B resolved_domains).
|
||||||
|
|
||||||
|
### Phase 14 — Plan de déception (#525, idée future)
|
||||||
|
|
||||||
|
- [ ] Pseudo-réponses proxy au lieu de blocage IP (indistinguable, pollue
|
||||||
|
le profil) + neutralisation des scripts CDN préchargés. R3 consenti,
|
||||||
|
réutilise la détection Phase 11/12. **Pour plus tard.**
|
||||||
|
|
||||||
### Phase 11 — Social mapping per device (#502) — ✅ COMPLETE (v2.13.15)
|
### Phase 11 — Social mapping per device (#502) — ✅ COMPLETE (v2.13.15)
|
||||||
|
|
||||||
- [x] **11.A backend** (#505, `2.6.0`) — correlation engine + SQLite + API.
|
- [x] **11.A backend** (#505, `2.6.0`) — correlation engine + SQLite + API.
|
||||||
|
|
@ -21,9 +42,9 @@
|
||||||
graph + by_cdn. Mergé.
|
graph + by_cdn. Mergé.
|
||||||
- [x] **12.B anti-bot** (#516, `2.6.5/2.6.6`) — detect_antibot (détection
|
- [x] **12.B anti-bot** (#516, `2.6.5/2.6.6`) — detect_antibot (détection
|
||||||
seule) + ring levels visibles + Carto/Reset opérateur. Mergé.
|
seule) + ring levels visibles + Carto/Reset opérateur. Mergé.
|
||||||
- [ ] **12.C opérateur-grade / state-adjacent** — étend #500 Utiq :
|
- [x] **12.C opérateur-grade / state-adjacent** (#518, `2.6.7`, v2.13.16) —
|
||||||
identité carrier-grade (MSISDN injection, CGNAT fingerprint) + analytics
|
detect_operator_grade (telco MSISDN/x-acr + consortium Utiq/TrustPid +
|
||||||
state-adjacent. Prochain track.
|
data-broker LiveRamp/BlueKai/Palantir-class). Top-severity lens + PDF.
|
||||||
- [ ] **12.B bypass** — résolution de challenge (gated derrière doctrine
|
- [ ] **12.B bypass** — résolution de challenge (gated derrière doctrine
|
||||||
lawful-use + design review ; R3 opt-in uniquement).
|
lawful-use + design review ; R3 opt-in uniquement).
|
||||||
- [ ] **12.D noise counter-measures** — cookie-noising / header-strip /
|
- [ ] **12.D noise counter-measures** — cookie-noising / header-strip /
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,53 @@
|
||||||
# WIP — Work In Progress
|
# WIP — Work In Progress
|
||||||
*Mis à jour : 2026-06-10*
|
*Mis à jour : 2026-06-11*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 2026-06-11 : Phase 12.C + Phase 13 COMPLETE (protection enforcement plane) — v2.13.16→19 (ref #518-#528)
|
||||||
|
|
||||||
|
### ✅ Phase 12.C — operator-grade / state-adjacent (#518, v2.13.16)
|
||||||
|
detect_operator_grade : telco header-enrichment (MSISDN/x-acr), consortium
|
||||||
|
(Utiq/TrustPid), data-broker / state-adjacent (LiveRamp/BlueKai/Acxiom/
|
||||||
|
Neustar/Tapad/Experian/Palantir-class). Top-severity void-purple lens +
|
||||||
|
PDF section. `secubox-toolbox 2.6.7`.
|
||||||
|
|
||||||
|
### ✅ Phase 13 — protection enforcement plane (#519) COMPLETE
|
||||||
|
Le plan de bannissement (Vortex DNS + WAF + CrowdSec) enforce maintenant
|
||||||
|
sur le browsing des appareils, à tous les niveaux egress.
|
||||||
|
|
||||||
|
| Track | Issue | Livré | Tag |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 13.A spine | #521 | nft set `inet secubox_blacklist` + forward-drop chain ; sync CrowdSec+threat-intel | v2.13.17 (2.6.8) |
|
||||||
|
| 13.B DNS-guard | #522 | résout domaines blocklistés → IPs (anti-DoH bypass) + détection DoH/DoT count-only | v2.13.17 (2.6.9) |
|
||||||
|
| 13.C attribution | #524 | per-device (WG/lease hash) blocked-attempts + quarantine set + endpoints | v2.13.18 (2.6.10) |
|
||||||
|
| 13.D feedback | #527 | escalation evaluator (detections→nft/cscli/quarantine), audit-log, **default OFF** | v2.13.19 (2.6.11) |
|
||||||
|
|
||||||
|
**Doctrine** : DEFAULT DROP préservé (policy accept n'ajoute que des drops) ;
|
||||||
|
pas de WAF bypass ; anonyme (mac_hash sel rotatif) ; tout réversible (TTL +
|
||||||
|
unban) ; escalade opt-in par source.
|
||||||
|
|
||||||
|
**Bug latent corrigé (#521)** : `override_dh_strip` ne tourne jamais pour
|
||||||
|
un paquet `Architecture: all` → tous les drop-ins nft/unbound/nginx/perf
|
||||||
|
avaient cessé de shipper (cause racine de la live-config-drift). Déplacé
|
||||||
|
vers `execute_after_dh_auto_install`. Mémoire ajoutée.
|
||||||
|
|
||||||
|
### 💡 Idée future capturée (#525)
|
||||||
|
Phase 14 « plan de déception » : au lieu de bloquer les IPs trackers,
|
||||||
|
générer des pseudo-réponses proxy (indistinguable du drop, pollue le
|
||||||
|
profil) ; idem neutraliser les scripts CDN préchargés. Pour plus tard.
|
||||||
|
|
||||||
|
### 🧹 État du dépôt
|
||||||
|
Toutes les branches Phase 11/12/13 mergées + supprimées sur origin.
|
||||||
|
master @ `v2.13.19` (`secubox-toolbox 2.6.11`). Worktrees Phase 11-13
|
||||||
|
nettoyés. (Worktrees plus anciens #429/#485/#486/#490/#494/#495/#498 +
|
||||||
|
license = travail parallèle, non touchés.)
|
||||||
|
|
||||||
|
### ⬜ Next up
|
||||||
|
- **Phase 13 opt-in tuning** : activer les sources d'escalade (env
|
||||||
|
`SECUBOX_ESCALATE_*`) selon politique opérateur quand voulu.
|
||||||
|
- **threatfox feed = 0 IOCs** : investiguer pourquoi l'ingestion domain
|
||||||
|
est vide (impacte 13.B resolved_domains).
|
||||||
|
- **Phase 14 déception** (#525) quand prêt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,104 @@
|
||||||
|
secubox-toolbox (2.6.11-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Phase 13.D (#527, parent #519) — feedback loop : detections escalate
|
||||||
|
to enforcement. ALL SOURCES DEFAULT OFF.
|
||||||
|
- secubox_toolbox/escalate.py : evaluator reads the social-mapping
|
||||||
|
(by_opgrade / by_antibot) + device-blocks aggregates, applies
|
||||||
|
operator-tunable thresholds, and escalates :
|
||||||
|
* operator-grade / state-adjacent data-broker hosts over
|
||||||
|
threshold → resolve IPs → blacklist_v4/v6 (+ optional cscli
|
||||||
|
decision for audit + TTL),
|
||||||
|
* anti-bot vendors over threshold → flag only (legit infra ;
|
||||||
|
no auto-ban),
|
||||||
|
* devices over the DoH-attempt threshold → 13.C quarantine.
|
||||||
|
Every action append-logged to /var/log/secubox/audit.log (CSPN)
|
||||||
|
and reversible (nft/cscli TTL + operator unban).
|
||||||
|
- sbin/secubox-escalate + systemd .service/.timer (every 10 min) ;
|
||||||
|
sources opted in per SECUBOX_ESCALATE_* env in a drop-in.
|
||||||
|
- api.py GET /admin/escalate : policy flags + last-cycle summary.
|
||||||
|
Phase 13 (protection enforcement plane) complete : A spine + B
|
||||||
|
DNS-guard + C per-device attribution/quarantine + D feedback loop.
|
||||||
|
Detection stays factual ; escalation is a separate, logged,
|
||||||
|
reversible, opt-in policy decision (#519 doctrine).
|
||||||
|
|
||||||
|
-- Gerald KERMA <devel@cybermind.fr> Thu, 11 Jun 2026 10:30:00 +0200
|
||||||
|
|
||||||
|
secubox-toolbox (2.6.10-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Phase 13.C (#524, parent #519) — DHCP/WG per-device attribution +
|
||||||
|
quarantine.
|
||||||
|
- secubox-blacklist.nft : quarantine_v4/v6 sets + a quarantine-first
|
||||||
|
rule in the enforce chain (a quarantined device's saddr is fully
|
||||||
|
dropped). Rate-limited log prefixes SBX-BL-DROP (enforce drops)
|
||||||
|
and SBX-DOH (DoH watch, ct state new) for attribution.
|
||||||
|
- secubox_toolbox/device_blocks.py : device resolver (R3 WG peer
|
||||||
|
hash / dnsmasq-lease MAC hash with rotating salt / IP fallback)
|
||||||
|
+ device_blocks SQLite aggregate + retention.
|
||||||
|
- sbin/secubox-blacklist-attrib : journald kernel-log tailer
|
||||||
|
(cursor-based) that buckets SBX-BL-DROP / SBX-DOH by source IP,
|
||||||
|
maps to an anonymous device, upserts device_blocks. 2-min timer.
|
||||||
|
- api.py : GET /admin/device-blocks (per-device aggregate),
|
||||||
|
POST /admin/quarantine/{ip} + /admin/unquarantine/{ip}.
|
||||||
|
- app.py : device_blocks 7-day retention in the purge loop.
|
||||||
|
- index.html operator tab : per-device blocked-attempts card with
|
||||||
|
a one-click quarantine action.
|
||||||
|
Anonymous attribution (rotating mac_hash). Auto-quarantine NOT wired
|
||||||
|
(operator-manual only this pass ; threshold policy is #519 Q2).
|
||||||
|
|
||||||
|
-- Gerald KERMA <devel@cybermind.fr> Thu, 11 Jun 2026 10:00:00 +0200
|
||||||
|
|
||||||
|
secubox-toolbox (2.6.9-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Phase 13.B (#522, parent #519) — DNS-guard : resolve blocklisted
|
||||||
|
domains into the enforcement set + DoH/DoT detection.
|
||||||
|
- secubox-blacklist-sync : Source 3 resolves threat_intel
|
||||||
|
ioc_type='domain' to A/AAAA (getent, per-lookup timeout 2s,
|
||||||
|
2000-domain cap) and pushes the IPs into blacklist_v4/v6 — so a
|
||||||
|
device that resolves a known-bad domain via DoH / hardcoded IP
|
||||||
|
is still dropped at the forward hook. Writes a state file
|
||||||
|
/run/secubox/blacklist-sync.json (resolved count + doh_block).
|
||||||
|
- nftables.d/secubox-blacklist.nft : doh_detect_v4/v6 sets + a
|
||||||
|
count-only doh_watch chain (priority -11) that counters device
|
||||||
|
egress to known DoH/DoT provider endpoints (Cloudflare/Google/
|
||||||
|
Quad9/AdGuard/OpenDNS/CleanBrowsing/Control-D/NextDNS) on
|
||||||
|
tcp 443/853. Detection only by default.
|
||||||
|
- secubox-blacklist-sync : populates the DoH sets every cycle ;
|
||||||
|
SECUBOX_DOH_BLOCK=1 ALSO adds them to the enforced blacklist
|
||||||
|
(forces devices back through Vortex DNS). Default off — gated.
|
||||||
|
- api.py /admin/blacklist : adds doh_detect counts, doh_hits
|
||||||
|
counter, resolved_domains, doh_block flag.
|
||||||
|
Detection-first ; DoH blocking opt-in only (intrusive, per #519 Q1).
|
||||||
|
|
||||||
|
-- Gerald KERMA <devel@cybermind.fr> Thu, 11 Jun 2026 09:30:00 +0200
|
||||||
|
|
||||||
|
secubox-toolbox (2.6.8-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Phase 13.A (#521, parent #519) — unified blacklist enforcement spine.
|
||||||
|
- nftables.d/secubox-blacklist.nft : table inet secubox_blacklist
|
||||||
|
with blacklist_v4 / blacklist_v6 sets (flags interval,timeout)
|
||||||
|
+ a single forward-hook chain (priority -10, policy accept) that
|
||||||
|
counter-drops any routed packet whose saddr OR daddr is
|
||||||
|
blacklisted. One rule set covers every device egress path
|
||||||
|
(captive / R3 WG / br-lxc / LAN) — no per-interface logic.
|
||||||
|
Loaded by nftables.service at boot ; survives reboot.
|
||||||
|
- sbin/secubox-blacklist-sync : unions CrowdSec ban decisions
|
||||||
|
(cscli) + threat-intel C2 IPs (threat_intel ip IOCs from
|
||||||
|
toolbox.db) into the sets, per-element timeout 2h (auto-expire
|
||||||
|
on stale feeds), 50k safety cap, idempotent add (no flush race
|
||||||
|
with CrowdSec's own table).
|
||||||
|
- systemd/secubox-blacklist-sync.{service,timer} : oneshot +
|
||||||
|
every-5-min timer (OnBootSec 90s).
|
||||||
|
- postinst : install drop-in to /etc/nftables.d/, reload nft,
|
||||||
|
enable+start the timer, kick a first sync.
|
||||||
|
- api.py : GET /admin/blacklist — element counts + drop counters
|
||||||
|
from nft -j (read-only status).
|
||||||
|
Doctrine preserved : policy accept only ADDS drops (a drop in any
|
||||||
|
chain wins) — never a WAF/default-drop bypass. Detection sources are
|
||||||
|
high-confidence only (CrowdSec bans + threat-intel C2) ; WAF-ban +
|
||||||
|
Vortex-resolved-IP feeds land in 13.B/13.D.
|
||||||
|
|
||||||
|
-- Gerald KERMA <devel@cybermind.fr> Thu, 11 Jun 2026 09:00:00 +0200
|
||||||
|
|
||||||
secubox-toolbox (2.6.7-1~bookworm1) bookworm; urgency=medium
|
secubox-toolbox (2.6.7-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
* Phase 12.C (#518, parent #514) — operator-grade / state-adjacent
|
* Phase 12.C (#518, parent #514) — operator-grade / state-adjacent
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,34 @@ fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Phase 13.A (#521) : unified blacklist enforcement spine.
|
||||||
|
# nft drop-in (loaded by nftables.service at boot) + sync timer that
|
||||||
|
# unions CrowdSec decisions + threat-intel C2 IPs into the sets.
|
||||||
|
if [ -f /usr/share/secubox/toolbox/nftables.d/secubox-blacklist.nft ]; then
|
||||||
|
install -d -m 0755 /etc/nftables.d
|
||||||
|
install -m 0644 /usr/share/secubox/toolbox/nftables.d/secubox-blacklist.nft \
|
||||||
|
/etc/nftables.d/secubox-blacklist.nft
|
||||||
|
if systemctl is-active --quiet nftables.service 2>/dev/null; then
|
||||||
|
systemctl reload nftables.service 2>/dev/null \
|
||||||
|
|| /usr/sbin/nft -f /etc/nftables.d/secubox-blacklist.nft 2>/dev/null \
|
||||||
|
|| true
|
||||||
|
fi
|
||||||
|
if [ -d /run/systemd/system ]; then
|
||||||
|
systemctl enable secubox-blacklist-sync.timer 2>/dev/null || true
|
||||||
|
systemctl start secubox-blacklist-sync.timer 2>/dev/null || true
|
||||||
|
# Kick an immediate first sync (best-effort).
|
||||||
|
systemctl start secubox-blacklist-sync.service 2>/dev/null || true
|
||||||
|
# Phase 13.C (#524) : per-device attribution tailer timer.
|
||||||
|
systemctl enable secubox-blacklist-attrib.timer 2>/dev/null || true
|
||||||
|
systemctl start secubox-blacklist-attrib.timer 2>/dev/null || true
|
||||||
|
# Phase 13.D (#527) : escalation evaluator timer. The TIMER runs,
|
||||||
|
# but every escalation SOURCE is default OFF — nothing escalates
|
||||||
|
# until the operator opts in via a SECUBOX_ESCALATE_* drop-in.
|
||||||
|
systemctl enable secubox-escalate.timer 2>/dev/null || true
|
||||||
|
systemctl start secubox-escalate.timer 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Phase 7 (#498) : install unbound listeners.
|
# Phase 7 (#498) : install unbound listeners.
|
||||||
# 99-secubox-wg.conf — WG peers (10.99.x). Without it WG peers' DNS
|
# 99-secubox-wg.conf — WG peers (10.99.x). Without it WG peers' DNS
|
||||||
# queries get ICMP port unreachable and the iPhone stops resolving.
|
# queries get ICMP port unreachable and the iPhone stops resolving.
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,14 @@ override_dh_installsystemd:
|
||||||
override_dh_auto_test:
|
override_dh_auto_test:
|
||||||
@true
|
@true
|
||||||
|
|
||||||
override_dh_strip:
|
# NOTE: dh_strip is NOT invoked in the binary-indep sequence (this is an
|
||||||
@true
|
# Architecture: all package with no binaries to strip), so an
|
||||||
|
# override_dh_strip target would silently never run. Everything that
|
||||||
|
# used to live there is installed from execute_after_dh_auto_install
|
||||||
|
# below — which DOES run — so nft drop-ins / unbound / nginx / perf
|
||||||
|
# drop-ins / blacklist spine actually ship in the .deb (#521 caught the
|
||||||
|
# stale-on-disk drift this caused).
|
||||||
|
execute_after_dh_auto_install:
|
||||||
# Phase 6.Q (#496) : DB tuning helper + uvicorn perf drop-in
|
# Phase 6.Q (#496) : DB tuning helper + uvicorn perf drop-in
|
||||||
install -m 755 sbin/secubox-toolbox-db-tune \
|
install -m 755 sbin/secubox-toolbox-db-tune \
|
||||||
debian/secubox-toolbox/usr/sbin/
|
debian/secubox-toolbox/usr/sbin/
|
||||||
|
|
@ -73,6 +79,29 @@ override_dh_strip:
|
||||||
# by symlinking /etc/nftables.d/secubox-toolbox-wg.nft → this file.
|
# by symlinking /etc/nftables.d/secubox-toolbox-wg.nft → this file.
|
||||||
install -m 0644 nftables.d/secubox-toolbox-wg-fanout.nft \
|
install -m 0644 nftables.d/secubox-toolbox-wg-fanout.nft \
|
||||||
debian/secubox-toolbox/usr/share/secubox/toolbox/nftables.d/
|
debian/secubox-toolbox/usr/share/secubox/toolbox/nftables.d/
|
||||||
|
# Phase 13.A (#521) : unified blacklist enforcement spine.
|
||||||
|
install -m 0644 nftables.d/secubox-blacklist.nft \
|
||||||
|
debian/secubox-toolbox/usr/share/secubox/toolbox/nftables.d/
|
||||||
|
install -m 0755 sbin/secubox-blacklist-sync \
|
||||||
|
debian/secubox-toolbox/usr/sbin/
|
||||||
|
install -m 0644 systemd/secubox-blacklist-sync.service \
|
||||||
|
debian/secubox-toolbox/lib/systemd/system/
|
||||||
|
install -m 0644 systemd/secubox-blacklist-sync.timer \
|
||||||
|
debian/secubox-toolbox/lib/systemd/system/
|
||||||
|
# Phase 13.C (#524) : per-device attribution tailer + timer.
|
||||||
|
install -m 0755 sbin/secubox-blacklist-attrib \
|
||||||
|
debian/secubox-toolbox/usr/sbin/
|
||||||
|
install -m 0644 systemd/secubox-blacklist-attrib.service \
|
||||||
|
debian/secubox-toolbox/lib/systemd/system/
|
||||||
|
install -m 0644 systemd/secubox-blacklist-attrib.timer \
|
||||||
|
debian/secubox-toolbox/lib/systemd/system/
|
||||||
|
# Phase 13.D (#527) : escalation evaluator (all sources default OFF).
|
||||||
|
install -m 0755 sbin/secubox-escalate \
|
||||||
|
debian/secubox-toolbox/usr/sbin/
|
||||||
|
install -m 0644 systemd/secubox-escalate.service \
|
||||||
|
debian/secubox-toolbox/lib/systemd/system/
|
||||||
|
install -m 0644 systemd/secubox-escalate.timer \
|
||||||
|
debian/secubox-toolbox/lib/systemd/system/
|
||||||
install -m 0755 sbin/secubox-toolbox-wg-restore \
|
install -m 0755 sbin/secubox-toolbox-wg-restore \
|
||||||
debian/secubox-toolbox/usr/sbin/
|
debian/secubox-toolbox/usr/sbin/
|
||||||
install -m 0644 systemd/secubox-toolbox-wg-restore.service \
|
install -m 0644 systemd/secubox-toolbox-wg-restore.service \
|
||||||
|
|
|
||||||
92
packages/secubox-toolbox/nftables.d/secubox-blacklist.nft
Normal file
92
packages/secubox-toolbox/nftables.d/secubox-blacklist.nft
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
#
|
||||||
|
# Phase 13.A (#521) — unified blacklist enforcement spine.
|
||||||
|
#
|
||||||
|
# One authoritative pair of sets (v4/v6) fed by secubox-blacklist-sync
|
||||||
|
# from ALL ban sources (CrowdSec decisions + threat-intel C2 IPs ;
|
||||||
|
# WAF-ban + Vortex-resolved feeds land in 13.B/13.D). A single
|
||||||
|
# forward-hook chain drops any ROUTED packet whose source OR destination
|
||||||
|
# is blacklisted — so it covers every device egress path at once
|
||||||
|
# (captive 10.99.0.0/24, R3 WG wg-toolbox 10.99.1.0/24, br-lxc, LAN)
|
||||||
|
# with no per-interface logic.
|
||||||
|
#
|
||||||
|
# Doctrine : policy accept here only ADDS drops ; it never bypasses the
|
||||||
|
# main inet filter default-drop (a drop in any chain wins regardless of
|
||||||
|
# another chain's accept policy). Loaded by nftables.service at boot
|
||||||
|
# via /etc/nftables.d/*.nft so it survives reboot (#498/#501 pattern).
|
||||||
|
#
|
||||||
|
# The sets are `interval` (CIDR support) + `timeout` (elements auto-expire
|
||||||
|
# if the sync stops re-adding them — fail-open on stale data, never a
|
||||||
|
# permanent black hole from a one-off bad feed).
|
||||||
|
#
|
||||||
|
# Idempotent load : the create-or-replace idiom below makes `nft -f` on
|
||||||
|
# this file safe to run repeatedly (postinst reload, manual reload)
|
||||||
|
# WITHOUT appending duplicate rules to the chains. The sets are wiped on
|
||||||
|
# reload but secubox-blacklist-sync repopulates them immediately (postinst
|
||||||
|
# kicks a sync ; the timer keeps them fresh every 5 min) — a brief empty
|
||||||
|
# window is the fail-open direction anyway.
|
||||||
|
table inet secubox_blacklist { }
|
||||||
|
delete table inet secubox_blacklist
|
||||||
|
|
||||||
|
table inet secubox_blacklist {
|
||||||
|
set blacklist_v4 {
|
||||||
|
type ipv4_addr
|
||||||
|
flags interval, timeout
|
||||||
|
}
|
||||||
|
set blacklist_v6 {
|
||||||
|
type ipv6_addr
|
||||||
|
flags interval, timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
# Phase 13.C (#524) — per-device quarantine. An operator (or the
|
||||||
|
# auto-quarantine policy, default off) adds a device's source IP here ;
|
||||||
|
# ALL its forward traffic is dropped until the entry expires / is
|
||||||
|
# removed. Separate from the C2 blacklist so un-quarantine is a single
|
||||||
|
# set op and the device dashboard can show quarantine state distinctly.
|
||||||
|
set quarantine_v4 {
|
||||||
|
type ipv4_addr
|
||||||
|
flags interval, timeout
|
||||||
|
}
|
||||||
|
set quarantine_v6 {
|
||||||
|
type ipv6_addr
|
||||||
|
flags interval, timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
chain enforce {
|
||||||
|
type filter hook forward priority -10; policy accept;
|
||||||
|
# Quarantine first : a quarantined device is fully cut off.
|
||||||
|
ip saddr @quarantine_v4 counter drop
|
||||||
|
ip6 saddr @quarantine_v6 counter drop
|
||||||
|
# C2 blacklist (13.A). Phase 13.C : rate-limited log so the
|
||||||
|
# attribution tailer can bucket drops per source device without
|
||||||
|
# flooding the kernel log.
|
||||||
|
ip daddr @blacklist_v4 limit rate 20/second log prefix "SBX-BL-DROP " counter drop
|
||||||
|
ip saddr @blacklist_v4 counter drop
|
||||||
|
ip6 daddr @blacklist_v6 limit rate 20/second log prefix "SBX-BL-DROP " counter drop
|
||||||
|
ip6 saddr @blacklist_v6 counter drop
|
||||||
|
}
|
||||||
|
|
||||||
|
# Phase 13.B (#522) — DoH/DoT detection. COUNT-ONLY by default : a
|
||||||
|
# device reaching a known DoH/DoT provider endpoint is counted (for
|
||||||
|
# passive stats + the "this device bypasses Vortex DNS" signal) but
|
||||||
|
# NOT blocked. Blocking is opt-in : secubox-blacklist-sync with
|
||||||
|
# SECUBOX_DOH_BLOCK=1 ALSO adds these IPs to blacklist_v4/v6 above,
|
||||||
|
# where the enforce chain drops them — forcing the device back through
|
||||||
|
# Vortex DNS. Default off (intrusive ; gated per #519 open Q1).
|
||||||
|
set doh_detect_v4 {
|
||||||
|
type ipv4_addr
|
||||||
|
flags interval, timeout
|
||||||
|
}
|
||||||
|
set doh_detect_v6 {
|
||||||
|
type ipv6_addr
|
||||||
|
flags interval, timeout
|
||||||
|
}
|
||||||
|
chain doh_watch {
|
||||||
|
type filter hook forward priority -11; policy accept;
|
||||||
|
# Phase 13.C : rate-limited log (new connections only) so the
|
||||||
|
# attribution tailer can bucket DoH attempts per device.
|
||||||
|
ip daddr @doh_detect_v4 tcp dport { 443, 853 } ct state new limit rate 5/second log prefix "SBX-DOH " counter
|
||||||
|
ip6 daddr @doh_detect_v6 tcp dport { 443, 853 } ct state new limit rate 5/second log prefix "SBX-DOH " counter
|
||||||
|
}
|
||||||
|
}
|
||||||
75
packages/secubox-toolbox/sbin/secubox-blacklist-attrib
Normal file
75
packages/secubox-toolbox/sbin/secubox-blacklist-attrib
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
#
|
||||||
|
# SecuBox-Deb :: secubox-blacklist-attrib (Phase 13.C #524)
|
||||||
|
#
|
||||||
|
# Reads the kernel log for the nft drop/DoH log prefixes (SBX-BL-DROP /
|
||||||
|
# SBX-DOH), extracts SRC=/DST=, maps SRC to an anonymous device hash, and
|
||||||
|
# records per-device block events into device_blocks. Runs on a timer ;
|
||||||
|
# uses a cursor file so each run only consumes new journal lines.
|
||||||
|
set -euo pipefail
|
||||||
|
readonly MODULE="secubox-blacklist-attrib"
|
||||||
|
|
||||||
|
CURSOR=/run/secubox/blacklist-attrib.cursor
|
||||||
|
PYHELPER=/usr/lib/secubox/toolbox
|
||||||
|
mkdir -p /run/secubox 2>/dev/null || true
|
||||||
|
|
||||||
|
log() { logger -t "$MODULE" -- "$*" 2>/dev/null || echo "[$MODULE] $*" >&2; }
|
||||||
|
|
||||||
|
# Pull new kernel-log lines since the saved cursor (journald), kernel
|
||||||
|
# facility only, matching our prefixes. --after-cursor needs a stored
|
||||||
|
# cursor ; first run uses --since to bound the backlog.
|
||||||
|
JARGS=(-k -o export --no-pager)
|
||||||
|
if [ -r "$CURSOR" ] && [ -s "$CURSOR" ]; then
|
||||||
|
JARGS+=(--after-cursor "$(cat "$CURSOR")")
|
||||||
|
else
|
||||||
|
JARGS+=(--since "-5min")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect SRC/DST per prefix into a temp, then hand to python for the
|
||||||
|
# device mapping + DB upsert (one process, not per-line).
|
||||||
|
TMP=$(mktemp); trap 'rm -f "$TMP"' EXIT
|
||||||
|
|
||||||
|
# journalctl -o export emits field=value blocks ; we grep MESSAGE lines.
|
||||||
|
# Save the last cursor seen so the next run resumes cleanly.
|
||||||
|
LASTCUR=""
|
||||||
|
while IFS= read -r line; do
|
||||||
|
case "$line" in
|
||||||
|
__CURSOR=*) LASTCUR="${line#__CURSOR=}" ;;
|
||||||
|
MESSAGE=*SBX-BL-DROP*)
|
||||||
|
src=$(printf '%s\n' "$line" | sed -n 's/.*SRC=\([0-9a-fA-F:.]*\).*/\1/p')
|
||||||
|
dst=$(printf '%s\n' "$line" | sed -n 's/.*DST=\([0-9a-fA-F:.]*\).*/\1/p')
|
||||||
|
[ -n "$src" ] && printf 'blacklist-drop\t%s\t%s\n' "$src" "$dst" >> "$TMP"
|
||||||
|
;;
|
||||||
|
MESSAGE=*SBX-DOH*)
|
||||||
|
src=$(printf '%s\n' "$line" | sed -n 's/.*SRC=\([0-9a-fA-F:.]*\).*/\1/p')
|
||||||
|
dst=$(printf '%s\n' "$line" | sed -n 's/.*DST=\([0-9a-fA-F:.]*\).*/\1/p')
|
||||||
|
[ -n "$src" ] && printf 'doh-attempt\t%s\t%s\n' "$src" "$dst" >> "$TMP"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < <(journalctl "${JARGS[@]}" 2>/dev/null | grep -aE '^(__CURSOR=|MESSAGE=.*SBX-(BL-DROP|DOH))')
|
||||||
|
|
||||||
|
# Persist the cursor for the next run.
|
||||||
|
[ -n "$LASTCUR" ] && printf '%s' "$LASTCUR" > "$CURSOR" 2>/dev/null || true
|
||||||
|
|
||||||
|
count=$(wc -l < "$TMP" 2>/dev/null || echo 0)
|
||||||
|
if [ "$count" -gt 0 ]; then
|
||||||
|
PYTHONPATH="$PYHELPER" python3 - "$TMP" <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
from secubox_toolbox import device_blocks as db
|
||||||
|
n = 0
|
||||||
|
with open(sys.argv[1]) as f:
|
||||||
|
for ln in f:
|
||||||
|
parts = ln.rstrip("\n").split("\t")
|
||||||
|
if len(parts) != 3:
|
||||||
|
continue
|
||||||
|
kind, src, dst = parts
|
||||||
|
mac = db.resolve_device(src)
|
||||||
|
db.record_block(mac, src, kind, dst or "?")
|
||||||
|
n += 1
|
||||||
|
print(n)
|
||||||
|
PYEOF
|
||||||
|
fi
|
||||||
|
log "attributed ${count} block events"
|
||||||
|
exit 0
|
||||||
147
packages/secubox-toolbox/sbin/secubox-blacklist-sync
Normal file
147
packages/secubox-toolbox/sbin/secubox-blacklist-sync
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
#
|
||||||
|
# SecuBox-Deb :: secubox-blacklist-sync
|
||||||
|
#
|
||||||
|
# Phase 13.A (#521) — union all ban sources into the nft enforcement
|
||||||
|
# sets (inet secubox_blacklist blacklist_v4 / blacklist_v6). Idempotent,
|
||||||
|
# safe to run on a timer. Each element gets a timeout so stale entries
|
||||||
|
# auto-expire if a source stops listing them.
|
||||||
|
set -euo pipefail
|
||||||
|
readonly MODULE="secubox-blacklist-sync"
|
||||||
|
readonly VERSION="13.A"
|
||||||
|
|
||||||
|
NFT=/usr/sbin/nft
|
||||||
|
TABLE="inet secubox_blacklist"
|
||||||
|
TOOLBOX_DB=/var/lib/secubox/toolbox/toolbox.db
|
||||||
|
ELEM_TIMEOUT="${SECUBOX_BL_TIMEOUT:-2h}" # auto-expiry per element
|
||||||
|
# Safety cap : never load more than this many IPs (a runaway feed
|
||||||
|
# shouldn't be able to black-hole the box).
|
||||||
|
MAX_ELEMS="${SECUBOX_BL_MAX:-50000}"
|
||||||
|
|
||||||
|
log() { logger -t "$MODULE" -- "$*" 2>/dev/null || echo "[$MODULE] $*" >&2; }
|
||||||
|
|
||||||
|
# The nft table must already exist (loaded by nftables.service from the
|
||||||
|
# drop-in). If it doesn't, load the drop-in once.
|
||||||
|
if ! $NFT list table $TABLE >/dev/null 2>&1; then
|
||||||
|
if [ -r /etc/nftables.d/secubox-blacklist.nft ]; then
|
||||||
|
$NFT -f /etc/nftables.d/secubox-blacklist.nft || {
|
||||||
|
log "ERROR: table missing and drop-in load failed"; exit 1; }
|
||||||
|
else
|
||||||
|
log "ERROR: table $TABLE missing and no drop-in to load"; exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Collect IPs from all sources into temp files (v4 / v6) ──
|
||||||
|
TMP4=$(mktemp); TMP6=$(mktemp)
|
||||||
|
trap 'rm -f "$TMP4" "$TMP6"' EXIT
|
||||||
|
|
||||||
|
# Source 1 : threat-intel C2 IPs (feodo / threatfox / sslbl) from the
|
||||||
|
# toolbox SQLite. ioc_type='ip'.
|
||||||
|
if [ -r "$TOOLBOX_DB" ] && command -v sqlite3 >/dev/null 2>&1; then
|
||||||
|
sqlite3 "$TOOLBOX_DB" \
|
||||||
|
"SELECT DISTINCT ioc FROM threat_intel WHERE ioc_type='ip';" \
|
||||||
|
2>/dev/null >> "$TMP4.raw" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Source 2 : CrowdSec decisions (ban scope=Ip). JSON via cscli.
|
||||||
|
if command -v cscli >/dev/null 2>&1; then
|
||||||
|
cscli decisions list -o json 2>/dev/null \
|
||||||
|
| { command -v jq >/dev/null 2>&1 \
|
||||||
|
&& jq -r '.[] | select(.decisions != null) | .decisions[] | select(.type=="ban") | .value' 2>/dev/null \
|
||||||
|
|| sed -n 's/.*"value":"\([0-9a-fA-F:.]*\)".*/\1/p'; } \
|
||||||
|
>> "$TMP4.raw" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Source 3 (Phase 13.B #522) : DNS-guard — resolve blocklisted DOMAINS
|
||||||
|
# (threat_intel ioc_type='domain') to their A/AAAA records and add those
|
||||||
|
# IPs. Closes the DoH / hardcoded-IP bypass : even if a device resolves a
|
||||||
|
# known-bad domain out-of-band, the resulting IP is dropped at forward.
|
||||||
|
# Bounded : cap on domains/cycle + per-lookup timeout so the sync never
|
||||||
|
# hangs on a dead resolver.
|
||||||
|
DOMAIN_CAP="${SECUBOX_BL_DOMAIN_CAP:-2000}"
|
||||||
|
RESOLVE_TIMEOUT="${SECUBOX_BL_RESOLVE_TIMEOUT:-2}"
|
||||||
|
resolved_domains=0
|
||||||
|
if [ -r "$TOOLBOX_DB" ] && command -v sqlite3 >/dev/null 2>&1; then
|
||||||
|
while IFS= read -r dom; do
|
||||||
|
[ -n "$dom" ] || continue
|
||||||
|
# getent ahosts returns both A + AAAA ; timeout guards a dead lookup.
|
||||||
|
ips=$(timeout "$RESOLVE_TIMEOUT" getent ahosts "$dom" 2>/dev/null \
|
||||||
|
| awk '{print $1}' | sort -u)
|
||||||
|
if [ -n "$ips" ]; then
|
||||||
|
printf '%s\n' "$ips" >> "$TMP4.raw"
|
||||||
|
resolved_domains=$((resolved_domains + 1))
|
||||||
|
fi
|
||||||
|
done < <(sqlite3 "$TOOLBOX_DB" \
|
||||||
|
"SELECT DISTINCT ioc FROM threat_intel WHERE ioc_type='domain' LIMIT $DOMAIN_CAP;" \
|
||||||
|
2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Split v4 / v6, validate, dedup, cap.
|
||||||
|
if [ -f "$TMP4.raw" ]; then
|
||||||
|
# IPv6 = contains a colon ; IPv4 = dotted quad (optionally /CIDR).
|
||||||
|
grep -E ':' "$TMP4.raw" 2>/dev/null | grep -vE '^\s*$' | sort -u > "$TMP6" || true
|
||||||
|
grep -vE ':' "$TMP4.raw" 2>/dev/null \
|
||||||
|
| grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$' \
|
||||||
|
| sort -u > "$TMP4" || true
|
||||||
|
rm -f "$TMP4.raw"
|
||||||
|
fi
|
||||||
|
|
||||||
|
n4=$(wc -l < "$TMP4" 2>/dev/null || echo 0)
|
||||||
|
n6=$(wc -l < "$TMP6" 2>/dev/null || echo 0)
|
||||||
|
|
||||||
|
if [ "$n4" -gt "$MAX_ELEMS" ]; then
|
||||||
|
log "WARN: $n4 v4 IPs exceeds cap $MAX_ELEMS — truncating"
|
||||||
|
head -n "$MAX_ELEMS" "$TMP4" > "$TMP4.cap" && mv "$TMP4.cap" "$TMP4"
|
||||||
|
n4=$MAX_ELEMS
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Push into the nft sets (batched add-element with per-elem timeout) ──
|
||||||
|
# We don't flush : timeouts handle expiry, and re-adding refreshes the
|
||||||
|
# timeout on still-active entries. Batching keeps it to one nft call.
|
||||||
|
add_batch() {
|
||||||
|
local set="$1" file="$2" fam="$3"
|
||||||
|
[ -s "$file" ] || return 0
|
||||||
|
local elems
|
||||||
|
elems=$(awk -v t="$ELEM_TIMEOUT" 'NF{printf "%s timeout %s, ", $1, t}' "$file")
|
||||||
|
elems="${elems%, }"
|
||||||
|
[ -n "$elems" ] || return 0
|
||||||
|
# add (not flush+add) so concurrent CrowdSec table updates don't race.
|
||||||
|
$NFT add element $TABLE "$set" "{ $elems }" 2>/dev/null \
|
||||||
|
|| log "WARN: partial add to $set ($fam)"
|
||||||
|
}
|
||||||
|
|
||||||
|
add_batch blacklist_v4 "$TMP4" v4
|
||||||
|
add_batch blacklist_v6 "$TMP6" v6
|
||||||
|
|
||||||
|
# ── Phase 13.B (#522) : DoH/DoT detection-list population ──
|
||||||
|
# Known DoH/DoT provider endpoints. Count-only by default (the doh_watch
|
||||||
|
# chain just counters) ; SECUBOX_DOH_BLOCK=1 also adds them to the
|
||||||
|
# enforced blacklist so devices are forced back through Vortex DNS.
|
||||||
|
DOH_BLOCK="${SECUBOX_DOH_BLOCK:-0}"
|
||||||
|
DOH_V4="1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112 \
|
||||||
|
94.140.14.14 94.140.15.15 208.67.222.222 208.67.220.220 \
|
||||||
|
185.228.168.9 76.76.2.0 76.76.10.0 45.90.28.0 45.90.30.0"
|
||||||
|
DOH_V6="2606:4700:4700::1111 2606:4700:4700::1001 2001:4860:4860::8888 \
|
||||||
|
2001:4860:4860::8844 2620:fe::fe 2620:fe::9"
|
||||||
|
doh4_elems=$(printf '%s ' $DOH_V4 | awk -v t="$ELEM_TIMEOUT" '{for(i=1;i<=NF;i++)printf "%s timeout %s, ",$i,t}')
|
||||||
|
doh6_elems=$(printf '%s ' $DOH_V6 | awk -v t="$ELEM_TIMEOUT" '{for(i=1;i<=NF;i++)printf "%s timeout %s, ",$i,t}')
|
||||||
|
[ -n "$doh4_elems" ] && $NFT add element $TABLE doh_detect_v4 "{ ${doh4_elems%, } }" 2>/dev/null || true
|
||||||
|
[ -n "$doh6_elems" ] && $NFT add element $TABLE doh_detect_v6 "{ ${doh6_elems%, } }" 2>/dev/null || true
|
||||||
|
if [ "$DOH_BLOCK" = "1" ]; then
|
||||||
|
# Opt-in : also enforce-drop DoH so devices fall back to Vortex.
|
||||||
|
[ -n "$doh4_elems" ] && $NFT add element $TABLE blacklist_v4 "{ ${doh4_elems%, } }" 2>/dev/null || true
|
||||||
|
[ -n "$doh6_elems" ] && $NFT add element $TABLE blacklist_v6 "{ ${doh6_elems%, } }" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Report + state file (consumed by /admin/blacklist) ──
|
||||||
|
live4=$($NFT list set $TABLE blacklist_v4 2>/dev/null | grep -c 'timeout' || echo 0)
|
||||||
|
live6=$($NFT list set $TABLE blacklist_v6 2>/dev/null | grep -c 'timeout' || echo 0)
|
||||||
|
STATE=/run/secubox/blacklist-sync.json
|
||||||
|
mkdir -p /run/secubox 2>/dev/null || true
|
||||||
|
printf '{"ts":%s,"v4_added":%s,"v6_added":%s,"resolved_domains":%s,"doh_block":%s}\n' \
|
||||||
|
"$(date +%s)" "${n4:-0}" "${n6:-0}" "${resolved_domains:-0}" "${DOH_BLOCK}" \
|
||||||
|
> "$STATE" 2>/dev/null || true
|
||||||
|
log "synced: +${n4} v4 / +${n6} v6 (live ~${live4}/${live6}, resolved ${resolved_domains:-0} domains, doh_block=${DOH_BLOCK}, timeout ${ELEM_TIMEOUT})"
|
||||||
|
exit 0
|
||||||
18
packages/secubox-toolbox/sbin/secubox-escalate
Executable file
18
packages/secubox-toolbox/sbin/secubox-escalate
Executable file
|
|
@ -0,0 +1,18 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
# SecuBox-Deb :: secubox-escalate (Phase 13.D #527)
|
||||||
|
# Thin wrapper : run one escalation cycle. All sources DEFAULT OFF —
|
||||||
|
# nothing escalates until an operator opts in via SECUBOX_ESCALATE_* env
|
||||||
|
# (set in a /etc/systemd/system/secubox-escalate.service.d/ drop-in).
|
||||||
|
set -euo pipefail
|
||||||
|
PYHELPER=/usr/lib/secubox/toolbox
|
||||||
|
STATE=/run/secubox/escalate.json
|
||||||
|
mkdir -p /run/secubox 2>/dev/null || true
|
||||||
|
PYTHONPATH="$PYHELPER" python3 - <<'PYEOF' > "$STATE" 2>/dev/null || true
|
||||||
|
import json
|
||||||
|
from secubox_toolbox import escalate
|
||||||
|
print(json.dumps(escalate.evaluate_and_apply()))
|
||||||
|
PYEOF
|
||||||
|
logger -t secubox-escalate -- "cycle done ($(cat "$STATE" 2>/dev/null | head -c 200))" 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
|
@ -2085,6 +2085,141 @@ async def admin_social_aggregate(hours: int = 24) -> dict:
|
||||||
return _s.aggregate(hours=hours)
|
return _s.aggregate(hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/blacklist")
|
||||||
|
async def admin_blacklist() -> dict:
|
||||||
|
"""Phase 13.A (#521) + 13.B (#522) — enforcement-spine status :
|
||||||
|
element counts + drop counters of the unified nft blacklist sets,
|
||||||
|
plus the DoH/DoT detection counters and the last-sync state.
|
||||||
|
Read-only ; parses `nft -j list table inet secubox_blacklist`.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
import subprocess as _sp
|
||||||
|
from pathlib import Path as _P
|
||||||
|
out: dict = {
|
||||||
|
"active": False,
|
||||||
|
"v4_count": 0,
|
||||||
|
"v6_count": 0,
|
||||||
|
"drops": 0,
|
||||||
|
"doh_detect_v4": 0,
|
||||||
|
"doh_detect_v6": 0,
|
||||||
|
"doh_hits": 0,
|
||||||
|
"resolved_domains": 0,
|
||||||
|
"doh_block": False,
|
||||||
|
"sources": ["crowdsec", "threat-intel", "dns-guard"],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
r = _sp.run(
|
||||||
|
["/usr/sbin/nft", "-j", "list", "table", "inet", "secubox_blacklist"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
data = _json.loads(r.stdout or "{}")
|
||||||
|
for item in data.get("nftables", []):
|
||||||
|
if "set" in item:
|
||||||
|
s = item["set"]
|
||||||
|
n = len(s.get("elem", []) or [])
|
||||||
|
name = s.get("name")
|
||||||
|
if name == "blacklist_v4":
|
||||||
|
out["v4_count"] = n
|
||||||
|
elif name == "blacklist_v6":
|
||||||
|
out["v6_count"] = n
|
||||||
|
elif name == "doh_detect_v4":
|
||||||
|
out["doh_detect_v4"] = n
|
||||||
|
elif name == "doh_detect_v6":
|
||||||
|
out["doh_detect_v6"] = n
|
||||||
|
if "rule" in item:
|
||||||
|
chain = item["rule"].get("chain", "")
|
||||||
|
for ex in item["rule"].get("expr", []):
|
||||||
|
c = ex.get("counter")
|
||||||
|
if not c:
|
||||||
|
continue
|
||||||
|
pk = int(c.get("packets", 0) or 0)
|
||||||
|
if chain == "doh_watch":
|
||||||
|
out["doh_hits"] += pk
|
||||||
|
else:
|
||||||
|
out["drops"] += pk
|
||||||
|
out["active"] = True
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.warning("admin_blacklist nft parse failed: %s", e)
|
||||||
|
# Last-sync state file (resolved-domain count + doh_block flag).
|
||||||
|
try:
|
||||||
|
st = _P("/run/secubox/blacklist-sync.json")
|
||||||
|
if st.exists():
|
||||||
|
j = _json.loads(st.read_text())
|
||||||
|
out["resolved_domains"] = int(j.get("resolved_domains", 0) or 0)
|
||||||
|
out["doh_block"] = str(j.get("doh_block", "0")) == "1"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/device-blocks")
|
||||||
|
async def admin_device_blocks(hours: int = 24) -> dict:
|
||||||
|
"""Phase 13.C (#524) — per-device blocked-attempts attribution :
|
||||||
|
which anonymous device hit blacklisted IPs / DoH endpoints, how often.
|
||||||
|
"""
|
||||||
|
from . import device_blocks as _db
|
||||||
|
return _db.aggregate(hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
def _quarantine_ip(ip: str, add: bool, ttl: str = "6h") -> dict:
|
||||||
|
"""Add/remove a device source IP to the nft quarantine set."""
|
||||||
|
import ipaddress as _ip
|
||||||
|
import subprocess as _sp
|
||||||
|
try:
|
||||||
|
addr = _ip.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f"invalid IP {ip!r}")
|
||||||
|
setname = "quarantine_v6" if addr.version == 6 else "quarantine_v4"
|
||||||
|
op = "add" if add else "delete"
|
||||||
|
elem = f"{ip} timeout {ttl}" if add else ip
|
||||||
|
try:
|
||||||
|
r = _sp.run(
|
||||||
|
["/usr/sbin/nft", op, "element", "inet", "secubox_blacklist",
|
||||||
|
setname, "{ " + elem + " }"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
ok = r.returncode == 0
|
||||||
|
if not ok and not add and "No such file" in (r.stderr or ""):
|
||||||
|
ok = True # already absent → treat as success
|
||||||
|
log.info("quarantine %s %s -> %s", op, ip, "ok" if ok else r.stderr.strip())
|
||||||
|
return {"ok": ok, "ip": ip, "action": op, "set": setname,
|
||||||
|
"error": None if ok else (r.stderr or "").strip()}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise HTTPException(500, f"nft {op} failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/quarantine/{ip}")
|
||||||
|
async def admin_quarantine_add(ip: str, ttl: str = "6h") -> dict:
|
||||||
|
"""Quarantine a device : drop ALL its forward traffic for `ttl`."""
|
||||||
|
return _quarantine_ip(ip, add=True, ttl=ttl)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/unquarantine/{ip}")
|
||||||
|
async def admin_quarantine_remove(ip: str) -> dict:
|
||||||
|
"""Lift a device's quarantine."""
|
||||||
|
return _quarantine_ip(ip, add=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/escalate")
|
||||||
|
async def admin_escalate() -> dict:
|
||||||
|
"""Phase 13.D (#527) — escalation policy + last cycle summary.
|
||||||
|
Read-only : the policy flags (all default OFF) come from the
|
||||||
|
evaluator's env, the last-run summary from its state file.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
from pathlib import Path as _P
|
||||||
|
from . import escalate as _esc
|
||||||
|
out: dict = {"policy": _esc.load_policy(), "last_run": None}
|
||||||
|
try:
|
||||||
|
st = _P("/run/secubox/escalate.json")
|
||||||
|
if st.exists():
|
||||||
|
out["last_run"] = _json.loads(st.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/social/report/{token}.pdf")
|
@router.get("/social/report/{token}.pdf")
|
||||||
async def social_report_pdf(token: str) -> Response:
|
async def social_report_pdf(token: str) -> Response:
|
||||||
"""Phase 11.C (#508) — bilingual FR/EN evidence PDF for a peer.
|
"""Phase 11.C (#508) — bilingual FR/EN evidence PDF for a peer.
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,12 @@ async def _startup() -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
social.purge_older_than(days=7)
|
social.purge_older_than(days=7)
|
||||||
|
# Phase 13.C (#524) — device-blocks retention.
|
||||||
|
try:
|
||||||
|
from . import device_blocks as _db
|
||||||
|
_db.purge_older_than(days=7)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log.error("social.purge_older_than failed: %s", e)
|
_log.error("social.purge_older_than failed: %s", e)
|
||||||
await asyncio.sleep(3600)
|
await asyncio.sleep(3600)
|
||||||
|
|
|
||||||
210
packages/secubox-toolbox/secubox_toolbox/device_blocks.py
Normal file
210
packages/secubox-toolbox/secubox_toolbox/device_blocks.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
|
||||||
|
"""
|
||||||
|
SecuBox-Deb :: ToolBoX device-blocks attribution (Phase 13.C #524)
|
||||||
|
|
||||||
|
Attributes blacklist drops + DoH-bypass attempts to the DEVICE that made
|
||||||
|
them, by mapping the packet source IP to a device identity :
|
||||||
|
|
||||||
|
- R3 WG peers (10.99.1.x) → sha256(wg_pubkey)[:16] (same hash as the
|
||||||
|
social-mapping model — anonymous, rotating-salt-equivalent via the
|
||||||
|
pubkey).
|
||||||
|
- captive subnet (10.99.0.x / br-lxc 10.100.0.x) → dnsmasq lease MAC
|
||||||
|
hashed with the rotating MAC salt.
|
||||||
|
|
||||||
|
Storage : `device_blocks` aggregate (one row per device × kind × dst).
|
||||||
|
Read by the /admin/device-blocks endpoint + the SOC tile.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
log = logging.getLogger("secubox.toolbox.device_blocks")
|
||||||
|
|
||||||
|
DB_PATH = Path("/var/lib/secubox/toolbox/toolbox.db")
|
||||||
|
_WG_PEERS_DB = Path("/var/lib/secubox/toolbox/wg-peers.json")
|
||||||
|
_SALT_FILE = Path("/etc/secubox/secrets/toolbox-mac-salt")
|
||||||
|
# Common dnsmasq lease locations.
|
||||||
|
_LEASE_FILES = (
|
||||||
|
Path("/var/lib/misc/dnsmasq.leases"),
|
||||||
|
Path("/var/lib/dnsmasq/dnsmasq.leases"),
|
||||||
|
Path("/run/secubox/toolbox-dnsmasq.leases"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS device_blocks (
|
||||||
|
mac_hash TEXT NOT NULL,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- 'blacklist-drop' | 'doh-attempt'
|
||||||
|
dst TEXT NOT NULL,
|
||||||
|
hits INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_seen INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (mac_hash, kind, dst)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devblk_ts ON device_blocks(last_seen);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devblk_mac ON device_blocks(mac_hash, last_seen);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _conn() -> sqlite3.Connection:
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
c = sqlite3.connect(str(DB_PATH), timeout=5.0, isolation_level=None)
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
c.executescript(_SCHEMA)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
# ── device resolver ──
|
||||||
|
_wg_cache: Dict[str, str] = {}
|
||||||
|
_wg_mtime: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _wg_hash(ip: str) -> Optional[str]:
|
||||||
|
global _wg_mtime
|
||||||
|
try:
|
||||||
|
if not _WG_PEERS_DB.exists():
|
||||||
|
return None
|
||||||
|
mt = _WG_PEERS_DB.stat().st_mtime
|
||||||
|
if mt != _wg_mtime or not _wg_cache:
|
||||||
|
_wg_cache.clear()
|
||||||
|
data = json.loads(_WG_PEERS_DB.read_text()).get("peers", {})
|
||||||
|
for pubkey, meta in data.items():
|
||||||
|
pip = meta.get("ip")
|
||||||
|
if pip:
|
||||||
|
_wg_cache[pip] = hashlib.sha256(pubkey.encode()).hexdigest()[:16]
|
||||||
|
_wg_mtime = mt
|
||||||
|
return _wg_cache.get(ip)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _salt() -> str:
|
||||||
|
try:
|
||||||
|
return _SALT_FILE.read_text().strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _lease_mac(ip: str) -> Optional[str]:
|
||||||
|
for lf in _LEASE_FILES:
|
||||||
|
try:
|
||||||
|
if not lf.exists():
|
||||||
|
continue
|
||||||
|
for line in lf.read_text().splitlines():
|
||||||
|
# dnsmasq lease : <expiry> <mac> <ip> <host> <clientid>
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 3 and parts[2] == ip:
|
||||||
|
return parts[1].lower()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_device(ip: str) -> str:
|
||||||
|
"""Map a source IP to an anonymous device hash.
|
||||||
|
|
||||||
|
R3 WG → wg pubkey hash ; captive/br-lxc → hashed lease MAC ; unknown
|
||||||
|
→ a stable hash of the IP itself (so unattributed drops still bucket).
|
||||||
|
"""
|
||||||
|
if not ip:
|
||||||
|
return "unknown"
|
||||||
|
if ip.startswith("10.99.1."):
|
||||||
|
h = _wg_hash(ip)
|
||||||
|
if h:
|
||||||
|
return h
|
||||||
|
mac = _lease_mac(ip)
|
||||||
|
if mac:
|
||||||
|
salt = _salt()
|
||||||
|
if salt:
|
||||||
|
key = (salt + ":" + time.strftime("%Y-%m-%d")).encode()
|
||||||
|
import hmac
|
||||||
|
return hmac.new(key, mac.encode(), hashlib.sha256).hexdigest()[:16]
|
||||||
|
return hashlib.sha256(mac.encode()).hexdigest()[:16]
|
||||||
|
# Fall back to an IP-derived bucket (still anonymous, last-octet kept
|
||||||
|
# for the operator to eyeball which subnet).
|
||||||
|
return "ip:" + ip
|
||||||
|
|
||||||
|
|
||||||
|
def record_block(mac_hash: str, ip: str, kind: str, dst: str) -> None:
|
||||||
|
"""Upsert a device block event (called by the attribution tailer)."""
|
||||||
|
if not (mac_hash and kind and dst):
|
||||||
|
return
|
||||||
|
now = int(time.time())
|
||||||
|
try:
|
||||||
|
with _conn() as c:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO device_blocks(mac_hash, ip, kind, dst, hits, "
|
||||||
|
"first_seen, last_seen) VALUES (?, ?, ?, ?, 1, ?, ?) "
|
||||||
|
"ON CONFLICT(mac_hash, kind, dst) DO UPDATE SET "
|
||||||
|
"hits = hits + 1, ip = excluded.ip, last_seen = excluded.last_seen",
|
||||||
|
(mac_hash, ip, kind, dst, now, now),
|
||||||
|
)
|
||||||
|
except Exception as e: # pragma: no cover
|
||||||
|
log.warning("record_block failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def recent(hours: int = 24, limit: int = 500) -> List[Dict]:
|
||||||
|
since = int(time.time()) - max(hours, 1) * 3600
|
||||||
|
try:
|
||||||
|
with _conn() as c:
|
||||||
|
return [dict(r) for r in c.execute(
|
||||||
|
"SELECT mac_hash, ip, kind, dst, hits, first_seen, last_seen "
|
||||||
|
"FROM device_blocks WHERE last_seen >= ? "
|
||||||
|
"ORDER BY last_seen DESC LIMIT ?",
|
||||||
|
(since, limit),
|
||||||
|
).fetchall()]
|
||||||
|
except Exception as e: # pragma: no cover
|
||||||
|
log.warning("recent failed: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate(hours: int = 24) -> Dict:
|
||||||
|
since = int(time.time()) - max(hours, 1) * 3600
|
||||||
|
out: Dict = {"window_hours": hours, "devices": 0, "total_hits": 0,
|
||||||
|
"by_device": [], "by_kind": []}
|
||||||
|
try:
|
||||||
|
with _conn() as c:
|
||||||
|
out["devices"] = c.execute(
|
||||||
|
"SELECT COUNT(DISTINCT mac_hash) FROM device_blocks WHERE last_seen >= ?",
|
||||||
|
(since,),
|
||||||
|
).fetchone()[0]
|
||||||
|
out["total_hits"] = c.execute(
|
||||||
|
"SELECT COALESCE(SUM(hits),0) FROM device_blocks WHERE last_seen >= ?",
|
||||||
|
(since,),
|
||||||
|
).fetchone()[0]
|
||||||
|
out["by_device"] = [dict(r) for r in c.execute(
|
||||||
|
"SELECT mac_hash, ip, "
|
||||||
|
"SUM(CASE WHEN kind='blacklist-drop' THEN hits ELSE 0 END) AS blacklist, "
|
||||||
|
"SUM(CASE WHEN kind='doh-attempt' THEN hits ELSE 0 END) AS doh, "
|
||||||
|
"SUM(hits) AS total, MAX(last_seen) AS last_seen "
|
||||||
|
"FROM device_blocks WHERE last_seen >= ? "
|
||||||
|
"GROUP BY mac_hash ORDER BY total DESC LIMIT 100",
|
||||||
|
(since,),
|
||||||
|
).fetchall()]
|
||||||
|
out["by_kind"] = [dict(r) for r in c.execute(
|
||||||
|
"SELECT kind, SUM(hits) AS hits, COUNT(DISTINCT mac_hash) AS devices "
|
||||||
|
"FROM device_blocks WHERE last_seen >= ? GROUP BY kind",
|
||||||
|
(since,),
|
||||||
|
).fetchall()]
|
||||||
|
except Exception as e: # pragma: no cover
|
||||||
|
log.warning("aggregate failed: %s", e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def purge_older_than(days: int = 7) -> int:
|
||||||
|
cutoff = int(time.time()) - max(days, 1) * 86400
|
||||||
|
try:
|
||||||
|
with _conn() as c:
|
||||||
|
return c.execute(
|
||||||
|
"DELETE FROM device_blocks WHERE last_seen < ?", (cutoff,)
|
||||||
|
).rowcount or 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
212
packages/secubox-toolbox/secubox_toolbox/escalate.py
Normal file
212
packages/secubox-toolbox/secubox_toolbox/escalate.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
|
||||||
|
"""
|
||||||
|
SecuBox-Deb :: ToolBoX escalation evaluator (Phase 13.D #527)
|
||||||
|
|
||||||
|
Closes the detection → enforcement loop : reads the social-mapping
|
||||||
|
(operator-grade / anti-bot) + device-blocks aggregates, applies
|
||||||
|
operator-tunable thresholds, and escalates high-confidence repeat
|
||||||
|
offenders to the enforcement plane :
|
||||||
|
|
||||||
|
- escalate a tracker/operator-grade HOST → resolve its IPs and add
|
||||||
|
them to the 13.A blacklist set (+ optionally a CrowdSec decision for
|
||||||
|
audit + TTL),
|
||||||
|
- escalate a DEVICE over the DoH/bypass threshold → 13.C quarantine.
|
||||||
|
|
||||||
|
**Everything is DEFAULT OFF.** Each source is enabled independently via
|
||||||
|
env flags (mirrors the SECUBOX_DOH_BLOCK pattern). Every action is
|
||||||
|
logged to the append-only CSPN audit log and is reversible (nft/cscli
|
||||||
|
timeouts + operator unban). This module decides + records ; the thin
|
||||||
|
`secubox-escalate` wrapper invokes it on a timer.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
log = logging.getLogger("secubox.toolbox.escalate")
|
||||||
|
|
||||||
|
NFT = "/usr/sbin/nft"
|
||||||
|
TABLE = "inet secubox_blacklist"
|
||||||
|
AUDIT_LOG = Path("/var/log/secubox/audit.log")
|
||||||
|
ESCALATE_TTL = os.environ.get("SECUBOX_ESCALATE_TTL", "4h")
|
||||||
|
|
||||||
|
|
||||||
|
def _flag(name: str, default: str = "0") -> bool:
|
||||||
|
return os.environ.get(name, default).strip() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def load_policy() -> Dict:
|
||||||
|
"""Operator policy from env (all default OFF). The systemd unit /
|
||||||
|
a drop-in sets these ; nothing escalates until an operator opts in."""
|
||||||
|
return {
|
||||||
|
"opgrade_enabled": _flag("SECUBOX_ESCALATE_OPGRADE"),
|
||||||
|
"opgrade_min_clients": int(os.environ.get("SECUBOX_ESCALATE_OPGRADE_MIN_CLIENTS", "2")),
|
||||||
|
"opgrade_min_events": int(os.environ.get("SECUBOX_ESCALATE_OPGRADE_MIN_EVENTS", "20")),
|
||||||
|
"antibot_enabled": _flag("SECUBOX_ESCALATE_ANTIBOT"),
|
||||||
|
"antibot_min_challenges": int(os.environ.get("SECUBOX_ESCALATE_ANTIBOT_MIN", "50")),
|
||||||
|
"device_doh_enabled": _flag("SECUBOX_ESCALATE_DEVICE_DOH"),
|
||||||
|
"device_doh_threshold": int(os.environ.get("SECUBOX_ESCALATE_DEVICE_DOH_THRESHOLD", "200")),
|
||||||
|
"use_crowdsec": _flag("SECUBOX_ESCALATE_CROWDSEC"),
|
||||||
|
"window_hours": int(os.environ.get("SECUBOX_ESCALATE_WINDOW_H", "24")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(msg: str) -> None:
|
||||||
|
try:
|
||||||
|
AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
with AUDIT_LOG.open("a") as f:
|
||||||
|
f.write(f"{ts} secubox-escalate {msg}\n")
|
||||||
|
except Exception as e: # pragma: no cover
|
||||||
|
log.warning("audit write failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ips(host: str, timeout: float = 2.0) -> List[str]:
|
||||||
|
out: List[str] = []
|
||||||
|
try:
|
||||||
|
socket.setdefaulttimeout(timeout)
|
||||||
|
for fam, *_rest, sockaddr in socket.getaddrinfo(host, None):
|
||||||
|
ip = sockaddr[0]
|
||||||
|
if ip and ip not in out:
|
||||||
|
out.append(ip)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
socket.setdefaulttimeout(None)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _nft_add_blacklist(ip: str) -> bool:
|
||||||
|
setname = "blacklist_v6" if ":" in ip else "blacklist_v4"
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[NFT, "add", "element", "inet", "secubox_blacklist", setname,
|
||||||
|
"{ " + ip + " timeout " + ESCALATE_TTL + " }"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
return r.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _nft_quarantine(ip: str) -> bool:
|
||||||
|
setname = "quarantine_v6" if ":" in ip else "quarantine_v4"
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[NFT, "add", "element", "inet", "secubox_blacklist", setname,
|
||||||
|
"{ " + ip + " timeout " + ESCALATE_TTL + " }"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
return r.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _cscli_decision(ip: str, reason: str) -> bool:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["cscli", "decisions", "add", "--ip", ip,
|
||||||
|
"--duration", ESCALATE_TTL, "--reason", reason, "--type", "ban"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
return r.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_and_apply() -> Dict:
|
||||||
|
"""Run one escalation cycle. Returns a summary of actions taken.
|
||||||
|
No-op (beyond reading aggregates) unless a source is opted-in."""
|
||||||
|
policy = load_policy()
|
||||||
|
summary: Dict = {
|
||||||
|
"ts": int(time.time()),
|
||||||
|
"policy": {k: v for k, v in policy.items()},
|
||||||
|
"opgrade_escalated": 0,
|
||||||
|
"antibot_escalated": 0,
|
||||||
|
"devices_quarantined": 0,
|
||||||
|
"ips_added": 0,
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
from . import social as _social
|
||||||
|
from . import device_blocks as _devblk
|
||||||
|
|
||||||
|
win = policy["window_hours"]
|
||||||
|
agg = _social.aggregate(hours=win)
|
||||||
|
|
||||||
|
# ── operator-grade / state-adjacent hosts ──
|
||||||
|
if policy["opgrade_enabled"]:
|
||||||
|
for row in agg.get("by_opgrade", []):
|
||||||
|
vendor = row.get("opgrade_vendor", "?")
|
||||||
|
clients = int(row.get("clients", 0) or 0)
|
||||||
|
events = int(row.get("events", 0) or 0)
|
||||||
|
if clients < policy["opgrade_min_clients"] or events < policy["opgrade_min_events"]:
|
||||||
|
continue
|
||||||
|
# The aggregate is keyed by vendor, not host — escalate the
|
||||||
|
# vendor's known host fragments by resolving them. We use the
|
||||||
|
# social_opgrade src sites? No — opgrade is host-stable in
|
||||||
|
# social_host_meta. Resolve the vendor's representative hosts.
|
||||||
|
# Conservative: skip if we can't map a concrete host.
|
||||||
|
host = _OPGRADE_VENDOR_HOST.get(vendor)
|
||||||
|
if not host:
|
||||||
|
continue
|
||||||
|
ips = _resolve_ips(host)
|
||||||
|
added = 0
|
||||||
|
for ip in ips:
|
||||||
|
if _nft_add_blacklist(ip):
|
||||||
|
added += 1
|
||||||
|
if policy["use_crowdsec"]:
|
||||||
|
_cscli_decision(ip, f"secubox-escalate:opgrade:{vendor}")
|
||||||
|
if added:
|
||||||
|
summary["opgrade_escalated"] += 1
|
||||||
|
summary["ips_added"] += added
|
||||||
|
summary["actions"].append(f"opgrade {vendor} -> +{added} ip ({host})")
|
||||||
|
_audit(f"ESCALATE opgrade vendor={vendor} host={host} clients={clients} events={events} ips=+{added} ttl={ESCALATE_TTL}")
|
||||||
|
|
||||||
|
# ── anti-bot endpoints (opt-in ; many are legit infra) ──
|
||||||
|
if policy["antibot_enabled"]:
|
||||||
|
for row in agg.get("by_antibot", []):
|
||||||
|
vendor = row.get("antibot_vendor", "?")
|
||||||
|
challenges = int(row.get("challenges", 0) or 0)
|
||||||
|
if challenges < policy["antibot_min_challenges"]:
|
||||||
|
continue
|
||||||
|
summary["antibot_escalated"] += 1
|
||||||
|
summary["actions"].append(f"antibot {vendor} flagged ({challenges})")
|
||||||
|
_audit(f"FLAG antibot vendor={vendor} challenges={challenges} (no auto-ban — review)")
|
||||||
|
|
||||||
|
# ── per-device DoH / bypass over threshold → quarantine ──
|
||||||
|
if policy["device_doh_enabled"]:
|
||||||
|
dev = _devblk.aggregate(hours=win)
|
||||||
|
for d in dev.get("by_device", []):
|
||||||
|
doh = int(d.get("doh", 0) or 0)
|
||||||
|
ip = d.get("ip", "")
|
||||||
|
if doh < policy["device_doh_threshold"] or not ip or ip == "unknown":
|
||||||
|
continue
|
||||||
|
if ip.startswith("ip:"):
|
||||||
|
ip = ip[3:]
|
||||||
|
if _nft_quarantine(ip):
|
||||||
|
summary["devices_quarantined"] += 1
|
||||||
|
summary["actions"].append(f"quarantine device {ip} (doh={doh})")
|
||||||
|
_audit(f"QUARANTINE device ip={ip} doh_attempts={doh} ttl={ESCALATE_TTL}")
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
# Representative resolvable host per operator-grade vendor (data-broker
|
||||||
|
# surfaces). Telco header-enrichment + consortium IDs aren't host-pinnable
|
||||||
|
# this way, so they're skipped (flagged only). Conservative on purpose.
|
||||||
|
_OPGRADE_VENDOR_HOST = {
|
||||||
|
"LiveRamp": "rlcdn.com",
|
||||||
|
"Oracle-BlueKai": "bluekai.com",
|
||||||
|
"Acxiom": "acxiom.com",
|
||||||
|
"Neustar": "agkn.com",
|
||||||
|
"Tapad": "tapad.com",
|
||||||
|
"Experian": "experian.com",
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Phase 13.C (#524) — attribute nft drops/DoH attempts to devices.
|
||||||
|
[Unit]
|
||||||
|
Description=SecuBox blacklist per-device attribution (kernel-log tailer)
|
||||||
|
Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/524
|
||||||
|
After=secubox-toolbox.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/sbin/secubox-blacklist-attrib
|
||||||
|
User=root
|
||||||
|
Nice=15
|
||||||
|
IOSchedulingClass=idle
|
||||||
|
TimeoutStartSec=60
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Phase 13.C (#524) — per-device attribution every 2 min.
|
||||||
|
[Unit]
|
||||||
|
Description=SecuBox blacklist attribution timer (every 2 min)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=120s
|
||||||
|
OnUnitActiveSec=2min
|
||||||
|
AccuracySec=20s
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Phase 13.A (#521) — union ban sources into the nft blacklist sets.
|
||||||
|
[Unit]
|
||||||
|
Description=SecuBox blacklist enforcement sync (CrowdSec + threat-intel -> nft sets)
|
||||||
|
Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/521
|
||||||
|
After=nftables.service secubox-toolbox.service
|
||||||
|
Wants=nftables.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/sbin/secubox-blacklist-sync
|
||||||
|
# Needs CAP_NET_ADMIN for nft set writes ; root is simplest + the script
|
||||||
|
# is read-only against the toolbox DB + cscli.
|
||||||
|
User=root
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=idle
|
||||||
|
TimeoutStartSec=120
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Phase 13.A (#521) — periodic blacklist sync.
|
||||||
|
[Unit]
|
||||||
|
Description=SecuBox blacklist sync timer (every 5 min + boot)
|
||||||
|
Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/521
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Run shortly after boot, then every 5 minutes. The element timeout
|
||||||
|
# (2h) is well above the period so active entries never lapse.
|
||||||
|
OnBootSec=90s
|
||||||
|
OnUnitActiveSec=5min
|
||||||
|
AccuracySec=30s
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
22
packages/secubox-toolbox/systemd/secubox-escalate.service
Normal file
22
packages/secubox-toolbox/systemd/secubox-escalate.service
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Phase 13.D (#527) — escalation evaluator. DEFAULT OFF : set the
|
||||||
|
# SECUBOX_ESCALATE_* env in a drop-in to opt a source in.
|
||||||
|
[Unit]
|
||||||
|
Description=SecuBox detection->enforcement escalation evaluator
|
||||||
|
Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/527
|
||||||
|
After=secubox-toolbox.service secubox-blacklist-sync.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/sbin/secubox-escalate
|
||||||
|
User=root
|
||||||
|
Nice=15
|
||||||
|
IOSchedulingClass=idle
|
||||||
|
TimeoutStartSec=90
|
||||||
|
# All escalation sources OFF by default. Opt in via a drop-in, e.g.:
|
||||||
|
# [Service]
|
||||||
|
# Environment=SECUBOX_ESCALATE_OPGRADE=1
|
||||||
|
# Environment=SECUBOX_ESCALATE_DEVICE_DOH=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
13
packages/secubox-toolbox/systemd/secubox-escalate.timer
Normal file
13
packages/secubox-toolbox/systemd/secubox-escalate.timer
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Phase 13.D (#527) — escalation evaluator every 10 min.
|
||||||
|
[Unit]
|
||||||
|
Description=SecuBox escalation evaluator timer (every 10 min)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=180s
|
||||||
|
OnUnitActiveSec=10min
|
||||||
|
AccuracySec=60s
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
|
@ -148,6 +148,10 @@
|
||||||
<h2>📡 Opérateur-grade / proche-État</h2>
|
<h2>📡 Opérateur-grade / proche-État</h2>
|
||||||
<div id="social-opgrade"><div class="empty">loading…</div></div>
|
<div id="social-opgrade"><div class="empty">loading…</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card" style="grid-column:1/-1">
|
||||||
|
<h2>🛡️ Blocages par appareil (13.C)</h2>
|
||||||
|
<div id="social-devblocks"><div class="empty">loading…</div></div>
|
||||||
|
</div>
|
||||||
<div class="card" style="grid-column:1/-1">
|
<div class="card" style="grid-column:1/-1">
|
||||||
<h2>🎯 Top tracker domains</h2>
|
<h2>🎯 Top tracker domains</h2>
|
||||||
<div id="social-trackers"><div class="empty">loading…</div></div>
|
<div id="social-trackers"><div class="empty">loading…</div></div>
|
||||||
|
|
@ -279,6 +283,17 @@ async function resetClient(macHash) {
|
||||||
} catch (e) { alert('Échec RAZ : ' + e.message); }
|
} catch (e) { alert('Échec RAZ : ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function quarantine(ip) {
|
||||||
|
if (!ip) return;
|
||||||
|
if (!confirm(`Mettre en quarantaine l'appareil ${ip} ? Tout son trafic sera coupé 6h.`)) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/admin/quarantine/${encodeURIComponent(ip)}`, { method: 'POST', credentials: 'same-origin' });
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
alert('Appareil en quarantaine (6h).');
|
||||||
|
loadSocial();
|
||||||
|
} catch (e) { alert('Échec quarantaine : ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMetrics() {
|
async function loadMetrics() {
|
||||||
const m = await J('/admin/metrics');
|
const m = await J('/admin/metrics');
|
||||||
const el = document.getElementById('metrics');
|
const el = document.getElementById('metrics');
|
||||||
|
|
@ -409,6 +424,17 @@ async function loadSocial() {
|
||||||
'</tbody></table>'
|
'</tbody></table>'
|
||||||
: '<div class="empty">aucun anti-bot détecté</div>';
|
: '<div class="empty">aucun anti-bot détecté</div>';
|
||||||
}
|
}
|
||||||
|
const dbk = document.getElementById('social-devblocks');
|
||||||
|
if (dbk) {
|
||||||
|
const db = await J('/admin/device-blocks?hours=24');
|
||||||
|
const bd = (db && db.by_device) || [];
|
||||||
|
dbk.innerHTML = bd.length
|
||||||
|
? `<p style="font-size:0.8rem;color:var(--p31-dim);margin-bottom:0.5rem">${db.devices||0} appareil(s) · ${db.total_hits||0} blocages 24h</p>` +
|
||||||
|
'<table><thead><tr><th>Appareil (hash)</th><th>IP</th><th>blacklist</th><th>DoH</th><th>total</th><th>Quarantaine</th></tr></thead><tbody>' +
|
||||||
|
bd.map(r => `<tr><td><code>${(r.mac_hash||'').slice(0,16)}</code></td><td>${r.ip||'—'}</td><td>${r.blacklist||0}</td><td>${r.doh||0}</td><td>${r.total||0}</td><td><a class="link" href="javascript:void(0)" onclick="quarantine('${r.ip||''}')" style="color:var(--red);font-size:0.78rem">⛔ Quarantaine</a></td></tr>`).join('') +
|
||||||
|
'</tbody></table>'
|
||||||
|
: '<div class="empty">aucun blocage attribué (24h)</div>';
|
||||||
|
}
|
||||||
const og = document.getElementById('social-opgrade');
|
const og = document.getElementById('social-opgrade');
|
||||||
if (og) {
|
if (og) {
|
||||||
const bo = agg.by_opgrade || [];
|
const bo = agg.by_opgrade || [];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user