Skip to content

Commit bae188b

Browse files
committed
feat: Phase 2 - complete 12 security agents
New agents implemented: - web.auth: login detection, CSRF analysis, JWT detection, weak cred testing - web.ssrf: SSRF parameter detection, internal URL probing - web.lfi: path traversal and file inclusion testing - web.js: JavaScript analysis for secrets, API routes - recon.subdomain: crt.sh lookup + DNS brute-force + TLD scanning - recon.whois: domain registration information gathering - recon.cert: certificate transparency log analysis - api.fuzz: HTTP method fuzzing, path discovery, parameter injection - api.auth: auth bypass testing, token manipulation - api.rate: rate limiting abuse testing - infra.port: TCP port scanning (25 common ports) - infra.service: service fingerprinting from HTTP banners Enhanced existing agents: - recon.dns: MX/NS/TXT/CNAME/SOA record support - verification: finding deduplication, low-quality filter, logging All 95 tests pass, mypy: 0 errors
1 parent 86da4db commit bae188b

16 files changed

Lines changed: 1259 additions & 17 deletions

File tree

bugfinder/agents/api/auth.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
from urllib.parse import urljoin
5+
6+
from bugfinder.agents.base import AgentResult, BaseAgent
7+
8+
API_AUTH_PATHS = [
9+
"/api/users", "/api/admin", "/api/config", "/api/settings",
10+
"/api/internal", "/api/debug", "/api/health", "/api/metrics",
11+
"/api/backup", "/api/logs", "/api/keys", "/api/secrets",
12+
"/api/tokens", "/api/credentials",
13+
]
14+
15+
16+
class APIAuthAgent(BaseAgent):
17+
name = "api.auth"
18+
description = "API authentication and authorization testing"
19+
20+
async def execute(self) -> AgentResult:
21+
base_url = self.context.target
22+
if not base_url.startswith("http"):
23+
base_url = f"https://{base_url}"
24+
base_url = base_url.rstrip("/")
25+
26+
findings: list[dict[str, Any]] = []
27+
assets: list[dict[str, Any]] = []
28+
29+
no_auth_req = await self._http_get(base_url)
30+
has_auth = "authorization" in no_auth_req.headers.get("www-authenticate", "").lower()
31+
32+
for path in API_AUTH_PATHS:
33+
url = urljoin(base_url, path)
34+
try:
35+
resp_noauth = await self._http_get(url)
36+
status_noauth = resp_noauth.status_code
37+
assets.append({
38+
"id": f"api-auth-{path.replace('/', '_')}",
39+
"name": url,
40+
"asset_type": "api_endpoint",
41+
"value": f"HTTP {status_noauth} (no auth)",
42+
"properties": {"path": path, "status_noauth": status_noauth},
43+
})
44+
if status_noauth == 200:
45+
findings.append({
46+
"title": f"Unauthenticated Access to {path}",
47+
"description": f"Endpoint {url} returns 200 without authentication",
48+
"severity": "high",
49+
"confidence": "verified",
50+
"category": "api_security",
51+
"evidence": {"url": url, "path": path, "status_noauth": status_noauth},
52+
"remediation": "Implement proper authentication and authorization for all API endpoints.",
53+
})
54+
elif status_noauth in (401, 403):
55+
findings.append({
56+
"title": f"Protected Endpoint: {path}",
57+
"description": f"Endpoint {url} requires authentication ({status_noauth})",
58+
"severity": "info",
59+
"confidence": "verified",
60+
"category": "api_security",
61+
"evidence": {"url": url, "path": path, "status": status_noauth},
62+
})
63+
token_bypass = await self._test_token_bypass(url)
64+
if token_bypass:
65+
findings.extend(token_bypass)
66+
except Exception:
67+
continue
68+
69+
for i, f in enumerate(findings):
70+
f["id"] = f"api-auth-{i}"
71+
72+
summary = f"Tested {len(API_AUTH_PATHS)} protected endpoints, {len(findings)} findings"
73+
return AgentResult(
74+
agent_name=self.name,
75+
status="completed",
76+
findings=findings,
77+
assets=assets,
78+
summary=summary,
79+
)
80+
81+
async def _test_token_bypass(self, url: str) -> list[dict[str, Any]]:
82+
results: list[dict[str, Any]] = []
83+
bypass_tokens = [
84+
("Authorization", "Bearer invalid"),
85+
("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.dGVzdA.test"),
86+
("X-API-Key", "test"),
87+
("X-Auth-Token", "admin"),
88+
("Cookie", "session=admin"),
89+
("Authorization", "Basic YWRtaW46YWRtaW4="),
90+
]
91+
for header, value in bypass_tokens:
92+
try:
93+
resp = await self._http_get(url, headers={header: value})
94+
if resp.status_code == 200:
95+
results.append({
96+
"title": f"Auth Bypass with {header}: {value[:20]}...",
97+
"description": f"Endpoint {url} returns 200 with {header} bypass attempt",
98+
"severity": "critical",
99+
"confidence": "needs_review",
100+
"category": "api_security",
101+
"evidence": {"url": url, "header": header, "value_prefix": value[:20]},
102+
"remediation": "Validate all authentication tokens properly. Do not trust user-supplied headers.",
103+
})
104+
break
105+
except Exception:
106+
continue
107+
return results

bugfinder/agents/api/fuzz.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
from urllib.parse import urljoin
5+
6+
from bugfinder.agents.base import AgentResult, BaseAgent
7+
8+
FUZZ_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
9+
FUZZ_PATHS = [
10+
"/admin", "/debug", "/test", "/backup", "/config", "/health",
11+
"/status", "/metrics", "/info", "/internal", "/private",
12+
"/swagger.json", "/openapi.json", "/api-docs", "/docs",
13+
"/graphql", "/api/graphql", "/v1/graphql",
14+
]
15+
16+
FUZZ_PARAMS = ["debug=true", "test=1", "admin=true", "config=true"]
17+
18+
19+
class APIFuzzAgent(BaseAgent):
20+
name = "api.fuzz"
21+
description = "API endpoint fuzzing and method discovery"
22+
23+
async def execute(self) -> AgentResult:
24+
base_url = self.context.target
25+
if not base_url.startswith("http"):
26+
base_url = f"https://{base_url}"
27+
base_url = base_url.rstrip("/")
28+
29+
findings: list[dict[str, Any]] = []
30+
assets: list[dict[str, Any]] = []
31+
32+
for path in FUZZ_PATHS:
33+
url = urljoin(base_url, path)
34+
for method in FUZZ_METHODS:
35+
try:
36+
resp = await self._http_request(method, url)
37+
if resp.status_code < 500:
38+
assets.append({
39+
"id": f"fuzz-{method}-{path.replace('/', '_')}",
40+
"name": f"{method} {url}",
41+
"asset_type": "api_route",
42+
"value": f"HTTP {resp.status_code}",
43+
"properties": {"method": method, "path": path, "status": resp.status_code},
44+
})
45+
if resp.status_code == 200 and method in ("PUT", "PATCH", "DELETE"):
46+
findings.append({
47+
"title": f"Unprotected {method} Endpoint: {path}",
48+
"description": f"Endpoint {url} responds with 200 to {method} requests",
49+
"severity": "medium",
50+
"confidence": "needs_review",
51+
"category": "api_security",
52+
"evidence": {"url": url, "method": method, "status": resp.status_code},
53+
})
54+
if resp.status_code in (401, 403) and method in ("PUT", "PATCH", "DELETE"):
55+
findings.append({
56+
"title": f"Authenticated {method} Endpoint: {path}",
57+
"description": f"Endpoint {url} requires auth for {method} ({resp.status_code})",
58+
"severity": "info",
59+
"confidence": "verified",
60+
"category": "api_security",
61+
"evidence": {"url": url, "method": method, "status": resp.status_code},
62+
})
63+
except Exception:
64+
continue
65+
66+
for param in FUZZ_PARAMS:
67+
url = f"{base_url}?{param}"
68+
try:
69+
resp = await self._http_get(url)
70+
if resp.status_code == 200 and resp.status_code != 404:
71+
findings.append({
72+
"title": f"Parameter Injection: {param}",
73+
"description": f"Endpoint accepts parameter injection: {param}",
74+
"severity": "low",
75+
"confidence": "needs_review",
76+
"category": "api_security",
77+
"evidence": {"url": url, "param": param, "status": resp.status_code},
78+
})
79+
except Exception:
80+
continue
81+
82+
for i, f in enumerate(findings):
83+
f["id"] = f"api-fuzz-{i}"
84+
85+
summary = f"Tested {len(FUZZ_PATHS) * len(FUZZ_METHODS)} endpoints, {len(findings)} findings"
86+
return AgentResult(
87+
agent_name=self.name,
88+
status="completed",
89+
findings=findings,
90+
assets=assets,
91+
summary=summary,
92+
)

bugfinder/agents/api/rate.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from typing import Any
5+
6+
from bugfinder.agents.base import AgentResult, BaseAgent
7+
8+
9+
class APIRateAgent(BaseAgent):
10+
name = "api.rate"
11+
description = "API rate limiting and abuse control testing"
12+
13+
async def execute(self) -> AgentResult:
14+
base_url = self.context.target
15+
if not base_url.startswith("http"):
16+
base_url = f"https://{base_url}"
17+
18+
findings: list[dict[str, Any]] = []
19+
20+
rate_limited = await self._test_rate_limiting(base_url)
21+
if rate_limited is False:
22+
findings.append({
23+
"title": "No Rate Limiting Detected",
24+
"description": f"Endpoint {base_url} does not rate-limit requests (100 rapid requests all returned 200)",
25+
"severity": "medium",
26+
"confidence": "verified",
27+
"category": "api_security",
28+
"cwe_id": "CWE-770",
29+
"evidence": {"url": base_url, "rapid_requests": 100, "blocked": rate_limited},
30+
"remediation": "Implement rate limiting to prevent abuse and DoS attacks.",
31+
})
32+
elif rate_limited:
33+
findings.append({
34+
"title": "Rate Limiting Detected",
35+
"description": f"Endpoint {base_url} rate-limits requests after rapid calls",
36+
"severity": "info",
37+
"confidence": "verified",
38+
"category": "api_security",
39+
"evidence": {"url": base_url, "rate_limited": True},
40+
})
41+
42+
for i, f in enumerate(findings):
43+
f["id"] = f"api-rate-{i}"
44+
45+
has_limit = "rate_limited" if rate_limited else "no_limit"
46+
summary = f"Rate limit test: {has_limit.replace('_', ' ')}"
47+
return AgentResult(
48+
agent_name=self.name,
49+
status="completed",
50+
findings=findings,
51+
summary=summary,
52+
)
53+
54+
async def _test_rate_limiting(self, url: str) -> bool | None:
55+
import httpx
56+
57+
count_429 = 0
58+
tasks = []
59+
for _ in range(100):
60+
tasks.append(self._http_get(url))
61+
results = await asyncio.gather(*tasks, return_exceptions=True)
62+
for r in results:
63+
if isinstance(r, (Exception, BaseException)):
64+
continue
65+
if isinstance(r, httpx.Response) and r.status_code == 429:
66+
count_429 += 1
67+
if count_429 > 0:
68+
return True
69+
return False

bugfinder/agents/infra/port.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from typing import Any
5+
6+
from bugfinder.agents.base import AgentResult, BaseAgent
7+
8+
COMMON_PORTS = [
9+
(21, "FTP"), (22, "SSH"), (23, "Telnet"), (25, "SMTP"),
10+
(53, "DNS"), (80, "HTTP"), (110, "POP3"), (143, "IMAP"),
11+
(443, "HTTPS"), (445, "SMB"), (993, "IMAPS"), (995, "POP3S"),
12+
(1433, "MSSQL"), (1521, "Oracle"), (2049, "NFS"),
13+
(2375, "Docker"), (2376, "Docker TLS"), (3306, "MySQL"),
14+
(3389, "RDP"), (5432, "PostgreSQL"), (5900, "VNC"),
15+
(6379, "Redis"), (6443, "Kubernetes"), (8080, "HTTP-Alt"),
16+
(8443, "HTTPS-Alt"), (27017, "MongoDB"),
17+
]
18+
19+
20+
class PortScanAgent(BaseAgent):
21+
name = "infra.port"
22+
description = "TCP port scanning"
23+
24+
async def execute(self) -> AgentResult:
25+
import socket
26+
27+
hostname = self.context.target
28+
from urllib.parse import urlparse
29+
parsed = urlparse(hostname)
30+
hostname = parsed.hostname or hostname
31+
hostname = hostname.split("://")[-1].split("/")[0]
32+
33+
findings: list[dict[str, Any]] = []
34+
assets: list[dict[str, Any]] = []
35+
open_ports: list[tuple[int, str]] = []
36+
37+
async def check_port(port: int, service: str) -> tuple[int, str, bool]:
38+
try:
39+
_, writer = await asyncio.wait_for(
40+
asyncio.open_connection(hostname, port), timeout=3
41+
)
42+
writer.close()
43+
await writer.wait_closed()
44+
return (port, service, True)
45+
except Exception:
46+
return (port, service, False)
47+
48+
tasks = [check_port(port, svc) for port, svc in COMMON_PORTS]
49+
results = await asyncio.gather(*tasks)
50+
51+
for port, service, is_open in results:
52+
if is_open:
53+
open_ports.append((port, service))
54+
assets.append({
55+
"id": f"port-{port}",
56+
"name": f"{hostname}:{port}",
57+
"asset_type": "open_port",
58+
"value": f"{service} (TCP/{port})",
59+
"properties": {"port": port, "service": service, "protocol": "tcp"},
60+
})
61+
62+
if open_ports:
63+
port_list = ", ".join(f"{p}/{s}" for p, s in open_ports[:10])
64+
findings.append({
65+
"title": f"Open Ports Found: {len(open_ports)}",
66+
"description": f"Open ports on {hostname}: {port_list}",
67+
"severity": "medium" if any(p in (22, 23, 3389, 3306, 5432, 6379, 27017) for p, _ in open_ports) else "low",
68+
"confidence": "verified",
69+
"category": "reconnaissance",
70+
"evidence": {"hostname": hostname, "open_ports": [{"port": p, "service": s} for p, s in open_ports]},
71+
"remediation": "Close unnecessary ports and restrict access with firewalls.",
72+
})
73+
74+
for i, f in enumerate(findings):
75+
f["id"] = f"port-{i}"
76+
77+
summary = f"Scanned {len(COMMON_PORTS)} ports, {len(open_ports)} open"
78+
return AgentResult(
79+
agent_name=self.name,
80+
status="completed",
81+
findings=findings,
82+
assets=assets,
83+
summary=summary,
84+
)

0 commit comments

Comments
 (0)