Skip to content

Commit bc9d022

Browse files
Lukas Geigerclaude
andcommitted
fix(security): honor robots.txt for all discovery sources; cap HATEOAS request budget
- robots.txt now applies to endpoints sourced from an OpenAPI/Swagger spec too. Phase 1 records spec endpoints unchecked, and phases 4 (method testing, incl. POST/PUT/DELETE under --test-all-methods) and 5 (schema extraction) previously probed them with no robots.txt check, bypassing the tool's ethical contract. - Response-driven (HATEOAS) discovery now respects the max_requests budget; it had no such parameter and followed unbounded links per round, exceeding the configured request limit that the other strategies honor. - tests: TestResponseDrivenBudget + TestRobotsAppliesToAllSources; both verified red against the unfixed code (no false pass). Suite 19 -> 21 green. - chore: gitignore LOCK*.txt. Reviewed by a fresh reviewer subagent; Fable 5 was flagged on this security tool so Opus 4.8 took over (module-review loop run 9). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011c2rFLwVHQVshAQJVNzWDS
1 parent ac0cbec commit bc9d022

5 files changed

Lines changed: 171 additions & 2 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ config.local.json
4444
.DS_Store
4545
Thumbs.db
4646
desktop.ini
47+
48+
# Multi-Agent Lock-System (lokal, nie committen)
49+
LOCK*.txt

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## 2026-07-04
4+
5+
- Security/ethics: robots.txt is now honored for endpoints that came from an
6+
OpenAPI/Swagger spec, too. Previously such endpoints were probed in the
7+
method-testing and schema-extraction phases (including POST/PUT/DELETE with
8+
`--test-all-methods`) without any robots.txt check.
9+
- Rate limiting: the response-driven (HATEOAS) discovery phase now respects the
10+
`max_requests` budget. Previously it followed unbounded links per round and
11+
could exceed the configured request limit.
12+
- Added regression tests for both (`test_smoke.py`): request-budget cap in
13+
response-driven discovery and robots.txt enforcement across discovery sources.
14+
315
## 2026-06-12
416

517
- Updated the smoke-test workflow to `actions/checkout@v6` and `actions/setup-python@v6`.

discovery/orchestrator.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@ def on_endpoint_found(path, resp):
180180
for ep in endpoints:
181181
if self._check_limits(max_requests):
182182
break
183+
# robots.txt gilt auch fuer Endpoints aus einer OpenAPI-Spec
184+
# (Phase 1 traegt sie ungeprueft ein) -- sonst wuerden hier
185+
# sogar POST/PUT/DELETE an gesperrte Pfade gehen
186+
if robots and not robots.is_allowed(ep["path"]):
187+
continue
183188
method_info = test_methods(
184189
self.client, base_url, ep["path"],
185190
skip_destructive=skip_destructive
@@ -207,6 +212,8 @@ def on_endpoint_found(path, resp):
207212
methods = json.loads(ep.get("methods_json", "[]"))
208213
if "GET" not in methods:
209214
continue
215+
if robots and not robots.is_allowed(ep["path"]):
216+
continue
210217
url = f"{base_url}{ep['path']}"
211218
resp = self.client.get(url)
212219
if resp.ok and resp.body:
@@ -238,7 +245,7 @@ def on_endpoint_found(path, resp):
238245
self.client, base_url, self.db, service_id,
239246
robots_checker=robots, known_paths=known_paths,
240247
max_depth=self.config.get("max_depth", 2),
241-
callback=on_endpoint_found
248+
callback=on_endpoint_found, max_requests=max_requests
242249
)
243250
self._process_results(service_id, results, "response_driven")
244251
print(f" {len(results)} neue Endpoints entdeckt")

discovery/response_driven.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
def discover_from_responses(client, base_url, db, service_id,
1111
robots_checker=None, known_paths=None,
12-
max_depth=2, callback=None):
12+
max_depth=2, callback=None, max_requests=None):
1313
"""Entdeckt neue Endpoints durch Link-Following.
1414
1515
Liest bestehende Responses aus der DB, extrahiert Links,
@@ -24,6 +24,9 @@ def discover_from_responses(client, base_url, db, service_id,
2424
known_paths: Set bekannter Pfade
2525
max_depth: Maximale Rekursionstiefe
2626
callback: Funktion(path, response)
27+
max_requests: Maximale Gesamtzahl Requests (Client-Zaehler); die
28+
HATEOAS-Runden folgen sonst unbegrenzt vielen Links und
29+
sprengen das Budget der anderen Strategien.
2730
2831
Returns:
2932
list: [(path, response), ...]
@@ -33,6 +36,8 @@ def discover_from_responses(client, base_url, db, service_id,
3336
all_results = []
3437

3538
for depth in range(max_depth):
39+
if max_requests and client.request_count >= max_requests:
40+
break
3641
new_links = set()
3742

3843
# Bekannte Endpoints durchgehen und Links aus deren Responses sammeln
@@ -60,6 +65,8 @@ def discover_from_responses(client, base_url, db, service_id,
6065
# Neue Links testen
6166
round_results = []
6267
for path in sorted(new_links):
68+
if max_requests and client.request_count >= max_requests:
69+
break
6370
if robots_checker and not robots_checker.is_allowed(path):
6471
continue
6572

test_smoke.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,146 @@ def test_404_means_everything_allowed(self, monkeypatch):
352352
assert checker.is_allowed("/api") is True
353353

354354

355+
class TestResponseDrivenBudget:
356+
"""Regressionstest: HATEOAS-Discovery (Phase 6) respektiert max_requests.
357+
358+
Vorher hatte discover_from_responses keinen max_requests-Parameter und
359+
folgte pro Runde beliebig vielen Links -- das Budget der anderen
360+
Strategien wurde umgangen.
361+
"""
362+
363+
def _ensure_package_importable(self):
364+
parent_dir = str(API_PROBER_DIR.parent)
365+
if parent_dir not in sys.path:
366+
sys.path.insert(0, parent_dir)
367+
368+
def test_stops_at_max_requests(self, monkeypatch):
369+
self._ensure_package_importable()
370+
from ApiProber.discovery import response_driven
371+
372+
# Jede Response liefert viele frische Links
373+
monkeypatch.setattr(
374+
response_driven, "extract_links_from_json",
375+
lambda data, base: [f"/item/{i}" for i in range(50)]
376+
)
377+
378+
class FakeResp:
379+
status_code = 200
380+
381+
class FakeClient:
382+
def __init__(self):
383+
self.request_count = 0
384+
385+
def get(self, url):
386+
self.request_count += 1
387+
return FakeResp()
388+
389+
class FakeDB:
390+
def get_endpoints(self, sid):
391+
return [{"id": 1, "path": "/seed"}]
392+
393+
def get_responses(self, eid):
394+
return [{"body_sample": '{"any": "thing"}'}]
395+
396+
client = FakeClient()
397+
response_driven.discover_from_responses(
398+
client, "https://example.invalid", FakeDB(), 1,
399+
robots_checker=None, known_paths=set(),
400+
max_depth=5, callback=None, max_requests=3
401+
)
402+
assert client.request_count <= 3, \
403+
f"HATEOAS-Discovery sprengte das Budget: {client.request_count} > 3"
404+
405+
406+
class TestRobotsAppliesToAllSources:
407+
"""Regressionstest: robots.txt gilt auch fuer Endpoints aus einer
408+
OpenAPI-Spec.
409+
410+
Vorher trug Phase 1 Spec-Endpoints ungeprueft ein; Phase 4 (Method-
411+
Testing, inkl. POST/PUT/DELETE) und Phase 5 (Schema-Extraktion) fragten
412+
sie dann ohne robots.txt-Pruefung an.
413+
"""
414+
415+
def _ensure_package_importable(self):
416+
parent_dir = str(API_PROBER_DIR.parent)
417+
if parent_dir not in sys.path:
418+
sys.path.insert(0, parent_dir)
419+
420+
def test_phase4_5_skip_robots_disallowed_paths(self, tmp_path, monkeypatch):
421+
self._ensure_package_importable()
422+
from copy import deepcopy
423+
from ApiProber.core.config import DEFAULT_CONFIG
424+
from ApiProber.discovery import orchestrator as orch_mod
425+
426+
class FakeRobots:
427+
crawl_delay = None
428+
unavailable_status = None
429+
430+
def __init__(self, base_url, ua=""):
431+
pass
432+
433+
def load(self):
434+
return True, "User-agent: *\nDisallow: /private\n"
435+
436+
def is_allowed(self, path):
437+
return not path.startswith("/private")
438+
439+
monkeypatch.setattr(orch_mod, "RobotsChecker", FakeRobots)
440+
441+
requested = []
442+
443+
class FakeResp:
444+
def __init__(self):
445+
self.status_code = 200
446+
self.ok = True
447+
self.headers = {}
448+
self.content_type = "application/json"
449+
self.body = '{"id": 1}'
450+
self.error = ""
451+
self.elapsed_ms = 1
452+
self.method = "GET"
453+
454+
class FakeClient:
455+
def __init__(self):
456+
self.request_count = 0
457+
self.delay_ms = 0
458+
459+
def get(self, url):
460+
requested.append(url)
461+
self.request_count += 1
462+
return FakeResp()
463+
464+
def head(self, url):
465+
requested.append(url)
466+
self.request_count += 1
467+
return FakeResp()
468+
469+
def request(self, url, method="GET"):
470+
requested.append(url)
471+
self.request_count += 1
472+
return FakeResp()
473+
474+
config = deepcopy(DEFAULT_CONFIG)
475+
config["db_path"] = str(tmp_path / "test.db")
476+
config["strategies"] = [] # Phase 1-3 + 6 aus -- nur Phase 4/5 testen
477+
config["delay_ms"] = 0
478+
479+
orch = orch_mod.ProbeOrchestrator(config)
480+
orch.client = FakeClient()
481+
482+
base = "https://example.invalid"
483+
sid = orch.db.upsert_service("example", base)
484+
orch.db.upsert_endpoint(sid, "/public", methods=["GET"], discovered_by="openapi")
485+
orch.db.upsert_endpoint(sid, "/private", methods=["GET"], discovered_by="openapi")
486+
487+
orch.probe(base)
488+
489+
assert not any("/private" in u for u in requested), \
490+
f"robots-gesperrter Spec-Pfad wurde angefragt: {requested}"
491+
assert any("/public" in u for u in requested), \
492+
"erlaubter Pfad haette angefragt werden muessen"
493+
494+
355495
class TestApiProberExport:
356496
"""Test: Export-Funktionen (konzeptionell)."""
357497

0 commit comments

Comments
 (0)