Skip to content

FEAT : Ratelimit per host #6

Description

@junkoku38

Feature: Rate-limiting par hôte cible

Contexte

AttackSim possède sandbox/budget.py qui implémente un RequestBudget global uniquement : rate_limit, max_requests, wall_budget sont partagés entre tous les modules et tous les hôtes. Sur un --cidr 10.0.0.0/24 (256 hôtes), le budget global (50 req/s par défaut) est réparti entre tous les modules concurrents sur tous les hôtes → un seul host populaire (ex. 10.0.0.5 avec 20 ports ouverts + HTTP) peut consommer tout le budget pendant que les 255 autres sont à peine touchés. Les outils pro (nmap --rate, nuclei -rl, ffuf -rate) ont un rate par hôte.

Description

Étendre RequestBudget pour supporter un rate-limiting par hôte en plus du global, avec un plafond global.

Spécifications fonctionnelles

Comportement attendu

  1. Le budget global (ex. 200 req/s) reste le plafond supérieur.
  2. Chaque hôte reçoit un sous-budget (ex. 50 req/s) qui ne peut pas être dépassé.
  3. Si rate_limit_per_host n'est pas défini, fallback au comportement actuel (global uniquement).
  4. Les modules qui accèdent à plusieurs hôtes (ex. host_sweep, cloud_pivot) utilisent le budget de l'hôte qu'ils contactent.

Interface CLI

--rate-limit 200              # global (existant)
--rate-limit-per-host 50      # nouveau, défaut: = rate_limit (rétro-compatible)

Output

Aucun changement dans le format de rapport. Le budget est un mécanisme interne. En mode verbose (-v), afficher :

[*] budget: global=200/s per_host=50/s | host 10.0.0.5: 48/s (96%)

Spécifications techniques

Fichier à modifier

sandbox/budget.py

Changements

class RequestBudget:
    def __init__(self, rate_limit=None, max_requests=None, wall_budget=None,
                 rate_limit_per_host=None):
        self._global_lock = threading.Lock()
        self._global_window = collections.deque()
        self._global_rate = rate_limit or GLOBAL_MAX_REQUESTS
        self._global_max = max_requests or GLOBAL_MAX_REQUESTS
        self._wall = wall_budget or GLOBAL_WALL_BUDGET
        self._start = time.monotonic()

        self._per_host = rate_limit_per_host or self._global_rate
        self._host_windows: dict[str, collections.deque] = {}
        self._host_locks: dict[str, threading.Lock] = {}

    def acquire(self, host: str = None, n: int = 1, timeout: float = 30.0) -> bool:
        """Acquire n request slots. If host is given, also enforce per-host rate."""
        # 1. Check wall budget
        if time.monotonic() - self._start > self._wall:
            return False
        # 2. Check global max
        with self._global_lock:
            if len(self._global_window) >= self._global_max:
                return False
        # 3. Acquire global slot (existing logic)
        if not self._acquire_window(self._global_window, self._global_lock,
                                     self._global_rate, n, timeout):
            return False
        # 4. Acquire per-host slot (new)
        if host and self._per_host != self._global_rate:
            hlock = self._host_locks.setdefault(host, threading.Lock())
            hwin = self._host_windows.setdefault(host, collections.deque())
            if not self._acquire_window(hwin, hlock, self._per_host, n, timeout):
                # rollback global slot
                with self._global_lock:
                    for _ in range(n):
                        if self._global_window: self._global_window.popleft()
                return False
        return True

    def _acquire_window(self, window, lock, rate, n, timeout):
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            with lock:
                now = time.monotonic()
                while window and window[0] < now - 1.0:
                    window.popleft()
                if len(window) + n <= rate:
                    for _ in range(n):
                        window.append(now)
                    return True
            time.sleep(0.05)
        return False

Changements dans main.py

  • _configure_target : passer rate_limit_per_host=args.rate_limit_per_host au RequestBudget.
  • _fanout_hostport : chaque sub-target hérite du budget parent (partagé).
  • Les modules qui appellent target.budget.acquire() doivent passer host=target.host :
    target.budget.acquire(host=target.host)

Changements dans les modules

Mettre à jour les appels budget.acquire() dans les modules qui scanment :

  • exploits/port_probe.py
  • exploits/http_probe.py
  • exploits/host_sweep.py
  • exploits/web_spider.py
  • exploits/content_discovery.py
  • exploits/fuzz_probe.py
  • exploits/default_creds.py

Configuration (config.py)

GLOBAL_RATE_LIMIT_PER_HOST = _env("GLOBAL_RATE_LIMIT_PER_HOST", 50)

Tests

tests/test_budget_per_host.py (nouveau) :

  • RequestBudget(rate_limit=100, rate_limit_per_host=10) — 10 hôtes en parallèle, chaque host peut faire 10/s, global plafond 100/s
  • Host A fait 10 req → 11e bloquée jusqu'à la fenêtre
  • Host B fait 10 req en même temps → OK (séparé)
  • 11 hôtes à 10/s → le 11e bloqué par le global
  • rate_limit_per_host=None → comportement actuel (rétro-compatible)
  • Vérifier thread-safety avec ThreadPoolExecutor

tests/test_budget.py (existant) — tous les tests doivent encore passer.

Backward compatibility

  • --rate-limit-per-host par défaut = --rate-limit (comportement actuel si non spécifié)
  • RequestBudget() sans argument → rate_limit_per_host=None → pas de per-host

Acceptance criteria

  • RequestBudget(rate_limit_per_host=N) limite chaque hôte à N req/s
  • Le plafond global reste respecté
  • rate_limit_per_host=None → comportement inchangé (rétro-compatible)
  • Thread-safe sous ThreadPoolExecutor
  • Les modules de scan passent host=target.host à acquire()
  • --rate-limit-per-host disponible en CLI
  • Tests nouveaux passent + tests existants non cassés
  • Documenté dans docs/budget-and-rate-limiting.md

Références

  • nmap --rate / --max-rate
  • nuclei -rl / -rate-limit
  • ffuf -rate

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions