Moteur unifié de capture + analyse web durci (recon anti-bot + analyse HTML hostile).
Voir docs/superpowers/specs/ pour le design.
Verdict et score décomposé. Chaque contribution au score est affichée : le score n'est jamais un nombre opaque, il se relit ligne à ligne.
Soumission. URL ou HTML collé, et un DSL scripté borné — une séquence
click/fill/sleep/press/scroll/capture rejouée après le chargement, sans
jamais exécuter de JavaScript arbitraire.
Détections statiques, classées par sévérité, avec l'extrait de code et la ligne qui les déclenche.
Session interactive isolée. La page hostile est rendue dans un conteneur éphémère, sur son propre réseau, et pilotée à distance : le contenu n'atteint jamais le navigateur de l'analyste.
Ces captures sont produites par Ocular lui-même — le moteur furtif du projet pilote sa propre interface.
make analyze FILE=suspect.htmlConstruit l'image ocular-runner-analysis si besoin, lance l'analyse dans le conteneur durci
(--network none, seccomp, --read-only, utilisateur non-root) et affiche le résultat JSON.
make analyze URL=https://exemple-suspect.tldConstruit ocular-runner-analysis et ocular-runner-recon si besoin, puis lance une
capture live (profil capture : Camoufox anti-detect + Xvfb, résolution auto du Turnstile
Cloudflare via vision) dans le conteneur durci — --cap-drop ALL, seccomp dédié, --read-only,
utilisateur non-root — et affiche le résultat JSON (verdict statique calculé sur le DOM capturé).
Auto-résolution Turnstile. Repose sur un rendu headed réel dans le Xvfb du conteneur
(runner_recon/vision.py + xdotool) : template matching sur le screenshot pour localiser la
case, puis clic OS (X11 réel, pas page.mouse) pour franchir la vérification isTrusted
d'un widget interactif. Le widget se chargeant dans une iframe async, solve_turnstile
(runner_recon/capture.py) retente la détection ~6 fois sur ~4s avant de conclure à l'absence de
Turnstile. Les coordonnées de clic sont mappées du repère image (viewport du screenshot,
ce que renvoie vision.detect()) au repère écran (ce qu'attend xdotool) via
window.mozInnerScreenX/Y (offset du chrome Firefox, API Gecko) et devicePixelRatio
(vision.image_to_screen) — sans cet offset le clic tombe à côté de la case. Après le clic,
une nouvelle détection vérifie que la case a bien disparu avant de marquer turnstile_solved
(jamais un True optimiste non vérifié). Limite connue : le mapping, la boucle de retry et
la logique de vérification sont couverts par des tests unitaires (page/vision mockées, cf.
tests/test_vision_coords.py et tests/test_capture_logic.py) et par le smoke d'intégration
docker sur une page sans Turnstile (tests/test_deploy_images.py::test_runner_recon_image_builds_and_navigates),
mais la résolution effective d'un vrai challenge Cloudflare n'a pas pu être validée en bout
en bout dans cet environnement — à confirmer contre une cible réelle.
analysis (--network none),
le profil capture a le réseau activé : le conteneur ocular-runner-recon effectue une
vraie requête sortante vers l'URL cible, ce qui expose l'IP de la machine qui exécute Ocular à
la cible (et à tout service tiers qu'elle charge). Pour analyser une cible sans révéler son IP
réelle (recon offensive, cible potentiellement hostile ou surveillée), faire transiter ce trafic
par un VPN ou Tor via les variables HTTP_PROXY/HTTPS_PROXY, lues et transmises au conteneur
par broker/launcher.py :
HTTPS_PROXY=socks5h://127.0.0.1:9050 make analyze URL=https://exemple-suspect.tldUne garde SSRF (engine/ssrf.py) bloque en amont les URL dont l'hôte résout vers une IP privée
(RFC1918), loopback, link-local ou le service de metadata cloud (169.254.169.254) — best-effort
au moment du submit, pas une protection complète contre le DNS-rebinding (cf. docstring du
module).
OCULAR_TOKEN=<jeton-fort> make up
curl -X POST http://localhost:8000/jobs \
-H "Authorization: Bearer $OCULAR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"profile": "analysis", "html": "<html>...</html>"}'
curl http://localhost:8000/jobs/<job_id> \
-H "Authorization: Bearer $OCULAR_TOKEN"Toutes les routes exigent Authorization: Bearer $OCULAR_TOKEN ; sans OCULAR_TOKEN configuré
côté serveur, l'API répond 503 (fail-closed), jamais un accès sans auth.
Le profil capture seul ne voit que ce qui se charge au chargement de la page. Or beaucoup de
comportements hostiles ne se déclenchent qu'après une interaction : formulaire de phishing
multi-étapes (identifiants → OTP → redirection), balise de tracking posée uniquement au clic
(« beacon »), contenu qui ne s'affiche qu'après un scroll ou la fermeture d'un consentement. Le
tier scripté rejoue une séquence d'actions déclarative dans le même conteneur capture
(ocular-runner-recon, aucune image supplémentaire) pendant que le trafic réseau est capturé, pour
révéler ces appels post-interaction en un run éphémère et jetable.
Le DSL. Une liste de steps, chacun un objet à une seule clé parmi les verbes en allowlist
goto, fill, click, wait, press, capture, scroll :
[
{"click": "#accept-cookies"},
{"fill": {"sel": "#email", "value": "victime@exemple.tld"}},
{"click": "#submit"},
{"wait": 1000},
{"capture": "apres-soumission"}
]goto navigue vers une nouvelle URL (revalidée SSRF), fill remplit un champ ({sel, value}),
click/wait(ms ou {selector})/press (touche en allowlist) pilotent l'interaction, scroll
déplace la page ("top"/"bottom"/pixels), capture prend un screenshot labellisé. Un capture
final est ajouté automatiquement si la séquence ne s'y termine pas déjà, pour toujours obtenir
un état de fin. Bornes strictes : ≤ 50 steps, sélecteur ≤ 500 caractères, valeur fill ≤ 2000,
wait ≤ 30000 ms, scroll ≤ 100000 px, label ≤ 64 caractères — tout dépassement ou verbe hors
allowlist est rejeté avant exécution.
Garanties de sécurité.
- Aucun JS arbitraire, aucun
eval: les verbes sont une allowlist stricte validée parengine.steps.validate_steps(source unique, importée à la fois par le web et par le runner — pas de seconde implémentation qui pourrait diverger) ; sélecteurs et valeurs passent par l'API locator Playwright (page.locator,page.fill), jamais interpolés dans du code exécuté. - Steps transmis par stdin, jamais par argument ni variable d'environnement : le broker écrit
{"url": ..., "steps": [...]}sur l'entrée standard du conteneur (docker run --rm -i) — les steps (et donc les valeurs saisies) sont absents dedocker inspectet des arguments de commande visibles par les autres processus de l'hôte. - Valeurs
fillredigées : toute valeur de champ est remplacée par"***"dans le journal d'actions renvoyé au client et dans les logs — jamais de mot de passe/identifiant en clair stocké ou affiché après l'exécution. - SSRF sur l'URL initiale ET chaque
goto:engine.ssrf.validate_capture_urls'applique à l'URL de départ comme à toute navigation demandée en cours de séquence (mêmes règles que le profilcapture— IP privées/loopback/link-local/metadata cloud bloquées en amont). - Même durcissement conteneur que le profil
capture3a, réutilisé tel quel (pas de duplication) :--cap-drop ALL, seccomp dédié,--read-only, non-root, réseau activé uniquement pour joindre la cible (mêmes avertissements IP/proxy que ci-dessus).
Utilisation.
cat > steps.json <<'EOF'
[{"click": "#accept-cookies"}, {"fill": {"sel": "#email", "value": "test@exemple.tld"}},
{"click": "#submit"}, {"wait": 1000}, {"capture": "apres-soumission"}]
EOF
OCULAR_TOKEN=<jeton-fort> make script URL=https://exemple-suspect.tld STEPS=steps.jsonmake script lit le fichier STEPS, construit {"profile":"capture","url":$URL,"steps":<contenu>}
et le soumet à POST /jobs — même mécanisme et même jeton Authorization: Bearer $OCULAR_TOKEN que
la section « Via l'API » ci-dessus. Steps invalides (verbe inconnu, borne dépassée, SSRF sur un
goto) → 422 avec le motif de rejet. Le résultat expose le journal d'actions (dynamic_steps :
action, succès, durée, erreur éventuelle — valeurs déjà redigées) et la galerie de captures
labellisées, aussi bien depuis l'API que depuis le formulaire scripté de l'UI (http://localhost:8000,
onglet capture — champ « script » au format JSON ci-dessus).
OCULAR_TOKEN=<jeton-fort> make upPuis ouvrir http://localhost:8000 — connexion avec le jeton, soumission de job, suivi des
jobs, détail (captures, DOM) en PWA installable.
Pour les cas où l'analyse automatique (profils analysis/capture) ne suffit pas — cible qui
détecte l'automatisation, formulaire à remplir à la main, navigation multi-étapes — Ocular
propose une session interactive : un conteneur Camoufox headed persistant, piloté à la souris/
clavier depuis le navigateur de l'analyste via un client noVNC embarqué dans l'UI.
Modèle de sécurité — pixels-only. L'analyste ne parle jamais directement au conteneur cible :
- Rendu pixels uniquement, via la gateway web : le navigateur de l'analyste ouvre un
WebSocket vers
web(/sessions/{id}/ws, auth par sous-protocole — le jeton capability ne transite jamais dans l'URL ni les logs), qui relaie le flux RFB/noVNC depuis le conteneur de session, sur le réseau Docker dédié à cette session (ocular-sess-net-{id}, créé au lancement et détruit au teardown ; leweby est attaché dynamiquement pour le seul besoin du proxy). Aucun DOM, aucun cookie, aucun fichier téléchargé côté cible n'atteint jamais la machine de l'analyste — seulement une image. - Presse-papiers coupé à la source : le serveur VNC du conteneur tourne avec
x11vnc -noclipboard -nosetclipboard(runner_recon_vnc/entrypoint_vnc.sh) — aucun texte ne peut transiter entre le presse-papiers de l'analyste et la session, quel que soit le client noVNC utilisé côté navigateur. - Aucun port hôte publié : le conteneur de session (
ocular-runner-recon-vnc) est lancé par le broker sans-p/--publish(broker/sessions.py::build_session_args) ;session_server(8090) et websockify/noVNC (6080) écoutent sur0.0.0.0uniquement parce qu'il n'y a rien à publier vers l'hôte — l'isolation vient de l'absence de mapping combinée au réseau interne dédié à la session, jamais d'un bind localhost. Le serveur VNC brut (5900) n'écoute lui QUE surlocalhostà l'intérieur du conteneur ; seul websockify (même conteneur) y accède. - Isolation inter-sessions : chaque session obtient son propre réseau docker
(
ocular-sess-net-{id}), créé par le broker au lancement et supprimé au teardown. Deux sessions sont donc sur des réseaux disjoints : une session compromise ne peut pas joindre le:6080(websockify, sans auth propre) ni le:8090d'un pair — elle ne le voit tout simplement pas. Seul lewebest attaché à chaque réseau de session (nécessaire au proxy) ; le broker, lui, n'y est attaché à aucun. Prouvé partests/test_session_isolation_integration.py. - Conteneur éphémère + reaper : chaque session a une durée de vie bornée — TTL absolu
(
OCULAR_SESSION_TTL, 1800 s par défaut) et timeout d'inactivité (OCULAR_SESSION_IDLE, 600 s), contrôlés par un reaper qui tourne dans le broker (thread démon, intervalleOCULAR_REAPER_INTERVAL, 60 s) et détruit (docker kill+docker rm -f) tout conteneur expiré, même orphelin (nom déterministeocular-sess-{id}, pas de dépendance au registre). - Balayage des orphelins : au démarrage du broker et périodiquement (thread démon,
intervalle
OCULAR_SWEEP_INTERVAL, 600 s), les conteneursocular-sess-*et les réseauxocular-sess-net-*sans session vivante au registre sont supprimés. Sans ce passage périodique, un résidu né en cours de vie (teardown partiellement échoué, conteneur tué hors flux) retiendrait un sous-réseau du pool d'adresses Docker — ressource finie — jusqu'au prochain redémarrage du broker.
capture, une
session interactive fait naviguer réellement Camoufox vers l'URL cible (ou rend le HTML fourni) :
l'IP de la machine qui exécute Ocular est exposée à la cible (utiliser HTTPS_PROXY/Tor si
nécessaire, cf. section précédente). Le contenu potentiellement hostile de la cible (JS, popups,
téléchargements déclenchés automatiquement) s'exécute et se rend dans le conteneur durci
(--cap-drop ALL, seccomp recon, --read-only, non-root) — jamais sur la machine de l'analyste,
qui ne reçoit que des pixels.
Ouvrir une session — la création est ASYNCHRONE (202) :
curl -X POST http://localhost:8000/sessions \
-H "Authorization: Bearer $OCULAR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://exemple-suspect.tld"}'
# HTTP 202 Accepted (réponse en < 1 s)
# -> {"session_id": "sess-…", "token": "…"} -- token capability WS, valable tant que la session vit202 ne veut PAS dire « prête ». La session est acceptée ; son conteneur met encore ~7-9 s à démarrer. Il faut ensuite sonder sa disponibilité (ci-dessous) avant de brancher le WebSocket noVNC ou de capturer.
Cette route était autrefois synchrone : elle attendait la disponibilité (jusqu'à
OCULAR_SESSION_READY_TIMEOUT, 30 s) avant de répondre. Un client qui abandonnait pendant
l'attente — timeout, Ctrl-C, proxy amont, onglet fermé — n'apprenait jamais son
session_id alors que la session était déjà créée : elle immobilisait un conteneur (~4 Go) et un
sous-réseau du pool docker jusqu'à son TTL, sans que personne ne puisse la supprimer. Le
client reçoit désormais l'identifiant immédiatement et peut donc toujours nettoyer.
Sonder la disponibilité : GET /sessions/{session_id}
curl http://localhost:8000/sessions/sess-… -H "Authorization: Bearer $OCULAR_TOKEN"
# -> {"session_id":"sess-…","state":"starting","ready":false,
# "kind":"recon-vnc","target":"https://exemple-suspect.tld/","created_at":…,"last_activity":…}state |
signification |
|---|---|
pending |
l'entrée registre existe, le conteneur n'est pas encore lancé |
starting |
conteneur lancé, son session_server ne répond pas encore /health |
ready |
prête : WebSocket noVNC et /capture utilisables |
ready (booléen) est le dérivé state == "ready" : s'arrêter dessus plutôt que sur une liste
d'états intermédiaires codée en dur, pour survivre à l'ajout d'un état.
La réponse ne contient jamais le token capability WS ni le secret de frontière conteneur
(même filtrage que GET /sessions) ; owner n'est rendu qu'à un admin. Comme toutes les routes
de session, elle rend 404 aussi bien pour une session inconnue que pour celle d'un autre
analyste (indistinguables à dessein : pas d'oracle d'existence) — l'admin passe outre.
Nettoyage — la contrepartie du 202. Si la disponibilité n'arrive pas dans un délai
raisonnable, ou si le sondage échoue, le client doit appeler DELETE /sessions/{session_id}.
Un 404 pendant le sondage signale que le serveur a lui-même renoncé et détruit la session (filet
de sécurité conservé du contrat synchrone) : c'est un échec terminal, pas un état d'attente.
Le client noVNC de l'UI se connecte ensuite à /sessions/{session_id}/ws avec ce jeton comme
sous-protocole WebSocket. Plus simple : ouvrir #/interactive dans l'UI (http://localhost:8000
une fois connecté avec $OCULAR_TOKEN), qui gère la création de session, le sondage (avec
progression visible), le nettoyage en cas d'échec et l'affichage noVNC sans manipuler l'API
directement.
Ocular n'impose aucun fournisseur d'identité ni gestionnaire de secrets — c'est un choix délibéré de portabilité :
- Authentification : toutes les routes protégées (
/jobs*,/saved*,/sessions*) exigent un simple jetonAuthorization: Bearer $OCULAR_TOKENvérifié côté serveur (web/app.py). Ce jeton opaque fonctionne aussi bien seul (déploiement mono-utilisateur) que derrière n'importe quel reverse-proxy ou couche SSO en amont (Authentik, Authelia, OIDC générique, LDAP, Basic Auth Caddy/Nginx…) — Ocular ne connaît ni ne dépend d'un fournisseur en particulier ; le proxy n'a qu'à transmettre ou fixer l'en-têteAuthorization. - Secrets :
OCULAR_TOKEN,OCULAR_ADMIN_TOKEN(et tout autre secret) sont lus depuis des variables d'environnement (deploy/.env, cf.deploy/.env.example). N'importe quel gestionnaire de secrets capable d'injecter des variables d'environnement au démarrage du conteneur (Vault, SOPS,docker compose --env-file, secrets Kubernetes, gestionnaire du VPS…) fonctionne sans modification — aucun n'est requis : un simple fichier.envlocal suffit pour un déploiement mono-VPS.
Pour tracer qui analyse/tranche (verdict analyste, provenance des sauvegardes), Ocular peut dériver l'identité de l'utilisateur depuis les en-têtes injectés par un reverse-proxy authentifié — compatible n'importe quel IdP (Keycloak, Authentik, Authelia, oauth2-proxy, LDAP fronté par un proxy…), sans verrouillage.
- Désactivé par défaut. L'en-tête d'identité n'est lu que si
OCULAR_TRUST_FORWARD_AUTH=1. Sans cette variable, seul leBearer $OCULAR_TOKENauthentifie (comportement inchangé). - Variables :
OCULAR_TRUST_FORWARD_AUTH(opt-in),OCULAR_FORWARD_USER_HEADER(défautX-Forwarded-User),OCULAR_FORWARD_EMAIL_HEADER(défautX-Forwarded-Email). - Quand activé, une requête proxifiée porteuse de l'en-tête d'identité est autorisée
automatiquement (l'analyste derrière l'IdP n'a aucun jeton à coller) ; l'identité alimente
saved_byet le verdict analyste.GET /auth/whoamirenvoie l'identité de l'appelant. - IP cliente dans la piste d'audit (même opt-in) : le frontal
gatewaydétient le port publié et relaie en L4, donc le pair TCP vu parwebest toujours le gateway. QuandOCULAR_TRUST_FORWARD_AUTH=1, la ligne d'auditsession createprend l'IP dansOCULAR_FORWARD_FOR_HEADER(défautX-Forwarded-For), élément le plus à gauche (le client d'origine ; la liste est construite par ajout successif). Sans l'opt-in, l'en-tête est ignoré et l'IP du pair est journalisée : une IP de frontal, honnête et connue, vaut mieux qu'une IP choisie par le client. Le gateway étant du L4, il transmet l'en-tête inchangé — c'est bien le reverse-proxy amont qui doit le poser et stripper les copies clientes. - Rôle admin via groupe IdP (opt-in, nécessite aussi
OCULAR_TRUST_FORWARD_AUTH=1) : définirOCULAR_ADMIN_GROUP=<nom-de-groupe>accorde le rôle admin (DELETE /saved) à tout appelant dont l'en-tête de groupes (OCULAR_FORWARD_GROUPS_HEADER, défautX-Forwarded-Groups, liste séparée par des virgules) contient exactement ce groupe (comparaison sensible à la casse, sans espaces parasites — unOCULAR_ADMIN_GROUP=" admins"ne matchera pasadmins).X-Admin-Tokenreste un fallback valable en parallèle. Vide (défaut) → admin uniquement viaX-Admin-Token.GET /auth/whoamiexposegroupsetis_admin(l'UI masque les contrôles admin aux non-admins — mais le backend reste la vraie garde).
⚠️ IMPÉRATIF de sécurité. N'activezOCULAR_TRUST_FORWARD_AUTHque derrière un reverse-proxy qui authentifie ET supprime (strip) toute copie des en-têtes de confiance venant du client — au minimumX-Forwarded-UserETX-Forwarded-Groups(et les en-têtes d'email/de groupes/d'IP que vous configurez, cf.X-Forwarded-Forci-dessus : non strippé, il laisse un client empoisonner l'IP de la piste d'audit). Sinon, un client peut usurperX-Forwarded-User: xou, siOCULAR_ADMIN_GROUPest activé,X-Forwarded-Groups: <groupe-admin>et escalader en admin. Recommandations : (1) gardezOCULAR_TOKEN/OCULAR_ADMIN_TOKENdéfinis même en mode forward-auth (filet) ; (2) le conteneurwebn'est jamais joignable en direct, seul le proxy l'atteint ; (3) le proxy doit écraser ces en-têtes, pas seulement les ajouter.
Quand il n'y a pas de reverse-proxy authentifiant devant Ocular, le client peut présenter
directement le jeton d'accès de l'IdP : Authorization: Bearer <jwt>. Ocular en vérifie
lui-même la signature contre le JWKS du fournisseur (Keycloak, Authentik…), puis iss, aud
et exp. Contrairement au forward-auth, aucun tiers n'est cru sur parole : un client peut
forger l'en-tête qu'il veut, pas la signature.
- Désactivé par défaut. Sans
OCULAR_OIDC_ENABLED=1, aucun bearer n'est interprété comme un JWT et aucun appel JWKS n'est émis — comportement strictement inchangé. - Trois variables sont OBLIGATOIRES ; s'il en manque une, tout jeton est refusé :
OCULAR_OIDC_ISSUER(valeur exacte attendue eniss),OCULAR_OIDC_AUDIENCE(attendue enaud),OCULAR_OIDC_JWKS_URL(ex.https://idp/realms/soc/protocol/openid-connect/certs). - Côté IdP, le jeton doit PORTER l'audience attendue. Vérifié sur un Keycloak 26.0 réel :
un jeton du client
admin-clin'a AUCUN claimaudet se fait donc refuser (aud-non-conforme) — ce n'est pas un défaut, la RFC 9068 exigeaudsur un jeton d'accès. Il faut un client muni d'un mappeur d'audience (oidc-audience-mapper,included.client.audience= la valeur d'OCULAR_OIDC_AUDIENCE). Unaudmulti-valué — le cas courant, par exemple["ocular", "master-realm", "account"]— est accepté dès que l'audience attendue en fait partie. - Le JWKS doit être en HTTPS. C'est l'ancre de confiance : qui répond à sa place publie ses
propres clés et signe les jetons qu'il veut. Pour un IdP joignable seulement en clair sur le
réseau interne, l'assumer explicitement avec
OCULAR_OIDC_ALLOW_INSECURE_JWKS=1. - Piège Keycloak : sans audience mapper sur le client du realm,
audvautaccountet non votreclient_id— les jetons seront refusés (à juste titre : sans contrôle d'audience, un jeton émis pour une autre application du même realm ouvrirait Ocular). - Réglages :
OCULAR_OIDC_USERNAME_CLAIM(défautpreferred_username, replisub),OCULAR_OIDC_GROUPS_CLAIM(défautgroups; chemin pointé accepté pour les rôles Keycloak :realm_access.roles),OCULAR_OIDC_CLOCK_SKEW(défaut 60 s),OCULAR_OIDC_JWKS_TTL(défaut 300 s),OCULAR_OIDC_HTTP_TIMEOUT(défaut 5 s). OCULAR_ADMIN_GROUPfonctionne à l'identique via le claim de groupes :DELETE /savedest accordé à qui présente un jeton valide contenant ce groupe.GET /auth/whoamirenvoie{"method": "oidc", ...}.- Algorithmes acceptés : RS256/RS384/RS512 uniquement (défauts de Keycloak et d'Authentik).
none, les algorithmes symétriquesHS*, ainsi quePS*/ES*sont refusés.
Les trois voies coexistent ; l'ordre de préséance est bearer statique → forward-auth → OIDC, de sorte que l'ajout de l'OIDC ne peut jamais changer la réponse d'une requête que les deux premières tranchaient déjà.
Sur un VPS :
- Créer
deploy/.env(copie dedeploy/.env.example) avecOCULAR_TOKENet (si l'admin par token est voulu)OCULAR_ADMIN_TOKEN, tous deux générés paropenssl rand -base64 32— jamais les valeurs d'exemple. make up— construit automatiquement l'image runner (build-runneren dépendance) puis démarreredis,webetbrokerviadocker compose.make downpour arrêter ;make gcpour nettoyer les artefacts orphelins (fichiers du volumeocular-artifactsdont plus aucun résultat Redis ne référence le ref).make gcs'exécute dans le conteneurbroker(viadocker compose exec, qui a accès au bon Redis et au volume partagé) — la stack doit être démarrée (make up) au préalable.
Le tier web n'a jamais accès à docker.sock (seul broker y accède). Il monte le volume partagé
ocular-artifacts en lecture-écriture — et non en lecture seule : il y écrit les artefacts des
captures de session interactive (POST /sessions/{id}/capture). Ce qui borne le risque n'est pas le
mode de montage mais le store adressé par contenu : store_blobs vérifie que ref == sha256(contenu) avant toute écriture, donc un web compromis ne peut déposer un blob que sous son
propre nom de hash — il ne peut pas empoisonner une entrée existante. Il est recommandé de mettre un
reverse-proxy (Caddy) avec TLS + une couche d'authentification supplémentaire devant web avant
toute exposition publique.
Ocular charge et exécute du contenu web hostile : c'est sa fonction. Le confinement
(séparation des privilèges, conteneurs éphémères durcis, isolation réseau par session, garde
SSRF avec épinglage d'IP) est décrit dans SECURITY.md, qui documente aussi
les limites connues et assumées — dont le fait que le broker monte le socket Docker.
Pour signaler une vulnérabilité : ne pas ouvrir d'issue publique, voir
SECURITY.md.
Prérequis de déploiement (règles DOCKER-USER, default-address-pools) :
docs/DEPLOY-SECURITY.md.
La convention d'opération du dépôt — discipline git, actions gatées, pièges vérifiés — est
dans AGENTS.md. Elle s'applique aux sessions humaines comme aux agents.
Avant toute proposition de fusion : make test et make test-int verts, plus une
vérification live de ce qui a changé.
Si le changement touche l'UI (web/ui/), ajouter make smoke-ui : il charge les vues
principales — login, submit, jobs, détail/verdict, interactif — dans un vrai navigateur
(Camoufox headless, dans le conteneur runner) et échoue sur la moindre exception JS non
rattrapée. C'est le seul contrôle qui EXÉCUTE l'application : make test vérifie que les
fichiers sont servis, les tests .mjs que des fonctions isolées calculent juste — un module
ES qui n'importe plus passe au vert chez les deux. Il exige une pile levée (make up) et
reste donc hors CI (les runners GitHub n'ont pas de navigateur) ; sans pile, il saute
en indiquant la commande à lancer.
GNU Affero General Public License v3.0 ou ultérieure (AGPL-3.0-or-later), pour le code propre au projet.
L'AGPL ajoute à la GPL une obligation qui compte pour un outil comme celui-ci : si vous exposez une version modifiée en service réseau, vous devez en proposer le source correspondant à ses utilisateurs — pas seulement à ceux à qui vous distribuez un binaire. C'est délibéré : Ocular est fait pour tourner en service.
Copyright (C) 2026 GuatX
Ce programme est un logiciel libre : vous pouvez le redistribuer et/ou le
modifier selon les termes de la GNU Affero General Public License telle que
publiée par la Free Software Foundation, soit la version 3 de la licence,
soit (à votre choix) toute version ultérieure.
Ce programme est distribué dans l'espoir qu'il sera utile, mais SANS AUCUNE
GARANTIE, sans même la garantie implicite de QUALITÉ MARCHANDE ou
D'ADÉQUATION À UN USAGE PARTICULIER. Voir la GNU Affero General Public
License pour plus de détails.
Vous devriez avoir reçu une copie de la GNU Affero General Public License
avec ce programme. Si ce n'est pas le cas, voir <https://www.gnu.org/licenses/>.
Texte intégral : COPYING.
Les composants tiers embarqués (noVNC sous MPL-2.0, pako sous MIT) restent sous leur
propre licence — voir THIRD-PARTY-NOTICES.md.



