From 2e2266718108b9a63d530a626c9daf0451323940 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 24 Sep 2026 17:50:43 +0100 Subject: [PATCH] first commit --- .gitignore | 21 +++++ README.md | 10 +-- docs/scope.md | 45 ++++++++++ gitleaks-report.json | 1 + lib/common.sh | 147 +++++++++++++++++++++++++++++++ output/.gitkeep | 0 targets.example.env | 74 ++++++++++++++++ tools/api-fuzzer/README.md | 36 ++++++++ tools/api-fuzzer/endpoints.txt | 34 +++++++ tools/api-fuzzer/fuzz.sh | 83 +++++++++++++++++ tools/brute-login/README.md | 43 +++++++++ tools/brute-login/brute-login.sh | 130 +++++++++++++++++++++++++++ tools/config-scan/README.md | 34 +++++++ tools/config-scan/scan.sh | 99 +++++++++++++++++++++ tools/rate-limit-probe/README.md | 41 +++++++++ tools/rate-limit-probe/probe.sh | 96 ++++++++++++++++++++ wordlists/.gitkeep | 0 17 files changed, 889 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100644 docs/scope.md create mode 100644 gitleaks-report.json create mode 100644 lib/common.sh create mode 100644 output/.gitkeep create mode 100644 targets.example.env create mode 100644 tools/api-fuzzer/README.md create mode 100644 tools/api-fuzzer/endpoints.txt create mode 100755 tools/api-fuzzer/fuzz.sh create mode 100644 tools/brute-login/README.md create mode 100755 tools/brute-login/brute-login.sh create mode 100644 tools/config-scan/README.md create mode 100755 tools/config-scan/scan.sh create mode 100644 tools/rate-limit-probe/README.md create mode 100755 tools/rate-limit-probe/probe.sh create mode 100644 wordlists/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f1df4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Fichier de cible et secrets — JAMAIS committés +targets.env +*.env +!targets.example.env + +# Wordlists (une par ligne) — locales, hors dépôt +wordlists/* +!wordlists/.gitkeep + +# Sorties des outils (journaux, résultats) +output/* +!output/.gitkeep + +# Journaux divers +*.log +*.out + +# Jetons / cookies de session capturés +*.token +*.cookie +.session \ No newline at end of file diff --git a/README.md b/README.md index ff482e3..e9df4b6 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ modulaires (un outil par sous-dossier). La preprod Yavsc est multi-hôtes : -| Hôte | Domaine | Rôle | -|-------------|----------------------------------|------------------------------| -| `Yavsc.Org` | `preprod-yavsc.pschneider.fr` | MVC + IdentityServer (auth) | -| `Yavsc.Api` | `preprod-api.pschneider.fr` | API business (JSON) | -| `Yavsc.Blogs` | `preprod-blogs.pschneider.fr` | Blogs + stockage perso | +| Hôte | Domaine | Rôle | +|--------------|----------------------------------|------------------------------| +| `Yavsc.Org` | `preprod-yavsc.pschneider.fr` | MVC + IdentityServer (auth) | +| `Yavsc.Api` | `preprod-api.pschneider.fr` | API business (JSON) | +| `Yavsc.Blogs`| `preprod-blogs.pschneider.fr` | Blogs + stockage perso | Les outils restreignent leurs requêtes à ce périmètre via `assert_in_scope` (`lib/common.sh`) ; toute dérive vers un autre hôte diff --git a/docs/scope.md b/docs/scope.md new file mode 100644 index 0000000..004bc8a --- /dev/null +++ b/docs/scope.md @@ -0,0 +1,45 @@ +# Périmètre et autorisation + +## Propriétaire et cible + +Cette suite d'outils est destinée **exclusivement** au test de +l'environnement de **préproduction** de Paul Schneider +(`pschneider.fr`), infrastructure qui lui appartient. + +**Aucun outil de ce dépôt ne doit être exécuté contre un tiers.** +Les scripts intègrent un garde-fou (`assert_in_scope` dans +`lib/common.sh`) qui restreint les requêtes au domaine déclaré dans +`targets.env` (`TARGET_BASE_URL`). Toute dérive vers un autre hôte +provoque un abandon immédiat. + +## Conditions d'utilisation + +1. **Autorisation** : vous ne testez que vos propres systèmes. Le + fichier `targets.env` exige `TARGET_SCOPE_ACK="yes"` pour confirmer + cette autorisation avant tout lancement. +2. **Périmètre** : uniquement le domaine de `TARGET_BASE_URL` et ses + sous-domaines. Pas de scan aveugle d'IP tierces, pas de rebond. +3. **Modération** : utilisez `REQUEST_DELAY` pour espacer les requêtes + et éviter de saturer la preprod. Un bruteforce n'a pas besoin + d'être un déni de service — l'objectif est de mesurer la résistance + de l'authentification, pas de l'abattre. +4. **Secrets** : `targets.env`, les wordlists et les sorties ne sont + **jamais** committés (voir `.gitignore`). Les jetons éventuels + restent locaux. +5. **Conséquences** : ces outils peuvent révéler des faiblesses + réelles. Corrigez ce qu'ils trouvent sur votre preprod avant toute + mise en production. + +## Hors périmètre + +- Toute cible n'appartenant pas à `pschneider.fr`. +- Les environnements de production (la préprod peut partager des + données : restez prudent sur ce que vous soumettez). +- Les tiers hébergeurs, DNS, CDN en tant que cibles (on les traverse + seulement pour atteindre la preprod). + +## Remarque + +Ces outils sont volontairement simples (Bash + curl) et lisibles. Le +but est pédagogique et opérationnel : mesurer la robustesse de votre +preprod, pas de fournir un framework d'attaque sophistiqué. \ No newline at end of file diff --git a/gitleaks-report.json b/gitleaks-report.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/gitleaks-report.json @@ -0,0 +1 @@ +[] diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..4c9890b --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# common.sh — bibliothèque partagée des outils de pentest preprod. +# +# Fournit : chargement de la cible (targets.env), vérification +# d'autorisation (scope), client HTTP (curl) avec journalisation, +# sortie colorée, gestion du délai entre requêtes. +# +# Toutes les fonctions supposent que TARGET_BASE_URL est défini. +# Charger avec : source "$(dirname "$0")/../lib/common.sh" +# depuis un outil sous tools//, ou adapter COMMON_SH ci-dessous. + +set -uo pipefail + +# --- Couleurs (désactivées si pas de TTY) ------------------------------- +if [[ -t 1 ]]; then + C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m' + C_CYAN=$'\033[36m'; C_DIM=$'\033[2m'; C_RESET=$'\033[0m' +else + C_RED=""; C_GREEN=""; C_YELLOW=""; C_CYAN=""; C_DIM=""; C_RESET="" +fi + +info() { printf '%s[INFO]%s %s\n' "$C_CYAN" "$C_RESET" "$*"; } +ok() { printf '%s[OK]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; } +warn() { printf '%s[WARN]%s %s\n' "$C_YELLOW" "$C_RESET" "$*"; } +err() { printf '%s[ERR]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; } +die() { err "$*"; exit 1; } + +# --- Chargement de la cible -------------------------------------------- +# Résout le répertoire racine de la suite (parent du dossier tools/). +# Usage depuis tools//script.sh : COMMON_SH pointe sur ../../lib +suite_root() { + # $BASH_SOURCE[0] = ce fichier (lib/common.sh) → racine = son parent + local this; this="${BASH_SOURCE[0]}" + local dir; dir="$(cd "$(dirname "$this")" && pwd)" + printf '%s' "$(dirname "$dir")" +} + +# Charge targets.env. Refuse de tourner sans fichier de cible. +# Argument optionnel : chemin vers un targets.env alternatif. +load_target() { + local env_file="${1:-$(suite_root)/targets.env}" + [[ -f "$env_file" ]] \ + || die "Aucun fichier de cible trouvé ($env_file). Copiez targets.example.env → targets.env et renseignez vos valeurs." + # shellcheck disable=SC1090 + source "$env_file" + + [[ -n "${TARGET_BASE_URL:-}" ]] \ + || die "targets.env: TARGET_BASE_URL est requis." + [[ -n "${TARGET_SCOPE_ACK:-}" ]] \ + || die "targets.env: TARGET_SCOPE_ACK doit valoir 'yes' (confirmation que la cible vous appartient et que le test est autorisé)." + [[ "$TARGET_SCOPE_ACK" == "yes" ]] \ + || die "targets.env: TARGET_SCOPE_ACK != 'yes'. Outil bloqué tant que l'autorisation n'est pas confirmée." + + # Hôtes autorisés. Par défaut : l'hôte de TARGET_BASE_URL. Si + # TARGET_ALLOWED_HOSTS est défini (liste séparée par espaces), il + # étend le périmètre — utile quand la preprod a plusieurs hôtes + # (ex : Yavsc.Org / Yavsc.Api / Yavsc.Blogs). Voir docs/scope.md. + local base_host; base_host="$(printf '%s' "$TARGET_BASE_URL" \ + | sed -E 's#^[a-zA-Z]+://##; s#/.*$##; s#:.*$##')" + # Construction + déduplication (l'hôte de base peut aussi figurer + # dans TARGET_ALLOWED_HOSTS). + local -A _seen=() + AUTHORIZED_HOSTS=() + for h in "$base_host" ${TARGET_ALLOWED_HOSTS:-}; do + [[ -n "$h" && -z "${_seen[$h]:-}" ]] || continue + _seen[$h]=1 + AUTHORIZED_HOSTS+=( "$h" ) + done + export TARGET_BASE_URL TARGET_ALLOWED_HOSTS AUTHORIZED_HOSTS +} + +# Vérifie qu'une URL donnée appartient bien au périmètre autorisé. +# Empêche un outil de dériver vers un tiers par maladresse. +assert_in_scope() { + local url="$1" + local host; host="$(printf '%s' "$url" \ + | sed -E 's#^[a-zA-Z]+://##; s#/.*$##; s#:.*$##')" + local h + for h in "${AUTHORIZED_HOSTS[@]}"; do + [[ "$host" == "$h" || "$host" == *".$h" ]] && return 0 + done + die "Hors périmètre : $host (autorisé : ${AUTHORIZED_HOSTS[*]}). Abandon." +} + +# --- Client HTTP ------------------------------------------------------- +# Globales positionnées par http_probe. Initialisées ici pour ne pas +# déclencher set -u au premier appel. +LAST_CODE="" +LAST_BODY="" + +# http_probe METHOD URL [curl-args...] +# Émet une requête et positionne les globales LAST_CODE (code HTTP, ou +# "000" si curl a échoué) et LAST_BODY (chemin d'un fichier temporaire +# contenant le corps de la réponse, ou "" si échec). +# +# IMPORTANT : ne PAS appeler dans une substitution de commande +# (code=$(http_probe ...)) : la substitution s'exécute dans un +# sous-shell et les globales ne remonteraient pas. Appeler +# directement, puis lire $LAST_CODE / $LAST_BODY, et appeler +# drop_body une fois le corps traité. +http_probe() { + local method="$1"; shift + local url="$1"; shift + assert_in_scope "$url" + local code body_file + body_file="$(mktemp)" + code="$(set +e; curl -s -o "$body_file" -w '%{http_code}' \ + -X "$method" \ + --connect-timeout "${CURL_TIMEOUT:-10}" \ + --max-time "${CURL_MAX_TIME:-30}" \ + "$@" "$url")" + local rc=$? + if [[ $rc -ne 0 ]]; then + warn "curl a échoué (rc=$rc) sur $method $url" + rm -f "$body_file" + LAST_CODE="000" + LAST_BODY="" + return 0 + fi + if [[ "${VERBOSE:-0}" == "1" ]]; then + printf '%s%s %s%s → %s%s\n' "$C_DIM" "$method" "$url" "$C_RESET" "$C_CYAN" "$code" + [[ -s "$body_file" ]] && head -c 200 "$body_file" && printf '\n---\n' + fi + LAST_CODE="$code" + LAST_BODY="$body_file" +} + +drop_body() { [[ -n "${LAST_BODY:-}" ]] && rm -f "$LAST_BODY"; LAST_BODY=""; } + +# Renvoie les URL de base (une par hôte autorisé) en conservant le +# schéma de TARGET_BASE_URL. Les scanners itèrent dessus pour couvrir +# tout le périmètre multi-hôtes. +each_scope_url() { + local scheme; scheme="$(printf '%s' "$TARGET_BASE_URL" \ + | sed -E 's#://.*$##')" + [[ -z "$scheme" ]] && scheme="https" + local h + for h in "${AUTHORIZED_HOSTS[@]}"; do + printf '%s://%s\n' "$scheme" "$h" + done +} + +# Délai entre requêtes (anti-DoS involontaire). Pacing obligatoire. +pace() { + local d="${REQUEST_DELAY:-0}" + [[ "$d" != "0" ]] && sleep "$d" +} \ No newline at end of file diff --git a/output/.gitkeep b/output/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/targets.example.env b/targets.example.env new file mode 100644 index 0000000..7453441 --- /dev/null +++ b/targets.example.env @@ -0,0 +1,74 @@ +# targets.example.env — copiez ce fichier en targets.env et renseignez +# vos valeurs. targets.env est ignoré par git (.gitignore) : il ne doit +# JAMAIS être committé (il contient votre cible et vos jetons). +# +# cp targets.example.env targets.env + +# --- Autorisation (OBLIGATOIRE) ---------------------------------------- +# Confirmez que la cible vous appartient et que ce test est autorisé. +# Les outils refusent de tourner tant que cette valeur n'est pas "yes". +# Voir docs/scope.md pour le périmètre exact. +TARGET_SCOPE_ACK="no" + +# --- Cible ------------------------------------------------------------- +# URL de base de la preprod. Tous les outils restent dans ce domaine +# (assert_in_scope rejette tout ce qui en dérive). +# Preprod Yavsc — host Yavsc.Org (MVC + IdentityServer, porte l'auth). +TARGET_BASE_URL="https://preprod-yavsc.pschneider.fr" + +# Hôtes additionnels du périmètre (séparés par espaces). La preprod +# Yavsc est multi-hôtes : +# Yavsc.Org → preprod-yavsc.pschneider.fr (auth, MVC) +# Yavsc.Api → preprod-api.pschneider.fr (API business, JSON) +# Yavsc.Blogs → preprod-blogs.pschneider.fr (blogs + stockage perso) +# assert_in_scope autorise ces hôtes en plus de TARGET_BASE_URL. +TARGET_ALLOWED_HOSTS="preprod-yavsc.pschneider.fr preprod-api.pschneider.fr preprod-blogs.pschneider.fr" + +# Timeout curl (secondes) +CURL_TIMEOUT=10 +CURL_MAX_TIME=30 + +# Délai entre requêtes (secondes). 0 = aucune pause. Gardez un délai +# raisonnable pour ne pas saturer la preprod. +REQUEST_DELAY=0.2 + +# Verbeux : 1 = affiche chaque requête + début du corps +VERBOSE=0 + +# Dossier de sortie (journaux, résultats). Ignoré par git. +OUTPUT_DIR="./output" + +# --- brute-login ------------------------------------------------------- +# Endpoint de connexion (relatif à TARGET_BASE_URL ou URL absolue dans +# le périmètre). Yavsc.Org expose l'auth via IdentityServer4 — la page +# de login MVC (POST /account/login) et le endpoint token OAuth/OIDC +# (POST /connect/token). Ciblez le endpoint que vous voulez éprouver. +# Exemples : +# LOGIN_PATH="/account/login" # formulaire MVC (HTML) +# LOGIN_PATH="/connect/token" # token OAuth2 (form-urlencoded) +LOGIN_PATH="/connect/token" +# Champ du nom d'utilisateur dans le corps JSON de la requête. +LOGIN_USER_FIELD="username" +# Champ du mot de passe. +LOGIN_PASS_FIELD="password" +# Utilisateur ciblé (le bruteforceur fait varier le mot de passe). +LOGIN_USERNAME="test" +# Wordlist de mots de passe (un par ligne). Ne PAS committer la vôtre ; +# utilisez le dossier wordlists/ (ignoré) ou un chemin absolu hors dépôt. +LOGIN_WORDLIST="./wordlists/passwords.txt" +# Nombre maximum de tentatives (0 = illimité, déconseillé). +LOGIN_MAX_ATTEMPTS=0 +# Indice de succès : code HTTP renvoyé en cas de connexion réussie +# (typiquement 200). Les autres codes sont considérés comme échec. +LOGIN_SUCCESS_CODE=200 +# Type de corps de la requête de connexion : +# "json" → application/json (API JSON) +# "form" → application/x-www-form-urlencoded (OAuth2 /connect/token, +# formulaire MVC) +# IdentityServer /connect/token attend "form" avec grant_type=password. +LOGIN_CONTENT_TYPE="form" +# Pour OAuth2 password grant, le corps form doit inclure grant_type. +# Si LOGIN_BODY_FORM est défini, il est utilisé tel quel (placeholders +# {{USER}} / {{PASS}}) ; sinon le script construit le form à partir +# des champs ci-dessus + grant_type=password. +#LOGIN_BODY_FORM='grant_type=password&username={{USER}}&password={{PASS}}&client_id=ro_client&scope=api' \ No newline at end of file diff --git a/tools/api-fuzzer/README.md b/tools/api-fuzzer/README.md new file mode 100644 index 0000000..85a655b --- /dev/null +++ b/tools/api-fuzzer/README.md @@ -0,0 +1,36 @@ +# api-fuzzer + +Cartographie la surface d'API des trois hôtes preprod (Org, Api, +Blogs) : codes de réponse par endpoint, verbes autorisés (via +`OPTIONS` + en-tête `Allow`), fuites d'information dans les erreurs +500. + +## Principe + +Lit une liste de chemins dans `endpoints.txt` (modifiable) et, pour +**chaque hôte du périmètre** (`TARGET_BASE_URL` + `TARGET_ALLOWED_HOSTS`), +émet `GET` puis `OPTIONS`. Journalise le code, la taille du corps et +les verbes autorisés. + +## Lancement + +```bash +./fuzz.sh +# ou avec une liste d'endpoints personnalisée : +./fuzz.sh targets.env mes-endpoints.txt +``` + +## Sortie + +- stdout : endpoints notables (200 accessible, 500 fuite possible). +- `output/api-fuzz.log` : une ligne par test + (`horodatage \t GET \t base \t code \t taille \t chemin`). + +## Actions typiques + +- Endpoint renvoyant 500 avec une stack trace → fuite d'info : activer + `UseExceptionHandler` en prod, masquer les détails. +- `OPTIONS` révèle `DELETE`/`PUT` sur une route qu'on croyait GET-only + → vérifier l'autorisation côté serveur (les verbes ne devraient pas + dépendre que du routage). +- Endpoint non documenté en 200 → inventorier et fermer si inutile. \ No newline at end of file diff --git a/tools/api-fuzzer/endpoints.txt b/tools/api-fuzzer/endpoints.txt new file mode 100644 index 0000000..5762b56 --- /dev/null +++ b/tools/api-fuzzer/endpoints.txt @@ -0,0 +1,34 @@ +# endpoints.txt — liste de chemins à énumérer (un par ligne). +# Lignes vides et commençant par # sont ignorées. +# Adaptez à la surface d'API d'Yavsc (voir docs/scope.md). +# Tout chemin est préfixé par TARGET_BASE_URL. + +# --- Auth / IdentityServer --- +/account/login +/account/register +/account/forgotpassword +/connect/token +/connect/authorize +/connect/userinfo +/connect/logout +/.well-known/openid-configuration +/.well-known/jwks + +# --- API business (host API, si exposé via la même preprod) --- +/api/v1/estimate +/api/v1/front/query +/api/v1/activity +/api/v1/profiles + +# --- Blogs / FS --- +/api/v1/fs +/api/v1/blogs + +# --- Surfaces de doc / debug --- +/swagger +/swagger/index.html +/swagger/v1/swagger.json +/openapi.json +/health +/healthz +/diagnostics \ No newline at end of file diff --git a/tools/api-fuzzer/fuzz.sh b/tools/api-fuzzer/fuzz.sh new file mode 100755 index 0000000..2eef91a --- /dev/null +++ b/tools/api-fuzzer/fuzz.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# fuzz.sh — énumère les endpoints d'une API et cartographie les codes +# de réponse, pour repérer les routes ouvertes, les fuites d'info dans +# les messages d'erreur, et les variations de comportement. +# +# USAGE +# ./fuzz.sh [targets.env] [endpoints.txt] +# +# Lit une liste de chemins (un par ligne) depuis le 2e argument, sinon +# FUZZ_PATHS, sinon ./endpoints.txt à côté du script. Pour chaque +# chemin, émet GET et OPTIONS (pour détecter les verbes autorisés via +# l'en-tête Allow). +# +# Journalise dans $OUTPUT_DIR/api-fuzz.log. + +COMMON_SH="$(cd "$(dirname "$0")/../.." && pwd)/lib/common.sh" +# shellcheck disable=SC1091 +source "$COMMON_SH" +load_target "${1:-}" + +LIST="${2:-${FUZZ_PATHS:-$(dirname "$0")/endpoints.txt}}" +[[ -f "$LIST" ]] \ + || die "Liste d'endpoints introuvable : $LIST (créez endpoints.txt ou réglez FUZZ_PATHS)" + +mkdir -p "${OUTPUT_DIR:-./output}" +LOG="${OUTPUT_DIR:-./output}/api-fuzz.log" +: > "$LOG" + +info "Fuzz de $(wc -l < "$LIST") endpoints sur ${#AUTHORIZED_HOSTS[@]} hôte(s) du périmètre" +info "Liste : $LIST" +info "Journal : $LOG" + +# Endpoints **intentionnellement publics** : un 200 dessus est normal, +# pas une anomalie. Ex : la discovery OIDC d'IdentityServer, que les +# clients (PostIt) interrogent pour découvrir les endpoints d'auth. +EXPECTED_PUBLIC=( + "/.well-known/openid-configuration" + "/.well-known/jwks" +) +is_expected_public() { + local p="$1" + local e + for e in "${EXPECTED_PUBLIC[@]}"; do [[ "$e" == "$p" ]] && return 0; done + return 1 +} + +while IFS= read -r base; do + info "→ $base" + while IFS= read -r p || [[ -n "$p" ]]; do + [[ -z "$p" || "$p" == \#* ]] && continue + url="${base}${p}" + http_probe GET "$url" + code="$LAST_CODE" + size=0 + [[ -n "$LAST_BODY" && -s "$LAST_BODY" ]] && size=$(wc -c < "$LAST_BODY") + printf '%s\tGET\t%s\t%s\t%8d\t%s\n' "$(date -Is)" "$base" "$code" "$size" "$p" >> "$LOG" + + # Verbes autorisés : la réponse à OPTIONS peut révéler POST/DELETE + # même sur une route GET-only côté serveur. + allow="$(set +e; curl -s -D - -o /dev/null -X OPTIONS \ + --connect-timeout "${CURL_TIMEOUT:-10}" --max-time "${CURL_MAX_TIME:-30}" \ + "$url" | tr -d '\r' | awk -F': ' 'tolower($1)=="allow"{print $2}')" + [[ -n "$allow" ]] && printf '%s\tOPT\t%s\tAllow: %s\t%s\n' "$(date -Is)" "$base" "$allow" "$p" >> "$LOG" + + case "$code" in + 200) + if is_expected_public "$p"; then + ok "200 $base$p — public attendu (OIDC discovery)" + else + warn "200 $base$p — accessible" + fi + ;; + 401|403) ;; + 404) ;; + 500) err "500 $base$p — erreur serveur (fuite d'info possible)" ;; + *) warn "$code $base$p" ;; + esac + drop_body + pace + done < "$LIST" +done < <(each_scope_url) + +info "Terminé. Voir $LOG." \ No newline at end of file diff --git a/tools/brute-login/README.md b/tools/brute-login/README.md new file mode 100644 index 0000000..c0a4668 --- /dev/null +++ b/tools/brute-login/README.md @@ -0,0 +1,43 @@ +# brute-login + +Teste la résistance de l'authentification preprod par énumération de +mots de passe sur un utilisateur connu. + +## Principe + +Pour chaque mot de passe d'une wordlist, émet un `POST` sur +`LOGIN_PATH` et compare le code HTTP renvoyé à `LOGIN_SUCCESS_CODE`. +S'arrête à la première réussite. Le but est de **mesurer** la +robustesse (wordlist courte = compromise rapide → authentification +fragile ; rate-limit / lockout / 2FA = résistance), pas de produire +un déni de service. + +## Préparation + +1. `cp targets.example.env targets.env` et renseignez + `TARGET_SCOPE_ACK="yes"`, `TARGET_BASE_URL`, `LOGIN_PATH`, + `LOGIN_USERNAME`, `LOGIN_WORDLIST`. +2. Placez votre wordlist dans `wordlists/passwords.txt` (ignoré par + git) ou pointez `LOGIN_WORDLIST` vers un chemin absolu. + +## Lancement + +```bash +./brute-login.sh +``` + +## Sortie + +- stdout : progression + verdict (réussite/échec). +- `output/brute-login.log` : une ligne par tentative + (`horodatage \t code \t mot-de-passe`). + +## Interprétation + +- **Réussite rapide** sur une wordlist triviale → politique de mots + de passe trop faible. Corriger : longueur minimale, complexité, + audit contre une liste de mots de passe courants. +- **429 / lockout** → rate-limiting actif (bon). Notez le seuil. +- **Même code d'échec partout** → impossible de distinguer réussite + d'échec par le code seul ; envisagez un indiceur de succès plus + fin (taille du corps, présence d'un jeton) en adaptant le script. \ No newline at end of file diff --git a/tools/brute-login/brute-login.sh b/tools/brute-login/brute-login.sh new file mode 100755 index 0000000..c53b81a --- /dev/null +++ b/tools/brute-login/brute-login.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# brute-login.sh — teste la robustesse de l'authentification de la +# preprod par énumération de mots de passe sur un utilisateur connu. +# +# USAGE +# ./brute-login.sh [targets.env] +# +# Comportement : +# - lit la wordlist LOGIN_WORDLIST (un mot de passe par ligne) ; +# - pour chaque entrée, émet POST LOGIN_PATH avec {{USER}}/{{PASS}} ; +# - considère réussite si le code HTTP == LOGIN_SUCCESS_CODE ; +# - s'arrête à la première réussite (ou à LOGIN_MAX_ATTEMPTS) ; +# - journalise les tentatives dans $OUTPUT_DIR/brute-login.log. +# +# Le délai REQUEST_DELAY espace les requêtes (voir docs/scope.md). +# Voir targets.example.env pour tous les paramètres. + +COMMON_SH="$(cd "$(dirname "$0")/../.." && pwd)/lib/common.sh" +# shellcheck disable=SC1091 +source "$COMMON_SH" +load_target "${1:-}" + +mkdir -p "${OUTPUT_DIR:-./output}" +LOG="${OUTPUT_DIR:-./output}/brute-login.log" +: > "$LOG" + +[[ -f "${LOGIN_WORDLIST}" ]] \ + || die "Wordlist introuvable : ${LOGIN_WORDLIST} (réglez LOGIN_WORDLIST dans targets.env)" + +# URL absolue de login (LOGIN_PATH peut être relatif ou absolu dans le périmètre). +login_url() { + case "$LOGIN_PATH" in + http*) printf '%s' "$LOGIN_PATH" ;; + /*) printf '%s%s' "$TARGET_BASE_URL" "$LOGIN_PATH" ;; + *) printf '%s/%s' "$TARGET_BASE_URL" "$LOGIN_PATH" ;; + esac +} + +URL="$(login_url)" +assert_in_scope "$URL" +info "Cible : $URL" +info "Utilisateur : ${LOGIN_USERNAME}" +info "Wordlist : ${LOGIN_WORDLIST}" +info "Succès si code == ${LOGIN_SUCCESS_CODE}" +info "Délai : ${REQUEST_DELAY}s entre requêtes" +info "Journal : $LOG" + +# Construit le corps de la requête pour un couple (user, pass). +# Deux formats selon LOGIN_CONTENT_TYPE : "json" ou "form". +build_body() { + local user="$1" pass="$2" + local ct="${LOGIN_CONTENT_TYPE:-json}" + case "$ct" in + form) + if [[ -n "${LOGIN_BODY_FORM:-}" ]]; then + printf '%s' "${LOGIN_BODY_FORM//\{\{USER\}\}/$user}" \ + | sed "s/{{PASS}}/$(printf '%s' "$pass" | sed 's/[&/\]/\\&/g')/" + else + # form-urlencoded : grant_type=password + user + pass. + # Encodage minimal des caractères spéciaux pour URL. + local u p + u="$(printf '%s' "$user" | sed 's/[&+/@=%]/\\&/g')" + p="$(printf '%s' "$pass" | sed 's/[&+/@=%]/\\&/g')" + printf 'grant_type=password&%s=%s&%s=%s' \ + "$LOGIN_USER_FIELD" "$u" "$LOGIN_PASS_FIELD" "$p" + fi + ;; + json|*) + if [[ -n "${LOGIN_BODY_JSON:-}" ]]; then + printf '%s' "${LOGIN_BODY_JSON//\{\{USER\}\}/$user}" \ + | sed "s/{{PASS}}/$(printf '%s' "$pass" | sed 's/[&/\]/\\&/g')/" + else + # Construction JSON simple, échappement basique. + local u p + u="$(printf '%s' "$user" | sed 's/[\\"]/\\&/g')" + p="$(printf '%s' "$pass" | sed 's/[\\"]/\\&/g')" + printf '{"%s":"%s","%s":"%s"}' \ + "$LOGIN_USER_FIELD" "$u" "$LOGIN_PASS_FIELD" "$p" + fi + ;; + esac +} + +# En-tête Content-Type selon le format. +content_type_header() { + case "${LOGIN_CONTENT_TYPE:-json}" in + form) printf 'application/x-www-form-urlencoded' ;; + *) printf 'application/json' ;; + esac +} + +count=0 +found=0 +while IFS= read -r pass || [[ -n "$pass" ]]; do + # ignore lignes vides et commentaires + [[ -z "$pass" || "$pass" == \#* ]] && continue + count=$((count + 1)) + if [[ "${LOGIN_MAX_ATTEMPTS}" != "0" && "$count" -gt "${LOGIN_MAX_ATTEMPTS}" ]]; then + info "Limite LOGIN_MAX_ATTEMPTS=${LOGIN_MAX_ATTEMPTS} atteinte, arrêt." + break + fi + + body="$(build_body "$LOGIN_USERNAME" "$pass")" + http_probe POST "$URL" \ + -H "Content-Type: $(content_type_header)" \ + --data "$body" + code="$LAST_CODE" + printf '%s\t%s\t%s\n' "$(date -Is)" "$code" "$pass" >> "$LOG" + drop_body + + if [[ "$code" == "${LOGIN_SUCCESS_CODE}" ]]; then + ok "Connexion réussie avec : $pass (code $code après $count tentatives)" + found=1 + break + fi + + # Anti-lockout : si on voit un 429 (trop de requêtes) ou un 403 + # persistant, on prévient plutôt que de marteler. + if [[ "$code" == "429" ]]; then + warn "Code 429 (rate limit) reçu — la cible limite les tentatives. Pause + longue." + sleep 5 + fi + + pace +done < "${LOGIN_WORDLIST}" + +if [[ "$found" -eq 0 ]]; then + warn "Aucun mot de passe trouvé après $count tentatives. Voir $LOG." + exit 1 +fi \ No newline at end of file diff --git a/tools/config-scan/README.md b/tools/config-scan/README.md new file mode 100644 index 0000000..a3229f9 --- /dev/null +++ b/tools/config-scan/README.md @@ -0,0 +1,34 @@ +# config-scan + +Détecte les fichiers de configuration et surfaces sensibles +accidentellement exposés publiquement sur la preprod. + +## Principe + +Émet des `GET` sur une liste de chemins courants (`/.git/HEAD`, +`/.env`, `/appsettings.json`, `/swagger`, `/openapi.json`, etc.) et +signale tout ce qui répond 200 (exposé) ou 401/403 (présent mais +protégé — bonne pratique confirmant l'existence, à garder sous +œil). + +## Lancement + +```bash +./scan.sh +``` + +## Sortie + +- stdout : chemins notables (200 en vert = exposé, 403 en jaune). +- `output/config-scan.log` : une ligne par chemin testé + (`horodatage \t code \t taille \t chemin`). + +## Actions typiques + +- `/.git/HEAD` en 200 → dépôt git servi statiquement : bloquer + l'accès aux répertoires `.*` au niveau du reverse proxy. +- `/appsettings*.json` en 200 → fuite potentielle de chaînes de + connexion / secrets : déplacer hors racine web, servir hors du + static. +- `/swagger` exposé en production → désactiver en prod, garder en + preprod uniquement derrière auth. \ No newline at end of file diff --git a/tools/config-scan/scan.sh b/tools/config-scan/scan.sh new file mode 100755 index 0000000..40c63a2 --- /dev/null +++ b/tools/config-scan/scan.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# scan.sh — recherche les fichiers de configuration et éléments sensibles +# accidentellement exposés sur la preprod. +# +# USAGE +# ./scan.sh [targets.env] +# +# Vérifie la présence et la lisibilité de chemins courants : +# - dépôts git exposés (/.git/...) +# - fichiers d'env / settings (.env, appsettings.json, web.config) +# - surfaces de doc API (swagger, openapi) +# - répertoires de listing +# +# Journalise dans $OUTPUT_DIR/config-scan.log. Signale tout ce qui +# répond 200 (ou 403 = présent mais protégé) pour un passage en revue. + +COMMON_SH="$(cd "$(dirname "$0")/../.." && pwd)/lib/common.sh" +# shellcheck disable=SC1091 +source "$COMMON_SH" +load_target "${1:-}" + +mkdir -p "${OUTPUT_DIR:-./output}" +LOG="${OUTPUT_DIR:-./output}/config-scan.log" +: > "$LOG" + +# Chemins à tester (relatifs à TARGET_BASE_URL). Pas de cible tierce. +PATHS=( + "/.git/HEAD" + "/.git/config" + "/.env" + "/.env.local" + "/appsettings.json" + "/appsettings.Development.json" + "/appsettings.Production.json" + "/web.config" + "/connectionstrings.json" + "/swagger" + "/swagger/v1/swagger.json" + "/swagger/index.html" + "/openapi.json" + "/openapi/v1.json" + "/robots.txt" + "/.well-known/openid-configuration" + "/elmah.axd" + "/trace.axd" + "/wp-config.php" + "/server-status" + "/.DS_Store" + "/backup" + "/.vscode" +) + +info "Scan de ${#PATHS[@]} chemins sur ${#AUTHORIZED_HOSTS[@]} hôte(s) du périmètre" +info "Journal : $LOG" + +# Endpoints **intentionnellement publics** (un 200 est normal, pas une +# fuite) : la discovery OIDC d'IdentityServer, requise par les clients +# (PostIt) pour se connecter. +EXPECTED_PUBLIC=( + "/.well-known/openid-configuration" + "/.well-known/jwks" +) +is_expected_public() { + local p="$1" + local e + for e in "${EXPECTED_PUBLIC[@]}"; do [[ "$e" == "$p" ]] && return 0; done + return 1 +} + +while IFS= read -r base; do + info "→ $base" + for p in "${PATHS[@]}"; do + url="${base}${p}" + # http_probe gère déjà assert_in_scope. + http_probe GET "$url" + code="$LAST_CODE" + size=0 + [[ -n "$LAST_BODY" && -s "$LAST_BODY" ]] && size=$(wc -c < "$LAST_BODY") + printf '%s\t%s\t%s\t%8d\t%s\n' "$(date -Is)" "$base" "$code" "$size" "$p" >> "$LOG" + + case "$code" in + 200) + if is_expected_public "$p"; then + ok "200 $base$p (${size} o) — public attendu (OIDC discovery)" + else + ok "200 $base$p (${size} o) — EXPOSÉ, à corriger" + fi + ;; + 401|403) warn "$code $base$p — présent mais protégé" ;; + 301|302) warn "$code $base$p — redirection (vérifier la cible)" ;; + 404) ;; + *) warn "$code $base$p" ;; + esac + drop_body + pace + done +done < <(each_scope_url) + +info "Terminé. Voir $LOG." \ No newline at end of file diff --git a/tools/rate-limit-probe/README.md b/tools/rate-limit-probe/README.md new file mode 100644 index 0000000..caeaf0b --- /dev/null +++ b/tools/rate-limit-probe/README.md @@ -0,0 +1,41 @@ +# rate-limit-probe + +Mesure si l'endpoint d'authentification (ou tout endpoint sensible) +de la preprod met en place un rate-limiting / verrouillage contre le +bruteforce. + +## Principe + +Envoie `RATE_PROBE_COUNT` requêtes (20 par défaut) avec un mauvais +mot de passe sur `LOGIN_PATH` et observe les codes de réponse : + +- **429** → rate-limiting actif (bon). Le seuil (après combien de + requêtes) se lit dans le journal. +- **changement soudain de code** (ex : 401 → 403/423) → verrouillage + (lockout) après N échecs. +- **rien de tout ça** (toujours 401/400) → AUCUN rate-limit : + vulnérabilité, le bruteforce n'est pas freiné. + +## Lancement + +```bash +./probe.sh +``` + +Paramètres (dans `targets.env`) : +- `RATE_PROBE_COUNT` — nombre de requêtes (défaut 20). +- `RATE_PROBE_DELAY` — délai entre requêtes en secondes (0 = rafale ; + pour tester le rate-limit on veut justement une rafale, mais + gardez un count modéré). + +## Sortie + +- stdout : verdict (rate-limit actif / lockout / absent). +- `output/rate-limit.log` : `horodatage \t n/N \t code`. + +## Actions typiques + +- Aucun rate-limit → en ajouter un au reverse proxy (ex : `limit_req` + nginx) ou côté application (bucket par IP+utilisateur). +- Rate-limit trop agressif (429 dès la 2e requête) → risque de bloquer + les légitimes ; ajuster le seuil. \ No newline at end of file diff --git a/tools/rate-limit-probe/probe.sh b/tools/rate-limit-probe/probe.sh new file mode 100755 index 0000000..dc42119 --- /dev/null +++ b/tools/rate-limit-probe/probe.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# probe.sh — mesure la mise en place d'un rate-limiting / lockout sur +# l'endpoint d'authentification (ou tout endpoint sensible) de la +# preprod. +# +# USAGE +# ./probe.sh [targets.env] +# +# Envoie RATE_PROBE_COUNT requêtes rapides (identiques, échec volontaire) +# sur LOGIN_PATH et observe l'évolution des codes HTTP : +# - 429 trop tôt → rate-limit agressif (pe-être trop pour les +# légitimes) ; +# - jamais de 429, toujours 401/400 → AUCUN rate-limit : vulnérabilité +# (bruteforce non freiné) ; +# - verrouillage après N échecs (code change, ex 403/423) → lockout. +# +# Journalise dans $OUTPUT_DIR/rate-limit.log. + +COMMON_SH="$(cd "$(dirname "$0")/../.." && pwd)/lib/common.sh" +# shellcheck disable=SC1091 +source "$COMMON_SH" +load_target "${1:-}" + +mkdir -p "${OUTPUT_DIR:-./output}" +LOG="${OUTPUT_DIR:-./output}/rate-limit.log" +: > "$LOG" + +RATE_PROBE_COUNT="${RATE_PROBE_COUNT:-20}" +RATE_PROBE_DELAY="${RATE_PROBE_DELAY:-0}" # 0 = rafale, par défaut + +# URL de login (même logique que brute-login). +login_url() { + case "${LOGIN_PATH:-}" in + http*) printf '%s' "$LOGIN_PATH" ;; + /*) printf '%s%s' "$TARGET_BASE_URL" "${LOGIN_PATH}" ;; + *) printf '%s/%s' "$TARGET_BASE_URL" "${LOGIN_PATH}" ;; + esac +} +URL="$(login_url)" +assert_in_scope "$URL" + +info "Rate-limit probe : $RATE_PROBE_COUNT requêtes sur $URL" +info "Délai entre requêtes : ${RATE_PROBE_DELAY}s (0 = rafale)" +info "Journal : $LOG" + +# Corps d'échec volontaire (mauvais mot de passe). On ne cherche pas +# à réussir ici, juste à saturer pour observer le rate-limit. +build_bad_body() { + case "${LOGIN_CONTENT_TYPE:-json}" in + form) + local u; u="$(printf '%s' "${LOGIN_USERNAME:-nobody}" | sed 's/[&+/@=%]/\\&/g')" + printf 'grant_type=password&%s=%s&%s=definitely-wrong-pass-%%42' \ + "${LOGIN_USER_FIELD:-username}" "$u" "${LOGIN_PASS_FIELD:-password}" + ;; + *) + printf '{"%s":"%s","%s":"definitely-wrong-pass"}' \ + "${LOGIN_USER_FIELD:-username}" "${LOGIN_USERNAME:-nobody}" \ + "${LOGIN_PASS_FIELD:-password}" + ;; + esac +} +ct() { case "${LOGIN_CONTENT_TYPE:-json}" in form) printf 'application/x-www-form-urlencoded';; *) printf 'application/json';; esac; } + +body="$(build_bad_body)" +seen_429=0 +seen_lock=0 +first_change="" +prev_code="" + +for i in $(seq 1 "$RATE_PROBE_COUNT"); do + http_probe POST "$URL" -H "Content-Type: $(ct)" --data "$body" + code="$LAST_CODE" + printf '%s\t%3d/%d\t%s\n' "$(date -Is)" "$i" "$RATE_PROBE_COUNT" "$code" >> "$LOG" + drop_body + + [[ "$code" == "429" ]] && seen_429=$((seen_429 + 1)) + # 423 Locked, ou un changement soudain de code après des 401 → lockout possible. + if [[ "$code" == "423" || ( -n "$prev_code" && "$code" != "$prev_code" && "$code" != "429" ) ]]; then + seen_lock=$((seen_lock + 1)) + [[ -z "$first_change" ]] && first_change="req $i : $prev_code → $code" + fi + prev_code="$code" + + [[ "${VERBOSE:-0}" == "1" ]] && printf ' %3d %s\n' "$i" "$code" + [[ "$RATE_PROBE_DELAY" != "0" ]] && sleep "$RATE_PROBE_DELAY" +done + +info "Résultats : $RATE_PROBE_COUNT requêtes, $seen_429 code(s) 429, $seen_lock changement(s) de code." +if [[ -n "$first_change" ]]; then + warn "Changement observé : $first_change (lockout possible)" +fi +if [[ "$seen_429" -eq 0 && -z "$first_change" ]]; then + err "AUCUN rate-limit détecté (pas de 429, pas de verrouillage). Endpoint vulnérable au bruteforce." +else + ok "Rate-limiting actif ($seen_429 réponses 429). Voir $LOG pour le seuil." +fi \ No newline at end of file diff --git a/wordlists/.gitkeep b/wordlists/.gitkeep new file mode 100644 index 0000000..e69de29