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.
@@ -0,0 +1,168 @@
1
+ """Check: does an endpoint that invites repeated attempts limit how often it can be called?
2
+
3
+ This is OWASP API4 (Unrestricted Resource Consumption). A login endpoint with no limit lets
4
+ someone try passwords all day; an unlimited search endpoint can be used to run up cost.
5
+
6
+ The check sends a small, fixed number of identical requests (the "burst", 15 by default, never
7
+ more than 50) one after another and watches for any sign of throttling:
8
+
9
+ - an HTTP 429 (Too Many Requests) answer, or
10
+ - a Retry-After, RateLimit-* or X-RateLimit-* header on any answer, or
11
+ - a clear slowdown: the last third of the burst is answered several times slower than the
12
+ first third (some APIs delay repeated attempts instead of refusing them).
13
+
14
+ It stops at the first sign. If the whole burst is answered with none, the endpoint is reported.
15
+ This is detection, not a load test: the requests are sequential and the burst is capped.
16
+
17
+ Which endpoints are tried, and how:
18
+ login-like POST (login, signin, token, auth, session...) HIGH
19
+ Sent with made-up credentials for a user that doesn't exist, so every attempt is a failed
20
+ login. Nothing is created and no real account can be locked out.
21
+ search-like GET (search, find, lookup, suggest...) MEDIUM
22
+ Sent with a harmless sample value for each required query parameter.
23
+
24
+ Endpoints that create data or send messages (sign-up, password reset, "send") are never burst,
25
+ even though they also need rate limits: fifteen sign-ups or reset e-mails are not harmless.
26
+ """
27
+
28
+ import re
29
+ import time
30
+ from collections.abc import Iterator
31
+
32
+ from apifuzz.checks.base import NOT_TESTED, TESTED, Check, ScanContext, fill_path, sample_body
33
+ from apifuzz.endpoint_model import Endpoint
34
+ from apifuzz.finding import Finding, Severity
35
+ from apifuzz.http_client import Exchange
36
+
37
+ MAX_BURST = 50 # hard ceiling, whatever the setting says
38
+ # Words (in a path segment or the operation id) that mark the two kinds of endpoint tried.
39
+ _LOGIN_WORDS = {"login", "signin", "sign-in", "sign_in", "token", "authenticate", "auth", "session", "sessions"}
40
+ _SEARCH_WORDS = {"search", "find", "lookup", "autocomplete", "suggest", "query"}
41
+ # Answers that show the request was handled normally. Anything else (404, 405, 5xx) says the
42
+ # probe itself was wrong or the route is broken, which settles nothing about throttling.
43
+ _HANDLED = {200, 201, 202, 204, 400, 401, 403, 422}
44
+ _PROBE_USER = "apifuzz-rate-limit-probe" # a username that should not exist on the target
45
+
46
+
47
+ def _words(endpoint: Endpoint) -> set[str]:
48
+ # "/api/v1/auth/login" -> {"api", "v1", "auth", "login"}, plus the operation id's words.
49
+ words = {segment.lower() for segment in endpoint.path.split("/") if segment and "{" not in segment}
50
+ if endpoint.operation_id:
51
+ # "searchProducts" -> search, products; "list_orders" -> list, orders
52
+ words |= {w.lower() for w in re.findall(r"[A-Za-z][a-z]+|[A-Z]+(?![a-z])", endpoint.operation_id)}
53
+ return words
54
+
55
+
56
+ def kind_of(endpoint: Endpoint) -> str | None:
57
+ """ "login", "search", or None when the check leaves the endpoint alone."""
58
+ words = _words(endpoint)
59
+ if endpoint.method == "POST" and words & _LOGIN_WORDS:
60
+ return "login"
61
+ if endpoint.method == "GET" and words & _SEARCH_WORDS:
62
+ return "search"
63
+ return None
64
+
65
+
66
+ def _throttle_sign(exchange: Exchange) -> str | None:
67
+ """What in this answer shows rate limiting, e.g. "HTTP 429" or "a RateLimit-Limit header"."""
68
+ if exchange.response.status_code == 429:
69
+ return "HTTP 429"
70
+ for name in exchange.response.headers:
71
+ lower = name.lower()
72
+ if lower == "retry-after" or lower.startswith(("ratelimit", "x-ratelimit", "x-rate-limit")):
73
+ return f"a {name} header"
74
+ return None
75
+
76
+
77
+ def _slowed_down(timings: list[float]) -> bool:
78
+ """True if the last third of the burst took several times longer than the first third.
79
+
80
+ Needs both a big ratio (5x) and a big absolute gap (half a second), so ordinary jitter on a
81
+ fast endpoint is not mistaken for throttling.
82
+ """
83
+ third = len(timings) // 3
84
+ if third < 1:
85
+ return False
86
+ early = sorted(timings[:third])[third // 2] # the median of each third
87
+ late = sorted(timings[-third:])[third // 2]
88
+ return late >= 5 * early and late - early >= 500
89
+
90
+
91
+ class RateLimit(Check):
92
+ id = "rate-limit"
93
+ owasp = "API4:2023 Unrestricted Resource Consumption"
94
+ description = "Login and search endpoints must limit repeated requests (answers 429 or sends rate-limit headers)."
95
+
96
+ def run(self, endpoint: Endpoint, ctx: ScanContext) -> Iterator[Finding]:
97
+ kind = kind_of(endpoint)
98
+ if kind is None:
99
+ return
100
+ headers = None
101
+ if endpoint.requires_auth:
102
+ headers = ctx.auth("a") or ctx.auth("b")
103
+ if not headers:
104
+ ctx.record(self.id, endpoint, NOT_TESTED, "needs a test account")
105
+ return
106
+
107
+ url = ctx.url(fill_path(endpoint))
108
+ body = None
109
+ params = None
110
+ if kind == "login":
111
+ # Made-up credentials: text fields get the probe user name, so each attempt just fails.
112
+ body = sample_body(endpoint, text=_PROBE_USER)
113
+ else:
114
+ params = {p.name: "1" if p.type in ("integer", "number") else "test"
115
+ for p in endpoint.parameters if p.location == "query" and p.required}
116
+
117
+ burst = max(2, min(ctx.settings.burst, MAX_BURST))
118
+ first = last = None
119
+ timings: list[float] = []
120
+ started = time.perf_counter()
121
+ for _ in range(burst):
122
+ last = ctx.client.request(endpoint.method, url, headers=headers, params=params or None, json=body)
123
+ first = first or last
124
+ timings.append(last.elapsed_ms)
125
+ sign = _throttle_sign(last)
126
+ if sign:
127
+ ctx.record(self.id, endpoint, TESTED) # throttled: the endpoint is doing its job
128
+ return
129
+ if last.response.status_code not in _HANDLED:
130
+ ctx.record(
131
+ self.id, endpoint, NOT_TESTED,
132
+ f"answered HTTP {last.response.status_code} to the probe request, which settles nothing",
133
+ )
134
+ return
135
+
136
+ ctx.record(self.id, endpoint, TESTED)
137
+ if _slowed_down(timings):
138
+ return # answers got much slower as the burst went on: the endpoint delays repeats
139
+ seconds = time.perf_counter() - started
140
+ statuses = f"HTTP {first.response.status_code}"
141
+ if kind == "login":
142
+ severity, title = Severity.HIGH, "Login endpoint has no rate limit"
143
+ risk = "Someone can try passwords against it without being slowed down (brute force, credential stuffing)."
144
+ fix = (
145
+ "Limit attempts per account and per client address (for example 5 a minute), answer 429 "
146
+ "with a Retry-After header once the limit is hit, and consider a growing delay or a "
147
+ "temporary lock after repeated failures."
148
+ )
149
+ else:
150
+ severity, title = Severity.MEDIUM, "Search endpoint has no rate limit"
151
+ risk = "Someone can call it in a loop to scrape data or run up cost."
152
+ fix = (
153
+ "Limit requests per client (per token or address), answer 429 with a Retry-After header "
154
+ "once the limit is hit, and cap how much one request can return."
155
+ )
156
+ yield Finding(
157
+ check=self.id,
158
+ owasp=self.owasp,
159
+ severity=severity,
160
+ title=title,
161
+ endpoint=f"{endpoint.method} {endpoint.path}",
162
+ detail=(
163
+ f"{burst} identical requests in a row ({seconds:.1f} s) were all answered ({statuses} first, "
164
+ f"HTTP {last.response.status_code} last) with no 429 and no rate-limit header. {risk}"
165
+ ),
166
+ remediation=fix,
167
+ evidence=[first, last], # the first and the last of the burst; the ones between look the same
168
+ )
apifuzz/cli.py ADDED
@@ -0,0 +1,425 @@
1
+ """Command-line interface: `apifuzz endpoints`, `apifuzz checks` and `apifuzz scan`."""
2
+
3
+ import json # used for the --json output
4
+ from enum import Enum # a fixed set of allowed values for an option (see AuthFilter)
5
+ from fnmatch import fnmatchcase # shell-style patterns like "/orders/*" for --path
6
+ from pathlib import Path # the --html output file
7
+ from typing import Annotated, NoReturn # lets us attach CLI settings to a parameter's type hint
8
+
9
+ import typer # builds the command-line interface from ordinary Python functions
10
+ from rich.console import Console # prints styled, colored output to the terminal
11
+ from rich.table import Table # draws the endpoints table
12
+ from rich.text import Text # plain text that rich will NOT try to interpret as markup
13
+
14
+ from apifuzz import __version__
15
+ from apifuzz.checks import ScanContext, all_checks, select_checks
16
+ from apifuzz.docker_verify import verify_targets
17
+ from apifuzz.endpoint_model import HTTP_METHODS, Endpoint, build_endpoints
18
+ from apifuzz.errors import ApifuzzError
19
+ from apifuzz.finding import Severity
20
+ from apifuzz.http_client import Allowlist, HttpClient
21
+ from apifuzz.report import coverage_notes, coverage_table, findings_table, to_html, to_json
22
+ from apifuzz.scanner import run_scan
23
+ from apifuzz.session_manager import authenticate, preflight
24
+ from apifuzz.settings import host_entry, require_docker_setting, resolve_settings
25
+ from apifuzz.spec_loader import load_spec
26
+
27
+ # The `apifuzz` command itself. pyproject.toml points the installed command at this object.
28
+ app = typer.Typer(
29
+ help="Find OWASP API Top 10 flaws in an API you are authorized to test.",
30
+ no_args_is_help=True, # plain `apifuzz` with nothing after it shows the help text
31
+ add_completion=False, # hide typer's shell-completion install options (not needed)
32
+ )
33
+
34
+ _out = Console() # normal output goes to stdout
35
+ _err = Console(stderr=True) # errors go to stderr, so `apifuzz ... > file` doesn't capture them
36
+
37
+
38
+ def _show_version(value: bool) -> None:
39
+ # Called by typer as soon as it sees --version (see is_eager below).
40
+ if value:
41
+ typer.echo(f"apifuzz {__version__}")
42
+ raise typer.Exit() # stop here with exit code 0; don't go on to run a subcommand
43
+
44
+
45
+ # @app.callback() runs before any subcommand. Its options belong to `apifuzz` itself,
46
+ # e.g. `apifuzz --version`, rather than to a subcommand like `apifuzz endpoints`.
47
+ @app.callback()
48
+ def main(
49
+ version: Annotated[
50
+ bool,
51
+ # is_eager=True makes typer handle this option before everything else, so
52
+ # `apifuzz --version` works even though no subcommand was given.
53
+ typer.Option("--version", callback=_show_version, is_eager=True, help="Show the version and exit."),
54
+ ] = False,
55
+ ) -> None:
56
+ pass # nothing to do here; the work happens in the callback and the subcommands
57
+
58
+
59
+ class AuthFilter(str, Enum):
60
+ # The values --auth accepts. typer shows them in --help and rejects anything else.
61
+ login = "login" # only endpoints that need a login
62
+ none = "none" # only endpoints anyone can call
63
+
64
+
65
+ def _check_methods(methods: list[str] | None) -> set[str]:
66
+ # Upper-case the --method values and reject unknown ones before any work is done.
67
+ wanted = {m.upper() for m in methods or []}
68
+ unknown = sorted(wanted - {m.upper() for m in HTTP_METHODS})
69
+ if unknown:
70
+ # BadParameter makes typer print a usage error and exit with code 2.
71
+ raise typer.BadParameter(f"unknown HTTP method: {', '.join(unknown)}", param_hint="--method")
72
+ return wanted
73
+
74
+
75
+ def _filter_endpoints(
76
+ everything: list[Endpoint],
77
+ methods: set[str],
78
+ path_pattern: str | None,
79
+ tags: list[str] | None,
80
+ auth: "AuthFilter | None",
81
+ ) -> list[Endpoint]:
82
+ # Keep the endpoints that match every filter given. Shared by `endpoints` and `scan`.
83
+ return [
84
+ e
85
+ for e in everything
86
+ if (not methods or e.method in methods)
87
+ # fnmatchcase: "*" matches anything, and upper/lower case must match (paths are case-sensitive).
88
+ and (path_pattern is None or fnmatchcase(e.path, path_pattern))
89
+ and (not tags or any(t in e.tags for t in tags))
90
+ and (auth is None or e.requires_auth == (auth is AuthFilter.login))
91
+ ]
92
+
93
+
94
+ def _load_or_exit(spec: str, *, follow_redirects: bool = True) -> dict:
95
+ try:
96
+ return load_spec(spec, follow_redirects=follow_redirects)
97
+ except ApifuzzError as exc:
98
+ _fail(exc)
99
+
100
+
101
+ def _warn(message: str) -> None:
102
+ _err.print(message, style="yellow", markup=False, highlight=False)
103
+
104
+
105
+ def _fail(exc: Exception) -> NoReturn:
106
+ # An expected problem (bad spec, missing file, bad config): print one clean red line, no
107
+ # traceback. markup=False and highlight=False stop rich from treating "[...]" in the message
108
+ # as styling tags or coloring parts of it. Exit code 2 tells scripts and CI it failed.
109
+ _err.print(f"error: {exc}", style="red", markup=False, highlight=False)
110
+ raise typer.Exit(code=2) from exc
111
+
112
+
113
+ # @app.command() turns the function into a subcommand named after it: `apifuzz endpoints`.
114
+ # Each function parameter becomes a CLI argument or option.
115
+ @app.command()
116
+ def endpoints(
117
+ # typer.Argument = positional: `apifuzz endpoints openapi.json`
118
+ spec: Annotated[str, typer.Argument(help="Path or http(s) URL of an OpenAPI/Swagger spec.")],
119
+ # typer.Option = a named flag. A bool option is off unless the flag is given.
120
+ as_json: Annotated[bool, typer.Option("--json", help="Print the endpoints as JSON instead of a table.")] = False,
121
+ # A list option can be given several times: `-m GET -m POST`.
122
+ methods: Annotated[
123
+ list[str] | None,
124
+ typer.Option("--method", "-m", help="Only show this HTTP method. Repeat for several (-m GET -m POST)."),
125
+ ] = None,
126
+ path_pattern: Annotated[
127
+ str | None,
128
+ typer.Option("--path", "-p", help="Only show paths matching this pattern, e.g. '/orders*' or '*{id}'."),
129
+ ] = None,
130
+ tags: Annotated[
131
+ list[str] | None,
132
+ typer.Option("--tag", "-t", help="Only show endpoints with this tag. Repeat for several."),
133
+ ] = None,
134
+ auth: Annotated[
135
+ AuthFilter | None,
136
+ typer.Option("--auth", help="Only show endpoints that need a login (login) or don't (none)."),
137
+ ] = None,
138
+ i_have_authorization: Annotated[
139
+ bool,
140
+ typer.Option(
141
+ "--i-have-authorization",
142
+ help="Confirm you are allowed to test the target. Not needed to list endpoints; "
143
+ "scanning commands will require it.",
144
+ ),
145
+ ] = False, # accepted so the same flags work for `endpoints` and `scan`; listing sends nothing
146
+ ) -> None:
147
+ """List every endpoint found in a spec. Sends no requests to the API itself."""
148
+ wanted_methods = _check_methods(methods) # fail fast on a typo, before reading the spec
149
+ document = _load_or_exit(spec)
150
+ everything = build_endpoints(document)
151
+ found = _filter_endpoints(everything, wanted_methods, path_pattern, tags, auth)
152
+ if as_json:
153
+ # model_dump() turns each pydantic Endpoint into a plain dict that json can serialize.
154
+ typer.echo(json.dumps([e.model_dump() for e in found], indent=2))
155
+ return
156
+ _out.print(_endpoint_table(found, document.get("info") or {}, total=len(everything)))
157
+
158
+
159
+ def _endpoint_table(found: list[Endpoint], info: dict, *, total: int) -> Table:
160
+ # Title like "VulnShop (sample) 1.0.0", taken from the spec's "info" section.
161
+ # Text(...) so a title such as "[bold]Shop" is shown as written, not as rich styling.
162
+ title = info.get("title") or "API"
163
+ count = f"{len(found)} endpoint{'' if len(found) == 1 else 's'}"
164
+ if len(found) != total: # a filter hid some: say how many there are in all
165
+ count = f"{len(found)} of {total} endpoints"
166
+ table = Table(title=Text(f"{title} {info.get('version', '')}".strip()), caption=count)
167
+ table.add_column("Method", no_wrap=True) # no_wrap: never split "PATCH" across two lines
168
+ table.add_column("Path")
169
+ table.add_column("Parameters")
170
+ table.add_column("Body fields")
171
+ table.add_column("Auth", no_wrap=True)
172
+ for endpoint in found:
173
+ table.add_row(
174
+ endpoint.method,
175
+ # Text(...) shows the value literally. A path could contain "[", which rich would
176
+ # otherwise read as a style tag.
177
+ Text(endpoint.path),
178
+ # e.g. "id (path), q (query)", or "-" when there are none.
179
+ Text(", ".join(f"{p.name} ({p.location})" for p in endpoint.parameters) or "-"),
180
+ # e.g. "username*, password*": a * marks a required field.
181
+ Text(", ".join(f"{f.name}*" if f.required else f.name for f in endpoint.request_fields) or "-"),
182
+ "login" if endpoint.requires_auth else "none",
183
+ # Deprecated operations get dimmed: often forgotten, so worth a look in later phases.
184
+ style="dim" if endpoint.deprecated else None,
185
+ )
186
+ return table
187
+
188
+
189
+ class FailOn(str, Enum):
190
+ # The lowest severity that makes `scan` exit with code 1 (useful in CI). "never" always exits 0.
191
+ info = "info"
192
+ low = "low"
193
+ medium = "medium"
194
+ high = "high"
195
+ critical = "critical"
196
+ never = "never"
197
+
198
+
199
+ @app.command("checks")
200
+ def list_checks() -> None:
201
+ """List the security checks `scan` can run."""
202
+ table = Table(title="Available checks")
203
+ table.add_column("Id", no_wrap=True)
204
+ table.add_column("Default", no_wrap=True)
205
+ table.add_column("What it looks for")
206
+ for cls in all_checks():
207
+ table.add_row(cls.id, "yes" if cls.default else "no", Text(cls.description))
208
+ _out.print(table)
209
+
210
+
211
+ @app.command()
212
+ def scan(
213
+ spec: Annotated[str, typer.Argument(help="Path or http(s) URL of an OpenAPI/Swagger spec.")],
214
+ i_have_authorization: Annotated[
215
+ bool,
216
+ typer.Option(
217
+ "--i-have-authorization",
218
+ help="Required. Confirms you own the target or have written permission to test it.",
219
+ ),
220
+ ] = False,
221
+ base_url: Annotated[
222
+ str | None,
223
+ typer.Option("--base-url", help="Root URL of the API. Default: APIFUZZ_BASE_URL, then the spec's servers."),
224
+ ] = None,
225
+ allow: Annotated[
226
+ list[str] | None,
227
+ typer.Option(
228
+ "--allow",
229
+ help="Extra host or host:port the scan may contact. Repeatable. The base URL's host is always allowed.",
230
+ ),
231
+ ] = None,
232
+ only: Annotated[
233
+ list[str] | None, typer.Option("--only", help="Run only this check id. Repeatable. See `apifuzz checks`.")
234
+ ] = None,
235
+ skip: Annotated[list[str] | None, typer.Option("--skip", help="Don't run this check id. Repeatable.")] = None,
236
+ methods: Annotated[
237
+ list[str] | None, typer.Option("--method", "-m", help="Only scan this HTTP method. Repeatable.")
238
+ ] = None,
239
+ path_pattern: Annotated[
240
+ str | None, typer.Option("--path", "-p", help="Only scan paths matching this pattern, e.g. '/orders*'.")
241
+ ] = None,
242
+ tags: Annotated[list[str] | None, typer.Option("--tag", "-t", help="Only scan endpoints with this tag.")] = None,
243
+ auth: Annotated[
244
+ AuthFilter | None, typer.Option("--auth", help="Only scan endpoints that need a login (login) or don't (none).")
245
+ ] = None,
246
+ require_docker: Annotated[
247
+ bool | None,
248
+ typer.Option(
249
+ "--require-docker/--no-require-docker",
250
+ help="Only scan if every target is a running local Docker container. "
251
+ "Default: APIFUZZ_REQUIRE_DOCKER, else off.",
252
+ ),
253
+ ] = None,
254
+ shared: Annotated[
255
+ list[str] | None,
256
+ typer.Option(
257
+ "--shared",
258
+ help="Path pattern whose objects are shared between users on purpose, e.g. '/posts/*'. "
259
+ "The bola check skips it. Repeatable. Default: APIFUZZ_SHARED.",
260
+ ),
261
+ ] = None,
262
+ burst: Annotated[
263
+ int | None,
264
+ typer.Option(
265
+ "--burst", min=2, max=50,
266
+ help="Requests the rate-limit check sends to one endpoint. Default: APIFUZZ_BURST, else 15.",
267
+ ),
268
+ ] = None,
269
+ timeout: Annotated[float, typer.Option("--timeout", min=0.1, help="Seconds to wait for each response.")] = 10.0,
270
+ max_requests: Annotated[
271
+ int, typer.Option("--max-requests", min=1, help="Stop the scan after sending this many requests.")
272
+ ] = 1000,
273
+ delay: Annotated[
274
+ float, typer.Option("--delay", min=0.0, help="Seconds to wait between requests, to go easy on the target.")
275
+ ] = 0.0,
276
+ fail_on: Annotated[
277
+ FailOn, typer.Option("--fail-on", help="Exit with code 1 if any finding is at least this severe.")
278
+ ] = FailOn.high,
279
+ as_json: Annotated[bool, typer.Option("--json", help="Print the results as JSON (secrets redacted).")] = False,
280
+ json_path: Annotated[
281
+ Path | None,
282
+ typer.Option("--json-out", help="Also write the JSON report to this file.", dir_okay=False),
283
+ ] = None,
284
+ html_path: Annotated[
285
+ Path | None, typer.Option("--html", help="Also write a standalone HTML report to this file.", dir_okay=False)
286
+ ] = None,
287
+ redact: Annotated[
288
+ bool,
289
+ typer.Option(
290
+ "--redact/--no-redact",
291
+ help="Make JSON/HTML reports safe to share: hide tokens and cookies, mask personal "
292
+ "fields in bodies, and shorten bodies. On by default.",
293
+ ),
294
+ ] = True,
295
+ ) -> None:
296
+ """Run security checks against an API you are authorized to test.
297
+
298
+ Test accounts are read only from the environment or .env, never from flags, so they don't end
299
+ up in shell history: APIFUZZ_TOKEN_A / _B, or APIFUZZ_USER_A / APIFUZZ_PASSWORD_A (and B) to
300
+ log in through APIFUZZ_LOGIN_PATH (default /login).
301
+ """
302
+ if not i_have_authorization:
303
+ _err.print(
304
+ "error: scanning sends requests to the target. Re-run with --i-have-authorization to "
305
+ "confirm you own it or have written permission to test it.",
306
+ style="red",
307
+ markup=False,
308
+ highlight=False,
309
+ )
310
+ raise typer.Exit(code=2)
311
+
312
+ wanted_methods = _check_methods(methods)
313
+ try:
314
+ chosen = select_checks(only, skip)
315
+ except KeyError as exc:
316
+ raise typer.BadParameter(str(exc.args[0]), param_hint="--only/--skip") from exc
317
+
318
+ docker_only = require_docker_setting(require_docker)
319
+ spec_is_url = spec.lower().startswith(("http://", "https://"))
320
+ if docker_only and spec_is_url:
321
+ # Downloading the spec is a request too. In Docker-only mode it must also go to a verified
322
+ # container, and may not be redirected somewhere else.
323
+ try:
324
+ verify_targets(spec, [])
325
+ except ApifuzzError as exc:
326
+ _fail(exc)
327
+ document = _load_or_exit(spec, follow_redirects=not docker_only)
328
+ try:
329
+ settings = resolve_settings(
330
+ spec=document, base_url=base_url, allowlist=allow, require_docker=docker_only,
331
+ shared_paths=shared, burst=burst, spec_source=spec,
332
+ )
333
+ if settings.require_docker:
334
+ # Confirm the base URL and every extra entry belong to running containers, then allow
335
+ # ONLY those exact host:port pairs (a bare "localhost" would allow every local port).
336
+ base = host_entry(settings.base_url)
337
+ extras = [e for e in settings.allowlist if e != base]
338
+ matches = verify_targets(settings.base_url, extras)
339
+ for m in matches:
340
+ _err.print(
341
+ f"verified: {m.entry} is container {m.container.name} ({m.container.image}), which {m.how}",
342
+ style="green", markup=False, highlight=False,
343
+ )
344
+ allowlist = Allowlist([m.entry for m in matches])
345
+ else:
346
+ allowlist = Allowlist(settings.allowlist)
347
+ except (ApifuzzError, ValueError) as exc: # ValueError: a malformed allowlist entry
348
+ _fail(exc)
349
+
350
+ targets = _filter_endpoints(build_endpoints(document), wanted_methods, path_pattern, tags, auth)
351
+ try:
352
+ with HttpClient(allowlist, timeout=timeout, max_requests=max_requests, delay=delay) as client:
353
+ # Log in the test accounts that have a username/password but no token.
354
+ settings = authenticate(client, settings)
355
+ # Make sure the tokens work and belong to two different users before relying on them.
356
+ # The full endpoint list is used: a filter such as --path must not hide /me from this.
357
+ for line in preflight(client, settings, build_endpoints(document)):
358
+ _warn(f"warning: {line}")
359
+ if any(c.id == "bola" for c in chosen) and not (settings.token_a and settings.token_b):
360
+ _warn(
361
+ "note: the bola check was skipped because it needs two accounts. Set APIFUZZ_TOKEN_A "
362
+ "and APIFUZZ_TOKEN_B, or APIFUZZ_USER_A/APIFUZZ_PASSWORD_A and the same for B."
363
+ )
364
+ _err.print(
365
+ f"Scanning {len(targets)} endpoint(s) at {settings.base_url} with {len(chosen)} check(s)...",
366
+ markup=False,
367
+ highlight=False,
368
+ )
369
+ result = run_scan(targets, chosen, ScanContext(client, settings))
370
+ answered = client.responses_received
371
+ except ApifuzzError as exc:
372
+ # A refused host or a hit request limit is a misconfiguration; a failed login or a dead
373
+ # token means the checks can't be trusted. All stop the scan.
374
+ _fail(exc)
375
+
376
+ if result.requests_sent and not answered:
377
+ # Nothing answered at all, so nothing was tested. Exit 2, not 0: in CI a target that is
378
+ # down must not look like a target with no findings.
379
+ for line in result.errors[:5]:
380
+ _warn(f"warning: {line}")
381
+ _fail(ApifuzzError(f"no request to {settings.base_url} got an answer, so nothing was tested. Is the API running?"))
382
+
383
+ # The tokens are replaced wherever they turn up in a report, not only in known headers.
384
+ secrets = [settings.token_a, settings.token_b]
385
+ if html_path is not None:
386
+ title = f"apifuzz report: {document.get('info', {}).get('title', 'API')}"
387
+ try:
388
+ html_path.write_text(to_html(result, title=title, redact=redact, secrets=secrets), encoding="utf-8")
389
+ except OSError as exc:
390
+ _fail(ApifuzzError(f"could not write the HTML report to {html_path}: {exc}"))
391
+ _err.print(f"HTML report written to {html_path}", markup=False, highlight=False)
392
+ if json_path is not None:
393
+ try:
394
+ json_path.write_text(to_json(result, redact=redact, secrets=secrets), encoding="utf-8")
395
+ except OSError as exc:
396
+ _fail(ApifuzzError(f"could not write the JSON report to {json_path}: {exc}"))
397
+ _err.print(f"JSON report written to {json_path}", markup=False, highlight=False)
398
+
399
+ if as_json:
400
+ typer.echo(to_json(result, redact=redact, secrets=secrets))
401
+ else:
402
+ _out.print(findings_table(result))
403
+ _out.print(coverage_table(result))
404
+ for line in coverage_notes(result):
405
+ _warn(f"not tested: {line}")
406
+ for line in result.errors:
407
+ _warn(f"warning: {line}")
408
+
409
+ threshold = None if fail_on is FailOn.never else Severity[fail_on.value.upper()]
410
+ if threshold is not None and any(f.severity >= threshold for f in result.findings):
411
+ raise typer.Exit(code=1)
412
+
413
+
414
+ @app.command("verify-docker")
415
+ def verify_docker(
416
+ target: Annotated[str, typer.Argument(help="A URL or host:port, e.g. http://localhost:8000")],
417
+ ) -> None:
418
+ """Check that a target is served by a running local Docker container. Sends no requests to it."""
419
+ try:
420
+ (match,) = verify_targets(target, [])
421
+ except ApifuzzError as exc:
422
+ _fail(exc)
423
+ c = match.container
424
+ _out.print(f"ok: {match.entry} is container {c.name} ({c.image}, id {c.id}), which {match.how}",
425
+ markup=False, highlight=False)