Skip to content

Commit 6f242b3

Browse files
author
Administrator
committed
Merge remote-tracking branch 'origin/main'
2 parents b9aa6d2 + 1fddc84 commit 6f242b3

26 files changed

Lines changed: 1539 additions & 0 deletions

‎honeypots/http/Dockerfile‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# syntax=docker/dockerfile:1
2+
# Honeypot HTTP (EPIC-2) — émulation WordPress, image minimale, exécutée en non-root.
3+
FROM python:3.12-slim-bookworm
4+
5+
# Empêche Python de bufferiser stdout : les événements JSONL sortent en temps
6+
# réel dans les logs Docker / vers Filebeat.
7+
ENV PYTHONUNBUFFERED=1 \
8+
PYTHONDONTWRITEBYTECODE=1
9+
10+
WORKDIR /app
11+
12+
# Dépendances d'abord (cache de build).
13+
COPY requirements.txt .
14+
RUN pip install --no-cache-dir -r requirements.txt
15+
16+
# Code applicatif.
17+
COPY app ./app
18+
COPY alerts ./alerts
19+
20+
# Utilisateur non privilégié + répertoire de logs inscriptible (aligné SSH).
21+
RUN useradd --system --no-create-home --uid 10001 honey \
22+
&& mkdir -p /var/log/honeypot \
23+
&& chown honey:honey /var/log/honeypot
24+
USER honey
25+
26+
EXPOSE 8080
27+
28+
# Vérifie que le port HTTP accepte les connexions TCP.
29+
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
30+
CMD python -c "import socket,os,sys; s=socket.socket(); s.settimeout(3); sys.exit(s.connect_ex(('127.0.0.1', int(os.getenv('HTTP_PORT','8080')))))"
31+
32+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--no-server-header"]
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Client Redis pour pousser les alertes critiques (US-11).
2+
3+
L'envoi est best-effort : si Redis est indisponible (dev local), l'alerte est
4+
seulement journalisée sur stderr, sans bloquer la réponse du honeypot.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import sys
11+
from typing import Any
12+
13+
from app.config import ALERT_CHANNEL, REDIS_URL
14+
15+
try:
16+
import redis
17+
except ImportError: # redis optionnel en dev
18+
redis = None
19+
20+
_client = None
21+
22+
23+
def _get_client():
24+
global _client
25+
if redis is None:
26+
return None
27+
if _client is None:
28+
_client = redis.Redis.from_url(REDIS_URL, socket_connect_timeout=1, socket_timeout=1)
29+
return _client
30+
31+
32+
def _log_stderr(event: dict[str, Any]) -> None:
33+
print(f"[ALERT] {json.dumps(event, ensure_ascii=False)}", file=sys.stderr, flush=True)
34+
35+
36+
def push_alert(event: dict[str, Any]) -> bool:
37+
"""Publie l'alerte sur le canal Redis + l'empile. Retourne False si indisponible."""
38+
client = _get_client()
39+
if client is None:
40+
_log_stderr(event)
41+
return False
42+
try:
43+
message = json.dumps(event, ensure_ascii=False)
44+
client.publish(ALERT_CHANNEL, message)
45+
client.lpush(ALERT_CHANNEL, message)
46+
return True
47+
except Exception: # Redis indisponible ne doit jamais casser le honeypot
48+
_log_stderr(event)
49+
return False

‎honeypots/http/app/config.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Configuration du honeypot HTTP : headers cohérents et constantes d'émulation.
2+
3+
Chaque réponse imite un serveur Apache/PHP réel pour rester crédible face aux
4+
scanners (cohérence imposée par US-08 et US-28).
5+
"""
6+
7+
import os
8+
9+
# Headers renvoyés sur CHAQUE réponse (US-08 / US-28).
10+
SERVER_HEADER = "Apache/2.4.57 (Debian)"
11+
X_POWERED_BY = "PHP/7.4.33"
12+
13+
COHERENT_HEADERS = {
14+
"Server": SERVER_HEADER,
15+
"X-Powered-By": X_POWERED_BY,
16+
}
17+
18+
# Port d'écoute interne du conteneur (l'hôte de bind est fourni par uvicorn/Docker).
19+
LISTEN_PORT = 8080
20+
21+
# Journalisation des événements (JSON Lines) — voir app/events/builder.py.
22+
# Même dossier/convention que le honeypot SSH (/var/log/honeypot/<service>.jsonl).
23+
LOG_FILE = os.getenv("HTTP_LOG_FILE", "/var/log/honeypot/http.jsonl")
24+
# Hostname simulé : identique au SSH pour que le SIEM corrèle une seule machine.
25+
HONEYPOT_HOST = os.getenv("HONEYPOT_HOST", "prod-srv-01")
26+
SCHEMA_VERSION = "1.0.0"
27+
28+
# Alertes canary (US-11) poussées vers Redis.
29+
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
30+
ALERT_CHANNEL = os.getenv("ALERT_CHANNEL", "honeypot:alerts")
31+
32+
33+
# --- Faux login WordPress (hardening anti-détection, comme le honeypot SSH) ---
34+
def _parse_credentials(raw: str) -> set[tuple[str, str]]:
35+
pairs: set[tuple[str, str]] = set()
36+
for item in raw.split(","):
37+
cleaned = item.strip()
38+
if ":" in cleaned:
39+
user, _, password = cleaned.partition(":")
40+
pairs.add((user, password))
41+
return pairs
42+
43+
44+
# Couples FAIBLES acceptés par le faux login. Accepter n'importe quel couple
45+
# serait un tell de honeypot : tout le reste est refusé.
46+
ALLOWED_WP_CREDENTIALS = _parse_credentials(
47+
os.getenv("WP_ALLOWED_CREDENTIALS", "admin:admin,admin:admin123,administrator:password123")
48+
)
49+
# Cookie d'auth posé après un login réussi (forme d'un vrai WordPress).
50+
LOGGED_IN_COOKIE = "wordpress_logged_in_" + os.getenv("WP_COOKIE_HASH", "8d2a1f4c9b7e6051")
51+
52+
# Jitter de latence (anti-fingerprint temporel, comme le honeypot SSH).
53+
# Mettre HTTP_JITTER_MAX_MS=0 pour désactiver (tests).
54+
JITTER_MIN_MS = int(os.getenv("HTTP_JITTER_MIN_MS", "50"))
55+
JITTER_MAX_MS = int(os.getenv("HTTP_JITTER_MAX_MS", "300"))
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
APP_NAME=AcmePortal
2+
APP_ENV=production
3+
APP_KEY=base64:Zk9Jx2cQ7v3mN1pR8sT4uW6yA0bC5dE7gH9iK2lM4n=
4+
APP_DEBUG=false
5+
APP_URL=https://portal.acme-corp.internal
6+
7+
DB_CONNECTION=mysql
8+
DB_HOST=10.0.4.12
9+
DB_PORT=3306
10+
DB_DATABASE=acme_prod
11+
DB_USERNAME=acme_admin
12+
DB_PASSWORD=Wint3r-2024-ProdDB-Decoy
13+
14+
REDIS_HOST=10.0.4.20
15+
MAIL_HOST=smtp.acme-corp.internal
16+
AWS_ACCESS_KEY_ID=AKIA5EXAMPLEDECOYKEY00
17+
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIK7MDENGbPxRfiCYDECOYEXAMPLEKEY
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[core]
2+
repositoryformatversion = 0
3+
filemode = true
4+
bare = false
5+
logallrefupdates = true
6+
[remote "origin"]
7+
url = https://github.com/acme-corp/internal-portal.git
8+
fetch = +refs/heads/*:refs/remotes/origin/*
9+
[branch "main"]
10+
remote = origin
11+
merge = refs/heads/main
12+
[user]
13+
name = deploy-bot
14+
email = deploy@acme-corp.internal
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Construction et écriture des événements honeypot (Epic 2).
2+
3+
Chaque événement est conforme à docs/event.schema.json (HoneypotEvent v1).
4+
Sortie : une ligne JSON par événement (JSON Lines) sur stdout (logs Docker) et,
5+
optionnellement, dans un fichier tail par Filebeat/Fluent Bit puis expédié au
6+
SIEM. Conventions alignées sur le honeypot SSH (honeypots/ssh/events.py).
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import sys
13+
import uuid
14+
from datetime import UTC, datetime
15+
from pathlib import Path
16+
from typing import Any, TextIO
17+
18+
from app.config import HONEYPOT_HOST, LOG_FILE, SCHEMA_VERSION
19+
20+
SERVICE = "http"
21+
22+
23+
def _now_iso() -> str:
24+
"""Timestamp ISO 8601 UTC avec millisecondes, ex: 2024-06-03T08:45:12.123Z."""
25+
now = datetime.now(UTC)
26+
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
27+
28+
29+
def normalize_ipv4(addr: str) -> str:
30+
"""Ramène une adresse à la forme IPv4 attendue par le schéma (cf. SSH).
31+
32+
Déplie les IPv4 mappées IPv6 (``::ffff:127.0.0.1``) et rabat le loopback
33+
IPv6 sur ``127.0.0.1`` pour respecter ``format: ipv4``.
34+
"""
35+
if addr.startswith("::ffff:") and "." in addr:
36+
return addr.rsplit(":", 1)[-1]
37+
if addr in ("::1", "::"):
38+
return "127.0.0.1"
39+
return addr
40+
41+
42+
def build_event(
43+
*,
44+
event_type: str,
45+
src_ip: str,
46+
payload: dict[str, Any],
47+
src_port: int | None = None,
48+
session_id: str | None = None,
49+
classification: dict[str, Any] | None = None,
50+
honeypot_host: str = HONEYPOT_HOST,
51+
) -> dict[str, Any]:
52+
"""Assemble un événement conforme au schéma HoneypotEvent."""
53+
event: dict[str, Any] = {
54+
"id": str(uuid.uuid4()),
55+
"timestamp": _now_iso(),
56+
"service": SERVICE,
57+
"event_type": event_type,
58+
"src_ip": normalize_ipv4(src_ip),
59+
"payload": payload,
60+
"meta": {"honeypot_host": honeypot_host, "schema_version": SCHEMA_VERSION},
61+
}
62+
if src_port is not None:
63+
event["src_port"] = src_port
64+
if session_id is not None:
65+
event["session_id"] = session_id
66+
if classification:
67+
event["classification"] = classification
68+
return event
69+
70+
71+
class EventSink:
72+
"""Écrit les événements en JSON Lines sur stdout et (optionnellement) un fichier.
73+
74+
Le fichier est ouvert en append et flush à chaque ligne pour que Filebeat
75+
voie les événements en quasi temps réel. Une panne d'écriture fichier ne
76+
doit jamais interrompre le honeypot : on retombe sur stdout seul.
77+
"""
78+
79+
def __init__(self, log_file: str | None = None, *, stream: TextIO | None = None) -> None:
80+
self._stream = stream if stream is not None else sys.stdout
81+
self._file: TextIO | None = None
82+
if log_file:
83+
try:
84+
path = Path(log_file)
85+
path.parent.mkdir(parents=True, exist_ok=True)
86+
self._file = path.open("a", encoding="utf-8")
87+
except OSError as exc: # pragma: no cover - dépend du FS
88+
print(f"[events] fichier de log indisponible ({exc}), stdout seul", file=sys.stderr)
89+
90+
def emit(self, event: dict[str, Any]) -> None:
91+
"""Sérialise et écrit un événement sur toutes les sorties disponibles."""
92+
line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
93+
print(line, file=self._stream, flush=True)
94+
if self._file is not None:
95+
try:
96+
self._file.write(line + "\n")
97+
self._file.flush()
98+
except OSError as exc: # pragma: no cover - dépend du FS
99+
print(f"[events] écriture fichier échouée: {exc}", file=sys.stderr)
100+
101+
def close(self) -> None:
102+
if self._file is not None:
103+
self._file.close()
104+
self._file = None
105+
106+
107+
# Sink partagé par les middlewares et les routes (le fichier reste ouvert).
108+
sink = EventSink(LOG_FILE)
109+
110+
111+
def emit(event: dict[str, Any]) -> None:
112+
"""Émet via le sink partagé (API conservée pour les routes/middlewares)."""
113+
sink.emit(event)

‎honeypots/http/app/main.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Honeypot HTTP — application FastAPI émulant un WordPress (Epic 2).
2+
3+
Pipeline de traitement :
4+
1. middleware log_requests : journalise chaque requête, détecte scanners (US-12)
5+
et exploits (US-10), émet un event `request`.
6+
2. middleware add_coherent_headers : injecte les headers Apache/PHP cohérents
7+
+ le header Link (api.w.org) d'un vrai WordPress.
8+
3. routeurs : wordpress (US-08), extra (US-28), credentials (US-09), canary (US-11).
9+
10+
Anti-détection : docs/openapi de FastAPI désactivés, 404 rendu en page WordPress
11+
(pas de JSON FastAPI), header `server: uvicorn` supprimé (cf. server_header=False
12+
/ --no-server-header dans le Dockerfile).
13+
"""
14+
15+
import os
16+
from pathlib import Path
17+
18+
import uvicorn
19+
from fastapi import FastAPI, Request, Response
20+
from fastapi.responses import HTMLResponse, PlainTextResponse
21+
from starlette.exceptions import HTTPException as StarletteHTTPException
22+
23+
from app.config import COHERENT_HEADERS, LISTEN_PORT
24+
from app.middleware.logging import log_requests
25+
from app.routes import canary, credentials, extra, wordpress
26+
27+
_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
28+
29+
30+
def _read_template(name: str, fallback: str) -> str:
31+
try:
32+
return (_TEMPLATE_DIR / name).read_text(encoding="utf-8")
33+
except OSError:
34+
return fallback
35+
36+
37+
_NOT_FOUND_HTML = _read_template(
38+
"404.html", "<!DOCTYPE html><html><body><h1>Page not found</h1></body></html>"
39+
)
40+
41+
app = FastAPI(title="wordpress", docs_url=None, redoc_url=None, openapi_url=None)
42+
43+
app.include_router(wordpress.router)
44+
app.include_router(extra.router)
45+
app.include_router(credentials.router)
46+
app.include_router(canary.router)
47+
48+
49+
@app.exception_handler(StarletteHTTPException)
50+
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> Response:
51+
"""Évite le JSON FastAPI : rend des réponses cohérentes Apache/WordPress."""
52+
if exc.status_code == 404:
53+
return HTMLResponse(_NOT_FOUND_HTML, status_code=404)
54+
return PlainTextResponse(str(exc.detail or ""), status_code=exc.status_code)
55+
56+
57+
@app.middleware("http")
58+
async def add_coherent_headers(request: Request, call_next) -> Response:
59+
"""Injecte Server / X-Powered-By + le header Link WordPress sur chaque réponse."""
60+
response = await call_next(request)
61+
for name, value in COHERENT_HEADERS.items():
62+
response.headers[name] = value
63+
host = request.headers.get("host")
64+
if host:
65+
response.headers["Link"] = f'<http://{host}/wp-json/>; rel="https://api.w.org/"'
66+
# Un vrai WordPress annonce son endpoint pingback.
67+
response.headers["X-Pingback"] = f"http://{host}/xmlrpc.php"
68+
return response
69+
70+
71+
# Enregistré après le middleware de headers => s'exécute en premier (lit la requête brute).
72+
app.middleware("http")(log_requests)
73+
74+
75+
def main() -> None:
76+
host = os.getenv("HTTP_HOST", "127.0.0.1")
77+
# server_header=False : pas de fuite "server: uvicorn" (le middleware met Apache).
78+
uvicorn.run(app, host=host, port=LISTEN_PORT, server_header=False)
79+
80+
81+
if __name__ == "__main__":
82+
main()

0 commit comments

Comments
 (0)