apifuzz 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apifuzz/__init__.py +13 -0
- apifuzz/__main__.py +5 -0
- apifuzz/checks/__init__.py +9 -0
- apifuzz/checks/auth_required.py +62 -0
- apifuzz/checks/base.py +152 -0
- apifuzz/checks/bola.py +337 -0
- apifuzz/checks/injection.py +289 -0
- apifuzz/checks/mass_assignment.py +283 -0
- apifuzz/checks/rate_limit.py +168 -0
- apifuzz/cli.py +425 -0
- apifuzz/docker_verify.py +168 -0
- apifuzz/endpoint_model.py +180 -0
- apifuzz/errors.py +37 -0
- apifuzz/finding.py +55 -0
- apifuzz/http_client.py +221 -0
- apifuzz/report.py +261 -0
- apifuzz/scanner.py +97 -0
- apifuzz/session_manager.py +146 -0
- apifuzz/settings.py +270 -0
- apifuzz/spec_loader.py +138 -0
- apifuzz-0.2.0.dist-info/METADATA +350 -0
- apifuzz-0.2.0.dist-info/RECORD +25 -0
- apifuzz-0.2.0.dist-info/WHEEL +4 -0
- apifuzz-0.2.0.dist-info/entry_points.txt +2 -0
- apifuzz-0.2.0.dist-info/licenses/LICENSE +21 -0
apifuzz/docker_verify.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Confirm that a scan target is a running local Docker container (the --require-docker mode).
|
|
2
|
+
|
|
3
|
+
Why: a lab target in Docker is the safest thing to point a scanner at. With --require-docker, a
|
|
4
|
+
typo in --base-url (or a .env left over from a real environment) can't send scan traffic anywhere
|
|
5
|
+
except a container running on this machine.
|
|
6
|
+
|
|
7
|
+
How: ask the Docker CLI for the running containers (`docker ps` + `docker inspect`) and check that
|
|
8
|
+
the target's host:port is one they publish. Two kinds of address count:
|
|
9
|
+
|
|
10
|
+
* a loopback address written literally (localhost, 127.0.0.1, ::1) on a port a container
|
|
11
|
+
publishes on all interfaces or on loopback; or
|
|
12
|
+
* a container's own network IP on a port that container exposes (works on Linux bridge networks).
|
|
13
|
+
|
|
14
|
+
Hostnames are never resolved through DNS. Only those literal names count, so a name that happens
|
|
15
|
+
to resolve to 127.0.0.1 can't slip past the check.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from urllib.parse import urlsplit
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
from apifuzz.errors import DockerVerifyError
|
|
26
|
+
from apifuzz.http_client import DEFAULT_PORTS
|
|
27
|
+
|
|
28
|
+
# Hosts that mean "this machine", accepted only as written (lower case, brackets removed).
|
|
29
|
+
_LOOPBACK = {"localhost", "127.0.0.1", "::1"}
|
|
30
|
+
# A port published on one of these host IPs is reachable through loopback.
|
|
31
|
+
_REACHABLE_VIA_LOOPBACK = {"", "0.0.0.0", "::", "127.0.0.1", "::1"}
|
|
32
|
+
|
|
33
|
+
# Runs a docker command and returns its stdout. Tests pass a fake one instead.
|
|
34
|
+
Runner = Callable[[list[str]], str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Container(BaseModel):
|
|
38
|
+
"""The parts of a running container that matter for matching a target."""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
name: str # without docker's leading "/"
|
|
42
|
+
image: str
|
|
43
|
+
# Published ports: (host_ip, host_port, container_port) e.g. ("0.0.0.0", 8000, "8000/tcp").
|
|
44
|
+
published: list[tuple[str, int, str]] = []
|
|
45
|
+
ips: list[str] = [] # the container's own addresses on its networks
|
|
46
|
+
exposed: list[int] = [] # TCP ports the container exposes (reachable on its own IPs)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DockerMatch(BaseModel):
|
|
50
|
+
"""A target address and the container that serves it."""
|
|
51
|
+
|
|
52
|
+
host: str
|
|
53
|
+
port: int
|
|
54
|
+
container: Container
|
|
55
|
+
how: str # a human-readable explanation, e.g. "publishes 8000/tcp on 0.0.0.0:8000"
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def entry(self) -> str:
|
|
59
|
+
"""The exact allowlist entry for this match, e.g. "localhost:8000" or "[::1]:8000"."""
|
|
60
|
+
host = f"[{self.host}]" if ":" in self.host else self.host
|
|
61
|
+
return f"{host}:{self.port}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _run_docker(args: list[str]) -> str:
|
|
65
|
+
try:
|
|
66
|
+
done = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=20)
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
raise DockerVerifyError(
|
|
69
|
+
"can't verify the target: the docker command was not found. Install Docker "
|
|
70
|
+
"(or, for a scan, drop --require-docker)."
|
|
71
|
+
) from None
|
|
72
|
+
except subprocess.TimeoutExpired:
|
|
73
|
+
raise DockerVerifyError("docker did not answer within 20 seconds; is the Docker daemon running?") from None
|
|
74
|
+
if done.returncode != 0:
|
|
75
|
+
detail = (done.stderr or done.stdout).strip().splitlines()
|
|
76
|
+
raise DockerVerifyError(f"docker {args[0]} failed: {detail[0] if detail else 'no output'}")
|
|
77
|
+
return done.stdout
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def running_containers(run: Runner = _run_docker) -> list[Container]:
|
|
81
|
+
"""Every running container, read from `docker ps` and `docker inspect`."""
|
|
82
|
+
ids = run(["ps", "-q", "--no-trunc"]).split()
|
|
83
|
+
if not ids:
|
|
84
|
+
return []
|
|
85
|
+
try:
|
|
86
|
+
details = json.loads(run(["inspect", *ids]))
|
|
87
|
+
except json.JSONDecodeError as exc:
|
|
88
|
+
raise DockerVerifyError(f"could not read docker inspect output: {exc}") from exc
|
|
89
|
+
return [_to_container(d) for d in details if (d.get("State") or {}).get("Running")]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _to_container(d: dict) -> Container:
|
|
93
|
+
net = d.get("NetworkSettings") or {}
|
|
94
|
+
published = []
|
|
95
|
+
for container_port, bindings in (net.get("Ports") or {}).items():
|
|
96
|
+
if not container_port.endswith("/tcp"):
|
|
97
|
+
continue # HTTP runs over TCP; UDP ports can't be a scan target
|
|
98
|
+
for b in bindings or []: # None when the port is exposed but not published
|
|
99
|
+
try:
|
|
100
|
+
published.append((b.get("HostIp") or "", int(b["HostPort"]), container_port))
|
|
101
|
+
except (KeyError, TypeError, ValueError):
|
|
102
|
+
continue # a binding without a usable port number
|
|
103
|
+
exposed = []
|
|
104
|
+
for port in ((d.get("Config") or {}).get("ExposedPorts") or {}):
|
|
105
|
+
number, _, proto = port.partition("/")
|
|
106
|
+
if proto in ("", "tcp") and number.isdigit():
|
|
107
|
+
exposed.append(int(number))
|
|
108
|
+
ips = [n["IPAddress"] for n in (net.get("Networks") or {}).values() if n and n.get("IPAddress")]
|
|
109
|
+
return Container(
|
|
110
|
+
id=str(d.get("Id", ""))[:12],
|
|
111
|
+
name=str(d.get("Name", "")).lstrip("/"),
|
|
112
|
+
image=str((d.get("Config") or {}).get("Image", "")),
|
|
113
|
+
published=published,
|
|
114
|
+
ips=ips,
|
|
115
|
+
exposed=exposed,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _host_port(target: str) -> tuple[str, int]:
|
|
120
|
+
"""Split a URL or "host:port" entry into (host, port), using 80/443 for a URL with no port."""
|
|
121
|
+
parsed = urlsplit(target if "://" in target else f"//{target}")
|
|
122
|
+
try:
|
|
123
|
+
host, port = parsed.hostname, parsed.port
|
|
124
|
+
except ValueError:
|
|
125
|
+
host, port = None, None
|
|
126
|
+
if not host:
|
|
127
|
+
raise DockerVerifyError(f"{target!r} has no usable host")
|
|
128
|
+
if port is None:
|
|
129
|
+
if parsed.scheme not in DEFAULT_PORTS:
|
|
130
|
+
raise DockerVerifyError(
|
|
131
|
+
f"allowlist entry {target!r} has no port; with --require-docker every entry must be host:port"
|
|
132
|
+
)
|
|
133
|
+
port = DEFAULT_PORTS[parsed.scheme]
|
|
134
|
+
return host, port
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def match_target(target: str, containers: list[Container]) -> DockerMatch:
|
|
138
|
+
"""Return the container serving `target` (a URL or host:port), or raise DockerVerifyError."""
|
|
139
|
+
host, port = _host_port(target)
|
|
140
|
+
for c in containers:
|
|
141
|
+
for host_ip, host_port, container_port in c.published:
|
|
142
|
+
if host_port != port:
|
|
143
|
+
continue
|
|
144
|
+
if (host in _LOOPBACK and host_ip in _REACHABLE_VIA_LOOPBACK) or host == host_ip:
|
|
145
|
+
return DockerMatch(
|
|
146
|
+
host=host, port=port, container=c,
|
|
147
|
+
how=f"publishes {container_port} on {host_ip or '0.0.0.0'}:{host_port}",
|
|
148
|
+
)
|
|
149
|
+
if host in c.ips and port in c.exposed:
|
|
150
|
+
return DockerMatch(host=host, port=port, container=c, how=f"has IP {host} and exposes {port}/tcp")
|
|
151
|
+
|
|
152
|
+
if not containers:
|
|
153
|
+
raise DockerVerifyError(f"{target}: no Docker containers are running")
|
|
154
|
+
if host not in _LOOPBACK and not any(host in c.ips for c in containers):
|
|
155
|
+
raise DockerVerifyError(
|
|
156
|
+
f"{target}: host {host!r} is not localhost, 127.0.0.1, ::1 or a running container's IP"
|
|
157
|
+
)
|
|
158
|
+
raise DockerVerifyError(f"{target}: no running container publishes port {port}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def verify_targets(base_url: str, extra_entries: list[str], run: Runner = _run_docker) -> list[DockerMatch]:
|
|
162
|
+
"""Check the base URL and every extra allowlist entry. All must match a running container.
|
|
163
|
+
|
|
164
|
+
Returns one match per target (base URL first), so the caller can build an allowlist of
|
|
165
|
+
exact host:port entries and show which container each one belongs to.
|
|
166
|
+
"""
|
|
167
|
+
containers = running_containers(run)
|
|
168
|
+
return [match_target(t, containers) for t in [base_url, *extra_entries]]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Flatten a resolved OpenAPI/Swagger document into a list of Endpoint objects.
|
|
2
|
+
|
|
3
|
+
Input: the spec as a Python dict, with every $ref already filled in (spec_loader does that).
|
|
4
|
+
Output: one Endpoint per path + method pair, e.g. GET /orders/{id}.
|
|
5
|
+
The security checks in later phases read these Endpoints to decide which requests to send.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any # type hint meaning "any value" (a spec can contain anything)
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel # gives classes typed fields, validation, and free JSON output
|
|
11
|
+
|
|
12
|
+
# The only keys in a path item that are operations. Other keys live next to them
|
|
13
|
+
# ("parameters", "summary", "x-something") and must not be mistaken for HTTP methods.
|
|
14
|
+
HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Parameter(BaseModel):
|
|
18
|
+
"""A value sent outside the request body: in the URL path, query string, a header or a cookie."""
|
|
19
|
+
|
|
20
|
+
name: str # e.g. "id" or "q"
|
|
21
|
+
location: str # "path", "query", "header" or "cookie" (copied from the spec's "in" field)
|
|
22
|
+
required: bool = False # class default only; _to_parameter always passes the real value
|
|
23
|
+
type: str | None = None # e.g. "integer"; None when the spec doesn't say
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RequestField(BaseModel):
|
|
27
|
+
"""One top-level field of a request body (a JSON property or a form field)."""
|
|
28
|
+
|
|
29
|
+
name: str # e.g. "username"
|
|
30
|
+
required: bool = False # True if the body schema lists this name under "required"
|
|
31
|
+
type: str | None = None # e.g. "string", "array[integer]", "object"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Endpoint(BaseModel):
|
|
35
|
+
"""Everything the scanner needs to know about one operation, e.g. PATCH /users/{id}."""
|
|
36
|
+
|
|
37
|
+
path: str # the URL template, placeholders included: "/orders/{id}"
|
|
38
|
+
method: str # upper case: GET, POST...
|
|
39
|
+
operation_id: str | None = None # the spec's optional unique name for the operation
|
|
40
|
+
summary: str | None = None # the spec's optional one-line description
|
|
41
|
+
parameters: list[Parameter] = [] # path/query/header/cookie values (pydantic copies this default safely)
|
|
42
|
+
request_fields: list[RequestField] = [] # fields of the request body, if it has one
|
|
43
|
+
requires_auth: bool = False # True if a login token must be sent
|
|
44
|
+
tags: list[str] = [] # the spec's grouping labels, e.g. ["orders"]; used by --tag
|
|
45
|
+
deprecated: bool = False # the spec marks this operation as deprecated
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_endpoints(spec: dict[str, Any]) -> list[Endpoint]:
|
|
49
|
+
"""List every operation in `spec`, in the order the spec declares them.
|
|
50
|
+
|
|
51
|
+
`spec` must already have its $refs resolved (see spec_loader.load_spec).
|
|
52
|
+
"""
|
|
53
|
+
endpoints: list[Endpoint] = []
|
|
54
|
+
# spec["paths"] maps each URL template to a "path item": {"/orders/{id}": {"get": {...}}}.
|
|
55
|
+
# `or {}` covers a spec where "paths" is present but empty (None).
|
|
56
|
+
for path, path_item in (spec.get("paths") or {}).items():
|
|
57
|
+
if not isinstance(path_item, dict): # skip anything malformed instead of crashing
|
|
58
|
+
continue
|
|
59
|
+
# Parameters declared on the path itself apply to every method under that path.
|
|
60
|
+
shared_params = path_item.get("parameters") or []
|
|
61
|
+
for method in HTTP_METHODS:
|
|
62
|
+
operation = path_item.get(method) # None if this path doesn't support this method
|
|
63
|
+
if not isinstance(operation, dict):
|
|
64
|
+
continue
|
|
65
|
+
# Combine the path's shared parameters with the operation's own ones.
|
|
66
|
+
parameters = _merge_parameters(shared_params, operation.get("parameters") or [])
|
|
67
|
+
endpoints.append(
|
|
68
|
+
Endpoint(
|
|
69
|
+
path=path,
|
|
70
|
+
method=method.upper(),
|
|
71
|
+
operation_id=operation.get("operationId"),
|
|
72
|
+
summary=operation.get("summary"),
|
|
73
|
+
# "body" and "formData" are Swagger 2's way of describing the request body.
|
|
74
|
+
# They are body content, not URL parameters, so _request_fields handles them.
|
|
75
|
+
parameters=[_to_parameter(p) for p in parameters if p.get("in") not in ("body", "formData")],
|
|
76
|
+
request_fields=_request_fields(operation, parameters),
|
|
77
|
+
requires_auth=_requires_auth(operation, spec),
|
|
78
|
+
tags=[str(t) for t in operation.get("tags") or []],
|
|
79
|
+
deprecated=bool(operation.get("deprecated", False)),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return endpoints
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _merge_parameters(path_level: list[dict], operation_level: list[dict]) -> list[dict]:
|
|
86
|
+
# A parameter is identified by (name, location); the operation's version wins over the path's.
|
|
87
|
+
merged: dict[tuple[str, str], dict] = {}
|
|
88
|
+
# Path-level ones go in first, operation-level ones second. Writing to a dict key that
|
|
89
|
+
# already exists replaces the old value, so the operation's version wins.
|
|
90
|
+
for param in [*path_level, *operation_level]:
|
|
91
|
+
if isinstance(param, dict) and "name" in param: # ignore malformed entries
|
|
92
|
+
merged[(param["name"], param.get("in", ""))] = param
|
|
93
|
+
return list(merged.values())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _to_parameter(param: dict) -> Parameter:
|
|
97
|
+
# OpenAPI 3 nests the type under "schema"; Swagger 2 puts it on the parameter itself.
|
|
98
|
+
# `param.get("schema") or param` handles both: use "schema" if there is one, else the parameter.
|
|
99
|
+
schema = param.get("schema") or param
|
|
100
|
+
location = param["in"]
|
|
101
|
+
return Parameter(
|
|
102
|
+
name=param["name"],
|
|
103
|
+
location=location,
|
|
104
|
+
# Use the spec's "required" value (False if it doesn't say). A path parameter is always
|
|
105
|
+
# required, because the URL can't be built without it.
|
|
106
|
+
required=bool(param.get("required", False)) or location == "path",
|
|
107
|
+
type=_type_label(schema),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _request_fields(operation: dict, parameters: list[dict]) -> list[RequestField]:
|
|
112
|
+
# OpenAPI 3: requestBody.content.<media type>.schema
|
|
113
|
+
content = (operation.get("requestBody") or {}).get("content") or {}
|
|
114
|
+
if content:
|
|
115
|
+
# Prefer the JSON version of the body; otherwise take the first media type listed.
|
|
116
|
+
media = content.get("application/json") or next(iter(content.values()))
|
|
117
|
+
return _schema_fields((media or {}).get("schema") or {})
|
|
118
|
+
|
|
119
|
+
# Swagger 2: an "in: body" parameter carries a schema; "in: formData" parameters are the fields.
|
|
120
|
+
for param in parameters:
|
|
121
|
+
if param.get("in") == "body":
|
|
122
|
+
return _schema_fields(param.get("schema") or {})
|
|
123
|
+
return [
|
|
124
|
+
RequestField(name=p["name"], required=bool(p.get("required", False)), type=_type_label(p))
|
|
125
|
+
for p in parameters
|
|
126
|
+
if p.get("in") == "formData"
|
|
127
|
+
] # empty list when the operation has no body at all (a GET, usually)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _schema_fields(schema: dict) -> list[RequestField]:
|
|
131
|
+
# Turn an object schema into one RequestField per top-level property.
|
|
132
|
+
properties, required = _collect_properties(schema)
|
|
133
|
+
return [
|
|
134
|
+
RequestField(name=name, required=name in required, type=_type_label(sub))
|
|
135
|
+
for name, sub in properties.items()
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _collect_properties(schema: dict) -> tuple[dict[str, dict], set[str]]:
|
|
140
|
+
"""Gather the properties of an object schema, merging allOf branches."""
|
|
141
|
+
properties: dict[str, dict] = {}
|
|
142
|
+
required: set[str] = set(schema.get("required") or []) # a set, so duplicates collapse
|
|
143
|
+
# "allOf" builds a schema out of several pieces that all apply at once (e.g. base fields +
|
|
144
|
+
# extras). This function calls itself on each piece and merges the results.
|
|
145
|
+
for branch in schema.get("allOf") or []:
|
|
146
|
+
sub_properties, sub_required = _collect_properties(branch or {})
|
|
147
|
+
properties.update(sub_properties)
|
|
148
|
+
required |= sub_required # `|=` adds the piece's required names to ours (set union)
|
|
149
|
+
# The schema's own properties go last, so they win over same-named ones from allOf.
|
|
150
|
+
properties.update(schema.get("properties") or {})
|
|
151
|
+
return properties, required
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _type_label(schema: dict) -> str | None:
|
|
155
|
+
# Produce a short, human-readable type like "integer", "array[string]" or "object".
|
|
156
|
+
schema_type = schema.get("type")
|
|
157
|
+
if schema_type == "array":
|
|
158
|
+
# Look inside "items" to say what the array holds.
|
|
159
|
+
item_type = _type_label(schema.get("items") or {})
|
|
160
|
+
return f"array[{item_type}]" if item_type else "array"
|
|
161
|
+
# Specs often leave out "type": "object" when the schema clearly has fields.
|
|
162
|
+
if schema_type is None and ("properties" in schema or "allOf" in schema):
|
|
163
|
+
return "object"
|
|
164
|
+
# OpenAPI 3.1 allows a list like ["string", "null"]. "null" only means "may be empty", so
|
|
165
|
+
# drop it; several real types left are joined, e.g. "string|integer".
|
|
166
|
+
if isinstance(schema_type, list):
|
|
167
|
+
real = [str(t) for t in schema_type if t != "null"]
|
|
168
|
+
return "|".join(real) or None
|
|
169
|
+
return schema_type if isinstance(schema_type, str) else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _requires_auth(operation: dict, spec: dict) -> bool:
|
|
173
|
+
# An operation's own "security" replaces the global one, and "security: []" switches auth off.
|
|
174
|
+
# .get(key, default) only uses the default when the key is ABSENT, so an explicit [] is kept.
|
|
175
|
+
security = operation.get("security", spec.get("security"))
|
|
176
|
+
if not security: # None (never mentioned) or [] (explicitly no auth)
|
|
177
|
+
return False
|
|
178
|
+
# "security" is a list of alternatives; meeting any one of them is enough.
|
|
179
|
+
# An empty requirement object ({}) is an alternative that means "no credentials needed".
|
|
180
|
+
return all(bool(requirement) for requirement in security)
|
apifuzz/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Exceptions raised by apifuzz. The CLI turns any ApifuzzError into a one-line message."""
|
|
2
|
+
|
|
3
|
+
# Why custom exception classes? They let the CLI tell "expected problems" (bad spec, blocked
|
|
4
|
+
# host) apart from real bugs. The CLI catches ApifuzzError and prints a friendly message;
|
|
5
|
+
# any other exception is a bug and is allowed to show a full traceback.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApifuzzError(Exception):
|
|
9
|
+
"""Base class for errors that are safe to show to the user as-is."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Each subclass below inherits from ApifuzzError, so `except ApifuzzError` catches all of them,
|
|
13
|
+
# while code that cares about one specific problem can catch just that subclass.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SpecError(ApifuzzError):
|
|
17
|
+
"""The OpenAPI spec could not be read, parsed, resolved or validated."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AllowlistError(ApifuzzError):
|
|
21
|
+
"""A request was refused because its target is not on the allowlist."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DockerVerifyError(ApifuzzError):
|
|
25
|
+
"""--require-docker is on, and the target couldn't be confirmed as a running Docker container."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RequestError(ApifuzzError):
|
|
29
|
+
"""A request to an allowed target failed at the network level (refused, timed out...)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SessionError(ApifuzzError):
|
|
33
|
+
"""Logging in a test account failed (bad credentials, no login endpoint, no token in the answer)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RequestLimitError(ApifuzzError):
|
|
37
|
+
"""The scan reached --max-requests. It stops there instead of sending more."""
|
apifuzz/finding.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""What a check reports when it finds something: a Finding, plus the severity scale.
|
|
2
|
+
|
|
3
|
+
A Finding is deliberately plain data. A check creates it; the reporter displays it. It carries the
|
|
4
|
+
Exchange(s) that back it up, so a reader can see the exact request and response behind the claim.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib # the finding's fingerprint
|
|
8
|
+
from enum import IntEnum # an ordered set of severities, so findings can be sorted worst-first
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, computed_field, field_serializer
|
|
11
|
+
|
|
12
|
+
from apifuzz.http_client import Exchange
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Severity(IntEnum):
|
|
16
|
+
"""How serious a finding is. IntEnum so `sorted(..., reverse=True)` puts the worst first."""
|
|
17
|
+
|
|
18
|
+
INFO = 0 # not a flaw: something worth noting, or a check that confirmed good behaviour
|
|
19
|
+
LOW = 1
|
|
20
|
+
MEDIUM = 2
|
|
21
|
+
HIGH = 3
|
|
22
|
+
CRITICAL = 4
|
|
23
|
+
|
|
24
|
+
def __str__(self) -> str: # so it prints as "HIGH", not "Severity.HIGH"
|
|
25
|
+
return self.name
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Finding(BaseModel):
|
|
29
|
+
"""One result from one check about one endpoint."""
|
|
30
|
+
|
|
31
|
+
check: str # the id of the check that produced this, e.g. "auth-required"
|
|
32
|
+
owasp: str = "" # the OWASP API Security Top 10 category, e.g. "API1:2023 Broken Object Level Authorization"
|
|
33
|
+
severity: Severity
|
|
34
|
+
title: str # a short headline, e.g. "Endpoint reachable without authentication"
|
|
35
|
+
endpoint: str # which operation, as "METHOD /path", e.g. "GET /orders/{id}"
|
|
36
|
+
detail: str # a sentence or two explaining what was observed and why it matters
|
|
37
|
+
remediation: str = "" # how a developer fixes it, in a sentence or two
|
|
38
|
+
# The request/response pairs that justify the finding. Kept so a report can show evidence and
|
|
39
|
+
# a reader can reproduce it. May be empty for an INFO note that isn't about a specific request.
|
|
40
|
+
evidence: list[Exchange] = []
|
|
41
|
+
# What tells this finding apart from another by the same check on the same endpoint, e.g. the
|
|
42
|
+
# field or parameter name. Empty when a check reports at most one finding per endpoint.
|
|
43
|
+
key: str = ""
|
|
44
|
+
|
|
45
|
+
# A short stable id built from the check, the endpoint and the key. It stays the same from one
|
|
46
|
+
# run to the next, so two reports can be compared and a finding can be marked as accepted.
|
|
47
|
+
@computed_field # included in the JSON output
|
|
48
|
+
@property
|
|
49
|
+
def fingerprint(self) -> str:
|
|
50
|
+
return hashlib.sha256(f"{self.check}|{self.endpoint}|{self.key}".encode()).hexdigest()[:16]
|
|
51
|
+
|
|
52
|
+
# In JSON, write the severity as "HIGH" rather than the number 3, so reports read clearly.
|
|
53
|
+
@field_serializer("severity")
|
|
54
|
+
def _severity_name(self, severity: Severity) -> str:
|
|
55
|
+
return severity.name
|
apifuzz/http_client.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""The only place apifuzz sends requests to a target.
|
|
2
|
+
|
|
3
|
+
Every request is checked against an allowlist before it leaves the process, and the raw request
|
|
4
|
+
and response are recorded so a finding can later show its evidence.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time # used to measure how long each request took
|
|
8
|
+
from collections.abc import Iterable # type hint for "anything you can loop over"
|
|
9
|
+
from types import TracebackType # type hint used by __exit__ (the `with` statement support)
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlparse # splits a URL into scheme, host, port, path...
|
|
12
|
+
|
|
13
|
+
import httpx # the HTTP library that actually sends requests
|
|
14
|
+
from pydantic import BaseModel # typed record classes with free JSON output
|
|
15
|
+
|
|
16
|
+
from apifuzz import __version__
|
|
17
|
+
from apifuzz.errors import AllowlistError, RequestError, RequestLimitError
|
|
18
|
+
|
|
19
|
+
# The only schemes we allow, and the port each one uses when the URL doesn't name a port.
|
|
20
|
+
DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Allowlist:
|
|
24
|
+
"""Hosts the tool may talk to. Entries are `host` (any port) or `host:port`.
|
|
25
|
+
|
|
26
|
+
Matching is exact on the host name: allowing `example.com` does not allow `api.example.com`.
|
|
27
|
+
An empty allowlist permits nothing.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, entries: Iterable[str] = ()) -> None:
|
|
31
|
+
# Stored as (host, port) pairs; port None means "any port on this host".
|
|
32
|
+
# A set makes duplicates disappear and lookups fast.
|
|
33
|
+
self._entries: set[tuple[str, int | None]] = set()
|
|
34
|
+
for entry in entries:
|
|
35
|
+
self._entries.add(self._parse_entry(entry))
|
|
36
|
+
|
|
37
|
+
@staticmethod # doesn't need `self`; it's a helper that belongs with this class
|
|
38
|
+
def _parse_entry(entry: str) -> tuple[str, int | None]:
|
|
39
|
+
text = entry.strip()
|
|
40
|
+
# urlparse only recognises a host when it follows "//". A bare entry like "localhost:8000"
|
|
41
|
+
# would be misread as a path, so we put "//" in front. Full URLs already have "://".
|
|
42
|
+
parsed = urlparse(text if "://" in text else f"//{text}")
|
|
43
|
+
try:
|
|
44
|
+
# .hostname is lower-cased and has [ ] removed from IPv6 addresses.
|
|
45
|
+
# .port raises ValueError for a non-number like "localhost:abc".
|
|
46
|
+
host, port = parsed.hostname, parsed.port
|
|
47
|
+
except ValueError:
|
|
48
|
+
host, port = None, None
|
|
49
|
+
if not host:
|
|
50
|
+
# A bad entry is a configuration mistake: fail loudly rather than allow nothing silently.
|
|
51
|
+
raise ValueError(f"invalid allowlist entry: {entry!r}")
|
|
52
|
+
return host, port
|
|
53
|
+
|
|
54
|
+
def permits(self, url: str) -> bool:
|
|
55
|
+
try:
|
|
56
|
+
parsed = urlparse(url)
|
|
57
|
+
# Using .hostname (not the raw text) defeats tricks like "http://allowed.com@evil.test/":
|
|
58
|
+
# the part before "@" is a username, and the real host is evil.test.
|
|
59
|
+
host, port = parsed.hostname, parsed.port
|
|
60
|
+
except ValueError:
|
|
61
|
+
return False # unparseable URL: refuse
|
|
62
|
+
if parsed.scheme not in DEFAULT_PORTS or not host:
|
|
63
|
+
return False # only http(s) with a real host; no file://, ftp://, etc.
|
|
64
|
+
port = port or DEFAULT_PORTS[parsed.scheme] # no port in the URL -> 80 or 443
|
|
65
|
+
# Allowed if ANY entry has the same host and either no port restriction or the same port.
|
|
66
|
+
return any(host == allowed_host and allowed_port in (None, port) for allowed_host, allowed_port in self._entries)
|
|
67
|
+
|
|
68
|
+
def check(self, url: str) -> None:
|
|
69
|
+
# Same test as permits(), but raises instead of returning False, so callers can't
|
|
70
|
+
# forget to look at the answer.
|
|
71
|
+
if not self.permits(url):
|
|
72
|
+
raise AllowlistError(f"refusing to contact {url}: its host is not on the allowlist")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# The three record classes below are the "evidence" a finding will show in a report.
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RecordedRequest(BaseModel):
|
|
79
|
+
method: str # "GET", "POST"...
|
|
80
|
+
url: str # the full URL as sent, query string included
|
|
81
|
+
headers: dict[str, str] # every header sent, including Authorization
|
|
82
|
+
body: str | None = None # None for requests without a body (a GET, usually)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RecordedResponse(BaseModel):
|
|
86
|
+
status_code: int # 200, 403, 404...
|
|
87
|
+
headers: dict[str, str]
|
|
88
|
+
body: str # the body as text, cut off at the capture limit
|
|
89
|
+
truncated: bool = False # True when the body was longer than the capture limit
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def ok(self) -> bool:
|
|
93
|
+
"""True for a 2xx status: the request succeeded."""
|
|
94
|
+
return 200 <= self.status_code < 300
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Exchange(BaseModel):
|
|
98
|
+
"""One request and the response it got, as sent and received."""
|
|
99
|
+
|
|
100
|
+
request: RecordedRequest
|
|
101
|
+
response: RecordedResponse
|
|
102
|
+
elapsed_ms: float # how long the round trip took (useful later for the rate-limit check)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class HttpClient:
|
|
106
|
+
"""Sends requests to allowlisted hosts and returns them as Exchange records.
|
|
107
|
+
|
|
108
|
+
Redirects are never followed: a redirect could point at a host outside the allowlist, so the
|
|
109
|
+
3xx response is returned as it is. Request headers (including Authorization) are recorded
|
|
110
|
+
unredacted; redaction belongs to the reporter.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
allowlist: Allowlist,
|
|
116
|
+
*, # everything after this must be passed by name, e.g. timeout=5
|
|
117
|
+
timeout: float = 10.0, # seconds before a request is abandoned
|
|
118
|
+
max_body_bytes: int = 64 * 1024, # keep at most 64 KB of each response body
|
|
119
|
+
transport: httpx.BaseTransport | None = None, # tests pass a fake network here
|
|
120
|
+
max_requests: int | None = None, # stop the scan after this many requests (None = no cap)
|
|
121
|
+
delay: float = 0.0, # seconds to wait between requests, to go easy on the target
|
|
122
|
+
) -> None:
|
|
123
|
+
self._allowlist = allowlist
|
|
124
|
+
self._max_body_bytes = max_body_bytes
|
|
125
|
+
self._max_requests = max_requests
|
|
126
|
+
self._delay = delay
|
|
127
|
+
self.requests_sent = 0 # requests that left the process (whether or not they were answered)
|
|
128
|
+
self.responses_received = 0 # requests that got an HTTP answer back
|
|
129
|
+
# One reusable httpx client: it keeps connections open between requests (faster).
|
|
130
|
+
# The User-Agent names the tool, so the target's logs show where the traffic came from.
|
|
131
|
+
self._client = httpx.Client(
|
|
132
|
+
timeout=timeout,
|
|
133
|
+
follow_redirects=False,
|
|
134
|
+
transport=transport,
|
|
135
|
+
headers={"User-Agent": f"apifuzz/{__version__}"},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def request(
|
|
139
|
+
self,
|
|
140
|
+
method: str,
|
|
141
|
+
url: str,
|
|
142
|
+
*,
|
|
143
|
+
headers: dict[str, str] | None = None, # e.g. {"Authorization": "Bearer <token>"}
|
|
144
|
+
params: dict[str, Any] | None = None, # query string values, e.g. {"q": "book"}
|
|
145
|
+
json: Any = None, # a body to send as JSON
|
|
146
|
+
data: dict[str, str] | None = None, # a body to send as form fields (some logins need this)
|
|
147
|
+
) -> Exchange:
|
|
148
|
+
# httpx's `params=` REPLACES a query string already in the URL ("/search?q=a" would lose
|
|
149
|
+
# q=a). Merge instead, so a URL from the spec or the user keeps its own query values.
|
|
150
|
+
full_url = httpx.URL(url).copy_merge_params(params) if params else url
|
|
151
|
+
# Build the request without sending it, so we can inspect the final URL first.
|
|
152
|
+
request = self._client.build_request(method, full_url, headers=headers, json=json, data=data)
|
|
153
|
+
self._allowlist.check(str(request.url)) # before anything is sent
|
|
154
|
+
if self._max_requests is not None and self.requests_sent >= self._max_requests:
|
|
155
|
+
raise RequestLimitError(f"request limit reached ({self._max_requests}); raise it with --max-requests")
|
|
156
|
+
if self._delay and self.requests_sent:
|
|
157
|
+
time.sleep(self._delay)
|
|
158
|
+
self.requests_sent += 1
|
|
159
|
+
|
|
160
|
+
started = time.perf_counter() # a high-precision clock, good for measuring durations
|
|
161
|
+
try:
|
|
162
|
+
# stream=True means "don't download the whole body yet"; we read it ourselves below
|
|
163
|
+
# so we can stop at the size limit instead of loading a huge response into memory.
|
|
164
|
+
response = self._client.send(request, stream=True)
|
|
165
|
+
try:
|
|
166
|
+
body, truncated = self._read_body(response)
|
|
167
|
+
finally:
|
|
168
|
+
response.close() # always release the connection, even if reading failed
|
|
169
|
+
except httpx.HTTPError as exc:
|
|
170
|
+
# Network-level failure (refused, timed out...). Convert it to our own error type.
|
|
171
|
+
# `exc or type(exc).__name__`: some httpx errors have an empty message; fall back to the class name.
|
|
172
|
+
raise RequestError(f"{method.upper()} {request.url}: {exc or type(exc).__name__}") from exc
|
|
173
|
+
elapsed_ms = (time.perf_counter() - started) * 1000 # seconds -> milliseconds
|
|
174
|
+
self.responses_received += 1
|
|
175
|
+
|
|
176
|
+
# Package everything into plain records that a report can show later.
|
|
177
|
+
return Exchange(
|
|
178
|
+
request=RecordedRequest(
|
|
179
|
+
method=request.method,
|
|
180
|
+
url=str(request.url),
|
|
181
|
+
headers=dict(request.headers),
|
|
182
|
+
# request.content is raw bytes; decode to text. errors="replace" swaps any bytes
|
|
183
|
+
# that aren't valid UTF-8 for a placeholder character instead of crashing.
|
|
184
|
+
body=request.content.decode("utf-8", errors="replace") if request.content else None,
|
|
185
|
+
),
|
|
186
|
+
response=RecordedResponse(
|
|
187
|
+
status_code=response.status_code,
|
|
188
|
+
headers=dict(response.headers),
|
|
189
|
+
body=body,
|
|
190
|
+
truncated=truncated,
|
|
191
|
+
),
|
|
192
|
+
elapsed_ms=elapsed_ms,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _read_body(self, response: httpx.Response) -> tuple[str, bool]:
|
|
196
|
+
# Read the body in chunks and stop once we have more than the limit.
|
|
197
|
+
received = bytearray() # a growable byte buffer
|
|
198
|
+
for chunk in response.iter_bytes():
|
|
199
|
+
received += chunk
|
|
200
|
+
if len(received) > self._max_body_bytes:
|
|
201
|
+
break
|
|
202
|
+
# "More than the limit" (not "equal to") means something was actually cut off.
|
|
203
|
+
truncated = len(received) > self._max_body_bytes
|
|
204
|
+
return bytes(received[: self._max_body_bytes]).decode("utf-8", errors="replace"), truncated
|
|
205
|
+
|
|
206
|
+
def close(self) -> None:
|
|
207
|
+
self._client.close() # close the open connections
|
|
208
|
+
|
|
209
|
+
# __enter__ and __exit__ let you write `with HttpClient(...) as client:`.
|
|
210
|
+
# The client is then closed automatically when the `with` block ends, even after an error.
|
|
211
|
+
|
|
212
|
+
def __enter__(self) -> "HttpClient":
|
|
213
|
+
return self
|
|
214
|
+
|
|
215
|
+
def __exit__(
|
|
216
|
+
self,
|
|
217
|
+
exc_type: type[BaseException] | None,
|
|
218
|
+
exc: BaseException | None,
|
|
219
|
+
tb: TracebackType | None,
|
|
220
|
+
) -> None:
|
|
221
|
+
self.close()
|