Skip to content

Commit beb73ae

Browse files
authored
Merge pull request #4 from mathiscapart/feat/http-honeypot-epic2
Feat/http honeypot epic2
2 parents 7881577 + 6cc426c commit beb73ae

17 files changed

Lines changed: 733 additions & 0 deletions

File tree

‎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"]
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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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")
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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
3. routeurs : wordpress (US-08), extra (US-28), credentials (US-09), canary (US-11).
8+
9+
Les endpoints docs/openapi de FastAPI sont désactivés : un vrai WordPress ne les
10+
exposerait pas (tell de honeypot évité).
11+
"""
12+
13+
import os
14+
15+
import uvicorn
16+
from fastapi import FastAPI, Request, Response
17+
18+
from app.config import COHERENT_HEADERS, LISTEN_PORT
19+
from app.middleware.logging import log_requests
20+
from app.routes import canary, credentials, extra, wordpress
21+
22+
app = FastAPI(title="wordpress", docs_url=None, redoc_url=None, openapi_url=None)
23+
24+
app.include_router(wordpress.router)
25+
app.include_router(extra.router)
26+
app.include_router(credentials.router)
27+
app.include_router(canary.router)
28+
29+
30+
@app.middleware("http")
31+
async def add_coherent_headers(request: Request, call_next) -> Response:
32+
"""Injecte Server / X-Powered-By cohérents sur chaque réponse."""
33+
response = await call_next(request)
34+
for name, value in COHERENT_HEADERS.items():
35+
response.headers[name] = value
36+
return response
37+
38+
39+
# Enregistré après le middleware de headers => s'exécute en premier (lit la requête brute).
40+
app.middleware("http")(log_requests)
41+
42+
43+
def main() -> None:
44+
host = os.getenv("HTTP_HOST", "127.0.0.1")
45+
uvicorn.run(app, host=host, port=LISTEN_PORT)
46+
47+
48+
if __name__ == "__main__":
49+
main()
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Détection d'exploits HTTP (US-10) : LFI, SQLi, RCE, XSS, Log4Shell, etc.
2+
3+
Analyse l'URL, la query string, le body et les headers. Renvoie une
4+
classification (catégorie EXPLOIT_ATTEMPT + sévérité + tags) ou None.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import re
10+
from typing import Any
11+
12+
_SEVERITY_RANK = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}
13+
14+
# (tag, sévérité, motif). Couvre les 7 patterns demandés par US-10.
15+
_PATTERNS: list[tuple[str, str, re.Pattern[str]]] = [
16+
("log4shell", "critical", re.compile(r"\$\{jndi:(ldap|ldaps|rmi|dns|iiop)://", re.IGNORECASE)),
17+
(
18+
"reverse_shell",
19+
"critical",
20+
re.compile(r"bash\s+-i|/dev/tcp/|nc\s+-e|mkfifo|socket\.socket|sh\s+-i", re.IGNORECASE),
21+
),
22+
(
23+
"rce",
24+
"critical",
25+
re.compile(
26+
r";\s*(cat|wget|curl|chmod|nc|bash|sh|id|uname|whoami)\b|"
27+
r"\|\s*(bash|sh)\b|`[^`]+`|\$\([^)]+\)",
28+
re.IGNORECASE,
29+
),
30+
),
31+
(
32+
"sqli",
33+
"high",
34+
re.compile(
35+
r"union\s+select|'\s*or\s*'?1'?\s*=\s*'?1|sleep\(\d+\)|benchmark\(|information_schema",
36+
re.IGNORECASE,
37+
),
38+
),
39+
(
40+
"path_traversal",
41+
"high",
42+
re.compile(r"(\.\./|\.\.\\|%2e%2e%2f|%2e%2e/){2,}|/etc/passwd|/etc/shadow", re.IGNORECASE),
43+
),
44+
(
45+
"lfi",
46+
"high",
47+
re.compile(r"php://(filter|input)|file://|/proc/self/environ|data://", re.IGNORECASE),
48+
),
49+
("xss", "medium", re.compile(r"<script\b|onerror\s*=|javascript:|<img[^>]+onerror", re.IGNORECASE)),
50+
]
51+
52+
53+
def detect(*parts: str) -> dict[str, Any] | None:
54+
"""Cherche des patterns d'exploit dans les fragments fournis (url, query, body, headers)."""
55+
haystack = "\n".join(p for p in parts if p)
56+
if not haystack:
57+
return None
58+
tags: list[str] = []
59+
severity = "info"
60+
for tag, sev, pattern in _PATTERNS:
61+
if pattern.search(haystack):
62+
tags.append(tag)
63+
if _SEVERITY_RANK[sev] > _SEVERITY_RANK[severity]:
64+
severity = sev
65+
if not tags:
66+
return None
67+
return {"category": "EXPLOIT_ATTEMPT", "severity": severity, "tags": tags}

0 commit comments

Comments
 (0)