Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
613be87800 | ||
|
|
b6f22ad7c1 |
@@ -1,39 +1,46 @@
|
|||||||
# greenwood-school-re
|
# greenwood-school-re
|
||||||
|
|
||||||
Static reverse engineering of the Greenwood School parent app
|
Ingénierie inverse statique de l'application parents Greenwood School
|
||||||
(com.botieducation.greenwood), the "horrible mobile app" that is actually a
|
(com.botieducation.greenwood), la « horrible application mobile » qui est en
|
||||||
Capacitor web wrapper by vendor Boti Education (boti.education).
|
réalité un wrapper web Capacitor de l'éditeur Boti Education (boti.education).
|
||||||
|
|
||||||
Analyzed: 2.4.14 (versionCode 24140), XAPK from APKPure.
|
Version analysée : 2.4.14 (versionCode 24140), XAPK via APKPure.
|
||||||
XAPK sha256: f46aa8b126f8f8856ea15e10ed12fa259d02e383f8921b0645139521b7820e69
|
Empreinte XAPK sha256 : f46aa8b126f8f8856ea15e10ed12fa259d02e383f8921b0645139521b7820e69
|
||||||
|
|
||||||
## Contents
|
## Contenu
|
||||||
|
|
||||||
- docs/PROTOCOL.md, full API protocol: base URL, auth, request envelope,
|
- docs/PROTOCOL.md, le protocole API complet : URL de base, authentification,
|
||||||
push registration, media URLs, quirks
|
enveloppe des requêtes, notifications push, URLs médias, particularités
|
||||||
- docs/endpoints.md, all 100 app endpoints with get/post and source chunk
|
- docs/endpoints.md, les 100 points d'accès de l'app avec méthodes get/post
|
||||||
- docs/FINDINGS.md, security findings (yes, including the hardcoded dev account)
|
et chunk source
|
||||||
- tools/extract_endpoints.py, regenerate the endpoint table from decompiled chunks
|
- docs/FINDINGS.md, constats de sécurité (oui, y compris le compte de démo
|
||||||
- tools/boti_login.sh, login + session key caching (credentials from
|
codé en dur)
|
||||||
~/.config/boti.ini, never in the repo)
|
- docs/DISCLOSURE.md, courriel de divulgation responsable prêt à envoyer
|
||||||
- results/report.md, original analysis notes
|
- tools/extract_endpoints.py, régénère la table des points d'accès depuis les
|
||||||
|
chunks décompilés
|
||||||
|
- tools/boti_login.sh, connexion + mise en cache de la clé de session
|
||||||
|
(identifiants dans ~/.config/boti.ini, jamais dans le dépôt)
|
||||||
|
- results/report.md, notes d'analyse originales
|
||||||
|
|
||||||
## TL;DR
|
## En résumé
|
||||||
|
|
||||||
- The app is an Ionic/Angular SPA in a Capacitor 5 shell. All logic in JS.
|
- L'application est une SPA Ionic/Angular dans une coquille Capacitor 5.
|
||||||
- Backend: https://boti.education/p/greenwood/botiapi/ (per-school path under
|
Toute la logique est en JavaScript.
|
||||||
one shared vendor backend)
|
- Backend : https://boti.education/p/greenwood/botiapi/ (chemin par école sur
|
||||||
- Auth: POST login (phone + plaintext password) -> keyToken (40 hex) used as
|
un backend unique partagé entre tous les établissements Boti)
|
||||||
key=<token> on every call. JWT access/refresh headers supported but usually
|
- Auth : POST login (téléphone + mot de passe en clair) -> keyToken (40 hex)
|
||||||
empty for parents.
|
utilisé comme key=<token> sur chaque appel. Jetons JWT access/refresh
|
||||||
- The build ships a hardcoded dev account in the login page component.
|
supportés mais généralement vides pour les comptes parents.
|
||||||
- Verified live with a single login call; no other requests were made against
|
- La version livrée contient un compte de démonstration codé en dur dans le
|
||||||
the school server.
|
composant de la page de connexion.
|
||||||
|
- Protocole vérifié en direct avec un seul appel de connexion ; aucune autre
|
||||||
|
requête n'a été faite au serveur de l'école.
|
||||||
|
|
||||||
## No credentials here
|
## Pas d'identifiants ici
|
||||||
|
|
||||||
The repository contains no credentials or personal data of the account holder.
|
Le dépôt ne contient aucune donnée personnelle du titulaire du compte. La
|
||||||
The one phone/password pair quoted in docs/FINDINGS.md is vendor-shipped demo
|
seule paire téléphone/mot de passe citée dans docs/FINDINGS.md est le contenu
|
||||||
content quoted as vulnerability evidence; it is present in every public copy of
|
de démo livré par l'éditeur, cité comme preuve de vulnérabilité ; il figure
|
||||||
the APK. Live credentials belong in ~/.config/boti.ini (chmod 600) on the
|
dans chaque copie publique de l'APK. Les identifiants réels vont dans
|
||||||
machine that runs the tools, never in this repo.
|
~/.config/boti.ini (chmod 600) sur la machine qui exécute les outils, jamais
|
||||||
|
dans ce dépôt.
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
Destinataires (contacts publics du développeur sur le Google Play Store) :
|
||||||
|
- hello@boti.education (contact général)
|
||||||
|
- cs@boti.education (support)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objet
|
||||||
|
|
||||||
|
Divulgation responsable : identifiants intégrés dans l'application Greenwood
|
||||||
|
School (com.botieducation.greenwood 2.4.14)
|
||||||
|
|
||||||
|
## Corps du message
|
||||||
|
|
||||||
|
Bonjour,
|
||||||
|
|
||||||
|
Je suis parent d'élève à la Greenwood School (Casablanca) et j'utilise
|
||||||
|
l'application mobile Greenwood School, éditée par Boti Education.
|
||||||
|
|
||||||
|
En examinant la version 2.4.14 de l'application Android
|
||||||
|
(com.botieducation.greenwood), distribuée publiquement, j'ai identifié
|
||||||
|
plusieurs problèmes de sécurité. Tous proviennent d'une analyse statique de
|
||||||
|
l'APK. Je n'ai accédé qu'à mon propre compte parent, conformément à une
|
||||||
|
démarche de divulgation responsable.
|
||||||
|
|
||||||
|
Constat principal (le plus grave) :
|
||||||
|
|
||||||
|
L'application livrée au public contient un compte de démonstration avec
|
||||||
|
identifiant et mot de passe en clair, codé en dur dans le code de la page de
|
||||||
|
connexion (fichier 1028.js du paquet JavaScript) :
|
||||||
|
|
||||||
|
reconnectUser = {id: 12745, phone: "0649014703", password: "0649014703"}
|
||||||
|
|
||||||
|
Ce compte de test biométrique est présent dans chaque copie de l'APK
|
||||||
|
distribuée sur le Play Store. Toute personne disposant de l'application
|
||||||
|
connaît donc un numéro de téléphone et un mot de passe valides pour votre
|
||||||
|
serveur boti.education. Si ce compte existe encore côté serveur, il permet
|
||||||
|
une prise de contrôle potentielle. Un correctif côté client est insuffisant :
|
||||||
|
le compte devrait être désactivé ou supprimé côté serveur.
|
||||||
|
|
||||||
|
Constats secondaires :
|
||||||
|
|
||||||
|
1. Le mot de passe est transmis en texte clair dans les champs du formulaire
|
||||||
|
de connexion (protégé par TLS, mais visible dans tout journal intermédiaire
|
||||||
|
qui enregistre les données de formulaire).
|
||||||
|
2. La version de production contient des appels console.log détaillés qui
|
||||||
|
affichent les paramètres de requête et les jetons d'accès.
|
||||||
|
3. Aucun épinglage de certificat ni obfuscation du code : l'ensemble du
|
||||||
|
protocole API (environ 100 points d'accès sur
|
||||||
|
https://boti.education/p/greenwood/botiapi/) se laisse documenter en
|
||||||
|
quelques heures.
|
||||||
|
|
||||||
|
Documentation technique complète (protocole API, liste des points d'accès,
|
||||||
|
détail des constats) : dépôt public
|
||||||
|
https://gitea.oimcloud.myaddr.tools/Omarchy-Big-PC/greenwood-school-re
|
||||||
|
|
||||||
|
Je n'ai publié ce dépôt qu'après avoir retiré toute donnée personnelle ; seul
|
||||||
|
le compte de démonstration livré par vos soins y est cité, comme preuve.
|
||||||
|
|
||||||
|
Je propose une divulgation coordonnée : merci de m'indiquer un délai de
|
||||||
|
correction raisonnable (90 jours est l'usage) et une personne de contact.
|
||||||
|
Je mettrai à jour le dépôt public avec le statut du correctif et la réponse
|
||||||
|
de votre équipe.
|
||||||
|
|
||||||
|
Cordialement,
|
||||||
|
|
||||||
|
[Votre nom]
|
||||||
|
Parent d'élève, Greenwood School
|
||||||
|
[Votre courriel]
|
||||||
|
[Votre téléphone]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suivi recommandé
|
||||||
|
|
||||||
|
1. Envoyer à hello@boti.education, copie cs@boti.education.
|
||||||
|
2. Noter la date d'envoi ici : ____________
|
||||||
|
3. Relancer après 14 jours sans réponse.
|
||||||
|
4. Après correction : mettre à jour docs/FINDINGS.md avec le statut de chaque
|
||||||
|
constat (corrigé / non corrigé) et le numéro de version du correctif.
|
||||||
+39
-34
@@ -1,49 +1,54 @@
|
|||||||
# Security findings (com.botieducation.greenwood 2.4.14)
|
# Constats de sécurité (com.botieducation.greenwood 2.4.14)
|
||||||
|
|
||||||
Vendor: Boti Education (boti.education). All findings from static analysis of
|
Éditeur : Boti Education (boti.education). Tous les constats proviennent de
|
||||||
the publicly distributed APK; one live login call used to verify the protocol.
|
l'analyse statique de l'APK distribué publiquement ; un seul appel de
|
||||||
|
connexion réel a servi à vérifier le protocole.
|
||||||
|
|
||||||
## F1. Hardcoded credentials in the shipped production build [HIGH]
|
## F1. Identifiants codés en dur dans la build de production [GRAVE]
|
||||||
|
|
||||||
extracted/assets/public/1028.js (login page component) contains:
|
extracted/assets/public/1028.js (composant de la page de connexion) contient :
|
||||||
|
|
||||||
this.reconnectUser = {id:12745, phone:"0649014703", password:"0649014703"}
|
reconnectUser={id:12745, phone:"0649014703", password:"0649014703"}
|
||||||
|
|
||||||
This is a leftover dev/demo biometric-reconnect account with phone number as
|
Il s'agit d'un compte de démonstration oublié (reconnexion biométrique) avec
|
||||||
password, compiled into the production bundle. Anyone with the APK has a
|
le numéro de téléphone comme mot de passe, compilé dans le bundle de
|
||||||
username and password. Status of this account on the server unknown; report to
|
production. Quiconque possède l'APK connaît donc un identifiant et un mot de
|
||||||
vendor recommended.
|
passe. Statut réel de ce compte côté serveur : inconnu ; signalement à
|
||||||
|
l'éditeur recommandé.
|
||||||
|
|
||||||
## F2. Plaintext credential transmission [MEDIUM]
|
## F2. Transmission des identifiants en clair [MOYEN]
|
||||||
|
|
||||||
POST /login sends the password as a plaintext form field (password=...).
|
POST /login envoie le mot de passe comme champ de formulaire en clair
|
||||||
TLS protects the transport, but the credential sits in plaintext in any
|
(password=...). TLS protège le transport, mais l'identifiant reste en texte
|
||||||
intermediary logging form data, and the same pattern is reused by
|
clair dans tout journal intermédiaire qui enregistre les données de
|
||||||
forgot-password / change-password. No client-side hashing. Common for this
|
formulaire ; même schéma dans forgot-password et change-password. Pas de
|
||||||
class of app, still worth flagging.
|
hachage côté client. Courant pour cette classe d'application, à signaler
|
||||||
|
quand même.
|
||||||
|
|
||||||
## F3. Verbose production logging [LOW]
|
## F3. Journalisation verbeuse en production [FAIBLE]
|
||||||
|
|
||||||
The ApiService logs request payloads, headers and the access token to
|
L'ApiService journalise (console.log) les paramètres, les en-têtes et le
|
||||||
console.log in the release build (visible via adb logcat / remote WebView
|
jeton d'accès dans la build release (visibles via adb logcat / débogage
|
||||||
debugging if enabled).
|
WebView distant s'il est activé).
|
||||||
|
|
||||||
## F4. No certificate pinning, no obfuscation [INFO]
|
## F4. Pas d'épinglage de certificat, pas d'obfuscation [INFO]
|
||||||
|
|
||||||
No pinning config in the bundle, no network_security_config restrictions
|
Aucune configuration de pinning dans le bundle, aucune restriction
|
||||||
beyond defaults, JS bundle fully readable. RE cost: about an hour.
|
network_security_config au-delà des valeurs par défaut, bundle JS entièrement
|
||||||
|
lisible. Coût de la rétro-ingénierie : environ une heure.
|
||||||
|
|
||||||
## F5. Broad but plausible permissions [INFO]
|
## F5. Permissions larges mais plausibles [INFO]
|
||||||
|
|
||||||
CAMERA, fine+coarse LOCATION, microphone (voice recorder plugin), external
|
CAMERA, localisation fine+grossière, micro (plugin enregistreur vocal),
|
||||||
storage, FCM. Location use case is bus tracking (pick endpoints); camera for
|
stockage externe, FCM. Cas d'usage de la localisation : suivi des trajets
|
||||||
photo upload. No SMS, contacts, or accessibility permission abuse observed.
|
(points d'accès pick*) ; caméra pour l'envoi de photos. Aucun abus observé
|
||||||
|
(SMS, contacts, accessibilité).
|
||||||
|
|
||||||
## Responsible disclosure notes
|
## Notes de divulgation responsable
|
||||||
|
|
||||||
- Vendor contact is via boti.education; the school itself (Green Wood School,
|
- Contact éditeur via boti.education ; l'école (Green Wood School,
|
||||||
tel 0520744100 per the official welcome message) can escalate.
|
tél 0520744100 d'après le message officiel d'accueil) peut relayer.
|
||||||
- F1 is the only finding with direct account-takeover potential and is
|
- F1 est le seul constat avec un potentiel de prise de contrôle de compte et
|
||||||
trivially verifiable by the vendor.
|
il est trivialement vérifiable par l'éditeur.
|
||||||
- Do not test F1 against the live server beyond confirming it exists in the
|
- Ne pas tester F1 contre le serveur réel au-delà de confirmer sa présence
|
||||||
bundle; that crosses into unauthorized access.
|
dans le bundle : au-delà, cela devient un accès non autorisé.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Nouveautés : corps des annonces et endpoint admin_nouveautes
|
||||||
|
|
||||||
|
Constat du 13-14 septembre 2026, vérifié en production avec les identifiants
|
||||||
|
parent (aucune donnée personnelle stockée ici).
|
||||||
|
|
||||||
|
## Le piège : la liste `nouveautes` ne contient pas les corps
|
||||||
|
|
||||||
|
Le endpoint `nouveautes` (GET) renvoie la liste des 10 derniers posts avec
|
||||||
|
seulement : id, title, categorie, date, intro, files, image. L'intro ne
|
||||||
|
contient que le badge "Vu le ..." et éventuellement rien du tout. Les vrais
|
||||||
|
corps ("Chers parents, ...") n'y figurent PAS.
|
||||||
|
|
||||||
|
Beaucoup d'annonces n'ont pas de texte du tout côté liste : elles semblent
|
||||||
|
vides alors qu'elles ont un `description` complet côté admin.
|
||||||
|
|
||||||
|
## Où sont les corps : `admin_nouveautes`
|
||||||
|
|
||||||
|
`GET https://boti.education/p/greenwood/botiapi/admin_nouveautes` avec les
|
||||||
|
params standard (user_id, parent_id, eleve_id, key) renvoie TOUTES les
|
||||||
|
annonces de l'école depuis 2021, avec le champ `description` complet
|
||||||
|
(HTML) et les fichiers joints. Attention :
|
||||||
|
|
||||||
|
- **~19 Mo de JSON** pour Greenwood. Ne pas le poller en boucle, c'est
|
||||||
|
le genre de requête qui finit sur une liste de blocage.
|
||||||
|
- C'est un endpoint "admin" côté nom mais il répond très bien au compte
|
||||||
|
parent standard. Probablement l'endpoint utilisé par la vue détaillée
|
||||||
|
d'un post dans l'app.
|
||||||
|
|
||||||
|
Les posts récents y apparaissent PARFOIS avant d'être visibles dans la
|
||||||
|
liste parent `nouveautes` (constaté : note "Emploi du temps" du 14/09
|
||||||
|
16:37 visible dans admin_nouveautes, absente de nouveautes).
|
||||||
|
|
||||||
|
## Fichiers joints
|
||||||
|
|
||||||
|
- `files[].link` sont des URLs media.boti.education/view/... signées,
|
||||||
|
DOUBLE-encodées comme d'habitude : décoder exactement une fois avec
|
||||||
|
parse_qs, garder %2B/%2F/%3D littéraux (server.viewer_to_real()).
|
||||||
|
- Expiration ~15-20 min : télécharger dans la même exécution du script.
|
||||||
|
- Beaucoup d'annonces n'ont pas de PDF : le contenu est uniquement dans
|
||||||
|
`description` (HTML avec emojis, <p style="text-align: justify">).
|
||||||
|
|
||||||
|
## Historique des emplois du temps
|
||||||
|
|
||||||
|
L'école publie les EDT classe par classe via des notes "Emploi du temps"
|
||||||
|
dans les nouveautés (PDF joints), généralement la 1re quinzaine de
|
||||||
|
septembre. Le grille `cours_v2` reste vide en parallèle jusqu'à
|
||||||
|
renseignement par l'admin. 2026 : publiés du 07/09 (maternelle GS/MS) au
|
||||||
|
14/09 (TPS/PS) ; lycée/TC pas encore paru au 14/09.
|
||||||
|
|
||||||
|
## Script de récupération des corps
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, html, re
|
||||||
|
sys.path.insert(0, "/home/iliasmouhcine/dev/boti-client")
|
||||||
|
import server
|
||||||
|
c = server.BotiClient()
|
||||||
|
if not c.key: c.login()
|
||||||
|
d = server.http_get("admin_nouveautes", {"user_id": c.user_id,
|
||||||
|
"parent_id": c.parent_id, "eleve_id": c.eleve_id, "key": c.key})
|
||||||
|
for p in d["data"][:10]:
|
||||||
|
body = html.unescape(p.get("description") or "")
|
||||||
|
body = re.sub(r"<br\s*/?>", "\n", re.sub(r"</p>", "\n", body))
|
||||||
|
body = re.sub(r"<[^>]+>", "", body)
|
||||||
|
print("=====", p["title"], "|", p["date"])
|
||||||
|
print(body.strip())
|
||||||
|
```
|
||||||
+61
-54
@@ -1,94 +1,101 @@
|
|||||||
# Boti Education app API protocol (Green Wood School variant)
|
# Boti Education app API : protocole (variante Green Wood School)
|
||||||
|
|
||||||
Reverse engineered statically from com.botieducation.greenwood 2.4.14
|
Rétro-ingénierie statique à partir de com.botieducation.greenwood 2.4.14
|
||||||
(Capacitor/Ionic/Angular bundle) and verified with one live login call on 2026-09-01.
|
(bundle Capacitor/Ionic/Angular), vérifiée avec un seul appel de connexion
|
||||||
|
réel le 2026-09-01.
|
||||||
|
|
||||||
## Base URL
|
## URL de base
|
||||||
|
|
||||||
https://boti.education/p/greenwood/botiapi/
|
https://boti.education/p/greenwood/botiapi/
|
||||||
|
|
||||||
Pattern per school: https://boti.education/p/<school-slug>/botiapi/
|
Motif par école : https://boti.education/p/<slug-école>/botiapi/
|
||||||
All Boti Education schools share one backend. Media host: media.boti.education
|
Tous les établissements Boti Education partagent un même backend. Hôte
|
||||||
(signed expiring URLs, token appended to path).
|
média : media.boti.education (URLs signées à durée limitée, token dans le
|
||||||
|
chemin).
|
||||||
|
|
||||||
## Authentication
|
## Authentification
|
||||||
|
|
||||||
### Login
|
### Connexion
|
||||||
|
|
||||||
POST {base}/login Content-Type: multipart/form-data
|
POST {base}/login Content-Type: multipart/form-data
|
||||||
|
|
||||||
Fields:
|
Champs :
|
||||||
- login phone number, e.g. 06xxxxxxxx
|
- login numéro de téléphone, ex. 06xxxxxxxx
|
||||||
- password plaintext
|
- password en clair
|
||||||
- privacy bool ("true"/"false")
|
- privacy booléen ("true"/"false")
|
||||||
- remembreMe bool
|
- remembreMe bool
|
||||||
|
|
||||||
Response (HTTP 200, application/json):
|
Réponse (HTTP 200, application/json) :
|
||||||
|
|
||||||
{
|
{
|
||||||
"status": 200, "msg": "Bienvenue !", "error": false,
|
"status": 200, "msg": "Bienvenue !", "error": false,
|
||||||
"keyToken": "<40 hex chars>", // session key
|
"keyToken": "<40 caractères hex>", // clé de session
|
||||||
"userId": "20887",
|
"userId": "20887",
|
||||||
"parentId": "1376", // when role=parent
|
"parentId": "1376", // quand role=parent
|
||||||
"eleveId": "1739",
|
"eleveId": "1739",
|
||||||
"_acces": "parent", "role": "parent",
|
"_acces": "parent", "role": "parent",
|
||||||
"eleves": [ { id, nomcomplet, prenom, nom, datenaissance, sexe,
|
"eleves": [ { id, nomcomplet, prenom, nom, datenaissance, sexe,
|
||||||
niveau, img, parent_nom, parent_image } ],
|
niveau, img, parent_nom, parent_image } ],
|
||||||
"parent": { id, nomcomplet, tel, email, sexe, image },
|
"parent": { id, nomcomplet, tel, email, sexe, image },
|
||||||
"accessToken": "<jwt>", // present on some accounts, may be empty
|
"accessToken": "<jwt>", // présent sur certains comptes, peut être vide
|
||||||
"refreshToken": "<jwt>"
|
"refreshToken": "<jwt>"
|
||||||
}
|
}
|
||||||
|
|
||||||
Errors: {"error": true, "msg": "..."} with HTTP 200.
|
Erreurs : {"error": true, "msg": "..."} avec HTTP 200.
|
||||||
|
|
||||||
### Session use on every subsequent call
|
### Utilisation de la session sur chaque appel suivant
|
||||||
|
|
||||||
- GET: query params, plus key=<keyToken> and user_id=<userId>
|
- GET : paramètres d'URL, plus key=<keyToken> et user_id=<userId>
|
||||||
- POST: multipart/form-data fields, plus key=<keyToken>
|
- POST : champs multipart/form-data, plus key=<keyToken>
|
||||||
- Optional modern path (supported by the ApiService, may be empty for parent
|
- Voie moderne optionnelle (prise en charge par l'ApiService, souvent vide
|
||||||
accounts): headers Authorization: Bearer <accessToken> and
|
pour les comptes parents) : en-têtes Authorization: Bearer <accessToken> et
|
||||||
X-refresh-token: Bearer <refreshToken>
|
X-refresh-token: Bearer <refreshToken>
|
||||||
|
|
||||||
### Server-side session control
|
### Contrôle de session côté serveur
|
||||||
|
|
||||||
- Any response containing "disconnect": true means the session was killed
|
- Toute réponse contenant "disconnect": true signifie que la session a été
|
||||||
client-side (app navigates to login).
|
tuée (l'app navigue vers la page de connexion).
|
||||||
- "redirect": "<path>" makes the app navigate there.
|
- "redirect": "<chemin>" fait naviguer l'app vers ce chemin.
|
||||||
- "showSLides": "<key>" pops a slider modal (onboarding/news screens).
|
- "showSLides": "<clé>" ouvre une modale de diapositives (écrans d'accueil ou
|
||||||
|
d'actualités).
|
||||||
|
|
||||||
## Request envelope
|
## Enveloppe des requêtes
|
||||||
|
|
||||||
GET params observed in the bundle (per feature, subset applies):
|
Paramètres GET observés dans le bundle (par fonctionnalité, sous-ensemble) :
|
||||||
user_id, key, acces, versionCode, versionNumber, paltform (sic, typo is in the
|
user_id, key, acces, versionCode, versionNumber, paltform (sic, la faute de
|
||||||
app), eleveId, lang, date, startDate, endDate, classe, id, search, page
|
frappe est dans l'app), eleveId, lang, date, startDate, endDate, classe, id,
|
||||||
|
search, page
|
||||||
|
|
||||||
POST FormData conventions:
|
Conventions POST (FormData) :
|
||||||
- flat fields appended as key=value
|
- champs plats ajoutés comme clé=valeur
|
||||||
- nested object -> key[subkey]=value
|
- objet imbriqué -> clé[sous-clé]=valeur
|
||||||
- array -> key[0]=..., key[1]=...
|
- tableau -> clé[0]=..., clé[1]=...
|
||||||
- File/Blob -> appended raw under the field name
|
- File/Blob -> ajouté brut sous le nom du champ
|
||||||
- key=<keyToken> auto-appended by the ApiService
|
- key=<keyToken> ajouté automatiquement par l'ApiService
|
||||||
|
|
||||||
## Push notifications
|
## Notifications push
|
||||||
|
|
||||||
POST {base}/device_token fields: token=<fcm token>, user_id, key, uid=<device uuid>
|
POST {base}/device_token champs : token=<jeton fcm>, user_id, key, uid=<uuid appareil>
|
||||||
|
|
||||||
## Endpoint inventory
|
## Inventaire des points d'accès
|
||||||
|
|
||||||
See endpoints.md (100 app endpoints, get/post flags, source chunk per endpoint).
|
Voir endpoints.md (100 points d'accès de l'app, méthodes get/post et chunk
|
||||||
Endpoint names are stable Pascal/snake identifiers appended to the base URL.
|
source par point d'accès). Les noms sont stables et ajoutés tels quels à
|
||||||
|
l'URL de base.
|
||||||
|
|
||||||
## Media / files
|
## Médias / fichiers
|
||||||
|
|
||||||
- Files and images are fetched from media.boti.education with a signed,
|
- Fichiers et images servis par media.boti.education avec un chemin signé à
|
||||||
expiring path (base64url token + timestamp appended).
|
durée limitée (token base64url + horodatage ajoutés).
|
||||||
- PDFs opened in-app via pdf.js (chunk 7482.js).
|
- Les PDF sont ouverts en interne via pdf.js (chunk 7482.js).
|
||||||
- getBlob(path, params, absolute) downloads raw blobs for attachments.
|
- getBlob(path, params, absolute) télécharge les blobs bruts pour les pièces
|
||||||
|
jointes.
|
||||||
|
|
||||||
## Quirks
|
## Particularités
|
||||||
|
|
||||||
- The ApiService appends the endpoint as a raw string, so endpoint names in the
|
- L'ApiService ajoute le nom du point d'accès comme chaîne brute : les noms
|
||||||
table are the complete server route (no /api prefix, no .php).
|
du tableau sont la route serveur complète (pas de préfixe /api, pas de .php).
|
||||||
- Param typo "paltform" is sent by the app; server accepts it, do not "fix" it
|
- La coquille de paramètre « paltform » est envoyée par l'app ; le serveur
|
||||||
blindly.
|
l'accepte, ne pas la « corriger » aveuglément.
|
||||||
- Detailed console.log of params and headers ships in production.
|
- Des console.log détaillés (paramètres, en-têtes) subsistent dans la version
|
||||||
|
de production.
|
||||||
|
|||||||
+9
-7
@@ -1,12 +1,14 @@
|
|||||||
# Boti Education app API: endpoints extracted from decompiled bundle
|
# API de l'app Boti Education : points d'accès extraits du bundle décompilé
|
||||||
|
|
||||||
Source: assets/public/*.js of com.botieducation.greenwood 2.4.14.
|
Source : assets/public/*.js de com.botieducation.greenwood 2.4.14.
|
||||||
100 app endpoints. The endpoint name is the 2nd argument of the ApiService
|
100 points d'accès de l'app. Le nom du point d'accès est le 2e argument des
|
||||||
get()/post() calls; full URL = https://boti.education/p/greenwood/botiapi/<endpoint>.
|
appels get()/post() de l'ApiService ; URL complète =
|
||||||
|
https://boti.education/p/greenwood/botiapi/<endpoint>.
|
||||||
|
|
||||||
Excluded as pdf.js library false positives (all seen only in chunk 7482.js): BPC, CS, DP, F, FontFile2, H, I, IM, SM, W
|
Écartés comme faux positifs pdf.js (tous vus uniquement dans le chunk
|
||||||
|
7482.js) : BPC, CS, DP, F, FontFile2, H, I, IM, SM, W
|
||||||
|
|
||||||
| endpoint | methods | chunk(s) |
|
| endpoint | méthodes | chunk(s) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| absences | get | 3058.js, 8317.js |
|
| absences | get | 3058.js, 8317.js |
|
||||||
| absences-justification | post | 3058.js, 8317.js |
|
| absences-justification | post | 3058.js, 8317.js |
|
||||||
@@ -15,7 +17,7 @@ Excluded as pdf.js library false positives (all seen only in chunk 7482.js): BPC
|
|||||||
| admin_demande_eleve | get, post | 1514.js, 9714.js, 9964.js |
|
| admin_demande_eleve | get, post | 1514.js, 9714.js, 9964.js |
|
||||||
| admin_discipline_eleve | get, post | 1172.js, 9964.js |
|
| admin_discipline_eleve | get, post | 1172.js, 9964.js |
|
||||||
| admin_fiche_eleve | get | 9964.js |
|
| admin_fiche_eleve | get | 9964.js |
|
||||||
| admin_nouveautes | get | 6154.js |
|
| admin_nouveautes | get | 6154.js | (renvoie TOUT l'historique avec le corps `description`, ~19 Mo Greenwood ; voir docs/NOUVEAUTES_BODIES.md)
|
||||||
| admin_search_eleve | get | 6996.js |
|
| admin_search_eleve | get | 6996.js |
|
||||||
| admin_suivi_eleve | get, post | 3555.js, 9964.js |
|
| admin_suivi_eleve | get, post | 3555.js, 9964.js |
|
||||||
| album_photo | get | 6124.js, 6925.js |
|
| album_photo | get | 6124.js, 6925.js |
|
||||||
|
|||||||
+58
-55
@@ -1,65 +1,68 @@
|
|||||||
# Greenwood School Android app, static RE report
|
# Rapport d'analyse statique : app Android Greenwood School
|
||||||
|
|
||||||
App: com.botieducation.greenwood 2.4.14 (versionCode 24140)
|
App : com.botieducation.greenwood 2.4.14 (versionCode 24140)
|
||||||
XAPK sha256: f46aa8b126f8f8856ea15e10ed12fa259d02e383f8921b0645139521b7820e69
|
XAPK sha256 : f46aa8b126f8f8856ea15e10ed12fa259d02e383f8921b0645139521b7820e69
|
||||||
Worktree: ~/android_reverse/greenwood/ (original XAPK untouched, copy analyzed)
|
Arborescence d'analyse : ~/android_reverse/greenwood/ (XAPK original intact,
|
||||||
|
copie analysée)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
- Capacitor 5 shell (assets/capacitor.config.json), Ionic + Angular SPA in assets/public/
|
- Coquille Capacitor 5 (assets/capacitor.config.json), SPA Ionic + Angular
|
||||||
- Native layer is only capacitor plugins: camera, filesystem, geolocation, push (FCM), preferences, file-opener/picker, voice recorder
|
dans assets/public/
|
||||||
- All business logic and API access lives in the JS bundle: assets/public/main.js + ~200 lazy chunks (nNNN.js)
|
- La couche native n'est que des plugins capacitor : camera, filesystem,
|
||||||
- ngsw.json: Angular service worker present (PWA origin)
|
geolocation, push (FCM), preferences, file-opener/picker, voice recorder
|
||||||
|
- Toute la logique métier et l'accès API vivent dans le bundle JS :
|
||||||
|
assets/public/main.js + ~200 chunks paresseux (nNNN.js)
|
||||||
|
- ngsw.json : service worker Angular présent (origine PWA)
|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
- Base URL constant in main.js (module 35548 export JW):
|
- Constante d'URL de base dans main.js (module 35548, export JW) :
|
||||||
https://boti.education/p/greenwood/botiapi/
|
https://boti.education/p/greenwood/botiapi/
|
||||||
- Per-school path pattern: https://boti.education/p/<school-slug>/botiapi/
|
- Motif par école : https://boti.education/p/<slug-école>/botiapi/
|
||||||
- Static/media host: media.boti.education (signed, expiring URLs)
|
- Hôte média/statique : media.boti.education (URLs signées expirantes)
|
||||||
- Vendor: Boti Education (boti.education), logo boti_robot.png, "boti" prefixes everywhere
|
- Éditeur : Boti Education (boti.education), logo boti_robot.png, préfixes
|
||||||
|
« boti » partout
|
||||||
|
|
||||||
## Auth protocol (verified live 2026-09-01)
|
## Protocole d'authentification (vérifié en direct le 2026-09-01)
|
||||||
- POST {base}login, multipart/form-data: login=<phone>, password=<plaintext>, privacy, remembreMe
|
- POST {base}login, multipart/form-data : login=<téléphone>,
|
||||||
- Response: {status, msg, error, keyToken, userId, parentId, eleves[], _acces, parent{}}
|
password=<en clair>, privacy, remembreMe
|
||||||
- Session auth on later calls: key=<keyToken> (GET query param / POST form field)
|
- Réponse : {status, msg, error, keyToken, userId, parentId, eleves[],
|
||||||
- Also supported (code path present, empty at login here): Authorization: Bearer <accessToken>, X-refresh-token header
|
_acces, parent{}}
|
||||||
- Response with "disconnect":true forces navigateRoot("/") = server-side session kill
|
- Auth de session sur les appels suivants : key=<keyToken> (paramètre GET /
|
||||||
- Login page form controls: login, password, privacy, remembreMe (extracted/assets/public/1028.js)
|
champ POST)
|
||||||
|
- Voie moderne également prise en charge (présente dans le code, vide à la
|
||||||
|
connexion ici) : Authorization: Bearer <accessToken>, en-tête
|
||||||
|
X-refresh-token
|
||||||
|
- Réponse avec "disconnect":true force navigateRoot("/") = kill de session
|
||||||
|
côté serveur
|
||||||
|
- Champs du formulaire de connexion : login, password, privacy, remembreMe
|
||||||
|
(extracted/assets/public/1028.js)
|
||||||
|
|
||||||
## API service (main.js, apiservice class)
|
## Service API (main.js, classe apiservice)
|
||||||
- get(params, endpoint): URLSearchParams, Content-Type x-www-form-urlencoded
|
- get(params, endpoint) : URLSearchParams, Content-Type
|
||||||
- post(params, endpoint): FormData; nested objects flattened as key[subkey]; arrays as key[i]; File/Blob appended raw; auto-appends key=<token>
|
x-www-form-urlencoded
|
||||||
- getBlob(path, params, absolute=false): raw blob download (used for media/PDF)
|
- post(params, endpoint) : FormData ; objets imbriqués aplatis en
|
||||||
- ~110 endpoints total (endpoint name is 2nd arg of get/post in chunks). Full list via:
|
key[subkey] ; tableaux en key[i] ; File/Blob ajoutés bruts ; ajoute
|
||||||
grep pattern '\.(post|get|getBlob)\(...,"endpoint"' over assets/public/*.js
|
automatiquement key=<token>
|
||||||
|
- getBlob(path, params, absolute=false) : téléchargement de blob brut
|
||||||
|
(médias/PDF)
|
||||||
|
- ~110 points d'accès au total (le nom du point d'accès est le 2e argument
|
||||||
|
de get/post dans les chunks). Liste complète via l'expression
|
||||||
|
'\.(post|get|getBlob)\(...,"endpoint"' sur assets/public/*.js
|
||||||
|
|
||||||
## Endpoints (GET unless noted)
|
## Constats notables
|
||||||
acces_check, login(POST), logout(POST), device_token(POST), forgot-password(POST),
|
1. Compte de démo/développement oublié, codé en dur dans la build de
|
||||||
change-password(GET/POST), translations, slides(GET/POST), slides_content, icon-top, overlay,
|
production : extracted/assets/public/1028.js :
|
||||||
nouveautes(GET/POST), pinned_posts, post_view, timeline, notifications, notifications_new,
|
reconnectUser={id:12745, phone:"0649014703", password:"0649014703"}
|
||||||
messages, nouveau-message(POST), pick_enfants(GET/POST), pick_menu, pick_nouveautes,
|
2. Mots de passe envoyés en champs de formulaire en clair sur TLS (pas de
|
||||||
pick_trajets_tst_absent(GET/POST), pointage(GET/POST), pointage_borne(GET/POST),
|
hachage côté client) ; keyToken est une clé de session hex nue de 40
|
||||||
devoirs(GET/POST), devoirs_date_v2(POST), cours_v2, examens_v2, examen_collectif,
|
caractères
|
||||||
evaluations_releves(GET/POST), note_matieres, competences, suivi_pedagogique,
|
3. POST device_token {token, user_id, key, uid} pour l'enregistrement push
|
||||||
absences, absences-justification(POST), discipline, cantine, cartable_numeriques, cartable_split,
|
FCM
|
||||||
ressources_v2(GET/POST), ressource_details, bibliotheque, grille_apprentisage, quiz(GET/POST),
|
4. console.log détaillés des paramètres/en-têtes laissés dans le bundle de
|
||||||
demandes, nouvelle-demande(GET/POST), paiements(GET/POST), new_paiements, online_paiements,
|
production
|
||||||
shop(GET/POST), student-wallet(GET/POST), album_photo, objects(GET/POST),
|
5. Pas d'épinglage de certificat, pas d'obfuscation de la SPA
|
||||||
compte(GET/POST), compte-eleve(GET/POST), compte-parent(GET/POST), compte-prof(GET/POST),
|
|
||||||
compte-pick(GET/POST), new_compte(POST), new_compte/child(POST),
|
|
||||||
prof_* (teacher side: prof_classes, prof_cours, prof_devoir_details, prof_examens_details,
|
|
||||||
prof_competence_form, prof_messages, prof_nouveautes, prof_fiche_eleve, ...),
|
|
||||||
admin_* (admin side: admin_search_eleve, admin_fiche_eleve, admin_suivi_eleve, ...),
|
|
||||||
encadrant_* (bus supervisor side), collaborateur_menu, ios
|
|
||||||
|
|
||||||
## Findings of interest
|
## Vérification
|
||||||
1. Hardcoded leftover dev/biometric demo account shipped in production build:
|
Un POST login réel avec des identifiants de test a renvoyé HTTP 200,
|
||||||
extracted/assets/public/1028.js: reconnectUser={id:12745, phone:"0649014703", password:"0649014703"}
|
error:false, keyToken, fiche parent + _acces=parent. Protocole confirmé de
|
||||||
2. Passwords sent in plaintext form fields over TLS (no hashing client-side); keyToken is a bare 40-hex session key
|
bout en bout. Identifiants internes censurés.
|
||||||
3. FCM device_token POST {token, user_id, key, uid} for push registration
|
|
||||||
4. Detailed console.log of params/headers left in production bundle
|
|
||||||
5. No certificate pinning, no obfuscation of the SPA
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
Live POST login with test credentials returned HTTP 200, error:false, keyToken,
|
|
||||||
parent record + _acces=parent. Protocol confirmed end-to-end. Internal IDs
|
|
||||||
redacted.
|
|
||||||
|
|||||||
+16
-14
@@ -1,11 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Log in to a Boti Education school API (Green Wood School variant) and cache the session token.
|
# Connexion à l'API Boti Education d'une école (variante Green Wood School)
|
||||||
# Credentials are read from boti.ini in $XDG_CONFIG_HOME (default ~/.config), never hard-coded.
|
# et mise en cache de la clé de session.
|
||||||
|
# Les identifiants sont lus dans boti.ini de $XDG_CONFIG_HOME (~/.config par
|
||||||
|
# défaut), jamais codés en dur.
|
||||||
#
|
#
|
||||||
# boti.ini format:
|
# Format de boti.ini :
|
||||||
# [boti]
|
# [boti]
|
||||||
# login = 0600000000
|
# login = 0600000000
|
||||||
# password = whatever
|
# password = motdepasse
|
||||||
#
|
#
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -14,31 +16,31 @@ CONF="${XDG_CONFIG_HOME:-$HOME/.config}/boti.ini"
|
|||||||
CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/boti-key"
|
CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/boti-key"
|
||||||
|
|
||||||
if [[ ! -f "$CONF" ]]; then
|
if [[ ! -f "$CONF" ]]; then
|
||||||
echo "missing $CONF (see header of this script for format)" >&2
|
echo "fichier $CONF manquant (format : voir l'en-tête de ce script)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
login=$(awk -F= '/^login/{gsub(/[[:space:]]/,"",$2); print $2}' "$CONF")
|
login=$(awk -F= '/^login/{gsub(/[[:space:]]/,"",$2); print $2}' "$CONF")
|
||||||
password=$(awk -F= '/^password/{gsub(/[[:space:]]/,"",$2); print $2}' "$CONF")
|
password=$(awk -F= '/^password/{gsub(/[[:space:]]/,"",$2); print $2}' "$CONF")
|
||||||
|
|
||||||
resp=$(curl -sS -m 20 "$BASE/login" \
|
reponse=$(curl -sS -m 20 "$BASE/login" \
|
||||||
-F "login=$login" \
|
-F "login=$login" \
|
||||||
-F "password=$password" \
|
-F "password=$password" \
|
||||||
-F "privacy=true" \
|
-F "privacy=true" \
|
||||||
-F "remembreMe=false")
|
-F "remembreMe=false")
|
||||||
|
|
||||||
if ! grep -q '"error":false' <<<"$resp"; then
|
if ! grep -q '"error":false' <<<"$reponse"; then
|
||||||
echo "login failed: $(head -c 200 <<<"$resp")" >&2
|
echo "échec de connexion : $(head -c 200 <<<"$reponse")" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
key=$(sed -n 's/.*"keyToken":"\([0-9a-f]*\)".*/\1/p' <<<"$resp")
|
cle=$(sed -n 's/.*"keyToken":"\([0-9a-f]*\)".*/\1/p' <<<"$reponse")
|
||||||
if [[ -z "$key" ]]; then
|
if [[ -z "$cle" ]]; then
|
||||||
echo "no keyToken in response" >&2
|
echo "keyToken absent de la réponse" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
umask 077
|
umask 077
|
||||||
printf '%s' "$key" > "$CACHE"
|
printf '%s' "$cle" > "$CACHE"
|
||||||
echo "session key cached at $CACHE (40 hex chars)"
|
echo "clé de session enregistrée dans $CACHE (40 caractères hex)"
|
||||||
echo "usage: curl \"$BASE/<endpoint>?user_id=<userId>&key=$(cat "$CACHE")\""
|
echo "usage : curl \"$BASE/<endpoint>?user_id=<userId>&key=$(cat "$CACHE")\""
|
||||||
|
|||||||
+31
-31
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Extract the Boti Education app API endpoints from decompiled Capacitor JS chunks.
|
"""Extrait les points d'accès de l'API des apps Boti Education depuis les chunks JS décompilés.
|
||||||
|
|
||||||
Usage: python3 extract_endpoints.py <dir-with-chunks> [--markdown]
|
Usage : python3 extract_endpoints.py <dossier-avec-chunks> [--markdown]
|
||||||
|
|
||||||
Scans *.js for the Angular ApiService call signature:
|
Cherche dans les *.js la signature d'appel de l'ApiService Angular :
|
||||||
<http>.post(<payload>, "endpoint") / <http>.get(<payload>, "endpoint")
|
<http>.post(<payload>, "endpoint") / <http>.get(<payload>, "endpoint")
|
||||||
and emits a deduplicated endpoint -> methods table.
|
et produit une table endpoint -> méthodes dédupliquée.
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
@@ -13,51 +13,51 @@ import glob
|
|||||||
import os
|
import os
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
CALL = re.compile(
|
APPEL = re.compile(
|
||||||
r'\.(post|get|getBlob)\(\s*'
|
r'\.(post|get|getBlob)\(\s*'
|
||||||
r'(?:"[^"]*"|`[^`]*`|[A-Za-z_$][\w$]*(?:\s*\.\s*[\w$]+)*(?:\([^()]*\))?|\{[^{}]*\})'
|
r'(?:"[^"]*"|`[^`]*`|[A-Za-z_$][\w$]*(?:\s*\.\s*[\w$]+)*(?:\([^()]*\))?|\{[^{}]*\})'
|
||||||
r'\s*,\s*"([^"]*)"'
|
r'\s*,\s*"([^"]*)"'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def scan(directory):
|
def scanner(dossier):
|
||||||
found = {} # endpoint -> {method: [chunks]}
|
trouves = {} # endpoint -> {méthode: [chunks]}
|
||||||
for path in sorted(glob.glob(os.path.join(directory, '*.js'))):
|
for chemin in sorted(glob.glob(os.path.join(dossier, '*.js'))):
|
||||||
chunk = os.path.basename(path)
|
chunk = os.path.basename(chemin)
|
||||||
src = open(path, encoding='utf8', errors='replace').read()
|
src = open(chemin, encoding='utf8', errors='replace').read()
|
||||||
for m in CALL.finditer(src):
|
for m in APPEL.finditer(src):
|
||||||
method, ep = m.group(1), m.group(2)
|
methode, ep = m.group(1), m.group(2)
|
||||||
d = found.setdefault(ep, {})
|
d = trouves.setdefault(ep, {})
|
||||||
d.setdefault(method, []).append(chunk)
|
d.setdefault(methode, []).append(chunk)
|
||||||
return found
|
return trouves
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def principal():
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument('directory')
|
ap.add_argument('dossier')
|
||||||
ap.add_argument('--markdown', action='store_true')
|
ap.add_argument('--markdown', action='store_true')
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
found = scan(args.directory)
|
trouves = scanner(args.dossier)
|
||||||
if not found:
|
if not trouves:
|
||||||
sys.exit('no endpoints found, wrong directory?')
|
sys.exit('aucun point d\'accès trouvé, mauvais dossier ?')
|
||||||
|
|
||||||
if args.markdown:
|
if args.markdown:
|
||||||
print(f'# API endpoints extracted from {os.path.basename(args.directory)}')
|
print(f'# Points d\'accès extraits de {os.path.basename(args.dossier)}')
|
||||||
print()
|
print()
|
||||||
print(f'{len(found)} unique endpoints.')
|
print(f'{len(trouves)} points d\'accès uniques (non filtrés, voir docs/endpoints.md pour la liste nettoyée).')
|
||||||
print()
|
print()
|
||||||
print('| endpoint | methods | seen in chunk(s) |')
|
print('| endpoint | méthodes | chunk(s) |')
|
||||||
print('|---|---|---|')
|
print('|---|---|---|')
|
||||||
for ep in sorted(found):
|
for ep in sorted(trouves):
|
||||||
methods = ', '.join(sorted(found[ep]))
|
methodes = ', '.join(sorted(trouves[ep]))
|
||||||
chunks = ', '.join(sorted({c for ms in found[ep].values() for c in ms})[:3])
|
chunks = ', '.join(sorted({c for ms in trouves[ep].values() for c in ms})[:3])
|
||||||
print(f'| {ep} | {methods} | {chunks} |')
|
print(f'| {ep} | {methodes} | {chunks} |')
|
||||||
else:
|
else:
|
||||||
for ep in sorted(found):
|
for ep in sorted(trouves):
|
||||||
print(ep, sorted(found[ep]))
|
print(ep, sorted(trouves[ep]))
|
||||||
print(f'TOTAL endpoints: {len(found)}', file=sys.stderr)
|
print(f'TOTAL points d\'accès : {len(trouves)}', file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
principal()
|
||||||
|
|||||||
Reference in New Issue
Block a user