Compare commits

..

6 Commits

Author SHA1 Message Date
9a275e2355 docs: WIP repo-clean state, drop Round Eye item
Some checks failed
License Headers / check (push) Has been cancelled
2026-06-11 07:46:12 +02:00
2dab321e36 docs: Phase 12.C + Phase 13 (protection enforcement plane) complete — v2.13.16-19
- Phase 12.C operator-grade detection (#518, v2.13.16).
  - Phase 13 A/B/C/D enforcement plane complete (#519-#528, v2.13.17-19):
    nft blacklist spine + DNS-guard + per-device attribution/quarantine +
    detection→enforcement feedback loop (all escalation default OFF).
  - override_dh_strip drift bug fixed (#521).
  - Phase 14 deception-proxy idea captured (#525) for later.
2026-06-11 07:42:42 +02:00
CyberMind
982176209a
Merge pull request #528 from CyberMind-FR/feature/527-phase-13-d-feedback-loop-social-mapping
Phase 13.D — detection→enforcement feedback loop (#527)
2026-06-11 07:40:32 +02:00
de8e6de23c feat(toolbox): Phase 13.D — detection→enforcement feedback loop (ref #527)
Completes Phase 13 (protection enforcement plane). ALL SOURCES DEFAULT OFF.

  - escalate.py: evaluator reads social-mapping (by_opgrade/by_antibot)
    + device-blocks aggregates, applies operator-tunable thresholds:
      * operator-grade 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),
      * devices over DoH threshold → 13.C quarantine.
    Every action append-logged to /var/log/secubox/audit.log (CSPN),
    reversible (nft/cscli TTL + operator unban).
  - sbin/secubox-escalate + .service/.timer (10min); sources opted in
    via SECUBOX_ESCALATE_* drop-in.
  - api.py GET /admin/escalate: policy flags + last-cycle summary.
  - changelog 2.6.11.

Live on gk2: timer active, default-off cycle takes 0 actions; synthetic
test with SECUBOX_ESCALATE_DEVICE_DOH=1 quarantined a device over
threshold + wrote the audit entry. Detection stays factual; escalation
is separate, logged, reversible, opt-in (#519 doctrine).
2026-06-11 07:40:15 +02:00
CyberMind
ffa75bbe9d
Merge pull request #526 from CyberMind-FR/feature/524-phase-13-c-dhcp-wg-per-device-attributio
Phase 13.C — DHCP/WG per-device attribution + quarantine (#524)
2026-06-11 07:35:10 +02:00
9697ea05a9 feat(toolbox): Phase 13.C — DHCP/WG per-device attribution + quarantine (ref #524)
Turns the enforcement counters into per-device intelligence.

  - secubox-blacklist.nft: quarantine_v4/v6 sets + quarantine-first drop
    in the enforce chain (a quarantined device's saddr is fully cut off).
    Rate-limited log prefixes SBX-BL-DROP (enforce) + SBX-DOH (doh_watch,
    ct state new) for attribution.
  - device_blocks.py: device resolver (R3 WG peer hash / dnsmasq-lease
    MAC hashed with rotating salt / IP fallback) + device_blocks SQLite
    aggregate + retention. Anonymous, same mac_hash model as social.
  - sbin/secubox-blacklist-attrib: cursor-based journald kernel-log
    tailer buckets SBX-BL-DROP / SBX-DOH by source IP -> device ->
    device_blocks. 2-min timer.
  - api.py: GET /admin/device-blocks, POST /admin/quarantine/{ip} +
    /admin/unquarantine/{ip}.
  - app.py: device_blocks 7-day retention.
  - index.html operator tab: per-device blocked-attempts card + one-click
    quarantine.
  - changelog 2.6.10.

Live on gk2: enforce chain quarantine-first + drop logs, attrib timer
active, quarantine add/remove via API verified, device resolver maps
10.99.1.60 -> 9433ceb90895a075 (WG hash), record+aggregate green.
Auto-quarantine NOT wired (operator-manual this pass; threshold = #519 Q2).
2026-06-11 07:32:43 +02:00
18 changed files with 896 additions and 9 deletions

View File

@ -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`,

View File

@ -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 /

View File

@ -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.
--- ---

View File

@ -1,3 +1,52 @@
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 secubox-toolbox (2.6.9-1~bookworm1) bookworm; urgency=medium
* Phase 13.B (#522, parent #519) — DNS-guard : resolve blocklisted * Phase 13.B (#522, parent #519) — DNS-guard : resolve blocklisted

View File

@ -168,6 +168,14 @@ fi
systemctl start 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). # Kick an immediate first sync (best-effort).
systemctl start secubox-blacklist-sync.service 2>/dev/null || true 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
fi fi

View File

@ -88,6 +88,20 @@ execute_after_dh_auto_install:
debian/secubox-toolbox/lib/systemd/system/ debian/secubox-toolbox/lib/systemd/system/
install -m 0644 systemd/secubox-blacklist-sync.timer \ install -m 0644 systemd/secubox-blacklist-sync.timer \
debian/secubox-toolbox/lib/systemd/system/ 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 \

View File

@ -39,11 +39,31 @@ table inet secubox_blacklist {
flags interval, timeout 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 { chain enforce {
type filter hook forward priority -10; policy accept; type filter hook forward priority -10; policy accept;
ip daddr @blacklist_v4 counter drop # 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 ip saddr @blacklist_v4 counter drop
ip6 daddr @blacklist_v6 counter drop ip6 daddr @blacklist_v6 limit rate 20/second log prefix "SBX-BL-DROP " counter drop
ip6 saddr @blacklist_v6 counter drop ip6 saddr @blacklist_v6 counter drop
} }
@ -64,7 +84,9 @@ table inet secubox_blacklist {
} }
chain doh_watch { chain doh_watch {
type filter hook forward priority -11; policy accept; type filter hook forward priority -11; policy accept;
ip daddr @doh_detect_v4 tcp dport { 443, 853 } counter # Phase 13.C : rate-limited log (new connections only) so the
ip6 daddr @doh_detect_v6 tcp dport { 443, 853 } counter # 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
} }
} }

View 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

View 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

View File

@ -2153,6 +2153,73 @@ async def admin_blacklist() -> dict:
return out 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.

View File

@ -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)

View 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

View 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",
}

View File

@ -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

View File

@ -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

View 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

View 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

View File

@ -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 || [];