Compare commits

...

23 Commits

Author SHA1 Message Date
CyberMind
dc6505a2f2
Merge pull request #552 from CyberMind-FR/feature/551-android-apk-zero-tap-auto-run-silent-onb
Some checks are pending
License Headers / check (push) Waiting to run
Android: zero-tap auto-run silent onboarding on root (#551)
2026-06-13 13:16:59 +02:00
CyberMind
51ed62f10a
Merge pull request #548 from CyberMind-FR/feature/547-linux-firefox-installer-script-for-the-t
Linux Firefox fast installer/launcher for the ToolBoX extension (#547)
2026-06-13 13:16:53 +02:00
CyberMind
dfa7e34ad0
Merge pull request #550 from CyberMind-FR/feature/549-social-tracker-domain-rollup-history-tim
Social: tracker domain-rollup + history + target↔tracker correlation (#549)
2026-06-13 13:16:17 +02:00
2a51348b9d feat(toolbox): tracker domain-rollup + history + target↔tracker correlation (closes #549)
fetch_graph() gains three additive, read-time keys (no schema change,
d3 /social/graph contract untouched):
- by_domain: trackers rolled up under their registrable parent (eTLD+1,
  all *.doubleclick.net → doubleclick.net) with tracker_count/hits/sites/vendors
- targets: inverse map — per 1st-party site, the trackers + parent domains
  watching it
- history: per-UTC-day timeline (hits/trackers/sites) from social_edges
stats gains total_domains; local _registrable_domain helper (no publicsuffix
dep). Integration-tested (rollup, inversion, history). secubox-toolbox 2.6.17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:16:06 +02:00
CyberMind
4cb8fb87fa
Merge pull request #546 from CyberMind-FR/feature/545-injected-banner-neon-tube-redesign-for-r
Neon-tube injected banner for R3 (+ R4-ready theme) (#545)
2026-06-13 13:15:19 +02:00
8d48d86bcd feat(toolbox): neon-tube injected banner for R3 (+R4-ready theme) (closes #545)
_LEVEL_THEME map drives the banner look per opt-in level: R3 (and the
planned R4) get a neon-tube treatment — dark glass bar, glowing tube
border (layered box-shadow) and neon text-shadow on the title — while R2
keeps the original amber flat bar. _banner_html_dynamic() now takes the
level and themes both the CSP-strict (JS-less) and JS (dismissible)
variants; all inline CSS, no injected <style>/@keyframes, ASCII/NCR-clean.
R4 theme is defined but inert until _client_level() returns 'r4'.
secubox-toolbox 2.6.16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:14:56 +02:00
CyberMind
7a651f360d
Merge pull request #544 from CyberMind-FR/feature/543-kbin-landing-radical-simplify-livelier-f
kbin landing: radical-simplify redesign (#543)
2026-06-13 13:14:08 +02:00
81da97e53e feat(android): zero-tap auto-run silent onboarding on root (closes #551)
On a rooted device the app now runs the full silent onboarding on launch
with no taps: a LaunchedEffect auto-starts the root sequence once when
root is detected and this host hasn't been onboarded yet. The root-auto
logic is extracted into a reusable runRootAuto lambda (shared by the
auto-launch and the  button, which remains for re-runs). An onboarded
flag is persisted per host in SharedPreferences so reopening doesn't redo
it. versionName 0.2.0 / versionCode 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:13:02 +02:00
2647cfffa1 feat(webext): Linux Firefox fast installer/launcher script (closes #547)
install-firefox-linux.sh: one call grabs the ToolBoX .xpi (from a cabine
host, --release, or --local) and launches Firefox with it loaded via
'web-ext run' (temporary, works unsigned — fastest), opening /social/me.
Falls back to opening the .xpi for the install prompt when web-ext/npx
is absent, with the unsigned-install note. Detects firefox/-esr/-bin/flatpak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:04:01 +02:00
e4d59569eb feat(toolbox): kbin landing radical-simplify redesign (closes #543)
Animated hero (gazing eye + floating tracker dots) + one big 'Protège-moi
(R3)' CTA + the auto-detected install panel up front. KPIs, cert-probe,
pitch, R0-R3 levels, charts, architecture, open-source and contact are
folded behind an 'En savoir plus' <details>. Quick-nav trimmed: dropped
the CA iPhone / CA Android / QR profil cards (now inside the per-platform
install panel); kept R3 Install / Mon rapport / Ma carto / Wiki / Cabine.
Count-up animation on the live KPIs. All Jinja vars + live-stats and
cert-probe scripts preserved. secubox-toolbox 2.6.15.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:52:34 +02:00
CyberMind
d2cb735f09
Merge pull request #542 from CyberMind-FR/feature/532-plan-browser-xpi-webextension-emancipate
docs: webext .xpi published + tag-pinned release note (#532)
2026-06-13 12:48:03 +02:00
9b7c6f67f1 fix(webext): 'redeclaration of const ext' SyntaxError + PNG icons, v0.1.1 (ref #532)
Real cause of the broken extension: api.js and background.js BOTH did
`const ext = …`. In a Firefox event page the background.scripts array
loads them into one shared scope (same via importScripts in a Chromium
SW), and popup.js's `const ext` collided with api.js's in the popup
page realm too — 'redeclaration of const ext' aborts the script. web-ext
lint misses it because it never concatenates/executes the scripts.
Now only api.js declares `const ext`; background.js and popup.js use
`api.ext`.

Also (defensive + Chromium-correct): replace the SVG action/extension
icon with rasterised PNGs (48/128) and keep the SVG out of the package,
so Firefox never renders SVG in chrome UI. Bump to v0.1.1; the
/wg/toolbox.xpi endpoint + fetch helper + docs point at webext-v0.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:32:17 +02:00
4289f8ff3c docs: webext .xpi published/downloadable + tag-pinned release note (ref #532)
README + wiki Browser-Extension: add the direct release download URL,
document make_latest:false / tag-pinned design. TODO/WIP: mark the
webext-v0.1.0 release published (downloadable, verified) and note the
remaining board deploy of secubox-toolbox 2.6.14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 11:29:06 +02:00
CyberMind
008da01444
Merge pull request #541 from CyberMind-FR/feature/532-plan-browser-xpi-webextension-emancipate
Tag-pinned .xpi release URL — don't steal "latest" from the APK (#532)
2026-06-13 10:57:16 +02:00
68e2723747 fix(webext): tag-pinned .xpi release URL + make_latest:false (ref #532)
Publishing webext-v* with the default make_latest would steal the
"latest" release pointer from the Android APK release, breaking the
APK endpoint's /releases/latest/download/secubox-toolbox-android.apk
fallback. Publish the webext release with make_latest:false and point
the /wg/toolbox.xpi endpoint + fetch helper at the tag-pinned download
URL instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:45:40 +02:00
CyberMind
b8da257b83
Merge pull request #540 from CyberMind-FR/feature/532-plan-browser-xpi-webextension-emancipate
Browser extension: emancipate R3 toolbox cartographie live (#532)
2026-06-13 10:33:01 +02:00
b2ee2a97ef fix(webext): clean web-ext lint — drop unused optional_host_permissions, ignore build.sh/README (ref #532)
web-ext lint: 0 errors, 2 benign warnings (AMO data-collection
declaration is submission-time, tied to the signing follow-up;
service_worker-ignored is the intentional cross-browser pattern).
optional_host_permissions needed FF128 and was unused at MVP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:21:45 +02:00
c77e6250a9 feat(webext): browser extension — emancipate R3 cartographie live (ref #532)
New client clients/webext-toolbox/ (WebExtension MV3, Firefox .xpi +
Chromium): surfaces the toolbox live tracker analysis in the browser.

- manifest.json MV3, cross-browser background (service_worker + scripts
  for Firefox 115+ event page); host_permissions *.secubox.in only
- api.js: shared client over /wg/r3-check, /social/me (pair → HMAC token),
  /social/graph/{token}, /social/wipe/{token}
- background.js: toolbar badge = live tracker count, silent re-pair on
  token expiry, colour escalates gold → anti-bot → operator-grade
- popup: 4 stat tiles + dependency-free mini Round-Eye SVG graph + top
  trackers tagged CDN/anti-bot/operator-grade + cartographie/PDF/RGPD-wipe
- options: host / analysis window / manual token
- build.sh + build-webext.yml (web-ext lint + build, release on webext-v*)

Serve from the toolbox (2.6.14):
- GET /wg/toolbox.xpi (local file, else 302 → latest release asset)
- '🧩 Extension navigateur' button on both onboard panels
- sbin/secubox-toolbox-fetch-xpi + postinst serve dir + rules install

No server-side CORS needed (MV3 host_permissions). MVP polls /social/graph
and computes the delta client-side; SSE /social/live is a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:08:06 +02:00
CyberMind
e7a84f0380
Merge pull request #491 from CyberMind-FR/feature/490-phase-2c-toolbox-receiving-modules-actua
feat(phase-2c): receiving modules enrichment (nDPI-style classify, JA4 fingerprint, providers, avatar)
2026-06-13 08:19:50 +02:00
e67baf4cd7 feat(secubox-core+5modules+toolbox): Phase 2c receiving modules enrichment (ref #490)
Phase 2b (#488/#489) wired mitm addons to 5 receiving modules but events
were persisted raw. This phase implements actual enrichment in each module
via the enrich_hook param, plus aggregator merge so reports show meaningful
data (YouTube/Signal/iPhone/Safari/JA4 fingerprints) instead of raw bytes.

  - host_app.py  : 60+ host patterns -> app + category + emoji
  - cookie.py    : 40+ cookie tracker patterns -> provider + category + emoji
  - avatar.py    : UA + Client Hints -> device + browser + OS + emoji
  - ja4.py       : NEW. Deterministic JA4-style fingerprint hash from
                   cipher_suites + alpn + extensions. Lookup table for
                   known JA4 fingerprints (empty for now, Phase 3 will
                   populate). 12-char hex (SHA256 truncated).

The host_app/cookie/avatar are copies of the secubox-toolbox classifiers
moved to secubox-core so all 5 receiving modules can import them. The
secubox-toolbox-local ones stay as-is to avoid breaking changes — Phase 3
will consolidate.

  - secubox-dpi             : host/SNI -> {app, category, emoji}
  - secubox-cookies         : cookie names -> {providers{}, categories{}}
  - secubox-avatar          : UA + CH -> {device, browser, os_label}
  - secubox-threat-analyst  : ClientHello -> {ja4_fingerprint, known_client}
  - secubox-soc             : indicators -> {total_weight, band, kinds}

Each is ~25 lines, called by mount_ingest_routes BEFORE persistence.
Enriched output joins the raw event under the 'enriched' key.

_pull_mitm_module_events() now also calls _summarize_enriched(kind, events)
to consolidate per-module enrichment :

  - dpi             : top_apps[] aggregated from enriched.app counts
  - cookies         : top_providers[] from enriched.providers + tracker_total
  - avatar          : devices{} + browsers{} from enriched.{device,browser}
  - threat-analyst  : top_fingerprints[] grouped by JA4 hash
  - soc             : total_weight + max_band + indicator_kinds

These appear under mitm_modules.<kind>.enriched_summary in the /report JSON.

POST realistic payloads to all 5 sockets :
  - YouTube host -> dpi/enriched.app = 'YouTube' (streaming)
  - GA + FB Pixel cookies -> cookies/providers : GA x3, FB x1, total 4
  - iPhone Safari UA -> avatar/device='iPhone' (📱 iOS 17.4) + Safari (🧭)
  - facebook.com ClientHello -> threat-analyst/ja4 = '7175ee3a68f0'
  - 2 indicators (weight 15+25) -> soc/band='medium', total=40, kinds=[dga, suspicious]

  - secubox-dpi : call live nDPI/netifyd socket (currently pattern-match only)
  - secubox-threat-analyst : implement full FoxIO JA4 string format (currently
    deterministic SHA256 trunc which is JA4-like but not the canonical format)
  - secubox-soc : threat-intel feed lookup (currently just sums static weights)
  - secubox-avatar : screen/timing fingerprinting via WebGL hash (currently UA only)
  - Reports (PDF + HTML) : surface mitm_modules.enriched_summary in the report UI
2026-06-13 08:00:18 +02:00
CyberMind
110133bee9
Merge pull request #539 from CyberMind-FR/feature/538-android-app-root-mode-fully-automated-si
Android root-mode fully-automated silent R3 onboarding (#538)
2026-06-13 07:47:00 +02:00
ac14e65353 docs: wiki/README/WIP/TODO/HISTORY for Android ToolBox app + root-mode (ref #538)
- new wiki page Android-ToolBox.md (install via /wg/toolbox.apk, manual +
  root onboarding flows, CI build, endpoints) + sidebar link
- README: document the root-mode silent path + revised constraints
- WIP/TODO/HISTORY: Android client section (#531/#536/#538)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:46:50 +02:00
1351054927 feat(android): root-mode fully-automated silent R3 onboarding (ref #538)
Add an optional one-tap, zero-interaction onboarding path for rooted
devices. When root is detected the app can:

- install the village3b CA into the SYSTEM trust store (bind-mount over
  /system/etc/security/cacerts + conscrypt APEX, SELinux ctx restored),
  so every app trusts the cabine CA — not just user-CA opt-in apps;
- bring the WireGuard tunnel up natively via the kernel module
  (ip link add … type wireguard + wg set), no WireGuard app needed;
- verify R3 reachability automatically.

Falls back to the existing manual handoff (KeyChain CA prompt + WG app)
when the kernel lacks WireGuard. All root actions are gated behind an
explicit ' Installation automatique (root)' tap — nothing runs as root
without the operator choosing it on their own device.

New: RootShell (su wrapper), RootOnboard (silent sequence + subject_hash_old
in pure Kotlin). MainActivity gains a RootAuto step with a streaming log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:40:58 +02:00
45 changed files with 2666 additions and 152 deletions

View File

@ -3,6 +3,41 @@
---
## 2026-06-13 — Browser extension : emancipate cartographie live (ref #532)
Nouveau client `clients/webext-toolbox/` (MV3 Firefox `.xpi` + Chromium),
sœur de l'app Android. Surface la cartographie sociale R3 dans le
navigateur : badge live des traceurs + popup (4 tuiles + mini Round-Eye
graph SVG sans dépendance + top-traceurs taggés CDN/anti-bot/opérateur +
actions cartographie/PDF/RGPD-wipe). Parle uniquement à la cabine via R3
(pas de CORS backend grâce à host_permissions).
`secubox-toolbox 2.6.14` : `GET /wg/toolbox.xpi` (local sinon 302 →
release), bouton onboard, helper `secubox-toolbox-fetch-xpi`, postinst
dir. CI `build-webext.yml` (`web-ext lint` + build, release asset sur tag
`webext-v*`). Suivi : signature AMO, SSE `/social/live`, icône PNG
Chromium, Poke/Emancipate (#525).
---
## 2026-06-13 — Android ToolBox app : serve + root-mode silent onboarding (ref #531/#536/#538)
App compagnon Android one-tap R3 (`clients/android-toolbox/`, Kotlin + Compose).
- **#531** — scaffold Gradle/Compose + CI `build-android-apk.yml` (debug APK
artifact, release asset sur tag `android-v*`). CI green.
- **#536** — `GET /wg/toolbox.apk` (build local sinon 302 → release GitHub) +
bouton onboard kbin + helper `secubox-toolbox-fetch-apk`.
- **#538** (PR #539) — root-mode silent onboarding : install CA système
(bind-mount cacerts + APEX conscrypt, SELinux ctx, `subject_hash_old`
pur Kotlin) + WireGuard natif noyau + vérif R3 auto, gated derrière le tap
`⚡ Installation automatique (root)`. Fallback handoff app WireGuard.
Fichiers `RootShell.kt`, `RootOnboard.kt`, step `RootAuto`. CI APK build
green (code compile).
- Suivi : release signing (keystore CI) pour empreinte publiée stable.
---
## 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.

View File

@ -1,10 +1,30 @@
# TODO — SecuBox-DEB Backlog
*Mis à jour : 2026-06-11*
*Mis à jour : 2026-06-13*
---
## 🔥 P0 — Immediate (in flight)
### ToolBox clients (`clients/`)
- [x] **#531 Android scaffold + CI** — Gradle/Compose one-tap onboarding,
debug APK via `build-android-apk.yml`. CI green.
- [x] **#536 serve APK from toolbox** — `GET /wg/toolbox.apk` + onboard button +
`secubox-toolbox-fetch-apk` helper.
- [x] **#538 Android root-mode silent** (PR #539) — system CA install + native
kernel WireGuard + auto R3 verify, gated behind explicit root tap.
- [x] **#532 browser extension** (`clients/webext-toolbox/`) — MV3 Firefox
`.xpi`/Chromium; live tracker badge + popup mini Round-Eye graph over
`/social/*`; `GET /wg/toolbox.xpi` + fetch helper + `build-webext.yml`.
- [x] **#532 release** — tag `webext-v0.1.1` published the `.xpi`
(downloadable, verified 200). `make_latest:false` + tag-pinned URL so it
doesn't steal "Latest" from the Android APK release.
- [ ] **release signing** — Android keystore + AMO `.xpi` signing secrets in CI
for stable published fingerprints (currently unsigned sideload).
- [ ] **#532 follow-ups** — optional `GET /social/live/{token}` SSE (replace the
client-side poll) ; Poke/Emancipate per-site control once #525 (deception)
ships ; Chromium PNG icon rasterisation for the Web Store.
### Phase 13 — Protection enforcement plane (#519) — ✅ COMPLETE
- [x] **13.A spine** (#521, `2.6.8`, v2.13.17) — nft blacklist set + forward-drop

View File

@ -1,5 +1,65 @@
# WIP — Work In Progress
*Mis à jour : 2026-06-11*
*Mis à jour : 2026-06-13*
---
## 🔄 2026-06-13 : Browser extension — emancipate cartographie live (#532)
Extension navigateur (`clients/webext-toolbox/`, MV3 Firefox `.xpi` +
Chromium) sœur de l'app Android. Sort la *cartographie sociale* R3 dans
le navigateur : badge live des traceurs + popup.
- **Extension** : `manifest.json` (MV3, background `service_worker` +
`scripts` pour FF115+/Chromium), `api.js` (client `/wg/r3-check`,
`/social/me` → token, `/social/graph/{token}`, `/social/wipe`),
`background.js` (badge = total_trackers, re-pair silencieux si token
expiré, couleur escalade gold→anti-bot→opérateur), popup (4 tuiles
stats + **mini Round-Eye graph SVG sans dépendance** + top-traceurs
taggés CDN/anti-bot/opérateur + actions cartographie/PDF/RGPD-wipe),
options (hôte/fenêtre/token manuel). Pas de CORS backend nécessaire
(host_permissions). Validé : JSON+JS+SVG OK, `.xpi` build 11.8 KB.
- **Serve depuis la toolbox** (`2.6.14`) : `GET /wg/toolbox.xpi` (local
sinon 302 → release), bouton `🧩 Extension navigateur` sur les 2
panneaux onboard, helper `secubox-toolbox-fetch-xpi`, postinst dir.
- **CI** : `build-webext.yml``web-ext lint` (0 erreur, 2 warnings
bénins) + build, artifact, release asset sur tag `webext-v*`.
- **Release** (PR #540 + #541, mergées) : tag `webext-v0.1.1` poussé →
CI a publié `secubox-toolbox-webext.xpi` (téléchargeable, vérifié 200).
`make_latest:false` + URL **tag-pinned** dans `/wg/toolbox.xpi` +
`secubox-toolbox-fetch-xpi` pour ne pas voler le pointeur "Latest" à la
release APK Android (dont l'endpoint résout via `/releases/latest/...`).
→ bumper le tag dans la constante + le helper à chaque `webext-v*`.
- **Reste à faire** : signature AMO (`.xpi` non signé = sideload/dev) ;
endpoint SSE `/social/live/{token}` optionnel ; icône PNG Chromium ;
contrôle Poke/Emancipate par-site quand #525 (déception) arrive ;
déployer `secubox-toolbox 2.6.14` sur la board pour activer le bouton.
---
## 🔄 2026-06-13 : Android ToolBox app — serve + root-mode silent onboarding (#531/#536/#538)
App compagnon Android **one-tap R3** pour la cabine VILLAGE3B
(`clients/android-toolbox/`, `in.secubox.toolbox`, Kotlin + Compose).
- **#531 — scaffold + CI** : projet Gradle/Compose (5-step stepper
Discover→InstallCa→ImportProfile→Verify→Done), client `HttpURLConnection`,
workflow `build-android-apk.yml` (debug APK artifact, release asset sur
tag `android-v*`). CI **GREEN**.
- **#536 — serve depuis la toolbox** : endpoint `GET /wg/toolbox.apk`
(sert le build local `/var/lib/secubox/toolbox/android/`, sinon 302 →
release GitHub) + bouton *📱 Installer l'app ToolBoX (1-tap)* dans les
panneaux onboard kbin + helper `secubox-toolbox-fetch-apk`. Vérifié :
200 `application/vnd.android.package-archive`, 14.8 MB.
- **#538 — root-mode silent onboarding** (PR #539, branche poussée) :
bouton *⚡ Installation automatique (root)* sur devices rootés →
install CA dans le magasin **système** (bind-mount cacerts + APEX
conscrypt, SELinux ctx, `subject_hash_old` en Kotlin pur) + tunnel
WireGuard **natif noyau** (`ip link add … type wireguard` + `wg set`) +
vérif R3 auto. Fallback handoff app WireGuard si noyau sans WG. Toutes
les actions root gated derrière le tap explicite. Nouveaux fichiers
`RootShell.kt`, `RootOnboard.kt`, step `RootAuto` (log streamé).
- **Reste à faire** : release signing (keystore secret CI) pour une
empreinte publiée stable — actuellement debug-signé (sideload).
---

63
.github/workflows/build-webext.yml vendored Normal file
View File

@ -0,0 +1,63 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Build the SecuBox ToolBoX browser extension (#532).
# Plain JS/HTML/CSS — no bundler. web-ext lints + packages the .xpi.
# Produces an unsigned .xpi artifact; release signing (AMO) is a
# follow-up (needs AMO API credentials as secrets).
name: build-webext
on:
push:
branches: [ master ]
paths: [ "clients/webext-toolbox/**", ".github/workflows/build-webext.yml" ]
tags: [ "webext-v*" ]
pull_request:
paths: [ "clients/webext-toolbox/**" ]
workflow_dispatch:
permissions:
contents: write # needed to attach the .xpi to a release on tags
jobs:
build:
runs-on: ubuntu-22.04
defaults:
run:
working-directory: clients/webext-toolbox
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Lint (web-ext)
run: npx --yes web-ext lint --source-dir . --self-hosted --ignore-files build.sh README.md
- name: Build .xpi (web-ext)
run: |
npx --yes web-ext build --source-dir . \
--artifacts-dir web-ext-artifacts --overwrite-dest \
--ignore-files build.sh README.md \
--filename "secubox-toolbox-webext.xpi"
- name: Upload .xpi artifact
uses: actions/upload-artifact@v4
with:
name: secubox-toolbox-webext
path: clients/webext-toolbox/web-ext-artifacts/secubox-toolbox-webext.xpi
if-no-files-found: error
# On webext-v* tags, publish the .xpi as a release asset under the
# stable name the toolbox fetch helper + /wg/toolbox.xpi expect.
# make_latest:false so this client release does NOT steal the
# "latest" pointer from the Android APK release (which the APK
# endpoint resolves via /releases/latest/download/…). The xpi
# endpoint/fetcher therefore use a tag-pinned download URL.
- name: Publish release
if: startsWith(github.ref, 'refs/tags/webext-v')
uses: softprops/action-gh-release@v2
with:
files: clients/webext-toolbox/web-ext-artifacts/secubox-toolbox-webext.xpi
fail_on_unmatched_files: true
make_latest: false

View File

@ -5,13 +5,39 @@ One-tap **R3 onboarding** for the VILLAGE3B cabine : install the CA,
import the WireGuard profile, verify the tunnel, then open the live
*cartographie sociale*. Replaces the manual Android tutorial.
## Flow
## Flow (manual path)
1. **Discover** — scan the kbin QR or type the booth host (`kbin.gk2.secubox.in`).
2. **Install CA** — downloads `/wg/ca.crt`, launches the Android cert-install intent (`KeyChain.createInstallIntent`).
3. **Import profile** — downloads `/wg/profile/new`, hands the `.conf` to the WireGuard app via `FileProvider` + `ACTION_VIEW`.
4. **Verify** — polls `/wg/r3-check` → "Tunnel R3 actif ✓".
5. **Live metrics** — opens `/social/me` (cartographie sociale).
## Root path — fully-automated silent onboarding (#538, #551)
When the device is **rooted**, the app runs the whole onboarding with **zero
taps**: on launch it auto-detects root and, if this cabine host hasn't been
onboarded yet, starts the silent sequence automatically (`RootAuto` step,
streaming log). The **⚡ Installation automatique (root)** button stays for
re-runs. The "already onboarded" flag is persisted per host (SharedPreferences)
so reopening the app doesn't redo it. Steps:
1. **System CA install** — downloads `/wg/ca.pem`, computes the OpenSSL
`subject_hash_old` in pure Kotlin, and bind-mounts a populated copy of
the trust store over `/system/etc/security/cacerts` (+ the conscrypt
APEX path on Android 14), restoring the SELinux context
(`u:object_r:system_security_cacerts_file:s0`). **Every** app trusts the
cabine CA — not just user-CA opt-in apps. Reversible via `umount`.
2. **Native WireGuard** — if the kernel has the WireGuard module + `wg`/`ip`,
brings the tunnel up natively (`ip link add … type wireguard` + `wg set`),
no WireGuard app required.
3. **Auto R3 verify** — polls `/wg/r3-check`.
**Fallback** — if the kernel lacks WireGuard, the root path installs the
system CA then hands off to the manual WireGuard-app flow (steps 35 above).
All root actions are **gated behind the explicit tap** — nothing runs as
root without the operator choosing root mode on their own device.
See `RootShell.kt` (su wrapper) and `RootOnboard.kt` (silent sequence).
## Build
No Gradle wrapper jar is committed (text-only scaffold). CI builds it:
- **GitHub Actions** `build-android-apk.yml` → debug APK artifact.
@ -22,12 +48,14 @@ gradle :app:assembleDebug # app/build/outputs/apk/debug/app-debug.apk
```
## Constraints (MVP)
- Android 11+ restricts **user CA trust** ; the app launches the install
intent + guides the manual confirm step. Browsers on the device need
the CA trusted for the mitm R3 break — this is the known Android
limitation (documented, not yet automated).
- WireGuard profile import uses the **official WireGuard app** (no embedded
tunnel in the MVP) — most reliable, no extra native deps.
- Android 11+ restricts **user CA trust** ; the *manual* path launches the
install intent + guides the confirm step. Browsers on the device need the
CA trusted for the mitm R3 break — this is the known Android limitation on
non-rooted devices. **Rooted devices bypass it entirely** via the system
CA install (see Root path above).
- The *manual* path imports the WireGuard profile via the **official
WireGuard app** (no embedded tunnel) — most reliable, no extra native
deps. The *root* path brings the tunnel up natively with the kernel module.
- Debug APK is self-signed (sideload). Release signing (published
fingerprint, served from the toolbox) is a follow-up needing a keystore
secret in CI.

View File

@ -12,8 +12,8 @@ android {
applicationId = "in.secubox.toolbox"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
versionCode = 2
versionName = "0.2.0"
}
buildTypes {

View File

@ -14,6 +14,7 @@ import android.provider.Settings
import android.security.KeyChain
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -40,7 +41,7 @@ private val Matrix = Color(0xFF00FF41)
private val Cinnabar = Color(0xFFE63946)
private val TextPrimary = Color(0xFFE8E6D9)
enum class Step { Discover, InstallCa, ImportProfile, Verify, Done }
enum class Step { Discover, RootAuto, InstallCa, ImportProfile, Verify, Done }
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -61,6 +62,52 @@ fun OnboardApp() {
var onTunnel by remember { mutableStateOf(false) }
var peerIp by remember { mutableStateOf<String?>(null) }
val api = remember(host) { ToolboxApi(host) }
var rootAvail by remember { mutableStateOf(false) }
val rootLog = remember { mutableStateListOf<String>() }
val prefs = remember {
ctx.getSharedPreferences("secubox-toolbox", android.content.Context.MODE_PRIVATE)
}
var autoTried by remember { mutableStateOf(false) }
// The whole root-mode silent run, reused by the ⚡ button AND the
// zero-tap auto-launch (#551). Persists an onboarded flag per host on
// success so reopening the app doesn't redo it.
val runRootAuto: () -> Unit = {
busy = true; status = ""; rootLog.clear()
scope.launch {
val ok = withContext(Dispatchers.IO) { api.reachable() }
if (!ok) {
busy = false; status = "Borne injoignable — vérifie le réseau."
} else {
step = Step.RootAuto
val onb = RootOnboard(api, ctx.cacheDir)
val out = withContext(Dispatchers.IO) {
onb.runSilent { line -> scope.launch(Dispatchers.Main) { rootLog.add(line) } }
}
busy = false
onTunnel = out.verified
if (out.verified) prefs.edit().putBoolean("onboarded:$host", true).apply()
when {
out.verified -> step = Step.Done
out.wgViaApp -> { step = Step.ImportProfile
status = "CA installé en root ✓ — termine le tunnel via l'app WireGuard." }
else -> { step = Step.Verify
status = "Active le tunnel puis vérifie." }
}
}
}
}
// Detect root once, off the main thread.
LaunchedEffect(Unit) { rootAvail = withContext(Dispatchers.IO) { RootShell.available() } }
// Zero-tap (#551): on a rooted device, auto-run the silent onboarding
// once on launch — unless this host was already onboarded.
LaunchedEffect(rootAvail) {
if (rootAvail && !autoTried && step == Step.Discover) {
autoTried = true
if (!prefs.getBoolean("onboarded:$host", false)) runRootAuto()
}
}
MaterialTheme(colorScheme = darkColorScheme(
primary = Gold, secondary = Cyan, background = Cosmos, surface = Cosmos,
@ -98,6 +145,36 @@ fun OnboardApp() {
else status = "Borne injoignable — vérifie l'adresse / le réseau."
}
}
if (rootAvail) {
Spacer(Modifier.height(10.dp))
Text("🔓 Root détecté — l'installation se lance automatiquement. " +
"Tu peux aussi la relancer ici.",
color = Matrix, fontSize = 12.sp)
Spacer(Modifier.height(6.dp))
OutlinedButton(onClick = runRootAuto, modifier = Modifier.fillMaxWidth(),
border = BorderStroke(1.dp, Matrix),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Matrix)) {
Text("⚡ Installation automatique (root)", fontWeight = FontWeight.Bold)
}
}
}
Step.RootAuto -> {
StepBody("Installation automatique (root)",
"CA système + tunnel WireGuard, sans intervention.")
Surface(color = Color(0xFF0E0E15), shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
rootLog.forEach { line ->
Text(line, color = if (line.startsWith("")) Cinnabar
else if (line.startsWith("")) Matrix else TextPrimary,
fontSize = 12.sp, fontFamily = FontFamily.Monospace)
}
if (busy) {
Spacer(Modifier.height(6.dp))
CircularProgressIndicator(Modifier.size(18.dp), color = Gold, strokeWidth = 2.dp)
}
}
}
}
Step.InstallCa -> {
StepBody("1 · Installer le certificat (CA R3)",

View File

@ -0,0 +1,161 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// Root-mode fully-automated silent R3 onboarding (#538).
// All actions are gated behind an explicit "root auto" tap in the UI.
package `in`.secubox.toolbox
import java.io.File
import java.security.MessageDigest
import java.security.cert.CertificateFactory
class RootOnboard(private val api: ToolboxApi, private val cacheDir: File) {
/** A line appended to the on-screen log during the silent run. */
fun interface Logger { fun log(line: String) }
data class Outcome(val caInstalled: Boolean, val wgUp: Boolean, val verified: Boolean,
val wgViaApp: Boolean)
// ── system CA install (silent, root) ──
/**
* OpenSSL `subject_hash_old` (pre-1.0 hash) computed WITHOUT openssl :
* MD5 of the DER-encoded subject name, first 4 bytes as a uint32
* little-endian, formatted "%08x". This is the filename the Android
* system cacerts store uses (<hash>.0).
*/
fun subjectHashOld(pem: ByteArray): String {
val cf = CertificateFactory.getInstance("X.509")
val cert = cf.generateCertificate(pem.inputStream()) as java.security.cert.X509Certificate
val subjectDer = cert.subjectX500Principal.encoded
val md5 = MessageDigest.getInstance("MD5").digest(subjectDer)
val h = (md5[0].toLong() and 0xff) or
((md5[1].toLong() and 0xff) shl 8) or
((md5[2].toLong() and 0xff) shl 16) or
((md5[3].toLong() and 0xff) shl 24)
return String.format("%08x", h)
}
/**
* Install the CA into the SYSTEM trust store so EVERY app (not just
* those opting into user CAs) trusts it. Uses the bind-mount-over-
* cacerts technique that works on Android 1014 (incl. the conscrypt
* APEX). Non-persistent across reboot fine for a temporary cabine
* diagnostic; the app can also unmount to revert.
*/
fun installCaSystem(log: Logger): Boolean {
log.log("• Téléchargement du CA…")
val pem = api.download("/wg/ca.pem", "village3b-ca.pem", cacheDir).readBytes()
val hash = subjectHashOld(pem)
log.log("• CA hash : $hash.0")
val local = File(cacheDir, "$hash.0").apply { writeBytes(pem) }
// Push the cert to a root-readable scratch path, then bind-mount a
// populated copy of the system store over the live cacerts dir.
val pushed = "/data/local/tmp/sbx-$hash.0"
val push = RootShell.install(local, pushed, "644")
if (!push.ok) { log.log("✗ push échoué : ${push.err.trim()}"); return false }
val r = RootShell.runScript(
"set -e",
"CERT_DIR=/system/etc/security/cacerts",
"TMP=/data/local/tmp/sbx-cacerts",
"rm -rf \$TMP; mkdir -p \$TMP",
// seed with the existing system + APEX certs so nothing is lost
"cp -f \$CERT_DIR/* \$TMP/ 2>/dev/null || true",
"cp -f /apex/com.android.conscrypt/cacerts/* \$TMP/ 2>/dev/null || true",
"cp -f $pushed \$TMP/$hash.0",
"chmod 644 \$TMP/* 2>/dev/null || true",
"chown 0:0 \$TMP/* 2>/dev/null || true",
"chcon u:object_r:system_security_cacerts_file:s0 \$TMP/* 2>/dev/null || true",
// bind-mount over the live store (and the APEX path on 14)
"mount -o bind \$TMP \$CERT_DIR",
"[ -d /apex/com.android.conscrypt/cacerts ] && mount -o bind \$TMP /apex/com.android.conscrypt/cacerts 2>/dev/null || true",
"echo OK",
)
if (r.ok && r.out.contains("OK")) {
log.log("✓ CA installé dans le magasin système (toutes les apps le font confiance)")
return true
}
log.log("✗ install CA système : ${r.err.trim().ifBlank { r.out.trim() }}")
return false
}
fun removeCaSystem(log: Logger): Boolean {
val r = RootShell.runScript(
"umount /system/etc/security/cacerts 2>/dev/null || true",
"umount /apex/com.android.conscrypt/cacerts 2>/dev/null || true",
"echo OK",
)
log.log(if (r.ok) "✓ CA système retiré (démonté)" else "✗ démontage : ${r.err.trim()}")
return r.ok
}
// ── WireGuard bring-up ──
/** Parse the wg-quick .conf into the fields we need. */
private data class WgConf(val privKey: String, val address: String,
val pubKey: String, val endpoint: String, val allowed: String)
private fun parse(conf: String): WgConf? {
var pk = ""; var addr = ""; var pub = ""; var ep = ""; var aip = ""
conf.lineSequence().forEach { raw ->
val l = raw.trim()
when {
l.startsWith("PrivateKey", true) -> pk = l.substringAfter("=").trim()
l.startsWith("Address", true) -> addr = l.substringAfter("=").trim()
l.startsWith("PublicKey", true) -> pub = l.substringAfter("=").trim()
l.startsWith("Endpoint", true) -> ep = l.substringAfter("=").trim()
l.startsWith("AllowedIPs", true) -> aip = l.substringAfter("=").trim()
}
}
return if (pk.isNotBlank() && pub.isNotBlank() && ep.isNotBlank()) WgConf(pk, addr, pub, ep, aip) else null
}
/**
* Bring the tunnel up natively with root IF the kernel has WireGuard
* + `wg`/`ip`. Returns true on success ; false means the caller
* should fall back to the WireGuard-app handoff.
*/
fun setupWireguardRoot(log: Logger): Boolean {
if (!RootShell.hasKernelWireguard()) {
log.log("• Noyau sans module WireGuard — bascule sur l'app WireGuard")
return false
}
log.log("• Génération du profil WireGuard…")
val conf = api.downloadProfile(cacheDir).readText()
val wg = parse(conf) ?: run { log.log("✗ profil illisible"); return false }
val iface = "wg-village3b"
val r = RootShell.runScript(
"set -e",
"ip link del $iface 2>/dev/null || true",
"ip link add $iface type wireguard",
"echo '${wg.privKey}' > /data/local/tmp/sbx-wg.key && chmod 600 /data/local/tmp/sbx-wg.key",
"wg set $iface private-key /data/local/tmp/sbx-wg.key peer ${wg.pubKey} endpoint ${wg.endpoint} allowed-ips ${wg.allowed.ifBlank { "0.0.0.0/0" }} persistent-keepalive 25",
"rm -f /data/local/tmp/sbx-wg.key",
if (wg.address.isNotBlank()) "ip addr add ${wg.address} dev $iface 2>/dev/null || true" else ":",
"ip link set $iface up",
"for n in ${wg.allowed.replace(",", " ")}; do ip route replace \$n dev $iface 2>/dev/null || true; done",
"echo OK",
)
if (r.ok && r.out.contains("OK")) { log.log("✓ Tunnel $iface actif (root, natif)"); return true }
log.log("✗ WG natif : ${r.err.trim().ifBlank { r.out.trim() }} — bascule sur l'app")
return false
}
/** Run the whole silent sequence. Blocking — call off-main. */
fun runSilent(log: Logger): Outcome {
val ca = installCaSystem(log)
val wgRoot = setupWireguardRoot(log)
var verified = false
if (wgRoot) {
log.log("• Vérification R3…")
Thread.sleep(1500)
val (t, ip) = api.r3Check()
verified = t
log.log(if (t) "✓ Tunnel R3 confirmé (${ip ?: "?"})" else "• Pas encore confirmé — réessaie la vérification")
}
return Outcome(caInstalled = ca, wgUp = wgRoot, verified = verified, wgViaApp = !wgRoot)
}
}

View File

@ -0,0 +1,63 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
package `in`.secubox.toolbox
import java.io.File
/**
* Thin wrapper around `su` for the root-mode silent onboarding (#538).
* Every action is gated behind an explicit user tap in the UI nothing
* runs as root without the operator choosing root mode on their own
* device.
*/
object RootShell {
data class Result(val code: Int, val out: String, val err: String) {
val ok get() = code == 0
}
/** True if a `su` binary is on PATH and grants a root shell. */
fun available(): Boolean = try {
run("id -u").out.trim() == "0"
} catch (_: Exception) { false }
/** Run a single command in a root shell. Blocking — call off-main. */
fun run(cmd: String): Result {
val p = ProcessBuilder("su", "-c", cmd)
.redirectErrorStream(false)
.start()
val out = p.inputStream.bufferedReader().readText()
val err = p.errorStream.bufferedReader().readText()
val code = p.waitFor()
return Result(code, out, err)
}
/** Run several commands in ONE root shell (atomic-ish, keeps remount). */
fun runScript(vararg lines: String): Result {
val p = ProcessBuilder("su").redirectErrorStream(false).start()
p.outputStream.bufferedWriter().use { w ->
lines.forEach { w.write(it); w.write("\n") }
w.write("exit $?\n")
}
val out = p.inputStream.bufferedReader().readText()
val err = p.errorStream.bufferedReader().readText()
val code = p.waitFor()
return Result(code, out, err)
}
/** Push a local file to a root-owned path via cat (avoids cp quirks). */
fun install(src: File, destPath: String, mode: String = "644"): Result {
val b64 = android.util.Base64.encodeToString(src.readBytes(), android.util.Base64.NO_WRAP)
return runScript(
"echo '$b64' | base64 -d > '$destPath'",
"chmod $mode '$destPath'",
)
}
/** Kernel has WireGuard + the `wg` tool available to root? */
fun hasKernelWireguard(): Boolean = try {
val w = run("command -v wg || ls /system/*bin/wg 2>/dev/null")
val ip = run("command -v ip")
w.out.isNotBlank() && ip.out.isNotBlank()
} catch (_: Exception) { false }
}

5
clients/webext-toolbox/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# build artefacts
*.xpi
*.zip
*.crx
web-ext-artifacts/

View File

@ -0,0 +1,110 @@
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
# SecuBox ToolBoX — browser extension (Cartographie sociale, #532)
A WebExtension (Firefox `.xpi` + Chromium MV3) that **emancipates** the R3
toolbox live tracker analysis into the browser: instead of only seeing the
*cartographie sociale* on `kbin/social/me`, a toolbar badge ticks up as
trackers fire, and a popup shows who is watching you — live.
Sibling of [`clients/android-toolbox/`](../android-toolbox/). Talks **only**
to your cabine over the R3 tunnel — no third-party calls.
## What it does
- **Pairing** — calls `/social/me` over the tunnel, which 303-redirects to
`/social/{token}`; the extension reads the minted HMAC token from the
final URL. Anonymous (rotating `mac_hash`), no account. Manual token entry
available in the options page.
- **Live badge** — the toolbar icon shows the live tracker count for the
session (polled once a minute). Colour escalates: gold → 🟥 anti-bot
present → 🟪 operator-grade present.
- **Popup** — four stat tiles (trackers / sites / anti-bot / operator-grade),
a dependency-free **mini Round-Eye graph** (device centre, trackers on the
ring, radius by hits, colour by tier), and a top-tracker list with CDN
(12.A) / anti-bot (12.B) / operator-grade (12.C) tags.
- **Actions***Cartographie complète* (opens the full d3 view at
`/social/{token}`), *Rapport PDF* (`/social/report/{token}.pdf`), and
*Effacer mes données* (RGPD art. 17 wipe → `POST /social/wipe/{token}`).
## Install
Published release `.xpi` (downloadable directly):
```
https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.1/secubox-toolbox-webext.xpi
```
The toolbox also serves it from the cabine:
```
https://kbin.<board>.secubox.in/wg/toolbox.xpi
```
The kbin onboard panel exposes a **🧩 Extension navigateur (cartographie)**
button. When a local build is present the cabine serves it; otherwise it
302-redirects to the **tag-pinned** release asset above. The webext release
is published `make_latest:false` so it does not steal the repo "Latest"
pointer from the Android APK release (whose endpoint resolves via
`/releases/latest/download/…`) — bump the tag in the `/wg/toolbox.xpi`
endpoint constant + `secubox-toolbox-fetch-xpi` when a new `webext-v*`
release is cut.
- **Firefox** — open the `.xpi`. A permanent install needs an AMO-signed
build (release CI step / `web-ext sign`); for development use
*about:debugging → Load Temporary Add-on*, or an ESR/Dev build with
`xpinstall.signatures.required=false`.
- **Linux Firefox (fast)** — one call grabs the `.xpi` and launches Firefox
with it loaded (via `web-ext run`, no signing needed):
```bash
clients/webext-toolbox/install-firefox-linux.sh # from kbin.gk2.secubox.in
clients/webext-toolbox/install-firefox-linux.sh --release # from the GitHub release
clients/webext-toolbox/install-firefox-linux.sh --local # from this checkout
```
- **Chromium** — load unpacked (`chrome://extensions` → Developer mode).
Ships rasterised PNG icons (`icons/icon-48/128.png`), so it loads as-is.
## Build
No bundler — the extension is plain JS/HTML/CSS. CI zips it:
- GitHub Actions `build-webext.yml``.xpi` artifact on push to `master` /
PRs touching `clients/webext-toolbox/**`; tagging `webext-v*` publishes the
`.xpi` as a release asset.
Locally:
```bash
cd clients/webext-toolbox
./build.sh # → secubox-toolbox-webext-<version>.xpi
```
## Files
| File | Role |
|------|------|
| `manifest.json` | MV3, cross-browser background (`service_worker` + `scripts`) |
| `api.js` | shared client over `/wg/r3-check`, `/social/*` |
| `background.js` | badge sync + silent re-pair (SW or event page) |
| `popup/` | live view, mini graph (`graph.js`), actions |
| `options/` | host / window / manual token |
## Cabine endpoints consumed
| Endpoint | Purpose |
|----------|---------|
| `/wg/r3-check` | tunnel presence indicator |
| `/social/me` | pair → mint token (303 → `/social/{token}`) |
| `/social/graph/{token}?since=` | per-session tracker graph JSON |
| `/social/wipe/{token}` | RGPD art. 17 erasure |
| `/social/{token}` | full d3 cartographie page |
| `/social/report/{token}.pdf` | bilingual PDF report |
## Notes
- No server-side CORS needed: an MV3 extension with `host_permissions` for
`*.secubox.in` fetches cross-origin from its background without CORS.
- MVP polls `/social/graph` and computes the delta client-side; a future
`GET /social/live/{token}` (SSE) can replace the poll. The deception-plane
*Poke/Emancipate* per-site control lands once #525 ships.
License `LicenseRef-CMSD-1.0`.

View File

@ -0,0 +1,116 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// SecuBox-Deb :: webext-toolbox :: api
// Thin client over the R3 toolbox social endpoints. Shared by the
// background service worker and the popup. Cross-origin fetches are
// allowed because the extension holds host_permissions for the cabine
// vhosts — no server-side CORS needed.
// browser (Firefox promise API) || chrome (Chromium / FF MV3 SW)
const ext = globalThis.browser || globalThis.chrome;
const DEFAULTS = {
host: "kbin.gk2.secubox.in",
token: "",
since: 86400,
};
// base URL from a stored host (accept bare host or full origin)
function baseUrl(host) {
const h = (host || DEFAULTS.host).trim().replace(/\/+$/, "");
if (/^https?:\/\//i.test(h)) return h;
return `https://${h}`;
}
async function getConfig() {
const stored = await ext.storage.local.get(["host", "token", "since"]);
return { ...DEFAULTS, ...stored };
}
async function setConfig(patch) {
await ext.storage.local.set(patch);
return getConfig();
}
// Extract the HMAC token from a /social/{token} URL path.
function tokenFromUrl(url) {
try {
const u = new URL(url);
const m = u.pathname.match(/\/social\/([^/?#]+)/);
if (m && m[1] !== "me" && m[1].split(".").length === 4) return m[1];
} catch (_) {}
return null;
}
// Pair: hit /social/me over the tunnel; it 303-redirects to
// /social/{token}. fetch follows the redirect, so response.url carries
// the minted token. Returns the token or throws.
async function pair(host) {
const url = `${baseUrl(host)}/social/me`;
const resp = await fetch(url, { redirect: "follow", credentials: "omit" });
const tok = tokenFromUrl(resp.url);
if (!tok) throw new Error("pairing failed — not on the R3 tunnel?");
return tok;
}
// r3-check: is this client on the R3 tunnel right now?
async function r3Check(host) {
try {
const resp = await fetch(`${baseUrl(host)}/wg/r3-check`, { credentials: "omit" });
if (!resp.ok) return { tunnel: false, peer_ip: null };
return await resp.json();
} catch (_) {
return { tunnel: false, peer_ip: null };
}
}
// graph: the per-session cartographie JSON. Throws on HTTP error so the
// caller can show "token expired — re-pair".
async function graph(host, token, since) {
const qs = new URLSearchParams({ since: String(since || DEFAULTS.since) });
const resp = await fetch(`${baseUrl(host)}/social/graph/${token}?${qs}`, {
credentials: "omit",
});
if (resp.status === 403 || resp.status === 404) {
throw new Error("token-expired");
}
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return await resp.json();
}
// RGPD art.17 wipe.
async function wipe(host, token) {
const resp = await fetch(`${baseUrl(host)}/social/wipe/${token}`, {
method: "POST",
credentials: "omit",
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return await resp.json();
}
function socialUrl(host, token) {
return `${baseUrl(host)}/social/${token}`;
}
function reportUrl(host, token) {
return `${baseUrl(host)}/social/report/${token}.pdf`;
}
const SbxApi = {
DEFAULTS,
ext,
baseUrl,
getConfig,
setConfig,
pair,
r3Check,
graph,
wipe,
socialUrl,
reportUrl,
tokenFromUrl,
};
// Usable both as a classic background script (globalThis) and an ES-less
// service worker. No module syntax to stay loadable as plain script.
globalThis.SbxApi = SbxApi;

View File

@ -0,0 +1,81 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// SecuBox-Deb :: webext-toolbox :: background
// Keeps the toolbar badge in sync with the live tracker count and
// re-pairs over the R3 tunnel when the token expires. Works as a
// Chromium MV3 service worker (importScripts) AND a Firefox event page
// (api.js preloaded via background.scripts).
if (typeof importScripts === "function") {
// Chromium service worker: api.js isn't auto-loaded, pull it in.
try { importScripts("api.js"); } catch (_) {}
}
// NB: do NOT declare `const ext` here — api.js already declares it in the
// shared script scope (event page) / worker global (importScripts), and a
// second `const ext` is a "redeclaration of const ext" SyntaxError that
// kills the whole background script. Use api.ext instead.
const api = globalThis.SbxApi;
const ALARM = "sbx-refresh";
const PERIOD_MIN = 1; // poll the cabine once a minute
function setBadge(text, color) {
try {
api.ext.action.setBadgeText({ text: text || "" });
if (color) ext.action.setBadgeBackgroundColor({ color });
} catch (_) {}
}
// Pull the graph, update the badge with the live tracker count. Auto
// re-pairs once if the stored token has expired.
async function refresh() {
const cfg = await api.getConfig();
if (!cfg.host) { setBadge("", "#6b6b7a"); return; }
let token = cfg.token;
const run = async (tok) => api.graph(cfg.host, tok, cfg.since);
try {
if (!token) token = await api.pair(cfg.host);
let data;
try {
data = await run(token);
} catch (e) {
if (String(e.message) === "token-expired") {
token = await api.pair(cfg.host); // one silent re-pair
data = await run(token);
} else throw e;
}
await api.setConfig({ token });
const n = (data.stats && data.stats.total_trackers) || 0;
// colour escalates with operator-grade / anti-bot presence
const opg = (data.stats && data.stats.opgrade_sites) || 0;
const ab = (data.stats && data.stats.antibot_sites) || 0;
const color = opg > 0 ? "#6e40c9" : ab > 0 ? "#e63946" : "#c9a84c";
setBadge(n > 999 ? "999+" : String(n), color);
await ext.storage.local.set({ lastStats: data.stats || {}, lastError: "" });
} catch (e) {
setBadge("!", "#6b6b7a");
await ext.storage.local.set({ lastError: String(e.message || e) });
}
}
api.ext.runtime.onInstalled.addListener(() => {
api.ext.alarms.create(ALARM, { periodInMinutes: PERIOD_MIN });
refresh();
});
api.ext.runtime.onStartup && api.ext.runtime.onStartup.addListener(() => {
api.ext.alarms.create(ALARM, { periodInMinutes: PERIOD_MIN });
refresh();
});
api.ext.alarms.onAlarm.addListener((a) => { if (a.name === ALARM) refresh(); });
// popup asks for an immediate refresh after pairing / config change
api.ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg && msg.type === "refresh") {
refresh().then(() => sendResponse({ ok: true }));
return true; // async response
}
});

26
clients/webext-toolbox/build.sh Executable file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
#
# SecuBox ToolBoX Cartographie — build the unsigned .xpi (a zip of the
# extension dir). Firefox loads it as-is (temporary add-on / ESR with
# signatures off) ; a release build signs it via web-ext / AMO.
# Usage: ./build.sh → produces ./secubox-toolbox-webext-<version>.xpi
set -euo pipefail
cd "$(dirname "$0")"
VER=$(grep -oE '"version"[^,]*' manifest.json | grep -oE '[0-9.]+' | head -1)
OUT="secubox-toolbox-webext-${VER}.xpi"
rm -f "$OUT"
# -FS = sync (drop stale entries) ; exclude VCS, dotfiles, build script,
# any previously built artefact, docs, and the SVG icon source (only the
# rasterised PNGs are referenced by the manifest — keep SVG out of the
# package so Firefox never renders it in chrome UI).
zip -r -FS "$OUT" . \
-x '*.git*' '*/.*' 'build.sh' '*.xpi' 'README.md' 'icons/icon.svg' >/dev/null
echo "built $OUT ($(stat -c%s "$OUT" 2>/dev/null || stat -f%z "$OUT") bytes)"
echo "Firefox: about:debugging → This Firefox → Load Temporary Add-on → pick the .xpi (or manifest.json)."
echo "Permanent install needs signing (web-ext sign / AMO) or Dev/ESR with xpinstall.signatures.required=false."
echo "Chromium: action icons must be raster — rasterise icons/icon.svg to PNG before a Chromium store build."

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<rect width="128" height="128" rx="24" fill="#0a0a0f"/>
<!-- outer eye almond -->
<path d="M64 36 C92 36 114 64 114 64 C114 64 92 92 64 92 C36 92 14 64 14 64 C14 64 36 36 64 36 Z"
fill="none" stroke="#c9a84c" stroke-width="5"/>
<!-- iris -->
<circle cx="64" cy="64" r="20" fill="#0c0c12" stroke="#00ff41" stroke-width="4"/>
<circle cx="64" cy="64" r="7" fill="#00ff41"/>
<!-- tracker spokes -->
<g stroke="#6e40c9" stroke-width="3" opacity="0.8">
<line x1="64" y1="64" x2="104" y2="40"/>
<line x1="64" y1="64" x2="24" y2="40"/>
<line x1="64" y1="64" x2="100" y2="92"/>
</g>
<g fill="#00d4ff">
<circle cx="104" cy="40" r="5"/>
<circle cx="24" cy="40" r="5"/>
</g>
<circle cx="100" cy="92" r="5" fill="#e63946"/>
</svg>

After

Width:  |  Height:  |  Size: 951 B

View File

@ -0,0 +1,87 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
#
# SecuBox ToolBoX — Linux Firefox installer (#547)
# One call: grab the ToolBoX cartographie extension and launch Firefox with
# it loaded. Prefers `web-ext run` (temporary load, works unsigned — fastest)
# and falls back to opening the .xpi for the install prompt.
#
# Usage:
# ./install-firefox-linux.sh # from kbin.gk2.secubox.in
# ./install-firefox-linux.sh kbin.my.box # from another cabine host
# ./install-firefox-linux.sh --release # from the latest GitHub release
# ./install-firefox-linux.sh --local # build from this checkout (web-ext)
set -euo pipefail
DEFAULT_HOST="kbin.gk2.secubox.in"
RELEASE_URL="https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.1/secubox-toolbox-webext.xpi"
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
say(){ printf '\033[1;36m▸\033[0m %s\n' "$*"; }
warn(){ printf '\033[1;33m!\033[0m %s\n' "$*" >&2; }
die(){ printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; }
# ── resolve source ──
MODE="host"; HOST="$DEFAULT_HOST"; SRC_URL=""
case "${1:-}" in
--release) MODE="release"; SRC_URL="$RELEASE_URL" ;;
--local) MODE="local" ;;
"") SRC_URL="https://${HOST}/wg/toolbox.xpi" ;;
-*) die "unknown flag: $1 (use --release | --local | <host>)" ;;
*) HOST="$1"; SRC_URL="https://${HOST}/wg/toolbox.xpi" ;;
esac
# ── find a Firefox binary ──
FX=""
for c in firefox firefox-esr firefox-bin firefox-developer-edition; do
if command -v "$c" >/dev/null 2>&1; then FX="$c"; break; fi
done
if [ -z "$FX" ] && command -v flatpak >/dev/null 2>&1 \
&& flatpak info org.mozilla.firefox >/dev/null 2>&1; then
FX="flatpak run org.mozilla.firefox"
fi
[ -n "$FX" ] || die "no Firefox found (install firefox / firefox-esr, or flatpak org.mozilla.firefox)"
say "Firefox: $FX"
have_webext(){ command -v web-ext >/dev/null 2>&1 || command -v npx >/dev/null 2>&1; }
runwebext(){ if command -v web-ext >/dev/null 2>&1; then web-ext "$@"; else npx --yes web-ext "$@"; fi; }
# ── fastest path: web-ext run (temporary load, no signing needed) ──
if have_webext; then
SRCDIR=""
if [ "$MODE" = "local" ]; then
[ -f "$SELF_DIR/manifest.json" ] || die "--local: no manifest.json next to this script"
SRCDIR="$SELF_DIR"
else
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
say "Downloading extension from ${SRC_URL}"
curl -fsSL "$SRC_URL" -o "$TMP/sbx.xpi" || die "download failed: $SRC_URL"
head -c2 "$TMP/sbx.xpi" | grep -q PK || die "not a valid .xpi (zip) — wrong host/URL?"
mkdir -p "$TMP/ext" && ( cd "$TMP/ext" && unzip -q "$TMP/sbx.xpi" )
SRCDIR="$TMP/ext"
fi
say "Launching Firefox with the ToolBoX extension loaded (temporary)…"
FXBIN="${FX%% *}" # web-ext wants the binary, not a flatpak wrapper
if [ "$FX" = "${FX# }" ] && command -v "$FXBIN" >/dev/null 2>&1; then
exec runwebext run --source-dir "$SRCDIR" --firefox "$FXBIN" \
--start-url "https://${HOST}/social/me"
fi
exec runwebext run --source-dir "$SRCDIR" --start-url "https://${HOST}/social/me"
fi
# ── fallback: open the .xpi so Firefox shows the install prompt ──
warn "web-ext not found (no npx) — falling back to the install prompt."
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
[ "$MODE" = "local" ] && die "--local needs web-ext/npx; install nodejs or use a host/--release"
say "Downloading ${SRC_URL}"
curl -fsSL "$SRC_URL" -o "$TMP/secubox-toolbox-webext.xpi" || die "download failed"
head -c2 "$TMP/secubox-toolbox-webext.xpi" | grep -q PK || die "not a valid .xpi"
cat <<'NOTE'
! The .xpi is unsigned. Stock Firefox release refuses a permanent install.
Use Firefox ESR/Developer/Nightly, or set in about:config:
xpinstall.signatures.required = false
…then accept the install prompt that opens now.
NOTE
say "Opening Firefox on the extension…"
exec $FX "$TMP/secubox-toolbox-webext.xpi"

View File

@ -0,0 +1,36 @@
{
"manifest_version": 3,
"name": "SecuBox ToolBoX — Cartographie sociale",
"version": "0.1.1",
"description": "Surface the SecuBox R3 toolbox live tracker analysis (cartographie sociale) in your browser: live badge, per-session trackers, mini Round-Eye graph, RGPD wipe + PDF report.",
"browser_specific_settings": {
"gecko": {
"id": "secubox-toolbox-webext@cybermind.fr",
"strict_min_version": "115.0"
}
},
"permissions": ["storage", "alarms"],
"host_permissions": [
"*://*.secubox.in/*"
],
"action": {
"default_title": "SecuBox Cartographie",
"default_popup": "popup/popup.html",
"default_icon": {
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"icons": {
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"background": {
"service_worker": "background.js",
"scripts": ["api.js", "background.js"]
},
"options_ui": {
"page": "options/options.html",
"open_in_tab": true
}
}

View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
<html lang="fr">
<head>
<meta charset="utf-8">
<title>SecuBox Cartographie — Réglages</title>
<style>
body { background:#0a0a0f; color:#e8e6d9; font:14px/1.5 system-ui,sans-serif;
max-width:520px; margin:40px auto; padding:0 20px; }
h1 { color:#c9a84c; font-size:18px; }
label { display:block; color:#6b6b7a; font-size:12px; margin:14px 0 4px; }
input,select { width:100%; padding:8px; border-radius:6px; border:1px solid #333;
background:#14141c; color:#e8e6d9; }
button { margin-top:16px; padding:9px 14px; border-radius:6px; border:1px solid #c9a84c;
background:#c9a84c; color:#0a0a0f; font-weight:700; cursor:pointer; }
.muted { color:#6b6b7a; font-size:12px; }
#msg { color:#00ff41; min-height:18px; margin-top:10px; }
</style>
</head>
<body>
<h1>👁️ SecuBox Cartographie — Réglages</h1>
<p class="muted">L'extension parle uniquement à ta cabine via le tunnel R3.
Aucune donnée n'est envoyée ailleurs.</p>
<label>Borne (hôte de la cabine)
<input id="host" type="text" placeholder="kbin.gk2.secubox.in" autocomplete="off">
</label>
<label>Fenêtre d'analyse
<select id="since">
<option value="3600">1 heure</option>
<option value="86400" selected>24 heures</option>
<option value="604800">7 jours</option>
</select>
</label>
<label>Jeton de session (optionnel — sinon appairage auto via R3)
<input id="token" type="text" placeholder="mac.exp.nonce.sig" autocomplete="off">
</label>
<button id="save">Enregistrer</button>
<p id="msg"></p>
<script src="../api.js"></script>
<script src="options.js"></script>
</body>
</html>

View File

@ -0,0 +1,24 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
const api = globalThis.SbxApi;
const $ = (id) => document.getElementById(id);
async function load() {
const cfg = await api.getConfig();
$("host").value = cfg.host;
$("token").value = cfg.token || "";
$("since").value = String(cfg.since);
}
$("save").addEventListener("click", async () => {
await api.setConfig({
host: $("host").value.trim() || api.DEFAULTS.host,
token: $("token").value.trim(),
since: parseInt($("since").value, 10) || api.DEFAULTS.since,
});
api.ext.runtime.sendMessage({ type: "refresh" });
$("msg").textContent = "Enregistré ✓";
setTimeout(() => ($("msg").textContent = ""), 1500);
});
load();

View File

@ -0,0 +1,80 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// Dependency-free mini "Round-Eye" cartographie : the device at the
// centre, top trackers on an outer ring, radius/colour by hits + tier.
// A compact stand-in for the full d3 view served at /social/{token}.
const SVGNS = "http://www.w3.org/2000/svg";
const PAL = {
base: "#c9a84c", // gold
cdn: "#00d4ff", // cyan
ab: "#e63946", // cinnabar (anti-bot)
opg: "#6e40c9", // void purple (operator-grade)
eye: "#00ff41", // matrix green
link: "#2a2a3a",
};
function el(name, attrs) {
const n = document.createElementNS(SVGNS, name);
for (const k in attrs) n.setAttribute(k, attrs[k]);
return n;
}
function tierOf(node) {
if (node.opgrade_vendor) return "opg";
if (node.antibot_vendor) return "ab";
if (node.cdn_vendor) return "cdn";
return "base";
}
function renderGraph(svg, data) {
while (svg.firstChild) svg.removeChild(svg.firstChild);
const W = 260, H = 180, cx = W / 2, cy = H / 2;
const nodes = (data && data.nodes ? data.nodes.slice() : [])
.sort((a, b) => (b.hits || 0) - (a.hits || 0))
.slice(0, 14);
if (!nodes.length) {
const t = el("text", { x: cx, y: cy, fill: "#6b6b7a", "font-size": 11,
"text-anchor": "middle" });
t.textContent = "Aucun traceur détecté pour l'instant";
svg.appendChild(t);
return;
}
const maxHits = Math.max(...nodes.map((n) => n.hits || 1));
const R = 66;
// spokes first (under the dots)
nodes.forEach((n, i) => {
const a = (i / nodes.length) * Math.PI * 2 - Math.PI / 2;
const x = cx + Math.cos(a) * R, y = cy + Math.sin(a) * R;
svg.appendChild(el("line", { x1: cx, y1: cy, x2: x, y2: y,
stroke: PAL.link, "stroke-width": 1 }));
});
// tracker dots
nodes.forEach((n, i) => {
const a = (i / nodes.length) * Math.PI * 2 - Math.PI / 2;
const x = cx + Math.cos(a) * R, y = cy + Math.sin(a) * R;
const r = 3 + Math.round(6 * Math.sqrt((n.hits || 1) / maxHits));
const fill = PAL[tierOf(n)];
const c = el("circle", { cx: x, cy: y, r, fill, "fill-opacity": 0.85 });
const title = el("title", {});
title.textContent = `${n.domain}${n.hits || 0} hits`
+ (n.cdn_vendor ? ` · ${n.cdn_vendor}` : "")
+ (n.antibot_vendor ? ` · anti-bot ${n.antibot_vendor}` : "")
+ (n.opgrade_vendor ? ` · opérateur ${n.opgrade_vendor}` : "");
c.appendChild(title);
svg.appendChild(c);
});
// the eye (device) at the centre
svg.appendChild(el("circle", { cx, cy, r: 13, fill: "#0c0c12",
stroke: PAL.eye, "stroke-width": 2 }));
svg.appendChild(el("circle", { cx, cy, r: 4.5, fill: PAL.eye }));
}
globalThis.renderGraph = renderGraph;

View File

@ -0,0 +1,83 @@
/* SPDX-License-Identifier: LicenseRef-CMSD-1.0 */
/* SecuBox cyberpunk/hermetic palette (DESIGN-CHARTER) */
:root {
--cosmos: #0a0a0f;
--gold: #c9a84c;
--cinnabar: #e63946;
--matrix: #00ff41;
--void: #6e40c9;
--cyan: #00d4ff;
--text: #e8e6d9;
--muted: #6b6b7a;
}
* { box-sizing: border-box; }
body {
width: 300px;
margin: 0;
background: var(--cosmos);
color: var(--text);
font: 13px/1.4 system-ui, "Segoe UI", sans-serif;
padding: 10px 12px 8px;
}
header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.logo { color: var(--gold); font-weight: 700; letter-spacing: .5px; }
.r3 {
font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px;
border: 1px solid currentColor;
}
.r3.on { color: var(--matrix); }
.r3.off { color: var(--muted); }
.muted { color: var(--muted); font-size: 11px; }
.err { color: var(--cinnabar); font-size: 11px; min-height: 14px; }
label { display: block; font-size: 11px; color: var(--muted); margin: 8px 0 4px; }
input[type=text] {
width: 100%; padding: 7px 8px; border-radius: 6px;
border: 1px solid #333; background: #14141c; color: var(--text);
}
button {
cursor: pointer; border: 1px solid #333; border-radius: 6px;
background: #14141c; color: var(--text); padding: 7px 8px; font-size: 12px;
}
button:hover { border-color: var(--gold); }
button.go {
width: 100%; margin-top: 8px; background: var(--gold); color: var(--cosmos);
font-weight: 700; border-color: var(--gold);
}
button.danger { color: var(--cinnabar); border-color: var(--cinnabar); }
.stats { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 6px; margin-bottom: 8px; }
.stat {
background: #12121a; border: 1px solid #222; border-radius: 6px;
padding: 6px 2px; text-align: center;
}
.stat b { display: block; font-size: 16px; color: var(--gold); }
.stat span { font-size: 9px; color: var(--muted); }
.stat.warn b { color: var(--cinnabar); }
.stat.opg b { color: var(--void); }
#graph { width: 100%; height: 180px; background: #0c0c12; border-radius: 8px; display: block; }
.toplist { margin: 8px 0; max-height: 132px; overflow-y: auto; }
.row {
display: flex; align-items: center; gap: 6px; padding: 3px 2px;
border-bottom: 1px solid #1a1a22; font-size: 11px;
}
.row .dom { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .hits { color: var(--muted); }
.tier { font-size: 9px; padding: 1px 4px; border-radius: 3px; }
.tier.cdn { background: #1d2a33; color: var(--cyan); }
.tier.ab { background: #2a1416; color: var(--cinnabar); }
.tier.opg { background: #1e1430; color: var(--void); }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin: 6px 0; }
.actions button:last-child { grid-column: 1 / 3; }
footer {
display: flex; justify-content: space-between; align-items: center;
margin-top: 6px; padding-top: 6px; border-top: 1px solid #1a1a22;
}
footer a { color: var(--cyan); text-decoration: none; font-size: 11px; }

View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="popup.css">
<title>SecuBox Cartographie</title>
</head>
<body>
<header>
<span class="logo">👁️ VILLAGE3B</span>
<span id="r3dot" class="r3 off" title="État du tunnel R3">R3</span>
</header>
<!-- Pairing (shown when no token) -->
<section id="pair" hidden>
<p class="muted">Connecte-toi à la cabine pour voir qui t'observe.</p>
<label>Borne
<input id="host" type="text" placeholder="kbin.gk2.secubox.in" autocomplete="off">
</label>
<button id="pairBtn" class="go">Appairer (R3)</button>
<p id="pairMsg" class="err"></p>
</section>
<!-- Live view (shown when paired) -->
<section id="live" hidden>
<div class="stats">
<div class="stat"><b id="sTrackers"></b><span>traceurs</span></div>
<div class="stat"><b id="sSites"></b><span>sites</span></div>
<div class="stat warn"><b id="sAntibot"></b><span>anti-bot</span></div>
<div class="stat opg"><b id="sOpgrade"></b><span>opérateur</span></div>
</div>
<svg id="graph" viewBox="0 0 260 180" role="img" aria-label="Mini cartographie"></svg>
<div class="toplist" id="topList"></div>
<div class="actions">
<button id="openFull">🗺️ Cartographie complète</button>
<button id="pdf">📄 Rapport PDF</button>
<button id="wipe" class="danger">🧹 Effacer mes données</button>
</div>
<p id="liveMsg" class="muted"></p>
</section>
<footer>
<a href="#" id="settings">Réglages</a>
<span class="muted" id="ver"></span>
</footer>
<script src="../api.js"></script>
<script src="graph.js"></script>
<script src="popup.js"></script>
</body>
</html>

View File

@ -0,0 +1,133 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// SecuBox-Deb :: webext-toolbox :: popup controller
// NB: api.js (loaded first in this page) already declares `const ext` in the
// shared script scope — re-declaring it here is a "redeclaration of const ext"
// SyntaxError that aborts popup.js. Use api.ext instead.
const api = globalThis.SbxApi;
const $ = (id) => document.getElementById(id);
function show(which) {
$("pair").hidden = which !== "pair";
$("live").hidden = which !== "live";
}
function fillTopList(nodes) {
const list = $("topList");
list.innerHTML = "";
(nodes || [])
.slice()
.sort((a, b) => (b.hits || 0) - (a.hits || 0))
.slice(0, 12)
.forEach((n) => {
const row = document.createElement("div");
row.className = "row";
const dom = document.createElement("span");
dom.className = "dom";
dom.textContent = n.domain || n.id;
row.appendChild(dom);
if (n.opgrade_vendor) addTier(row, "opg", n.opgrade_vendor);
else if (n.antibot_vendor) addTier(row, "ab", n.antibot_vendor);
else if (n.cdn_vendor) addTier(row, "cdn", n.cdn_vendor);
const hits = document.createElement("span");
hits.className = "hits";
hits.textContent = (n.hits || 0) + "×";
row.appendChild(hits);
list.appendChild(row);
});
}
function addTier(row, cls, label) {
const t = document.createElement("span");
t.className = "tier " + cls;
t.textContent = label;
row.appendChild(t);
}
function paint(data) {
const s = data.stats || {};
$("sTrackers").textContent = s.total_trackers ?? 0;
$("sSites").textContent = s.total_sites ?? 0;
$("sAntibot").textContent = s.antibot_sites ?? 0;
$("sOpgrade").textContent = s.opgrade_sites ?? 0;
globalThis.renderGraph($("graph"), data);
fillTopList(data.nodes);
}
async function load() {
const cfg = await api.getConfig();
$("ver").textContent = "v" + (api.ext.runtime.getManifest().version || "");
// tunnel indicator
api.r3Check(cfg.host).then((r) => {
const dot = $("r3dot");
dot.className = "r3 " + (r.tunnel ? "on" : "off");
dot.title = r.tunnel ? `Tunnel R3 actif (${r.peer_ip || "?"})` : "Hors tunnel R3";
});
if (!cfg.token) {
$("host").value = cfg.host;
show("pair");
return;
}
show("live");
$("liveMsg").textContent = "Chargement…";
try {
const data = await api.graph(cfg.host, cfg.token, cfg.since);
paint(data);
$("liveMsg").textContent = "";
} catch (e) {
if (String(e.message) === "token-expired") {
// token died — drop it and go back to pairing
await api.setConfig({ token: "" });
show("pair");
$("host").value = cfg.host;
$("pairMsg").textContent = "Session expirée — ré-appaire.";
} else {
$("liveMsg").textContent = "Erreur : " + e.message;
}
}
}
// ── events ──
$("pairBtn").addEventListener("click", async () => {
const host = $("host").value.trim() || api.DEFAULTS.host;
$("pairMsg").textContent = "Appairage…";
try {
await api.setConfig({ host });
const token = await api.pair(host);
await api.setConfig({ token });
api.ext.runtime.sendMessage({ type: "refresh" });
await load();
} catch (e) {
$("pairMsg").textContent = e.message + " (es-tu sur le tunnel ?)";
}
});
$("openFull").addEventListener("click", async () => {
const cfg = await api.getConfig();
api.ext.tabs.create({ url: api.socialUrl(cfg.host, cfg.token) });
});
$("pdf").addEventListener("click", async () => {
const cfg = await api.getConfig();
api.ext.tabs.create({ url: api.reportUrl(cfg.host, cfg.token) });
});
$("wipe").addEventListener("click", async () => {
if (!confirm("Effacer toutes tes données de cartographie sur la cabine ?")) return;
const cfg = await api.getConfig();
try {
const r = await api.wipe(cfg.host, cfg.token);
$("liveMsg").textContent = `Effacé : ${r.rows_deleted ?? 0} entrées.`;
await api.setConfig({ token: "" });
setTimeout(load, 800);
} catch (e) {
$("liveMsg").textContent = "Erreur effacement : " + e.message;
}
});
$("settings").addEventListener("click", (e) => {
e.preventDefault();
api.ext.runtime.openOptionsPage();
});
load();

View File

@ -0,0 +1,10 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Shared classifiers used by mitm-ingest enrich_hooks across modules.
- host_app : host/SNI app + category + emoji
- cookie : cookie name provider + category + emoji
- avatar : UA device + browser + os + emoji
- ja4 : TLS ClientHello fingerprint hash
"""
from . import host_app, cookie, avatar, ja4 # noqa: F401

View File

@ -0,0 +1,116 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Avatar analysis : UA + Client Hints → device emoji + readable name."""
from __future__ import annotations
import re
# Devices identification patterns. Order = priority (first match wins).
DEVICE_PATTERNS = [
# ── iPhone ──
(re.compile(r"iPhone\s?OS\s?(\d+_\d+)|iPhone.*OS\s?(\d+_\d+)", re.I),
"iPhone", "📱", "iPhone iOS {}"),
(re.compile(r"iPhone", re.I), "iPhone", "📱", "iPhone"),
# ── iPad ──
(re.compile(r"iPad", re.I), "iPad", "📱", "iPad"),
# ── Mac ──
(re.compile(r"Mac OS X (\d+[._]\d+)", re.I), "Mac", "💻", "macOS {}"),
(re.compile(r"Macintosh", re.I), "Mac", "💻", "Mac"),
# ── Android ──
(re.compile(r"Pixel\s?(\d+)", re.I), "Pixel", "📱", "Pixel {}"),
(re.compile(r"SM-[A-Z]\d+", re.I), "Samsung", "📱", "Samsung"),
(re.compile(r"Android (\d+)", re.I), "Android", "📱", "Android {}"),
(re.compile(r"Android", re.I), "Android", "📱", "Android"),
# ── Windows ──
(re.compile(r"Windows NT 11"), "Windows", "💻", "Windows 11"),
(re.compile(r"Windows NT 10"), "Windows", "💻", "Windows 10"),
(re.compile(r"Windows NT"), "Windows", "💻", "Windows"),
# ── Linux ──
(re.compile(r"Linux", re.I), "Linux", "🐧", "Linux"),
# ── Game / IoT ──
(re.compile(r"PlayStation", re.I), "PlayStation", "🎮", "PlayStation"),
(re.compile(r"Xbox", re.I), "Xbox", "🎮", "Xbox"),
(re.compile(r"Nintendo", re.I), "Nintendo", "🎮", "Nintendo"),
(re.compile(r"AppleTV", re.I), "Apple TV", "📺", "Apple TV"),
(re.compile(r"Roku", re.I), "Roku", "📺", "Roku"),
# ── Bot / known clients ──
(re.compile(r"curl/", re.I), "curl", "🛠", "curl"),
(re.compile(r"wget/", re.I), "wget", "🛠", "wget"),
]
BROWSER_PATTERNS = [
(re.compile(r"Edg/(\d+)"), "Edge", "🪟", "Edge {}"),
(re.compile(r"Chrome/(\d+)"), "Chrome", "🟢", "Chrome {}"),
(re.compile(r"Firefox/(\d+)"), "Firefox","🦊", "Firefox {}"),
(re.compile(r"Safari/(\d+)"), "Safari", "🧭", "Safari"),
(re.compile(r"OPR/(\d+)|Opera/(\d+)"), "Opera", "🔴", "Opera"),
(re.compile(r"DuckDuckGo/(\d+)"), "DuckDuckGo", "🦆", "DuckDuckGo {}"),
]
def classify_user_agent(ua: str) -> dict:
"""Returns {device, device_emoji, os_label, browser, browser_emoji, browser_label, raw}."""
if not ua:
return {"device": "unknown", "device_emoji": "", "os_label": "?",
"browser": "unknown", "browser_emoji": "", "browser_label": "?",
"raw": ""}
device_match = None
device_label = "unknown"
for pattern, label, emoji, template in DEVICE_PATTERNS:
m = pattern.search(ua)
if m:
# Try to fill the template with first non-None group
groups = [g for g in m.groups() if g]
if groups and "{}" in template:
device_label = template.format(groups[0].replace("_", "."))
else:
device_label = template
device_match = {"device": label, "device_emoji": emoji,
"os_label": device_label}
break
if not device_match:
device_match = {"device": "unknown", "device_emoji": "",
"os_label": ua[:50]}
browser_match = None
for pattern, label, emoji, template in BROWSER_PATTERNS:
m = pattern.search(ua)
if m:
groups = [g for g in m.groups() if g]
if groups and "{}" in template:
bl = template.format(groups[0])
else:
bl = template
browser_match = {"browser": label, "browser_emoji": emoji, "browser_label": bl}
break
if not browser_match:
browser_match = {"browser": "unknown", "browser_emoji": "", "browser_label": "?"}
return {**device_match, **browser_match, "raw": ua[:200]}
def analyze_user_agents(ua_set: set[str] | list[str]) -> dict:
"""Aggregate a set of UAs : returns {devices, browsers, most_common, raw_count}."""
if not ua_set:
return {"devices": {}, "browsers": {}, "most_common": None, "raw_count": 0}
devices: dict[str, dict] = {}
browsers: dict[str, dict] = {}
for ua in ua_set:
cls = classify_user_agent(ua)
d = cls["device"]
if d not in devices:
devices[d] = {"count": 0, "emoji": cls["device_emoji"], "os_label": cls["os_label"]}
devices[d]["count"] += 1
b = cls["browser"]
if b not in browsers:
browsers[b] = {"count": 0, "emoji": cls["browser_emoji"], "label": cls["browser_label"]}
browsers[b]["count"] += 1
# Most common device
most_common = max(devices.items(), key=lambda x: x[1]["count"])[0] if devices else None
return {
"devices": devices,
"browsers": browsers,
"most_common": most_common,
"most_common_emoji": devices[most_common]["emoji"] if most_common else "",
"raw_count": len(ua_set),
}

View File

@ -0,0 +1,140 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Cookie analysis : identify trackers + providers + categorize.
Phase 2a+ heuristic: pattern matching sur les noms de cookies bien connus,
mapping vers fournisseur + catégorie (analytics / advertising / social / etc.).
Database extensible pour Phase 3 on chargera depuis cookiepedia ou EasyList.
"""
from __future__ import annotations
import re
# Pattern → (provider, category, emoji)
COOKIE_PATTERNS = [
# ── Analytics ──
(re.compile(r"^_ga(_|$|t)"), "Google Analytics", "analytics", "📊"),
(re.compile(r"^_gid$"), "Google Analytics", "analytics", "📊"),
(re.compile(r"^_gat"), "Google Analytics", "analytics", "📊"),
(re.compile(r"^_gcl_au$"), "Google Ads conversion", "advertising", "💰"),
(re.compile(r"^_pk_(id|ses|cvar)"), "Matomo / Piwik", "analytics", "📊"),
(re.compile(r"^plausible_"), "Plausible", "analytics", "📊"),
(re.compile(r"^_mkto_trk$"), "Marketo", "analytics", "📊"),
(re.compile(r"^__hssc$|^__hstc$"), "HubSpot", "analytics", "📊"),
(re.compile(r"^mp_[a-z0-9]+_mixpanel"), "Mixpanel", "analytics", "📊"),
(re.compile(r"^amplitude_"), "Amplitude", "analytics", "📊"),
(re.compile(r"^optimizelyEndUserId$"), "Optimizely", "analytics", "📊"),
(re.compile(r"^_hjSession"), "Hotjar", "analytics", "📊"),
(re.compile(r"^_hjFirstSeen$"), "Hotjar", "analytics", "📊"),
(re.compile(r"^crisp-client/session/"), "Crisp Chat", "analytics", "💬"),
# ── Advertising / Tracking ──
(re.compile(r"^_fbp$|^fr$"), "Facebook Pixel", "advertising","🎯"),
(re.compile(r"^IDE$"), "Google DoubleClick", "advertising","🎯"),
(re.compile(r"^NID$"), "Google", "advertising","🎯"),
(re.compile(r"^DSID$"), "Google DoubleClick", "advertising","🎯"),
(re.compile(r"^uid$|^bcookie$|^lidc$"), "LinkedIn Insight", "advertising","💼"),
(re.compile(r"^MUID$|^_uetsid$|^_uetvid$"), "Microsoft Clarity / Bing Ads", "advertising", "🎯"),
(re.compile(r"^_pin_unauth$|^_pinterest_ct_"), "Pinterest", "advertising","📌"),
(re.compile(r"^tt_appInfo$|^tt_webid"), "TikTok", "advertising","🎵"),
(re.compile(r"^_ttp$"), "TikTok Pixel", "advertising","🎵"),
(re.compile(r"^ANID$"), "Google", "advertising","🎯"),
(re.compile(r"^__qca$"), "Quantcast", "advertising","🎯"),
(re.compile(r"^__gads$|^__gpi$"), "Google AdSense", "advertising","💰"),
(re.compile(r"^test_cookie$"), "Google", "advertising","🎯"),
# ── Social ──
(re.compile(r"^c_user$|^xs$|^datr$"), "Facebook", "social", "👥"),
(re.compile(r"^sb$|^locale$|^wd$"), "Facebook", "social", "👥"),
(re.compile(r"^twid$|^ct0$|^auth_token$"), "Twitter / X", "social", "👥"),
(re.compile(r"^li_at$"), "LinkedIn", "social", "👥"),
(re.compile(r"^IG_"), "Instagram", "social", "👥"),
# ── Auth / Session (legit, no tracker) ──
(re.compile(r"^session(_id)?$|^sessionid$"), "Session generic", "session", "🔑"),
(re.compile(r"^csrftoken$|^_csrf$"), "CSRF token", "session", "🔒"),
(re.compile(r"^XSRF-TOKEN$"), "XSRF token", "session", "🔒"),
(re.compile(r"^remember_token$"), "Remember-me", "session", "🔑"),
(re.compile(r"^PHPSESSID$"), "PHP session", "session", "🔑"),
(re.compile(r"^JSESSIONID$"), "Java session", "session", "🔑"),
(re.compile(r"^connect\.sid$"), "Express.js session", "session", "🔑"),
# ── CDN / infra ──
(re.compile(r"^__cf_bm$|^cf_clearance$"), "Cloudflare", "infra", ""),
(re.compile(r"^_dd_s$"), "Datadog RUM", "monitoring", "📈"),
]
def classify_cookie_name(name: str) -> dict:
"""Returns {provider, category, emoji} for a single cookie name.
Unknown {provider: 'unknown', category: 'other', emoji: ''}."""
for pattern, provider, category, emoji in COOKIE_PATTERNS:
if pattern.search(name):
return {"provider": provider, "category": category, "emoji": emoji}
return {"provider": "unknown", "category": "other", "emoji": ""}
def parse_cookie_header(header_value: str) -> list[str]:
"""Parse 'Cookie:' or 'Set-Cookie:' value, return list of cookie NAMES."""
if not header_value:
return []
names = []
for part in header_value.split(";"):
if "=" in part:
n = part.split("=", 1)[0].strip()
if n:
names.append(n)
return names
def analyze_cookie_events(cookie_events: list[dict]) -> dict:
"""Aggregate cookie events into stats + per-provider breakdown.
Input : list of {url, set_cookie_count, cookie_count, ...} from local_store
(note : Phase 1.5 stored only counts, not names. Phase 2a+ local_store
should store names. Until then, this function works on whatever's present.)
Returns :
{
providers: {provider: {count, category, emoji}, ...},
categories: {category: count, ...},
unknown_count: int,
}
"""
providers: dict[str, dict] = {}
categories: dict[str, int] = {}
unknown_count = 0
for ev in cookie_events:
# The cookie name might be in `set_cookie_names` or `cookie_names` if Phase 2a+
# local_store. Backward-compat : skip if absent.
for key in ("set_cookie_names", "cookie_names"):
names = ev.get(key, [])
if not isinstance(names, list):
continue
for n in names:
cls = classify_cookie_name(n)
p = cls["provider"]
if p == "unknown":
unknown_count += 1
else:
if p not in providers:
providers[p] = {"count": 0, "category": cls["category"],
"emoji": cls["emoji"]}
providers[p]["count"] += 1
cat = cls["category"]
categories[cat] = categories.get(cat, 0) + 1
return {
"providers": providers,
"categories": categories,
"unknown_count": unknown_count,
}
# Quick lookup for live use in /report endpoints
def top_providers(cookie_events: list[dict], limit: int = 10) -> list[dict]:
"""Returns top providers by hit count : [{provider, count, category, emoji}, ...]"""
stats = analyze_cookie_events(cookie_events)
return sorted(
[{"provider": p, **v} for p, v in stats["providers"].items()],
key=lambda x: -x["count"],
)[:limit]

View File

@ -0,0 +1,84 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""JA4 / JA4-like TLS ClientHello fingerprint.
Reference: https://github.com/FoxIO-LLC/ja4 (BSD-3)
Phase 2c implementation : compute a deterministic, JA4-style fingerprint
hash from cipher_suites + alpn_protocols + extensions. The output is
12-char hex (truncated SHA256), suitable for matching against external
JA4 databases (custom curation, not the full FoxIO format).
This is NOT the canonical FoxIO JA4 string. It's a deterministic
fingerprint that's stable per-client-stack, so the same iPhone Safari
will always yield the same hash. We can map known hashes to bots,
trackers, malware C2 in Phase 3.
"""
from __future__ import annotations
import hashlib
def _sort_norm(items: list | None) -> str:
"""Sort + join items as canonical comma-separated lowercase string."""
if not items:
return ""
parts = []
for x in items:
if isinstance(x, bytes):
parts.append(x.hex())
else:
parts.append(str(x).lower())
return ",".join(sorted(parts))
def compute_ja4_hash(
*,
sni: str | None = None,
alpn_protocols: list | None = None,
cipher_suites: list | None = None,
extensions: list | None = None,
transport: str = "t", # 't' for TCP, 'q' for QUIC
tls_version: str = "13", # 13 for TLS 1.3, 12 for TLS 1.2
) -> dict:
"""Compute a JA4-style fingerprint dict.
Returns {
fingerprint : 12-char hex hash,
transport : t/q,
tls_version : 13/12,
alpn_count : int,
cipher_count : int,
ext_count : int,
sni_present : bool,
raw_repr : compact str repr for debug,
}
"""
alpn_str = _sort_norm(alpn_protocols)
cipher_str = _sort_norm(cipher_suites)
ext_str = _sort_norm(extensions)
raw = f"{transport}{tls_version}|alpn={alpn_str}|c={cipher_str}|x={ext_str}"
h = hashlib.sha256(raw.encode("utf-8", errors="ignore")).hexdigest()[:12]
return {
"fingerprint": h,
"transport": transport,
"tls_version": tls_version,
"alpn_count": len(alpn_protocols or []),
"cipher_count": len(cipher_suites or []),
"ext_count": len(extensions or []),
"sni_present": bool(sni),
"raw_repr": raw[:200],
}
# Phase 3-ready : map known JA4 hashes to client tags. Empty for now.
KNOWN_JA4_FINGERPRINTS: dict[str, dict] = {
# "abc123def456": {"label": "iPhone Safari 17.x", "category": "browser", "trust": "high"},
# "deadbeef0000": {"label": "Tor Browser 14.x", "category": "browser-anon", "trust": "medium"},
}
def lookup_ja4(fingerprint: str) -> dict | None:
"""Return known label for a fingerprint, or None if unknown."""
return KNOWN_JA4_FINGERPRINTS.get(fingerprint)

View File

@ -26,13 +26,43 @@ from secubox_core.logger import get_logger
app = FastAPI(title="secubox-avatar", version="1.0.0", root_path="/api/v1/avatar")
# Phase 2b (#488) : ingest mitm avatar fingerprint events from secubox-toolbox addon
# Phase 2b/2c (#488/#490) : ingest mitm avatar events + UA/CH device classification
from secubox_core.mitm_ingest import mount_ingest_routes # noqa: E402
from secubox_core.classifiers import avatar as _avatar_cls # noqa: E402
def _avatar_enrich(event: dict) -> dict:
"""Phase 2c enrichment : UA + Client Hints -> {device, browser, os, emoji}."""
ua = event.get("user_agent") or ""
if not ua:
return event
cls = _avatar_cls.classify_user_agent(ua)
# Augment with Client Hints if present (more reliable than UA spoofing)
chints = event.get("client_hints") or {}
if "sec-ch-ua-platform" in chints:
cls["ch_platform"] = chints["sec-ch-ua-platform"].strip('"')
if "sec-ch-ua-model" in chints:
cls["ch_model"] = chints["sec-ch-ua-model"].strip('"')
event["enriched"] = {
"device": cls.get("device", "unknown"),
"device_emoji": cls.get("device_emoji", ""),
"os_label": cls.get("os_label", "?"),
"browser": cls.get("browser", "unknown"),
"browser_emoji": cls.get("browser_emoji", ""),
"browser_label": cls.get("browser_label", "?"),
"ch_platform": cls.get("ch_platform"),
"ch_model": cls.get("ch_model"),
"source": "secubox-avatar/classifier",
}
return event
mount_ingest_routes(
app,
endpoint_path="/fingerprint",
db_path="/var/lib/secubox/avatar/mitm-ingest.db",
kind="avatar",
enrich_hook=_avatar_enrich,
)
# ══════════════════════════════════════════════════════════════════

View File

@ -28,13 +28,45 @@ except ImportError:
app = FastAPI(title="SecuBox Cookies API", version="1.0.0")
# Phase 2b (#488) : ingest mitm cookies events from secubox-toolbox addon
# Phase 2b/2c (#488/#490) : ingest mitm cookies events + provider classification
from secubox_core.mitm_ingest import mount_ingest_routes # noqa: E402
from secubox_core.classifiers import cookie as _cookie_cls # noqa: E402
def _cookies_enrich(event: dict) -> dict:
"""Phase 2c enrichment : map cookie names -> {providers[], categories{}}."""
set_names = event.get("set_cookie_names", []) or []
sent_names = event.get("cookie_names", []) or []
all_names = list(set_names) + list(sent_names)
if not all_names:
return event
providers: dict[str, dict] = {}
categories: dict[str, int] = {}
for n in all_names:
cls = _cookie_cls.classify_cookie_name(n)
p = cls["provider"]
if p != "unknown":
if p not in providers:
providers[p] = {"count": 0, "category": cls["category"], "emoji": cls["emoji"]}
providers[p]["count"] += 1
cat = cls["category"]
categories[cat] = categories.get(cat, 0) + 1
event["enriched"] = {
"providers": providers,
"categories": categories,
"total_names": len(all_names),
"tracker_count": sum(v["count"] for v in providers.values()),
"source": "secubox-cookies/classifier",
}
return event
mount_ingest_routes(
app,
endpoint_path="/inject",
db_path="/var/lib/secubox/cookies/mitm-ingest.db",
kind="cookies",
enrich_hook=_cookies_enrich,
)
# Configuration paths

View File

@ -23,13 +23,36 @@ import httpx
app = FastAPI(title="secubox-dpi", version="2.0.0", root_path="/api/v1/dpi")
# Phase 2b (#488) : ingest mitm DPI events from secubox-toolbox addon
# Phase 2b/2c (#488/#490) : ingest mitm DPI events + nDPI-style classification
from secubox_core.mitm_ingest import mount_ingest_routes # noqa: E402
from secubox_core.classifiers import host_app as _host_app # noqa: E402
def _dpi_enrich(event: dict) -> dict:
"""Phase 2c enrichment : classify host/SNI -> {app, category, emoji}.
Future Phase 3 : query nDPI/netifyd daemon socket for live classification.
"""
host = event.get("host") or event.get("sni") or ""
if not host:
return event
cls = _host_app.classify_host(host)
event["enriched"] = {
"app": cls["app"],
"category": cls["category"],
"emoji": cls["emoji"],
"source": "secubox-dpi/host_app",
"method": "pattern-match",
}
return event
mount_ingest_routes(
app,
endpoint_path="/classify",
db_path="/var/lib/secubox/dpi/mitm-ingest.db",
kind="dpi",
enrich_hook=_dpi_enrich,
)
# ══════════════════════════════════════════════════════════════════

View File

@ -34,13 +34,42 @@ P2P_SOCKET = "/run/secubox/p2p.sock"
app = FastAPI(title="SecuBox SOC", version="2.0.0")
# Phase 2b (#488) : ingest mitm SOC indicator events from secubox-toolbox addon
# Phase 2b/2c (#488/#490) : ingest mitm SOC events + score aggregation
from secubox_core.mitm_ingest import mount_ingest_routes # noqa: E402
def _soc_enrich(event: dict) -> dict:
"""Phase 2c enrichment : sum indicator weights -> score band.
Future Phase 3 : query threat-intel feeds (CrowdSec/ThreatFox/etc.)
locally instead of just summing static weights.
"""
indicators = event.get("indicators") or []
if not indicators:
return event
total_weight = sum((i.get("weight") or 0) for i in indicators if isinstance(i, dict))
band = "low"
if total_weight >= 50:
band = "high"
elif total_weight >= 20:
band = "medium"
kinds = sorted({i.get("kind", "?") for i in indicators if isinstance(i, dict)})
event["enriched"] = {
"total_weight": total_weight,
"band": band,
"indicator_kinds": kinds,
"indicator_count": len(indicators),
"source": "secubox-soc/scoring",
}
return event
mount_ingest_routes(
app,
endpoint_path="/event",
db_path="/var/lib/secubox/soc/mitm-ingest.db",
kind="soc",
enrich_hook=_soc_enrich,
)
# Data directories

View File

@ -54,13 +54,39 @@ QUEUE_FILE = DATA_DIR / "pending_rules.json"
app = FastAPI(title="SecuBox Threat Analyst", version="1.0.0")
logger = logging.getLogger("secubox.threat-analyst")
# Phase 2b (#488) : ingest mitm JA4 clienthello events from secubox-toolbox addon
# Phase 2b/2c (#488/#490) : ingest mitm JA4 events + compute fingerprint hash
from secubox_core.mitm_ingest import mount_ingest_routes # noqa: E402
from secubox_core.classifiers import ja4 as _ja4_cls # noqa: E402
def _ja4_enrich(event: dict) -> dict:
"""Phase 2c enrichment : compute JA4-style fingerprint + lookup known clients."""
ja4_hash = _ja4_cls.compute_ja4_hash(
sni=event.get("sni"),
alpn_protocols=event.get("alpn_protocols"),
cipher_suites=event.get("cipher_suites"),
extensions=event.get("extensions"),
)
known = _ja4_cls.lookup_ja4(ja4_hash["fingerprint"])
event["enriched"] = {
"ja4_fingerprint": ja4_hash["fingerprint"],
"ja4_raw_repr": ja4_hash["raw_repr"],
"cipher_count": ja4_hash["cipher_count"],
"alpn_count": ja4_hash["alpn_count"],
"ext_count": ja4_hash["ext_count"],
"sni_present": ja4_hash["sni_present"],
"known_client": known, # None if unknown, dict if matched
"source": "secubox-threat-analyst/ja4",
}
return event
mount_ingest_routes(
app,
endpoint_path="/ja4",
db_path="/var/lib/secubox/threat-analyst/mitm-ingest.db",
kind="ja4",
enrich_hook=_ja4_enrich,
)

View File

@ -1,20 +1,57 @@
{# SPDX-License-Identifier: LicenseRef-CMSD-1.0 #}
{# Public landing page — kbin.gk2.secubox.in #}
{# Radical-simplify redesign (#543): animated hero + one CTA + install panel
up top ; everything else folded behind "En savoir plus". #}
<!DOCTYPE html>
<html lang=fr><head>
<meta charset=UTF-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<meta name=description content="VILLAGE3B — Cabine numérique Gondwana : diagnostic compromission iPhone/Android anonyme, gratuit, open source CMSD">
<title>📡 VILLAGE3B — Cabine Numérique Gondwana</title>
<title>👁️ VILLAGE3B — Qui te piste ?</title>
<link rel=manifest href=/manifest.json>
<style>
:root{--bg:#0a0a0f;--bg2:#0e0e15;--phos:#00dd44;--phos-hot:#00ff55;--dim:#006622;--text:#e8e6d9;--purple:#9e76ff;--gold:#c9a84c;--amber:#ffb347;--red:#ff4466}
:root{--bg:#0a0a0f;--bg2:#0e0e15;--phos:#00dd44;--phos-hot:#00ff55;--dim:#006622;--text:#e8e6d9;--purple:#9e76ff;--gold:#c9a84c;--amber:#ffb347;--red:#ff4466;--cyan:#00d4ff}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Courier New',Menlo,monospace;background:var(--bg);color:var(--text);line-height:1.55;padding-bottom:3rem}
.hero{background:linear-gradient(135deg,#1a0a2e 0%,#0a0a0f 100%);padding:2.5rem 1.5rem;text-align:center;border-bottom:2px solid var(--phos)}
.hero h1{font-size:2.4rem;color:var(--phos-hot);text-shadow:0 0 8px var(--phos);letter-spacing:0.08em}
.hero p.tag{color:var(--gold);font-size:1rem;margin-top:0.4rem;letter-spacing:0.08em}
.hero p.sub{color:var(--dim);font-size:0.85rem;margin-top:0.6rem;max-width:600px;margin-left:auto;margin-right:auto}
a{color:var(--phos);text-decoration:none}
a:hover{text-decoration:underline}
/* ── HERO ── */
.hero{position:relative;overflow:hidden;background:radial-gradient(120% 120% at 50% -10%,#221041 0%,#0a0a0f 60%);padding:3rem 1.5rem 2.4rem;text-align:center;border-bottom:2px solid var(--phos)}
.eye{font-size:3.4rem;line-height:1;display:inline-block;animation:gaze 5s ease-in-out infinite;filter:drop-shadow(0 0 14px rgba(0,255,85,0.55))}
@keyframes gaze{0%,100%{transform:translateX(0) scale(1)}25%{transform:translateX(-6px) scale(1.04)}60%{transform:translateX(7px) scale(1.04)}}
.hero h1{font-size:2.6rem;color:var(--phos-hot);text-shadow:0 0 10px var(--phos);letter-spacing:0.08em;margin-top:0.3rem}
.hero .punch{color:var(--text);font-size:1.25rem;margin-top:0.6rem;font-weight:bold}
.hero .punch b{color:var(--gold)}
.hero .sub{color:var(--dim);font-size:0.82rem;margin-top:0.5rem;max-width:560px;margin-left:auto;margin-right:auto}
/* floating tracker dots = "who's watching" */
.dots{position:absolute;inset:0;pointer-events:none;z-index:0}
.dots i{position:absolute;width:7px;height:7px;border-radius:50%;opacity:0.0;animation:float 7s ease-in-out infinite}
.dots i:nth-child(1){left:12%;top:30%;background:var(--cyan);animation-delay:.0s}
.dots i:nth-child(2){left:82%;top:24%;background:var(--amber);animation-delay:1.1s}
.dots i:nth-child(3){left:24%;top:68%;background:var(--red);animation-delay:2.3s}
.dots i:nth-child(4){left:70%;top:64%;background:var(--purple);animation-delay:.7s}
.dots i:nth-child(5){left:50%;top:14%;background:var(--cyan);animation-delay:3.0s}
.dots i:nth-child(6){left:90%;top:54%;background:var(--red);animation-delay:1.8s}
@keyframes float{0%{opacity:0;transform:translateY(8px) scale(.6)}30%{opacity:.85}70%{opacity:.7}100%{opacity:0;transform:translateY(-14px) scale(1.1)}}
.hero>*{position:relative;z-index:1}
/* ── big CTA row ── */
.ctas{margin-top:1.4rem;display:flex;gap:0.6rem;justify-content:center;flex-wrap:wrap}
.cta{display:inline-block;padding:0.85rem 1.6rem;font-weight:bold;border-radius:8px;font-size:1.02rem;text-shadow:none;transition:transform .12s,box-shadow .12s}
.cta:hover{text-decoration:none;transform:translateY(-2px)}
.cta.go{background:var(--phos);color:#0a0a0f;box-shadow:0 4px 18px rgba(0,221,68,0.4)}
.cta.go:hover{box-shadow:0 6px 24px rgba(0,221,68,0.6)}
.cta.alt{background:transparent;color:var(--purple);border:1px solid var(--purple)}
.cta.alt:hover{background:rgba(158,118,255,0.12)}
/* ── quicknav (trimmed) ── */
.quicknav{display:flex;flex-wrap:wrap;justify-content:center;gap:0.6rem;margin-top:1.4rem;max-width:620px;margin-left:auto;margin-right:auto}
.qi{display:flex;flex-direction:column;align-items:center;gap:4px;padding:0.5rem 0.4rem;min-width:74px;background:rgba(110,64,201,0.08);border:1px solid var(--purple);border-radius:8px;text-decoration:none;color:var(--text);transition:all 0.12s;font-family:inherit}
.qi:hover{background:rgba(110,64,201,0.22);transform:translateY(-2px);box-shadow:0 4px 14px rgba(158,118,255,0.35);text-decoration:none}
.qi-emoji{font-size:1.5rem;line-height:1}
.qi-label{font-size:0.62rem;letter-spacing:0.04em;color:var(--phos-hot);font-weight:bold;white-space:nowrap}
.container{max-width:1080px;margin:auto;padding:2rem 1.5rem}
.section{margin-bottom:2.5rem}
h2{color:var(--phos-hot);text-shadow:0 0 4px var(--phos);font-size:1.3rem;margin-bottom:0.8rem;border-bottom:1px solid var(--dim);padding-bottom:0.4rem;letter-spacing:0.04em}
@ -43,30 +80,54 @@ svg.chart{width:100%;max-width:400px;height:auto}
.svg-bar{fill:var(--phos);transition:fill 0.3s}
.svg-bar.medium{fill:var(--amber)}
.svg-bar.high{fill:var(--red)}
.steps{counter-reset:step}
.steps li{counter-increment:step;list-style:none;padding:0.6rem 0 0.6rem 2.4rem;position:relative;font-size:0.9rem}
.steps li::before{content:counter(step);position:absolute;left:0;top:0.5rem;width:1.8rem;height:1.8rem;border-radius:50%;background:var(--phos);color:#0a0a0f;text-align:center;line-height:1.8rem;font-weight:bold;text-shadow:none}
code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;color:var(--phos-hot)}
a{color:var(--phos);text-decoration:none}
a:hover{text-decoration:underline}
.cta{display:inline-block;background:var(--phos);color:#0a0a0f;padding:0.7rem 1.4rem;text-decoration:none;font-weight:bold;border-radius:4px;margin:0.5rem 0.3rem 0.5rem 0;text-shadow:none}
.cta.outline{background:transparent;color:var(--phos);border:1px solid var(--phos)}
.cta.purple{background:var(--purple);color:#0a0a0f}
.cta-sm{display:inline-block;background:var(--phos);color:#0a0a0f;padding:0.7rem 1.4rem;text-decoration:none;font-weight:bold;border-radius:4px;margin:0.5rem 0.3rem 0.5rem 0;text-shadow:none}
.cta-sm.outline{background:transparent;color:var(--phos);border:1px solid var(--phos)}
.footer{text-align:center;font-size:0.78rem;color:var(--dim);padding:1.5rem;border-top:1px solid var(--dim);margin-top:2rem}
.arch{font-family:monospace;font-size:0.75rem;color:var(--phos-hot);text-shadow:0 0 4px var(--phos);background:var(--bg2);padding:1rem;border:1px solid var(--dim);border-radius:4px;overflow-x:auto;white-space:pre;line-height:1.4}
.quicknav{display:flex;flex-wrap:wrap;justify-content:center;gap:0.7rem;margin-top:1.2rem;max-width:780px;margin-left:auto;margin-right:auto}
.qi{display:flex;flex-direction:column;align-items:center;gap:4px;padding:0.6rem 0.4rem;min-width:78px;background:rgba(110,64,201,0.08);border:1px solid var(--purple);border-radius:8px;text-decoration:none;color:var(--text);transition:all 0.12s;font-family:inherit}
.qi:hover{background:rgba(110,64,201,0.22);transform:translateY(-2px);box-shadow:0 4px 14px rgba(158,118,255,0.35);text-decoration:none}
.qi-emoji{font-size:1.6rem;line-height:1}
.qi-label{font-size:0.65rem;letter-spacing:0.04em;color:var(--phos-hot);font-weight:bold;white-space:nowrap}
/* ── install panel (kept up top) ── */
.install-panel{background:rgba(0,255,65,0.04);border:1px solid rgba(0,255,65,0.25);border-radius:6px;padding:0.6rem 0.9rem;margin:0.45rem 0;text-align:left}
.install-panel summary{cursor:pointer;font-size:0.95rem;color:var(--phos-hot);list-style:none;outline:none}
.install-panel summary::-webkit-details-marker{display:none}
.install-panel[open] summary{margin-bottom:0.6rem}
.install-panel .emoji{font-size:1.1rem;margin-right:0.3rem}
.install-panel ol{padding-left:1.1rem;line-height:1.5;font-size:0.85rem}
.install-panel .btn{display:inline-block;padding:0.45rem 0.75rem;margin:0.25rem 0.2rem 0.25rem 0;background:var(--purple);color:#fff;text-decoration:none;border-radius:5px;font-weight:bold;font-size:0.82rem}
.install-panel .btn.alt{background:transparent;border:1px solid var(--purple);color:var(--purple)}
.install-panel code{background:rgba(0,0,0,0.4);padding:0.1rem 0.35rem;border-radius:3px;font-size:0.8rem;color:var(--phos-hot)}
.install-panel .note{color:var(--dim);font-size:0.78rem;margin-top:0.6rem;border-left:2px solid var(--amber);padding-left:0.6rem}
.install-panel img{max-width:100%;border-radius:5px;margin:0.4rem 0}
.install-panel pre{background:rgba(0,0,0,0.4);padding:0.5rem 0.7rem;border-radius:4px;overflow-x:auto;font-size:0.78rem;margin:0.4rem 0}
/* ── "En savoir plus" fold ── */
.more{max-width:1080px;margin:0 auto;padding:0 1.5rem}
.more>summary{cursor:pointer;list-style:none;text-align:center;color:var(--purple);font-size:0.95rem;letter-spacing:0.05em;padding:0.9rem;border:1px dashed var(--purple);border-radius:8px;margin-bottom:1rem;transition:background .12s}
.more>summary::-webkit-details-marker{display:none}
.more>summary:hover{background:rgba(158,118,255,0.1)}
.more[open]>summary{margin-bottom:1.6rem}
.more>summary .chev{display:inline-block;transition:transform .2s}
.more[open]>summary .chev{transform:rotate(90deg)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
.v.tick{animation:flash 0.6s}
@keyframes flash{0%{color:var(--gold);transform:scale(1.15)}100%{color:var(--phos-hot);transform:scale(1)}}
</style></head><body>
<div class=hero>
<h1>📡 VILLAGE3B</h1>
<p class=tag>// CABINE NUMÉRIQUE GONDWANA · TOOLBOX</p>
<div class=dots><i></i><i></i><i></i><i></i><i></i><i></i></div>
<span class=eye>👁️</span>
<h1>VILLAGE3B</h1>
<p class=punch>Qui te piste ? <b>La cabine te le montre.</b></p>
<p class=sub>Diagnostic gratuit de compromission iPhone / Android · Anonyme · Open Source · CMSD-1.0</p>
{# Phase 6.I : quick-access icon nav — one-tap to all key endpoints #}
<div class=ctas>
<a href="/wg/r3-install" class="cta go">✨ Protège-moi (R3)</a>
<a href="/social/me" class="cta alt">🕸️ Qui me piste ?</a>
</div>
{# trimmed quick-nav — CA iPhone / CA Android / QR profil moved into the
per-platform install panel below (#543) #}
<div class=quicknav>
<a href="/wg/r3-install" class=qi title="Installer R3 WireGuard">
<span class=qi-emoji>🌐</span><span class=qi-label>R3 Install</span>
@ -77,15 +138,6 @@ a:hover{text-decoration:underline}
<a href="/social/me" class=qi title="Cartographie sociale — qui me piste, où ?">
<span class=qi-emoji>🕸️</span><span class=qi-label>Ma carto</span>
</a>
<a href="/wg/ca.mobileconfig" class=qi title="CA R3 iPhone (.mobileconfig)">
<span class=qi-emoji>📲</span><span class=qi-label>CA iPhone</span>
</a>
<a href="/wg/ca.pem" class=qi title="CA R3 Android/PC (.pem)">
<span class=qi-emoji>🤖</span><span class=qi-label>CA Android</span>
</a>
<a href="/wg/qr.png" class=qi title="QR profil WireGuard">
<span class=qi-emoji>📱</span><span class=qi-label>QR profil</span>
</a>
<a href="https://github.com/CyberMind-FR/secubox-deb/wiki/R3-WireGuard-install" class=qi title="Wiki R3 multi-OS">
<span class=qi-emoji>📖</span><span class=qi-label>Wiki</span>
</a>
@ -95,7 +147,25 @@ a:hover{text-decoration:underline}
</div>
</div>
{# ── Install : auto-detected platform panel, front and centre ── #}
<div class=container>
<div class=section style="margin-bottom:1.5rem">
<h2>📥 Installe en 1 tap</h2>
<p style="font-size:0.85rem;color:var(--dim);margin-bottom:0.8rem">
On a détecté <code>{{ install_platform }}</code> — le panneau adapté est ouvert.
Le CA, le QR et le profil sont dedans. Autre appareil ? Déplie le bon panneau.
</p>
{{ install_panels | safe }}
<p style="margin-top:0.8rem;font-size:0.78rem;color:var(--dim)">
R3 marche hors-cabine (4G/5G, autre WiFi), couvre tout le HTTPS, et se révoque
à tout moment. Page standalone : <a href=/wg/onboard>/wg/onboard</a>.
</p>
</div>
</div>
{# ── Everything else, folded ── #}
<details class=more>
<summary><span class=chev>▸</span> En savoir plus — la cabine en détail, en chiffres, en open source</summary>
{# ── KPI live (auto-refresh 5s via /cumulative-stats.json) ── #}
<div class=section>
@ -241,43 +311,6 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
</div>
</div>
{# ── Install : auto-detected platform panels (Phase 8.2 #500) ── #}
<div class=section>
<h2>📥 Installer R3 sur ton appareil</h2>
<p style="font-size:0.85rem;color:var(--dim);margin-bottom:0.8rem">
On a détecté <code>{{ install_platform }}</code> via ton navigateur — le panneau adapté
est ouvert en premier. Autre appareil ? Déplie le bon panneau ci-dessous.
</p>
<style>
.install-panel{background:rgba(0,255,65,0.04);border:1px solid rgba(0,255,65,0.25);
border-radius:6px;padding:0.6rem 0.9rem;margin:0.45rem 0}
.install-panel summary{cursor:pointer;font-size:0.95rem;color:var(--phos-peak,#00dd44);
list-style:none;outline:none}
.install-panel summary::-webkit-details-marker{display:none}
.install-panel[open] summary{margin-bottom:0.6rem}
.install-panel .emoji{font-size:1.1rem;margin-right:0.3rem}
.install-panel ol{padding-left:1.1rem;line-height:1.5;font-size:0.85rem}
.install-panel .btn{display:inline-block;padding:0.45rem 0.75rem;margin:0.25rem 0.2rem 0.25rem 0;
background:var(--purple,#6e40c9);color:#fff;text-decoration:none;border-radius:5px;
font-weight:bold;font-size:0.82rem}
.install-panel .btn.alt{background:transparent;border:1px solid var(--purple,#6e40c9);
color:var(--purple,#6e40c9)}
.install-panel code{background:rgba(0,0,0,0.4);padding:0.1rem 0.35rem;border-radius:3px;
font-size:0.8rem;color:var(--phos-peak,#00dd44)}
.install-panel .note{color:var(--dim,#888);font-size:0.78rem;margin-top:0.6rem;
border-left:2px solid var(--phos-hot,#ffb347);padding-left:0.6rem}
.install-panel img{max-width:100%;border-radius:5px;margin:0.4rem 0}
.install-panel pre{background:rgba(0,0,0,0.4);padding:0.5rem 0.7rem;border-radius:4px;
overflow-x:auto;font-size:0.78rem;margin:0.4rem 0}
</style>
{{ install_panels | safe }}
<p style="margin-top:0.8rem;font-size:0.78rem;color:var(--dim)">
Avantage R3 : marche hors-cabine (4G/5G, autre WiFi). Inclut tout le trafic (HTTPS).
Profil + CA bundlés. Le tunnel est révoquable à tout moment depuis Réglages.
Page équivalente standalone : <a href=/wg/onboard>/wg/onboard</a>.
</p>
</div>
{# ── Open Source ── #}
<div class=section>
<h2>🔓 Open Source — CMSD-1.0</h2>
@ -286,8 +319,8 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
(audit citoyen possible, droits d'usage régis par licence CMSD). Pas de boîte noire.
</p>
<div style="margin-top:0.6rem">
<a href="https://github.com/CyberMind-FR/secubox-deb" class=cta>📂 Code source GitHub</a>
<a href="https://github.com/CyberMind-FR/secubox-deb/blob/master/LICENCE-CMSD-1.0.md" class="cta outline">📜 Licence CMSD-1.0</a>
<a href="https://github.com/CyberMind-FR/secubox-deb" class=cta-sm>📂 Code source GitHub</a>
<a href="https://github.com/CyberMind-FR/secubox-deb/blob/master/LICENCE-CMSD-1.0.md" class="cta-sm outline">📜 Licence CMSD-1.0</a>
</div>
</div>
@ -314,7 +347,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
</div>
</div>
</div>
</details>
<div class=footer>
📡 Gondwana ToolBox · CyberMind / Gérald Kerma · Notre-Dame-du-Cruet (73130) · Savoie · France<br>
@ -322,14 +355,8 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
// DIY · Open Source · Open Audit
</div>
<style>
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
.v.tick{animation:flash 0.6s}
@keyframes flash{0%{color:var(--gold);transform:scale(1.15)}100%{color:var(--phos-hot);transform:scale(1)}}
</style>
<script>
// ── Live KPI auto-refresh from /cumulative-stats.json ──
// ── Live KPI auto-refresh from /cumulative-stats.json (+ count-up on first paint) ──
(function(){
function dig(o,path){
var parts = path.split('.');
@ -349,6 +376,23 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
var ss = String(d.getSeconds()).padStart(2,'0');
return 'maj ' + hh+':'+mm+':'+ss;
}
// count-up: animate each KPI from 0 → its server-rendered value, once.
function countUp(el, target){
var start = 0, dur = 900, t0 = null;
function step(ts){
if (t0 === null) t0 = ts;
var p = Math.min(1, (ts - t0) / dur);
var eased = 1 - Math.pow(1 - p, 3);
el.textContent = Math.round(start + (target - start) * eased);
if (p < 1) requestAnimationFrame(step);
else el.textContent = target;
}
requestAnimationFrame(step);
}
document.querySelectorAll('.kpi .v[data-live]').forEach(function(el){
var n = parseInt(el.textContent.trim(), 10);
if (!isNaN(n) && n > 0) countUp(el, n);
});
function refresh(){
fetch('/cumulative-stats.json', {cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
document.querySelectorAll('[data-live]').forEach(function(el){
@ -379,16 +423,10 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
var fp = document.getElementById('cert-fp-r3');
if (!btn) return;
// Display the WG CA fingerprint (use ?ca=wg flag if endpoint supports it,
// else fallback to default ca fingerprint).
fetch('/ca/fingerprint').then(function(r){return r.json();}).then(function(d){
fp.textContent = d.sha1 || d.sha256 || '?';
}).catch(function(){fp.textContent='?';});
// Phase 6.H : 3-step probe :
// 1) Detect if user is in WG R3 tunnel (probe our internal-only endpoint)
// 2) Probe an external HTTPS (verifies mitm decrypt + CA trust)
// 3) Combine results into a clear verdict
function runProbe(){
emj.textContent = '⏳';
txt.innerHTML = 'Test en cours… (1/2 détection tunnel)';
@ -416,31 +454,12 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
}
}
// Phase 7 (#498) — same-origin HTTPS R3 probe.
// The previous probe loaded http://10.99.0.1:8088/qr/splash.png as
// an Image. iOS Safari blocks mixed content (HTTP from an HTTPS
// page) so the request never fired and inWG always stayed false.
// /wg/r3-check returns { tunnel: bool } based on the X-R3-Peer /
// XFF headers mitm-wg sets via the inject_xff addon.
fetch('/wg/r3-check?t=' + Date.now(), {cache: 'no-store'})
.then(function(r){ return r.ok ? r.json() : {tunnel:false}; })
.then(function(d){
inWG = !!(d && d.tunnel);
if (!inWG) { finalize(); return; }
txt.innerHTML = 'Tunnel R3 détecté ✓ — test 2/2 cert mitm…';
// Step 2 : probe an external HTTPS host that mitm-wg DOES
// intercept (i.e. not in ignore_hosts). gstatic / google /
// apple / fbcdn are whitelisted so they pass through with
// their real cert ; useless to test CA trust. duckduckgo
// isn't whitelisted, so the TLS handshake is performed by
// mitm-wg with the R3 CA — it succeeds only if the iPhone
// trusts that CA.
//
// fetch(no-cors) resolves on any successful TLS handshake
// regardless of HTTP status, and rejects on cert / network
// error — exactly the signal we want. Image.onload was
// ambiguous : a 204 No Content reply (no image data) also
// triggered onerror, making CA-trusted look like CA-untrusted.
var extDone = false;
fetch('https://duckduckgo.com/favicon.ico?t=' + Date.now(),
{mode: 'no-cors', cache: 'no-store'})

View File

@ -1,3 +1,69 @@
secubox-toolbox (2.6.17-1~bookworm1) bookworm; urgency=medium
* Social correlation: domain-rollup + history + target↔tracker (#549).
- fetch_graph() now returns three additive top-level keys (read-time,
no schema change, d3 contract untouched):
· by_domain — trackers rolled up under their registrable parent
(eTLD+1, e.g. all *.doubleclick.net → doubleclick.net) with
tracker_count / hits / sites / vendors ;
· targets — inverse map: per 1st-party site, the trackers +
parent domains watching it ;
· history — per-UTC-day timeline (hits / trackers / sites) from
the raw social_edges log over the window.
- stats gains total_domains. Added a local _registrable_domain eTLD+1
helper (mirrors the addon, no publicsuffix dep).
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 13:00:00 +0200
secubox-toolbox (2.6.16-1~bookworm1) bookworm; urgency=medium
* Injected banner neon-tube redesign (#545) — inject_banner.py.
- New _LEVEL_THEME map: R3 (and the planned R4) get a neon-tube look
(dark glass bar, glowing tube border via layered box-shadow + neon
text-shadow on the title) ; R2 keeps the original amber flat bar.
- _banner_html_dynamic() takes the level and themes both the
CSP-strict (JS-less) and JS (dismissible) variants ; all inline CSS,
no injected <style>/@keyframes, ASCII/NCR-clean for legacy charsets.
- R4 theme defined but inert until _client_level() returns 'r4'.
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 12:30:00 +0200
secubox-toolbox (2.6.15-1~bookworm1) bookworm; urgency=medium
* kbin landing radical-simplify redesign (#543) — conf/landing.html.j2.
- Animated hero (gazing 👁️ + floating tracker dots) + one big
"✨ Protège-moi (R3)" CTA + "🕸️ Qui me piste ?" secondary.
- Auto-detected install panel pulled up front ("📥 Installe en 1 tap").
- KPIs / cert-probe / pitch / R0-R3 levels / charts / architecture /
open-source / contact moved behind an "En savoir plus" <details> fold.
- Quick-nav trimmed: removed CA iPhone / CA Android / QR profil cards
(they live inside the per-platform install panel now) — kept R3
Install / Mon rapport / Ma carto / Wiki / Cabine.
- Count-up animation on the live KPIs on first paint. All Jinja
variables + the live-stats and cert-probe scripts preserved.
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 12:00:00 +0200
secubox-toolbox (2.6.14-1~bookworm1) bookworm; urgency=medium
* Serve the browser ToolBoX extension .xpi from the toolbox (#532).
- api.py GET /wg/toolbox.xpi : serves the local .xpi
(/var/lib/secubox/toolbox/webext/secubox-toolbox-webext.xpi) with
content-type application/x-xpinstall ; if absent, 302 → the
tag-pinned GitHub release asset (button never dead-ends). The
webext release is published make_latest:false so it does not steal
"latest" from the Android APK release.
- /wg/onboard : new "🧩 Extension navigateur (cartographie)" button
on both the inline + _install_panels variants.
- sbin/secubox-toolbox-fetch-xpi : pulls the release asset into the
serve path (best-effort, ZIP-magic sanity check).
- postinst : create the webext serve dir + best-effort first fetch.
- New client clients/webext-toolbox/ (MV3 Firefox/Chromium): live
tracker badge + popup mini Round-Eye graph over /social/* ;
build-webext.yml publishes the .xpi on webext-v* tags.
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 10:30:00 +0200
secubox-toolbox (2.6.13-1~bookworm1) bookworm; urgency=medium
* Serve the Android ToolBox APK from the toolbox (#536, follow-up #531).

View File

@ -52,6 +52,13 @@ case "$1" in
if [ -x /usr/sbin/secubox-toolbox-fetch-apk ]; then
/usr/sbin/secubox-toolbox-fetch-apk 2>&1 | head -2 || true
fi
# #532 : browser extension serve dir + best-effort fetch of the
# latest release .xpi (so GET /wg/toolbox.xpi serves it locally).
# Non-blocking : falls back to redirecting to the public release.
install -d -m 0755 -o secubox-toolbox -g secubox-toolbox /var/lib/secubox/toolbox/webext
if [ -x /usr/sbin/secubox-toolbox-fetch-xpi ]; then
/usr/sbin/secubox-toolbox-fetch-xpi 2>&1 | head -2 || true
fi
# /var/log/secubox is a SHARED parent traversed by many service users
# (the aggregator runs as `secubox` and reads waf-threats.log under
# here). It MUST be 0755 — a 0750 owned by secubox-toolbox silently

View File

@ -105,6 +105,9 @@ execute_after_dh_auto_install:
# #536 : Android APK fetch helper.
install -m 0755 sbin/secubox-toolbox-fetch-apk \
debian/secubox-toolbox/usr/sbin/
# #532 : browser extension .xpi fetch helper.
install -m 0755 sbin/secubox-toolbox-fetch-xpi \
debian/secubox-toolbox/usr/sbin/
install -m 0755 sbin/secubox-toolbox-wg-restore \
debian/secubox-toolbox/usr/sbin/
install -m 0644 systemd/secubox-toolbox-wg-restore.service \

View File

@ -404,8 +404,40 @@ def _detect_csp_strict(flow: http.HTTPFlow) -> bool:
return False
# Per-level visual theme (#545). R3 — and the planned R4 — get the
# neon-tube treatment (dark glass bar, glowing tube border + neon
# text-shadow). R2 keeps the original amber flat bar. All values are inline
# CSS only (no injected <style>/@keyframes) so it survives strict CSP and
# arbitrary third-party pages.
_LEVEL_THEME = {
"r2": {
"neon": False,
"bg": "linear-gradient(90deg,#ffb347 60%,#0a0a0f 100%)",
"fg": "#0a0a0f", "edge": "#C04E24", "accent": "#ffb347",
"glow": "", "link": "#0a5840", "chip": "rgba(0,0,0,0.1)",
},
"r3": {
"neon": True,
"bg": "rgba(8,8,14,0.95)",
"fg": "#e8e6d9", "edge": "#00d4ff", "accent": "#00d4ff",
"glow": "rgba(0,212,255,0.45)", "link": "#00d4ff",
"chip": "rgba(0,212,255,0.12)",
},
# planned (#545): R4 drops in with its own neon colour — inert until
# _client_level() can return 'r4'.
"r4": {
"neon": True,
"bg": "rgba(12,8,16,0.96)",
"fg": "#e8e6d9", "edge": "#ff3df0", "accent": "#ff3df0",
"glow": "rgba(255,61,240,0.45)", "link": "#ff3df0",
"chip": "rgba(255,61,240,0.12)",
},
}
def _banner_html_dynamic(sha1: str, ctx: dict, csp_strict: bool,
report_url: str, level_label: str) -> bytes:
report_url: str, level_label: str,
level: str = "r2") -> bytes:
"""Render the injection payload.
Two flavors depending on CSP strictness :
@ -455,22 +487,47 @@ def _banner_html_dynamic(sha1: str, ctx: dict, csp_strict: bool,
# Static emojis used in the left-side text
SAT_EMOJI = "&#x1F4E1;" # 📡 satellite dish
# ── theme resolution (#545) : R3/R4 neon tube, R2 amber flat ──
th = _LEVEL_THEME.get(level, _LEVEL_THEME["r2"])
_base = (
"position:fixed!important;top:0!important;left:0!important;right:0!important;"
"z-index:2147483647!important;font-family:Menlo,Consolas,monospace!important;"
"padding:6px 12px!important;font-size:11px!important;line-height:1.4!important;"
"text-align:left!important;display:flex!important;justify-content:space-between!important;"
"align-items:center!important;gap:8px!important;"
)
if th["neon"]:
# glowing glass tube : outer + inset accent glow, neon edge
bar_css = (
_base
+ f"background:{th['bg']}!important;color:{th['fg']}!important;"
+ f"border-bottom:2px solid {th['edge']}!important;"
+ f"box-shadow:0 0 10px {th['accent']},0 3px 22px {th['glow']},"
f"inset 0 -1px 6px {th['glow']}!important;"
+ "backdrop-filter:blur(3px)!important;"
)
title_css = f"color:{th['accent']};text-shadow:0 0 6px {th['accent']},0 0 12px {th['accent']}"
else:
bar_css = (
_base
+ f"background:{th['bg']}!important;color:{th['fg']}!important;"
+ f"border-bottom:2px solid {th['edge']}!important;"
+ "box-shadow:0 2px 8px rgba(0,0,0,0.3)!important;"
)
title_css = ""
code_css = f"background:{th['chip']};padding:1px 4px;border-radius:2px"
link_css = f"color:{th['link']};text-decoration:underline;font-weight:bold"
title_attr = f" style=\"{title_css}\"" if title_css else ""
if csp_strict:
# JS-less HTML banner — visible only, no close button. !important
# everywhere so page CSS can't override the fixed positioning.
# NCRs work even when page charset is iso-8859-1.
html = (
f"<div id=\"gondwana-mitm-banner\" role=\"status\" "
f"style=\"position:fixed!important;top:0!important;left:0!important;right:0!important;"
f"z-index:2147483647!important;"
f"background:linear-gradient(90deg,#ffb347 60%,#0a0a0f 100%)!important;"
f"color:#0a0a0f!important;font-family:Menlo,Consolas,monospace!important;"
f"padding:6px 12px!important;font-size:11px!important;line-height:1.4!important;"
f"border-bottom:2px solid #C04E24!important;text-align:left!important;"
f"display:flex!important;justify-content:space-between!important;align-items:center!important;gap:8px!important\">"
f"<span><b>{SAT_EMOJI} ToolBoX {level_label}</b> &#xB7; CA SHA1: "
f"<code style=\"background:rgba(0,0,0,0.1);padding:1px 4px;border-radius:2px\">{sha1[:23]}</code>"
f" &#xB7; <a href=\"{report_url}\" style=\"color:#0a5840;text-decoration:underline;font-weight:bold\">Mon rapport</a></span>"
f"<div id=\"gondwana-mitm-banner\" role=\"status\" style=\"{bar_css}\">"
f"<span><b{title_attr}>{SAT_EMOJI} ToolBoX {level_label}</b> &#xB7; CA SHA1: "
f"<code style=\"{code_css}\">{sha1[:23]}</code>"
f" &#xB7; <a href=\"{report_url}\" style=\"{link_css}\">Mon rapport</a></span>"
f"<span style=\"color:#e8e6d9;background:rgba(0,0,0,0.4);padding:3px 8px;border-radius:3px\">"
f"{right_text}"
f" &#xB7; <b style=\"color:{grade_color};background:#0a0a0f;padding:1px 5px;border-radius:2px\">{grade}</b>"
@ -489,6 +546,12 @@ def _banner_html_dynamic(sha1: str, ctx: dict, csp_strict: bool,
level_js = _json.dumps(level_label)
sat_js = _json.dumps(SAT_EMOJI)
mid_js = _json.dumps(" &#xB7; ")
# theme (#545) — JS-encoded so the same neon/amber styling applies here
bar_css_js = _json.dumps(bar_css)
title_attr_js = _json.dumps(title_attr)
code_css_js = _json.dumps(code_css)
link_css_js = _json.dumps(link_css)
close_col_js = _json.dumps(th["fg"])
js = f"""
(function(){{
@ -499,14 +562,7 @@ def _banner_html_dynamic(sha1: str, ctx: dict, csp_strict: bool,
var b=document.createElement('div');
b.id='gondwana-mitm-banner';
b.setAttribute('role','status');
b.style.cssText='position:fixed!important;top:0!important;left:0!important;right:0!important;'+
'z-index:2147483647!important;'+
'background:linear-gradient(90deg,#ffb347 60%,#0a0a0f 100%)!important;'+
'color:#0a0a0f!important;font-family:Menlo,Consolas,monospace!important;'+
'padding:6px 12px!important;font-size:11px!important;line-height:1.4!important;'+
'border-bottom:2px solid #C04E24!important;box-shadow:0 2px 8px rgba(0,0,0,0.3)!important;'+
'text-align:left!important;display:flex!important;'+
'justify-content:space-between!important;align-items:center!important;gap:8px!important';
b.style.cssText={bar_css_js};
var rightText={right_js};
var grade={grade_js};
var gradeCol={grade_col_js};
@ -515,14 +571,18 @@ def _banner_html_dynamic(sha1: str, ctx: dict, csp_strict: bool,
var level={level_js};
var SAT={sat_js};
var MID={mid_js};
b.innerHTML='<span><b>'+SAT+' ToolBoX '+level+'</b>'+MID+'CA SHA1: '+
'<code style=\"background:rgba(0,0,0,0.1);padding:1px 4px;border-radius:2px\">'+sha1+'</code>'+
MID+'<a href=\"'+reportUrl+'\" style=\"color:#0a5840;text-decoration:underline;font-weight:bold\">Mon rapport</a></span>'+
var TITLE_ATTR={title_attr_js};
var CODE_CSS={code_css_js};
var LINK_CSS={link_css_js};
var CLOSE_COL={close_col_js};
b.innerHTML='<span><b'+TITLE_ATTR+'>'+SAT+' ToolBoX '+level+'</b>'+MID+'CA SHA1: '+
'<code style=\"'+CODE_CSS+'\">'+sha1+'</code>'+
MID+'<a href=\"'+reportUrl+'\" style=\"'+LINK_CSS+'\">Mon rapport</a></span>'+
'<span style=\"display:flex;align-items:center;gap:8px\">'+
'<span style=\"color:#e8e6d9;background:rgba(0,0,0,0.4);padding:3px 8px;border-radius:3px\">'+
rightText+MID+'<b style=\"color:'+gradeCol+';background:#0a0a0f;padding:1px 5px;border-radius:2px\">'+grade+'</b>'+
'</span>'+
'<a href=\"javascript:void(0)\" onclick=\"document.getElementById(\\'gondwana-mitm-banner\\').style.display=\\'none\\';document.body.style.paddingTop=0\" style=\"color:#0a0a0f;text-decoration:none;font-weight:bold;cursor:pointer\">[&#xD7;]</a>'+
'<a href=\"javascript:void(0)\" onclick=\"document.getElementById(\\'gondwana-mitm-banner\\').style.display=\\'none\\';document.body.style.paddingTop=0\" style=\"color:'+CLOSE_COL+';text-decoration:none;font-weight:bold;cursor:pointer\">[&#xD7;]</a>'+
'</span>';
if(document.body.firstChild){{document.body.insertBefore(b,document.body.firstChild)}}
else{{document.body.appendChild(b)}}
@ -610,8 +670,9 @@ class InjectBanner:
csp_strict = _detect_csp_strict(flow)
report_url = _report_url_for(flow)
level_label = _level_label(flow)
level = _client_level(flow)
snippet = _banner_html_dynamic(_CA_SHA1, ctx, csp_strict,
report_url, level_label)
report_url, level_label, level)
except Exception as e:
log.warning("banner compute failed for %s: %s", flow.request.host, e)
# Fail-open : skip injection rather than break the page

View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
#
# SecuBox-Deb :: secubox-toolbox-fetch-xpi (#532)
#
# Pull the latest browser ToolBoX extension .xpi (published as a GitHub
# release asset by build-webext.yml on webext-v* tags) into the toolbox
# serve path, so GET /wg/toolbox.xpi serves it locally (offline-capable
# install from the cabine). Best-effort : a failure leaves any existing
# .xpi in place ; the endpoint falls back to the public release redirect.
set -euo pipefail
readonly MODULE="secubox-toolbox-fetch-xpi"
DEST_DIR="/var/lib/secubox/toolbox/webext"
DEST="${DEST_DIR}/secubox-toolbox-webext.xpi"
# Tag-pinned (not /latest/): the webext release is make_latest:false so it
# doesn't steal "latest" from the Android APK release. Bump on new webext-v*.
RELEASE_URL="https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.1/secubox-toolbox-webext.xpi"
log() { logger -t "$MODULE" -- "$*" 2>/dev/null || echo "[$MODULE] $*" >&2; }
install -d -m 0755 -o secubox-toolbox -g secubox-toolbox "$DEST_DIR" 2>/dev/null \
|| mkdir -p "$DEST_DIR"
TMP=$(mktemp --suffix=.xpi)
trap 'rm -f "$TMP"' EXIT
if command -v wget >/dev/null 2>&1; then
if wget -q --timeout=20 --tries=2 "$RELEASE_URL" -O "$TMP" && [ -s "$TMP" ]; then
# Sanity : an .xpi is a ZIP — must start with PK\x03\x04.
if head -c 2 "$TMP" | grep -q "PK"; then
install -m 0644 "$TMP" "$DEST"
chown secubox-toolbox:secubox-toolbox "$DEST" 2>/dev/null || true
log "fetched .xpi -> ${DEST} ($(stat -c%s "$DEST" 2>/dev/null) bytes)"
exit 0
else
log "downloaded file is not an .xpi (no release asset yet?) — keeping existing"
fi
else
log "fetch failed (no release yet / network) — /wg/toolbox.xpi will redirect to the release"
fi
else
log "wget missing — cannot fetch .xpi"
fi
exit 0

View File

@ -622,6 +622,7 @@ pre{background:#1a1a25;color:var(--phos-hot);padding:0.6rem 0.8rem;border-radius
<div class="tab-content" data-content=android>
<a href="/wg/toolbox.apk" class="btn btn-go">📱 Installer l'app ToolBoX (1-tap)</a>
<a href="/wg/toolbox.xpi" class="btn">🧩 Extension navigateur (cartographie)</a>
<div class=warn style="margin-top:0.5rem">
<b>Le plus simple</b> : l'app fait tout (CA + tunnel + vérif) en 5 étapes.
Active « sources inconnues » à l'installation. Sinon, méthode manuelle ci-dessous :
@ -1192,6 +1193,7 @@ _ONBOARD_BODY = {
"android": """
<p><b> Le plus simple l'app ToolBoX fait tout :</b></p>
<a class=btn href="/wg/toolbox.apk">📱 Installer l'app ToolBoX (.apk, 1-tap)</a>
<a class=btn href="/wg/toolbox.xpi">🧩 Extension navigateur (cartographie live)</a>
<p class=note>Active « sources inconnues » à l'installation. L'app installe le CA, importe le tunnel et vérifie le R3 en 5 étapes. Sinon, méthode manuelle :</p>
<ol>
<li>Installe l'app <a class=btn href="https://play.google.com/store/apps/details?id=com.wireguard.android" target=_blank rel=noopener>WireGuard</a> depuis le Play Store.</li>
@ -1365,6 +1367,37 @@ async def wg_toolbox_apk() -> Response:
return RedirectResponse(url=_ANDROID_APK_RELEASE, status_code=302)
# Browser extension (Firefox .xpi), same serve pattern as the APK (#532).
# Tag-pinned URL (not /latest/): the webext release is published with
# make_latest:false so it does not steal "latest" from the Android APK
# release. Bump the tag here when a new webext-v* release is cut.
_WEBEXT_XPI = Path("/var/lib/secubox/toolbox/webext/secubox-toolbox-webext.xpi")
_WEBEXT_XPI_RELEASE = (
"https://github.com/CyberMind-FR/secubox-deb/releases/download/"
"webext-v0.1.1/secubox-toolbox-webext.xpi"
)
@router.get("/wg/toolbox.xpi")
async def wg_toolbox_xpi() -> Response:
"""Serve the browser ToolBoX extension .xpi (#532).
Local file first (install from the cabine, works offline) ; if it
hasn't been fetched yet, 302 to the latest public GitHub release
asset so the onboard button never dead-ends.
"""
if _WEBEXT_XPI.exists() and _WEBEXT_XPI.stat().st_size > 0:
return Response(
content=_WEBEXT_XPI.read_bytes(),
media_type="application/x-xpinstall",
headers={
"Content-Disposition": "attachment; filename=secubox-toolbox-webext.xpi",
"Cache-Control": "public, max-age=300",
},
)
return RedirectResponse(url=_WEBEXT_XPI_RELEASE, status_code=302)
@router.get("/wg/ca.mobileconfig")
async def wg_ca_mobileconfig() -> Response:
"""iOS profile that installs the mitm-wg CA in trust store."""
@ -1852,11 +1885,17 @@ _MITM_MODULES = [
]
def _pull_mitm_module_events(mac_hash: str) -> dict:
def _pull_mitm_module_events(mac_hash: str, limit: int = 50) -> dict:
"""Query each receiving module's GET /mitm-events for this client.
Returns a dict {module: {count, sample_events}} for the report. Errors per
module are non-fatal if a module is down, it just shows count=0.
Returns a dict {module: {count, sample_events, enriched_summary}} for the
report. Errors per module are non-fatal if a module is down, it just
shows count=0.
Phase 2c (#490) : also build an enriched_summary per module aggregating
the enrich_hook output (top apps from dpi, top providers from cookies,
devices from avatar, JA4 fingerprints from threat-analyst, score band
from soc).
"""
import socket as _sock
import urllib.parse as _up
@ -1872,15 +1911,17 @@ def _pull_mitm_module_events(mac_hash: str) -> dict:
self.sock.connect(sock_path)
conn = UDSConnection("localhost", timeout=2)
qs = _up.urlencode({"mac_hash": mac_hash, "limit": 20})
qs = _up.urlencode({"mac_hash": mac_hash, "limit": limit})
conn.request("GET", f"/mitm-events?{qs}")
resp = conn.getresponse()
if resp.status == 200:
import json as _json
data = _json.loads(resp.read().decode("utf-8", errors="ignore")[:50000])
data = _json.loads(resp.read().decode("utf-8", errors="ignore")[:200000])
events = data.get("events", [])
out[kind] = {
"count": data.get("count", 0),
"sample": data.get("events", [])[:5],
"sample": events[:5],
"enriched_summary": _summarize_enriched(kind, events),
}
else:
out[kind] = {"count": 0, "error": f"HTTP {resp.status}"}
@ -1892,6 +1933,88 @@ def _pull_mitm_module_events(mac_hash: str) -> dict:
return out
def _summarize_enriched(kind: str, events: list[dict]) -> dict:
"""Phase 2c (#490) : per-module aggregation of enrich_hook output.
Each receiving module attaches its enrich_hook result under 'enriched'
inside the event payload. This function consolidates them into a
compact summary suitable for the /report display.
"""
if not events:
return {}
if kind == "dpi":
apps: dict[str, dict] = {}
for ev in events:
e = (ev.get("payload") or {}).get("enriched") or {}
app = e.get("app")
if not app or app == "?":
continue
if app not in apps:
apps[app] = {"count": 0, "category": e.get("category"), "emoji": e.get("emoji")}
apps[app]["count"] += 1
top = sorted([{"app": k, **v} for k, v in apps.items()], key=lambda x: -x["count"])[:15]
return {"top_apps": top, "classified_events": sum(v["count"] for v in apps.values())}
if kind == "cookies":
providers: dict[str, dict] = {}
total_trackers = 0
for ev in events:
e = (ev.get("payload") or {}).get("enriched") or {}
for p, info in (e.get("providers") or {}).items():
if p not in providers:
providers[p] = {"count": 0, "category": info.get("category"), "emoji": info.get("emoji")}
providers[p]["count"] += info.get("count", 1)
total_trackers += info.get("count", 1)
top = sorted([{"provider": k, **v} for k, v in providers.items()], key=lambda x: -x["count"])[:10]
return {"top_providers": top, "tracker_total": total_trackers}
if kind == "avatar":
devices: dict[str, dict] = {}
browsers: dict[str, dict] = {}
for ev in events:
e = (ev.get("payload") or {}).get("enriched") or {}
d = e.get("device")
if d and d != "unknown":
if d not in devices:
devices[d] = {"count": 0, "emoji": e.get("device_emoji"), "os_label": e.get("os_label")}
devices[d]["count"] += 1
b = e.get("browser")
if b and b != "unknown":
if b not in browsers:
browsers[b] = {"count": 0, "emoji": e.get("browser_emoji"), "label": e.get("browser_label")}
browsers[b]["count"] += 1
return {"devices": devices, "browsers": browsers}
if kind == "threat-analyst":
fps: dict[str, dict] = {}
for ev in events:
e = (ev.get("payload") or {}).get("enriched") or {}
fp = e.get("ja4_fingerprint")
if not fp:
continue
if fp not in fps:
fps[fp] = {
"count": 0,
"known_client": e.get("known_client"),
"raw_repr": e.get("ja4_raw_repr"),
}
fps[fp]["count"] += 1
top = sorted([{"fingerprint": k, **v} for k, v in fps.items()], key=lambda x: -x["count"])[:10]
return {"top_fingerprints": top, "unique_count": len(fps)}
if kind == "soc":
total_w = 0
kinds_seen: dict[str, int] = {}
max_band = "low"
band_order = ["low", "medium", "high"]
for ev in events:
e = (ev.get("payload") or {}).get("enriched") or {}
total_w += e.get("total_weight") or 0
for k in e.get("indicator_kinds") or []:
kinds_seen[k] = kinds_seen.get(k, 0) + 1
b = e.get("band") or "low"
if band_order.index(b) > band_order.index(max_band):
max_band = b
return {"total_weight": total_w, "max_band": max_band, "indicator_kinds": kinds_seen}
return {}
def _enrich_with_geo(matches: list[dict]) -> list[dict]:
"""Add geo info to threat_intel matches."""
out = []

View File

@ -682,12 +682,39 @@ def fold_recent(window_seconds: int = 300) -> Tuple[int, int]:
return nodes_touched, links_touched
# eTLD+1 rollup (#549). Mirror of the addon's _registrable_domain so the
# graph can group trackers under their registrable parent (all
# *.doubleclick.net → doubleclick.net) without a publicsuffix dependency.
_MULTI_LABEL_TLDS = {
"co.uk", "ac.uk", "gov.uk", "org.uk", "net.uk",
"co.jp", "ne.jp", "ac.jp",
"com.au", "net.au", "org.au",
"com.br", "com.cn", "com.hk", "com.tw", "com.mx",
}
def _registrable_domain(host: str) -> str:
"""Cheap eTLD+1 : www.lemonde.fr → lemonde.fr ; a.b.example.co.uk →
example.co.uk. Raw IPs and single-label hosts pass through."""
h = (host or "").lower().strip(".")
if not h or h.replace(".", "").replace(":", "").isdigit():
return h
parts = h.split(".")
if len(parts) < 2:
return h
last_two = ".".join(parts[-2:])
if last_two in _MULTI_LABEL_TLDS and len(parts) >= 3:
return ".".join(parts[-3:])
return last_two
def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
"""Return the per-client graph JSON contract.
{nodes:[{id,domain,family,hits,sites_count}],
edges:[{src,dst,reuse_count,shared_trackers[],ja4_match}],
stats:{total_trackers,total_sites,first_seen,last_seen}}
stats:{total_trackers,total_sites,first_seen,last_seen},
by_domain:[...], targets:[...], history:[...]} # additive (#549)
"""
since = int(time.time()) - max(since_seconds, 3600)
out: Dict = {"nodes": [], "edges": [], "stats": {}}
@ -770,9 +797,84 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
# Phase 12.C — operator-grade / state-adjacent surfaces.
opgrade = opgrade_for_client(mac_hash, since_seconds=since_seconds)
out["opgrade"] = opgrade
# ── #549 additive aggregations (read-time, no schema change) ──
# (a) by_domain : roll trackers up under registrable parent.
_dom: Dict[str, dict] = {}
for n in out["nodes"]:
parent = _registrable_domain(n["domain"])
d = _dom.setdefault(parent, {
"domain": parent, "tracker_count": 0, "hits": 0,
"_trackers": set(), "_sites": set(), "_vendors": set(),
"last_seen": 0,
})
d["_trackers"].add(n["domain"])
d["hits"] += n["hits"] or 0
d["_sites"].update(n["sites"])
d["last_seen"] = max(d["last_seen"], n["last_seen"] or 0)
for v in (n.get("cdn_vendor"), n.get("antibot_vendor"),
n.get("opgrade_vendor")):
if v:
d["_vendors"].add(v)
by_domain = []
for d in _dom.values():
by_domain.append({
"domain": d["domain"],
"tracker_count": len(d["_trackers"]),
"trackers": sorted(d["_trackers"])[:30],
"hits": d["hits"],
"sites_count": len(d["_sites"]),
"sites": sorted(d["_sites"])[:20],
"vendors": sorted(d["_vendors"]),
"last_seen": d["last_seen"],
})
by_domain.sort(key=lambda x: (-x["hits"], -x["tracker_count"]))
out["by_domain"] = by_domain
# (b) targets : invert sites→trackers (who watches each page).
_tgt: Dict[str, dict] = {}
for n in out["nodes"]:
for s in n["sites"]:
t = _tgt.setdefault(s, {
"site": s, "hits": 0,
"_trackers": set(), "_domains": set(),
})
t["_trackers"].add(n["domain"])
t["_domains"].add(_registrable_domain(n["domain"]))
t["hits"] += n["hits"] or 0
targets = []
for t in _tgt.values():
targets.append({
"site": t["site"],
"tracker_count": len(t["_trackers"]),
"trackers": sorted(t["_trackers"])[:30],
"parent_domains": sorted(t["_domains"]),
"hits": t["hits"],
})
targets.sort(key=lambda x: (-x["tracker_count"], -x["hits"]))
out["targets"] = targets
# (c) history : per-(UTC)day timeline from the raw edge log.
history = []
for r in c.execute(
"SELECT (ts/86400) AS day_epoch, COUNT(*) AS hits, "
"COUNT(DISTINCT tracker_domain) AS trackers, "
"COUNT(DISTINCT src_site) AS sites "
"FROM social_edges WHERE client_mac_hash = ? AND ts >= ? "
"GROUP BY day_epoch ORDER BY day_epoch",
(mac_hash, since),
).fetchall():
history.append({
"day": int(r["day_epoch"]) * 86400,
"hits": r["hits"],
"trackers": r["trackers"],
"sites": r["sites"],
})
out["history"] = history
out["stats"] = {
"total_trackers": (stats_row["total_trackers"] or 0) if stats_row else 0,
"total_sites": sites_count,
"total_domains": len(by_domain),
"first_seen": stats_row["first_seen"] if stats_row else None,
"last_seen": stats_row["last_seen"] if stats_row else None,
"antibot_sites": len({a["src_site"] for a in antibot}),

91
wiki/Android-ToolBox.md Normal file
View File

@ -0,0 +1,91 @@
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
# 📱 Android ToolBox — one-tap R3 onboarding
The **SecuBox Android ToolBox** is a tiny companion app that onboards a
phone onto the VILLAGE3B *cabine* in one tap: it installs the cabine CA,
brings up the WireGuard R3 tunnel, verifies reachability, then opens the
live *cartographie sociale*. It replaces the manual Android tutorial.
- Source : [`clients/android-toolbox/`](https://github.com/CyberMind-FR/secubox-deb/tree/master/clients/android-toolbox)
- Package : `in.secubox.toolbox` · Kotlin + Jetpack Compose · minSdk 26 / targetSdk 34
- License : `LicenseRef-CMSD-1.0`
## Install
Grab the APK directly from the cabine — the toolbox serves it:
```
https://kbin.<board>.secubox.in/wg/toolbox.apk
```
The onboard panels in the kbin WebUI expose a **📱 Installer l'app ToolBoX
(1-tap)** button pointing at that endpoint. When the cabine has a locally
fetched build it serves it (`application/vnd.android.package-archive`);
otherwise it 302-redirects to the latest GitHub release asset
`secubox-toolbox-android.apk`.
> The MVP APK is **debug-signed** (sideload — enable *Install unknown
> apps*). A release-signed build with a published fingerprint is a
> follow-up (needs a keystore secret in CI).
## Onboarding flows
### Manual path (non-rooted)
1. **Discover** — scan the kbin QR or type the booth host (`kbin.<board>.secubox.in`).
2. **Install CA** — downloads `/wg/ca.crt`, launches the Android cert-install
intent (`KeyChain.createInstallIntent`).
3. **Import profile** — downloads `/wg/profile/new`, hands the `.conf` to the
official WireGuard app (`FileProvider` + `ACTION_VIEW`).
4. **Verify** — polls `/wg/r3-check`*Tunnel R3 actif ✓*.
5. **Live metrics** — opens `/social/me` (cartographie sociale).
Android 11+ restricts **user CA trust**, so on a non-rooted device the
browser CA confirm is a guided manual step.
### Root path — fully-automated, silent (#538)
When the device is **rooted**, the Discover step shows an extra
**⚡ Installation automatique (root)** button. One tap runs everything with
no further interaction (a `RootAuto` step streams the progress log):
1. **System CA install** — downloads `/wg/ca.pem`, computes the OpenSSL
`subject_hash_old` in pure Kotlin, then bind-mounts a populated copy of
the trust store over `/system/etc/security/cacerts` (+ the conscrypt
APEX on Android 14), restoring the SELinux context
`u:object_r:system_security_cacerts_file:s0`. **Every** app trusts the
cabine CA — not just user-CA opt-in apps. Reversible via `umount`.
2. **Native WireGuard** — if the kernel has the WireGuard module + `wg`/`ip`,
the tunnel comes up natively (`ip link add … type wireguard` + `wg set`) —
no WireGuard app required.
3. **Auto R3 verify** — polls `/wg/r3-check`.
If the kernel lacks WireGuard, the root path installs the system CA then
falls back to the manual WireGuard-app handoff.
**Safety** — every root action is gated behind the explicit tap; nothing
runs as root unless the operator chooses root mode on their own device.
Code: `RootShell.kt` (su wrapper) + `RootOnboard.kt` (silent sequence).
## Build (CI)
No Gradle wrapper jar is committed (text-only scaffold). CI builds it:
- GitHub Actions `build-android-apk.yml` → debug APK artifact on push to
`master` / PRs touching `clients/android-toolbox/**`.
- Tagging `android-v*` publishes the APK as a release asset.
Locally (Android SDK + Gradle 8.9 + JDK 17):
```bash
cd clients/android-toolbox
gradle :app:assembleDebug # app/build/outputs/apk/debug/app-debug.apk
```
## Cabine endpoints consumed
| Endpoint | Purpose |
|----------|---------|
| `/wg/ca.crt` / `/wg/ca.pem` | cabine CA (user / system store) |
| `/wg/profile/new` | fresh WireGuard `.conf` |
| `/wg/r3-check` | tunnel reachability probe |
| `/social/me` | live cartographie sociale |
| `/wg/toolbox.apk` | the APK itself |

93
wiki/Browser-Extension.md Normal file
View File

@ -0,0 +1,93 @@
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
# 🧩 Browser extension — Cartographie sociale live
The **SecuBox ToolBoX browser extension** *emancipates* the R3 toolbox live
tracker analysis into your browser. Instead of only seeing the *cartographie
sociale* on `kbin/social/me`, a toolbar badge ticks up as trackers fire and a
popup shows who is watching you — live, as you browse.
Sibling of the [[Android-ToolBox]] app. Talks **only** to your cabine over the
R3 tunnel — no third-party calls.
- Source : [`clients/webext-toolbox/`](https://github.com/CyberMind-FR/secubox-deb/tree/master/clients/webext-toolbox)
- WebExtension **MV3** (Firefox `.xpi` + Chromium) · plain JS/HTML/CSS, no bundler
- License : `LicenseRef-CMSD-1.0`
## Install
Published release `.xpi` (downloadable directly):
```
https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.1/secubox-toolbox-webext.xpi
```
The toolbox also serves it from the cabine:
```
https://kbin.<board>.secubox.in/wg/toolbox.xpi
```
The kbin onboard panel exposes a **🧩 Extension navigateur (cartographie)**
button. When a local build is present the cabine serves it
(`application/x-xpinstall`); otherwise it 302-redirects to the **tag-pinned**
release asset above. The webext release is published `make_latest:false` so it
does not steal the repo "Latest" pointer from the Android APK release.
- **Firefox** — open the `.xpi`. A permanent install needs an AMO-signed build
(release CI / `web-ext sign`); for development use *about:debugging → Load
Temporary Add-on*, or an ESR/Dev build with
`xpinstall.signatures.required=false`.
- **Chromium** — load unpacked (`chrome://extensions` → Developer mode).
Chromium action icons must be raster — rasterise `icons/icon.svg` to PNG
before a Web Store build (Firefox accepts the SVG as-is).
## What it does
- **Pairing** — calls `/social/me` over the tunnel, which 303-redirects to
`/social/{token}`; the extension reads the minted HMAC token from the final
URL. Anonymous (rotating `mac_hash`), no account. Manual token entry in the
options page.
- **Live badge** — the toolbar icon shows the live tracker count (polled once a
minute). Colour escalates: 🟡 gold → 🟥 anti-bot present → 🟪 operator-grade
present.
- **Popup** — four stat tiles (trackers / sites / anti-bot / operator-grade), a
dependency-free **mini Round-Eye graph** (device centre, trackers on the ring,
radius by hits, colour by tier), and a top-tracker list tagged with CDN
(12.A) / anti-bot (12.B) / operator-grade (12.C).
- **Actions***Cartographie complète* (full d3 view at `/social/{token}`),
*Rapport PDF* (`/social/report/{token}.pdf`), *Effacer mes données* (RGPD
art. 17 wipe → `POST /social/wipe/{token}`).
## Build (CI)
No bundler — `build-webext.yml` runs `web-ext lint` then packages the `.xpi`:
- artifact on push to `master` / PRs touching `clients/webext-toolbox/**`
- tagging `webext-v*` publishes the `.xpi` as a release asset
Locally:
```bash
cd clients/webext-toolbox
./build.sh # → secubox-toolbox-webext-<version>.xpi
```
## Cabine endpoints consumed
| Endpoint | Purpose |
|----------|---------|
| `/wg/r3-check` | tunnel presence indicator |
| `/social/me` | pair → mint token (303 → `/social/{token}`) |
| `/social/graph/{token}?since=` | per-session tracker graph JSON |
| `/social/wipe/{token}` | RGPD art. 17 erasure |
| `/social/{token}` | full d3 cartographie page |
| `/social/report/{token}.pdf` | bilingual PDF report |
| `/wg/toolbox.xpi` | the extension itself |
## Notes
- No server-side CORS needed: an MV3 extension with `host_permissions` for
`*.secubox.in` fetches cross-origin from its background without CORS.
- MVP polls `/social/graph` and computes the delta client-side; a future
`GET /social/live/{token}` (SSE) can replace the poll. The deception-plane
*Poke/Emancipate* per-site control lands once the deception plane ships.

View File

@ -29,6 +29,8 @@
* [[ARM-Installation]] | [FR](ARM-Installation-FR) | [DE](ARM-Installation-DE) | [中文](ARM-Installation-ZH)
* [[ESPRESSObin]] | [FR](ESPRESSObin-FR) | [DE](ESPRESSObin-DE) | [中文](ESPRESSObin-ZH)
* [[Eye-Remote]] 📡
* [[Android-ToolBox]] 📱 one-tap R3
* [[Browser-Extension]] 🧩 cartographie
* [[QEMU-ARM64]] 🖥️
### 🟢 ROOT — Configuration