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
|
@@ -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)
|