apifuzz 0.2.0__tar.gz
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-0.2.0/.dockerignore +4 -0
- apifuzz-0.2.0/.env.example +44 -0
- apifuzz-0.2.0/.gitattributes +2 -0
- apifuzz-0.2.0/.github/workflows/ci.yml +74 -0
- apifuzz-0.2.0/.github/workflows/release.yml +44 -0
- apifuzz-0.2.0/.gitignore +30 -0
- apifuzz-0.2.0/LICENSE +21 -0
- apifuzz-0.2.0/Makefile +19 -0
- apifuzz-0.2.0/PKG-INFO +350 -0
- apifuzz-0.2.0/README.md +319 -0
- apifuzz-0.2.0/docker-compose.yml +20 -0
- apifuzz-0.2.0/lab/Dockerfile +18 -0
- apifuzz-0.2.0/lab/app.py +362 -0
- apifuzz-0.2.0/lab/demo.ps1 +15 -0
- apifuzz-0.2.0/lab/openapi.json +420 -0
- apifuzz-0.2.0/pyproject.toml +65 -0
- apifuzz-0.2.0/src/apifuzz/__init__.py +13 -0
- apifuzz-0.2.0/src/apifuzz/__main__.py +5 -0
- apifuzz-0.2.0/src/apifuzz/checks/__init__.py +9 -0
- apifuzz-0.2.0/src/apifuzz/checks/auth_required.py +62 -0
- apifuzz-0.2.0/src/apifuzz/checks/base.py +152 -0
- apifuzz-0.2.0/src/apifuzz/checks/bola.py +337 -0
- apifuzz-0.2.0/src/apifuzz/checks/injection.py +289 -0
- apifuzz-0.2.0/src/apifuzz/checks/mass_assignment.py +283 -0
- apifuzz-0.2.0/src/apifuzz/checks/rate_limit.py +168 -0
- apifuzz-0.2.0/src/apifuzz/cli.py +425 -0
- apifuzz-0.2.0/src/apifuzz/docker_verify.py +168 -0
- apifuzz-0.2.0/src/apifuzz/endpoint_model.py +180 -0
- apifuzz-0.2.0/src/apifuzz/errors.py +37 -0
- apifuzz-0.2.0/src/apifuzz/finding.py +55 -0
- apifuzz-0.2.0/src/apifuzz/http_client.py +221 -0
- apifuzz-0.2.0/src/apifuzz/report.py +261 -0
- apifuzz-0.2.0/src/apifuzz/scanner.py +97 -0
- apifuzz-0.2.0/src/apifuzz/session_manager.py +146 -0
- apifuzz-0.2.0/src/apifuzz/settings.py +270 -0
- apifuzz-0.2.0/src/apifuzz/spec_loader.py +138 -0
- apifuzz-0.2.0/tests/conftest.py +80 -0
- apifuzz-0.2.0/tests/data/sample_openapi.json +113 -0
- apifuzz-0.2.0/tests/test_bola.py +336 -0
- apifuzz-0.2.0/tests/test_cli.py +71 -0
- apifuzz-0.2.0/tests/test_cli_options.py +159 -0
- apifuzz-0.2.0/tests/test_docker_verify.py +144 -0
- apifuzz-0.2.0/tests/test_edge_cases.py +182 -0
- apifuzz-0.2.0/tests/test_endpoint_model.py +153 -0
- apifuzz-0.2.0/tests/test_http_client.py +166 -0
- apifuzz-0.2.0/tests/test_injection.py +198 -0
- apifuzz-0.2.0/tests/test_mass_assignment.py +274 -0
- apifuzz-0.2.0/tests/test_phase1_complete.py +196 -0
- apifuzz-0.2.0/tests/test_phase1_framework.py +382 -0
- apifuzz-0.2.0/tests/test_rate_limit.py +157 -0
- apifuzz-0.2.0/tests/test_review_fixes.py +430 -0
- apifuzz-0.2.0/tests/test_spec_loader.py +150 -0
- apifuzz-0.2.0/uv.lock +1094 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# apifuzz settings. Copy this file to .env and fill in your own values; .env is git-ignored.
|
|
2
|
+
# `apifuzz scan` reads them; command-line options override them.
|
|
3
|
+
|
|
4
|
+
# Where the API under test lives. If empty, the spec's first "servers" entry is used, but only
|
|
5
|
+
# when the spec was downloaded from that same host or the host is in APIFUZZ_ALLOWLIST below.
|
|
6
|
+
# A spec file may not choose the target on its own.
|
|
7
|
+
APIFUZZ_BASE_URL=
|
|
8
|
+
|
|
9
|
+
# Hosts apifuzz may send requests to, comma-separated. Each is "host" (any port) or "host:port".
|
|
10
|
+
# Requests to any other host are refused. Example: localhost:8000,staging.example.com
|
|
11
|
+
APIFUZZ_ALLOWLIST=
|
|
12
|
+
|
|
13
|
+
# Two DIFFERENT test accounts, A and B. The bola check uses A's token to try to read B's objects
|
|
14
|
+
# (and the reverse). Use throwaway staging accounts, never real users or production credentials.
|
|
15
|
+
#
|
|
16
|
+
# Either give each account a token...
|
|
17
|
+
APIFUZZ_TOKEN_A=
|
|
18
|
+
APIFUZZ_TOKEN_B=
|
|
19
|
+
# ...or a username and password, and apifuzz logs in itself by POSTing {"username", "password"}
|
|
20
|
+
# to APIFUZZ_LOGIN_PATH. A token, when set, wins over the username and password.
|
|
21
|
+
APIFUZZ_USER_A=
|
|
22
|
+
APIFUZZ_PASSWORD_A=
|
|
23
|
+
APIFUZZ_USER_B=
|
|
24
|
+
APIFUZZ_PASSWORD_B=
|
|
25
|
+
APIFUZZ_LOGIN_PATH=/login
|
|
26
|
+
# How the login request is encoded: json (default) or form. The field names can be changed too,
|
|
27
|
+
# e.g. APIFUZZ_LOGIN_USER_FIELD=email.
|
|
28
|
+
APIFUZZ_LOGIN_FORMAT=
|
|
29
|
+
APIFUZZ_LOGIN_USER_FIELD=
|
|
30
|
+
APIFUZZ_LOGIN_PASSWORD_FIELD=
|
|
31
|
+
|
|
32
|
+
# How a token is sent. Leave empty to follow the spec's security scheme ("Authorization: Bearer
|
|
33
|
+
# <token>" when the spec doesn't say). Example for "X-API-Key: <token>": APIFUZZ_AUTH_HEADER=X-API-Key
|
|
34
|
+
# Example for "Authorization: Token <token>": APIFUZZ_AUTH_PREFIX=Token
|
|
35
|
+
APIFUZZ_AUTH_HEADER=
|
|
36
|
+
APIFUZZ_AUTH_PREFIX=
|
|
37
|
+
|
|
38
|
+
# Paths whose objects are shared between users on purpose, comma-separated shell-style patterns.
|
|
39
|
+
# The bola check skips them. Example: /posts/*,/products/*
|
|
40
|
+
APIFUZZ_SHARED=
|
|
41
|
+
|
|
42
|
+
# Set to 1 to refuse any scan whose target isn't a running local Docker container
|
|
43
|
+
# (same as --require-docker). Recommended when your test API runs in Docker.
|
|
44
|
+
APIFUZZ_REQUIRE_DOCKER=
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Continuous integration for apifuzz itself. (Phase 3 is about other people running the scanner
|
|
2
|
+
# in their CI; this is the project's own quality gate.)
|
|
3
|
+
name: CI
|
|
4
|
+
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
pull_request:
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
test:
|
|
15
|
+
name: test (${{ matrix.os }}, py${{ matrix.python-version }})
|
|
16
|
+
runs-on: ${{ matrix.os }}
|
|
17
|
+
strategy:
|
|
18
|
+
fail-fast: false
|
|
19
|
+
matrix:
|
|
20
|
+
os: [ubuntu-latest, windows-latest]
|
|
21
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- uses: actions/setup-python@v5
|
|
25
|
+
with:
|
|
26
|
+
python-version: ${{ matrix.python-version }}
|
|
27
|
+
cache: pip
|
|
28
|
+
- run: python -m pip install --upgrade pip
|
|
29
|
+
- run: pip install -e ".[dev]"
|
|
30
|
+
- run: ruff check .
|
|
31
|
+
- run: pytest
|
|
32
|
+
|
|
33
|
+
demo:
|
|
34
|
+
# Boot the real VulnShop container and scan it end to end, so the Docker path and the
|
|
35
|
+
# ground-truth demo are exercised, not just the in-process tests.
|
|
36
|
+
name: demo scan (Docker)
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v4
|
|
40
|
+
- uses: actions/setup-python@v5
|
|
41
|
+
with:
|
|
42
|
+
python-version: "3.12"
|
|
43
|
+
cache: pip
|
|
44
|
+
- run: pip install -e .
|
|
45
|
+
- name: Boot VulnShop and scan it
|
|
46
|
+
run: |
|
|
47
|
+
docker compose up -d --build --wait
|
|
48
|
+
apifuzz verify-docker http://localhost:8000
|
|
49
|
+
APIFUZZ_USER_A=alice APIFUZZ_PASSWORD_A=alice \
|
|
50
|
+
APIFUZZ_USER_B=bob APIFUZZ_PASSWORD_B=bob \
|
|
51
|
+
apifuzz scan http://localhost:8000/openapi.json \
|
|
52
|
+
--i-have-authorization --require-docker \
|
|
53
|
+
--json-out report.json --html report.html --fail-on never
|
|
54
|
+
- name: Check the planted BOLA was found
|
|
55
|
+
run: |
|
|
56
|
+
python - <<'PY'
|
|
57
|
+
import json, sys
|
|
58
|
+
report = json.load(open("report.json"))
|
|
59
|
+
found = {(f["check"], f["endpoint"]) for f in report["findings"]}
|
|
60
|
+
assert ("bola", "GET /orders/{id}") in found, found
|
|
61
|
+
assert ("auth-required", "GET /users/{id}") in found, found
|
|
62
|
+
assert ("injection", "GET /search") in found, found
|
|
63
|
+
assert ("injection", "POST /login") in found, found
|
|
64
|
+
print("demo scan OK:", report["requests_sent"], "requests,", len(report["findings"]), "findings")
|
|
65
|
+
PY
|
|
66
|
+
- uses: actions/upload-artifact@v4
|
|
67
|
+
if: always()
|
|
68
|
+
with:
|
|
69
|
+
name: demo-report
|
|
70
|
+
path: |
|
|
71
|
+
report.json
|
|
72
|
+
report.html
|
|
73
|
+
- if: always()
|
|
74
|
+
run: docker compose down
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Publish apifuzz to PyPI when a GitHub Release is published, using Trusted Publishing (OIDC):
|
|
2
|
+
# no API token is stored anywhere — PyPI verifies the release came from this repo's workflow.
|
|
3
|
+
# Setup (one time) is in the README's "Releasing" note. The published version is the one in
|
|
4
|
+
# pyproject.toml, not the git tag, so bump it before cutting each release.
|
|
5
|
+
name: Publish to PyPI
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
release:
|
|
9
|
+
types: [published]
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
build:
|
|
16
|
+
name: Build distributions
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
- run: python -m pip install --upgrade build
|
|
24
|
+
- run: python -m build # writes the sdist and wheel to dist/
|
|
25
|
+
- run: pipx run twine check dist/* # fails if the metadata or README would be rejected
|
|
26
|
+
- uses: actions/upload-artifact@v4
|
|
27
|
+
with:
|
|
28
|
+
name: dist
|
|
29
|
+
path: dist/
|
|
30
|
+
|
|
31
|
+
publish:
|
|
32
|
+
name: Publish to PyPI
|
|
33
|
+
needs: build
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
# Must match the environment registered with the PyPI trusted publisher.
|
|
36
|
+
environment: pypi
|
|
37
|
+
permissions:
|
|
38
|
+
id-token: write # lets the job mint the OIDC token PyPI checks; nothing else is granted
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/download-artifact@v4
|
|
41
|
+
with:
|
|
42
|
+
name: dist
|
|
43
|
+
path: dist/
|
|
44
|
+
- uses: pypa/gh-action-pypi-publish@release/v1 # uploads everything in dist/ via OIDC
|
apifuzz-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
|
|
8
|
+
# Virtual environments
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
|
|
12
|
+
# Tooling caches
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
htmlcov/
|
|
18
|
+
|
|
19
|
+
# Scan output (reports may contain captured requests/tokens)
|
|
20
|
+
reports/
|
|
21
|
+
out/
|
|
22
|
+
|
|
23
|
+
# Local secrets and editor files
|
|
24
|
+
.env
|
|
25
|
+
.env.*
|
|
26
|
+
!.env.example
|
|
27
|
+
.vscode/
|
|
28
|
+
.idea/
|
|
29
|
+
report.html
|
|
30
|
+
report.json
|
apifuzz-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mateivarvara100-cloud
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
apifuzz-0.2.0/Makefile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# make demo: boot the VulnShop lab in Docker, scan it, and write report.html.
|
|
2
|
+
# On Windows without make, run: powershell -File lab/demo.ps1
|
|
3
|
+
.PHONY: demo lab-up lab-down test
|
|
4
|
+
|
|
5
|
+
lab-up:
|
|
6
|
+
docker compose up -d --build --wait
|
|
7
|
+
|
|
8
|
+
lab-down:
|
|
9
|
+
docker compose down
|
|
10
|
+
|
|
11
|
+
demo: lab-up
|
|
12
|
+
APIFUZZ_USER_A=alice APIFUZZ_PASSWORD_A=alice APIFUZZ_USER_B=bob APIFUZZ_PASSWORD_B=bob \
|
|
13
|
+
apifuzz scan http://localhost:8000/openapi.json --i-have-authorization --require-docker \
|
|
14
|
+
--html report.html --json-out report.json --fail-on never
|
|
15
|
+
@echo "Open report.html to see the findings. Stop the lab with: make lab-down"
|
|
16
|
+
|
|
17
|
+
test:
|
|
18
|
+
ruff check .
|
|
19
|
+
pytest
|
apifuzz-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: apifuzz
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: OpenAPI-driven security scanner for authorized staging APIs (OWASP API Top 10).
|
|
5
|
+
Project-URL: Homepage, https://github.com/mateivarvara100-cloud/apisecurityfuzzer
|
|
6
|
+
Project-URL: Issues, https://github.com/mateivarvara100-cloud/apisecurityfuzzer/issues
|
|
7
|
+
Author: mateivarvara100-cloud
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api,bola,idor,openapi,owasp,scanner,security,swagger
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: openapi-spec-validator>=0.7
|
|
23
|
+
Requires-Dist: prance>=23.6
|
|
24
|
+
Requires-Dist: pydantic>=2.6
|
|
25
|
+
Requires-Dist: rich>=13.7
|
|
26
|
+
Requires-Dist: typer>=0.12
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# apifuzz
|
|
33
|
+
|
|
34
|
+
A command-line security scanner that reads an OpenAPI/Swagger spec and tests the API it
|
|
35
|
+
describes for common OWASP API Top 10 flaws. You point it at an API you are authorized to test,
|
|
36
|
+
give it two test accounts, and get a report a developer can act on: the endpoint, the evidence,
|
|
37
|
+
the severity, and the fix.
|
|
38
|
+
|
|
39
|
+
> **Status.** Five checks work today: `bola` (BOLA/IDOR by token swapping), `auth-required`,
|
|
40
|
+
> `mass-assignment`, `rate-limit` and `injection` (SQL/NoSQL, detection only). SARIF output is
|
|
41
|
+
> planned, not built. Every check is verified against the bundled VulnShop lab on each test run.
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
$ make demo
|
|
45
|
+
...
|
|
46
|
+
12 endpoints scanned · 7 findings (2 critical, 4 high, 1 medium) · 5 checks run
|
|
47
|
+
What was tested
|
|
48
|
+
+--------------------------------------------------------+
|
|
49
|
+
| Check | Tested | Not tested | Not applicable |
|
|
50
|
+
|-----------------+--------+------------+----------------|
|
|
51
|
+
| auth-required | 7 | 0 | 5 |
|
|
52
|
+
| bola | 4 | 0 | 8 |
|
|
53
|
+
| injection | 2 | 0 | 10 |
|
|
54
|
+
| mass-assignment | 2 | 0 | 10 |
|
|
55
|
+
| rate-limit | 2 | 0 | 10 |
|
|
56
|
+
+--------------------------------------------------------+
|
|
57
|
+
111 requests sent to http://localhost:8000
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## What it checks
|
|
61
|
+
|
|
62
|
+
| Check | OWASP | How it works, in one line |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| `bola` | API1:2023 | Logs in as two users and checks whether one can read the other's objects (`GET /orders/{id}`). |
|
|
65
|
+
| `auth-required` | API2:2023 | Calls endpoints the spec says need a login with no credentials, and reports the ones that answer. |
|
|
66
|
+
| `mass-assignment` | API3:2023 | Sends an update or create with one extra field the spec doesn't list (such as `role`) and reads the object back to see if it was saved. |
|
|
67
|
+
| `rate-limit` | API4:2023 | Sends a small burst of identical requests to login and search endpoints and looks for a `429` or rate-limit headers. |
|
|
68
|
+
| `injection` | API8:2019 | Sends inert marker values to query-string parameters and to JSON body fields of login/search endpoints, and watches the response *shape* for a database error, an always-true/always-false differential, or an accepted NoSQL operator object. Detection only. |
|
|
69
|
+
|
|
70
|
+
All probes are meant to detect a flaw, not to use it. Details for each check are under
|
|
71
|
+
[How each check works](#how-each-check-works).
|
|
72
|
+
|
|
73
|
+
## Quickstart
|
|
74
|
+
|
|
75
|
+
Needs Python 3.11 or newer, and Docker for the demo.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python -m venv .venv
|
|
79
|
+
# Windows: .venv\Scripts\activate macOS/Linux: source .venv/bin/activate
|
|
80
|
+
pip install -e ".[dev]"
|
|
81
|
+
|
|
82
|
+
make demo # macOS/Linux, or Windows with make
|
|
83
|
+
powershell -File lab/demo.ps1 # Windows without make
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The demo boots VulnShop (a deliberately vulnerable API, see [Lab target](#lab-target-vulnshop))
|
|
87
|
+
in Docker, logs in both lab accounts, scans it, and writes `report.html` and `report.json`.
|
|
88
|
+
|
|
89
|
+
## Scan your own API
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
apifuzz checks # list the available checks
|
|
93
|
+
apifuzz endpoints openapi.json # see what the spec contains; sends nothing
|
|
94
|
+
apifuzz scan openapi.json --i-have-authorization --base-url https://staging.example.com
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`scan` refuses to run without `--i-have-authorization`. It contacts only the base URL's host,
|
|
98
|
+
plus any `--allow host[:port]` entries, and never follows redirects.
|
|
99
|
+
|
|
100
|
+
**The target.** Pass `--base-url` or set `APIFUZZ_BASE_URL`. Without either, the spec's `servers`
|
|
101
|
+
entry is used only if the spec was downloaded from that same host, or you listed the host with
|
|
102
|
+
`--allow`. A spec is a document: it may not choose the target (and receive your test tokens) on
|
|
103
|
+
its own.
|
|
104
|
+
|
|
105
|
+
**Test accounts.** `bola` needs two different accounts, A and B; `mass-assignment` needs A. Give
|
|
106
|
+
each one either a token (`APIFUZZ_TOKEN_A` / `_B`) or a username and password (`APIFUZZ_USER_A` /
|
|
107
|
+
`APIFUZZ_PASSWORD_A`, and the same for B). They are read only from the environment or `.env`
|
|
108
|
+
(see [.env.example](.env.example)), never from flags, so they stay out of shell history.
|
|
109
|
+
|
|
110
|
+
- With a username and password, apifuzz POSTs them to `APIFUZZ_LOGIN_PATH` (default `/login`) and
|
|
111
|
+
reads the token from the answer. `APIFUZZ_LOGIN_FORMAT=form` sends form fields instead of JSON;
|
|
112
|
+
`APIFUZZ_LOGIN_USER_FIELD` / `APIFUZZ_LOGIN_PASSWORD_FIELD` rename the fields.
|
|
113
|
+
- The token is sent the way the spec's security scheme says (a bearer token, an API-key header,
|
|
114
|
+
or a cookie). `APIFUZZ_AUTH_HEADER` / `APIFUZZ_AUTH_PREFIX` override that.
|
|
115
|
+
- Before scanning, each token is tried on a protected endpoint. The scan stops if a token is
|
|
116
|
+
refused, if A and B have the same token, or if both turn out to be the same user. Otherwise it
|
|
117
|
+
would test nothing and report a clean API, or "prove" two accounts can read each other's data.
|
|
118
|
+
|
|
119
|
+
| Option | What it does |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| `--base-url` | Root URL of the API |
|
|
122
|
+
| `--allow` | Extra host the scan may contact (repeatable) |
|
|
123
|
+
| `--only` / `--skip` | Run only, or leave out, a check id (repeatable) |
|
|
124
|
+
| `-m`, `-p`, `-t`, `--auth` | Scan only some endpoints: by method, path pattern, tag, or login needed (`login`/`none`) |
|
|
125
|
+
| `--shared` | Path pattern whose objects are shared between users on purpose, e.g. `'/posts/*'`; `bola` skips it (repeatable) |
|
|
126
|
+
| `--burst` | Requests the `rate-limit` check sends to one endpoint (default 15, at most 50) |
|
|
127
|
+
| `--max-requests` | Stop the scan after this many requests (default 1000) |
|
|
128
|
+
| `--delay` | Seconds to wait between requests |
|
|
129
|
+
| `--timeout` | Seconds to wait for each response (default 10) |
|
|
130
|
+
| `--require-docker` | Only scan if every target is a running local Docker container. See below. |
|
|
131
|
+
| `--fail-on` | Exit 1 if any finding is at least this severe: `info`…`critical`, or `never` (default `high`) |
|
|
132
|
+
| `--json` | Print the report as JSON instead of tables |
|
|
133
|
+
| `--json-out FILE` / `--html FILE` | Also write a JSON report, or a standalone HTML report (one file, no scripts) |
|
|
134
|
+
| `--redact` / `--no-redact` | Make reports safe to share (default: on). See [Reports](#reports). |
|
|
135
|
+
|
|
136
|
+
**Exit codes:** `0` no findings at or above `--fail-on`; `1` findings at or above it; `2` usage
|
|
137
|
+
or configuration error, a failed login or dead token, or a target where no request got an answer.
|
|
138
|
+
An unreachable API is an error, not a clean result, so it can't pass a CI job by being down.
|
|
139
|
+
|
|
140
|
+
Every request carries `User-Agent: apifuzz/<version>`, so the target's logs show where the
|
|
141
|
+
traffic came from.
|
|
142
|
+
|
|
143
|
+
### Docker-only mode
|
|
144
|
+
|
|
145
|
+
With `--require-docker` (or `APIFUZZ_REQUIRE_DOCKER=1`), apifuzz asks Docker which containers are
|
|
146
|
+
running before it sends anything. It scans only if the base URL, every `--allow` entry, and the
|
|
147
|
+
spec URL (when the spec is downloaded) are ports that one of them publishes. The host must be
|
|
148
|
+
written as `localhost`, `127.0.0.1`, `::1`, or a container's own IP; other hostnames are refused
|
|
149
|
+
without a DNS lookup. The allowlist then holds only those exact `host:port` pairs. Check a target
|
|
150
|
+
on its own, without scanning, with:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
apifuzz verify-docker http://localhost:8000
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Reports
|
|
157
|
+
|
|
158
|
+
Every finding has a severity, its OWASP category, the evidence (the exact requests and
|
|
159
|
+
responses), a fix, and a `fingerprint` that stays the same between runs so two reports can be
|
|
160
|
+
compared. A finding is reported only after a repeated request confirms it.
|
|
161
|
+
|
|
162
|
+
Every report also says **what was tested**. For each check it counts the endpoints it tested,
|
|
163
|
+
the ones it could not judge (with the reason), and the ones it doesn't apply to. "No findings"
|
|
164
|
+
is only good news for endpoints that were tested.
|
|
165
|
+
|
|
166
|
+
With `--redact` (the default), JSON and HTML reports are safe to attach to a ticket:
|
|
167
|
+
|
|
168
|
+
- `Authorization`, cookie and API-key headers are hidden, and the test accounts' tokens are
|
|
169
|
+
replaced wherever else they appear (URLs, bodies, warnings).
|
|
170
|
+
- In bodies, values of fields such as `email`, `phone`, `password` and `token` are masked, and
|
|
171
|
+
e-mail addresses are masked in plain text. A BOLA finding's evidence is another user's data.
|
|
172
|
+
- Bodies are cut to a short excerpt.
|
|
173
|
+
|
|
174
|
+
The JSON report carries `schema_version`, `tool_version`, `target`, `started_at`, `duration_s`
|
|
175
|
+
and `requests_sent`.
|
|
176
|
+
|
|
177
|
+
## How each check works
|
|
178
|
+
|
|
179
|
+
**`bola`.** First it does recon: it calls the protected GET endpoints that take no id (such as
|
|
180
|
+
`/me` or `/orders`) as account A and as account B, and notes which ids each account is shown,
|
|
181
|
+
and on which endpoint. Those real ids (numbers or strings such as UUIDs), plus 1–5 for numeric
|
|
182
|
+
ids, are requested on every `GET /thing/{id}` as A and as B. An object that both accounts receive
|
|
183
|
+
(the same object, not just the same status code), while a request with no token is refused, is
|
|
184
|
+
reported once a second request confirms it:
|
|
185
|
+
|
|
186
|
+
- **CRITICAL** when ownership is proven: `GET /orders` lists the id for one account only, yet
|
|
187
|
+
the other could read `GET /orders/{id}`.
|
|
188
|
+
- **HIGH** when it is likely: the object has an owner field (`owner`, `user_id`...). That is not
|
|
189
|
+
proof, because a public post names its author too.
|
|
190
|
+
- **LOW** when nothing shows whose object it is. It may be shared on purpose.
|
|
191
|
+
- Not reported: an object the API itself lists to both accounts, and paths marked `--shared`.
|
|
192
|
+
|
|
193
|
+
Ids are attributed per collection: an id seen in `/orders` says nothing about `/products/{id}`.
|
|
194
|
+
On a nested path such as `/users/{userId}/orders/{orderId}` the last id is probed and the ids
|
|
195
|
+
before it are filled with account A's own, so the question is whether B can read an object under
|
|
196
|
+
A's part of the tree. Only GET endpoints are probed, and recon sends only GETs.
|
|
197
|
+
|
|
198
|
+
**`auth-required`.** For GET/HEAD endpoints the spec marks as needing a login: one request with
|
|
199
|
+
no credentials. A 2xx answer, confirmed by a second request, is HIGH. A 401/403 is correct. Any
|
|
200
|
+
other answer (a 404 for the sample id, a 5xx) is counted as not tested.
|
|
201
|
+
|
|
202
|
+
**`mass-assignment`.** For PATCH/PUT endpoints, on one of account A's own objects: it reads the
|
|
203
|
+
object, then sends the update with one extra field the spec doesn't list as writable, and reads
|
|
204
|
+
the object back. A field that was saved is a finding: CRITICAL if it controls what the user may
|
|
205
|
+
do (`role`, `is_admin`, `verified`...), HIGH otherwise. The probe proves the flaw without using
|
|
206
|
+
it: a text field is set to the marker `apifuzz-probe`, never to `admin`, and the original value
|
|
207
|
+
is put back afterwards (the finding says whether that worked). Ids, owner fields, timestamps and
|
|
208
|
+
secrets are never written to. Create (POST) endpoints are probed the same way, but only when the
|
|
209
|
+
spec also has a DELETE and a GET for the created object: every object made for the test is
|
|
210
|
+
deleted straight after it is read, and the report names any that could not be. Without a DELETE
|
|
211
|
+
the endpoint is listed as not tested.
|
|
212
|
+
|
|
213
|
+
**`rate-limit`.** For login-like POST endpoints and search-like GET endpoints: a burst of
|
|
214
|
+
identical requests, one after another, stopping at the first `429` or `Retry-After` /
|
|
215
|
+
`RateLimit-*` header. Answers that get several times slower as the burst goes on also count as
|
|
216
|
+
throttling. If the whole burst is answered with no sign of throttling, a login endpoint
|
|
217
|
+
is HIGH and a search endpoint MEDIUM. Login attempts use made-up credentials for a user that
|
|
218
|
+
doesn't exist. Endpoints that create data or send messages (sign-up, password reset) are never
|
|
219
|
+
burst. This is detection, not a load test: the burst is capped at 50.
|
|
220
|
+
|
|
221
|
+
**`injection`.** On one input at a time it sends a benign baseline value, then a value with one
|
|
222
|
+
extra quote (`apifuzz'`), then an always-true and an always-false value (`apifuzz' OR '1'='1' -- `
|
|
223
|
+
/ `apifuzz' AND '1'='2' -- `). A database error in the answer to the quote, or a result set that
|
|
224
|
+
differs between the always-true and always-false values, means the input is reaching a query
|
|
225
|
+
unescaped — HIGH, once the signal repeats on a second request. Two kinds of input are probed:
|
|
226
|
+
query-string parameters on GET/HEAD endpoints, and JSON body fields on endpoints that read or
|
|
227
|
+
authenticate (login / search / lookup / query shaped) — for body fields it also sends an operator
|
|
228
|
+
object (`{"$ne": …}`) where a scalar is expected, to catch NoSQL operator injection. Create and
|
|
229
|
+
update endpoints are never probed with a body, so the check can neither create nor change data;
|
|
230
|
+
the markers are inert, and it counts records, never reads their contents.
|
|
231
|
+
|
|
232
|
+
## Lab target (VulnShop)
|
|
233
|
+
|
|
234
|
+
`lab/` contains VulnShop, a tiny deliberately vulnerable API (spec:
|
|
235
|
+
[lab/openapi.json](lab/openapi.json)). Each planted flaw is marked `PLANTED` in
|
|
236
|
+
[lab/app.py](lab/app.py); everything else behaves correctly, so a finding anywhere else is a
|
|
237
|
+
false positive.
|
|
238
|
+
|
|
239
|
+
| Route | Behaviour | Expected result |
|
|
240
|
+
| --- | --- | --- |
|
|
241
|
+
| `GET /orders/{id}` | Planted: checks the login but not the order's owner | `bola` (CRITICAL) |
|
|
242
|
+
| `GET /users/{id}` | Planted: doesn't check the login at all | `auth-required` (HIGH) |
|
|
243
|
+
| `PATCH /users/{id}` | Planted: saves every field in the body, including `role` | `mass-assignment` (CRITICAL) |
|
|
244
|
+
| `POST /login` | Planted: accepts any number of attempts, and pastes `username` into a SQL user lookup | `rate-limit` (HIGH), `injection` (HIGH) |
|
|
245
|
+
| `GET /search` | Planted: accepts any number of searches, and pastes `q` into a SQL query | `rate-limit` (MEDIUM), `injection` (HIGH) |
|
|
246
|
+
| `GET /me`, `GET /orders` | Correct: only the caller's own data | nothing |
|
|
247
|
+
| `POST /orders`, `DELETE /orders/{id}` | Correct: saves only the fields the spec allows; you can delete only your own orders | nothing |
|
|
248
|
+
| `GET /products/{id}` | Correct: shared on purpose with every logged-in user | nothing |
|
|
249
|
+
| `GET /invoices/{id}` | Correct: only the owner can read an invoice | nothing |
|
|
250
|
+
|
|
251
|
+
With `LAB_HARDENED=1` every planted flaw is fixed. This is the known-negative variant, and a scan
|
|
252
|
+
of it must report nothing:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
LAB_HARDENED=1 docker compose up -d --wait # PowerShell: $env:LAB_HARDENED="1"; docker compose up -d --wait
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The hardened lab limits logins to 5 per 30 seconds, so a second scan started right away is
|
|
259
|
+
refused at login with a clear message. Wait half a minute.
|
|
260
|
+
|
|
261
|
+
The container listens on `127.0.0.1:8000` only, runs as a non-root user with a read-only
|
|
262
|
+
filesystem and no capabilities, and keeps its data in memory (seeded the same way on every
|
|
263
|
+
start). The app refuses to start without `DEMO_MODE=1` and marks every response with an
|
|
264
|
+
`X-VulnShop-Lab` header, so it can't be mistaken for a real service.
|
|
265
|
+
|
|
266
|
+
Step by step instead of `make demo`:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
docker compose up -d --build --wait # start (first run builds the image)
|
|
270
|
+
apifuzz verify-docker http://localhost:8000 # confirm it's the lab container
|
|
271
|
+
apifuzz scan http://localhost:8000/openapi.json --i-have-authorization --require-docker --html report.html
|
|
272
|
+
docker compose down # stop
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Lab accounts: `alice`/`alice` (token `lab-token-alice`) and `bob`/`bob` (token `lab-token-bob`).
|
|
276
|
+
They exist only in the lab, so it's fine to put them in `.env`.
|
|
277
|
+
|
|
278
|
+
## Architecture
|
|
279
|
+
|
|
280
|
+
```mermaid
|
|
281
|
+
flowchart LR
|
|
282
|
+
spec[OpenAPI spec] --> loader[spec_loader] --> model[endpoint_model]
|
|
283
|
+
env[flags / .env] --> settings
|
|
284
|
+
settings --> session[session_manager]
|
|
285
|
+
model --> scanner
|
|
286
|
+
session --> scanner
|
|
287
|
+
scanner --> checks["checks: bola, auth-required,<br/>mass-assignment, rate-limit, injection"]
|
|
288
|
+
checks --> client[http_client<br/>allowlist, limits]
|
|
289
|
+
client --> target[(API under test)]
|
|
290
|
+
scanner --> report[report: table, JSON, HTML]
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
The HTTP client is the only code that touches the target. Checks share one interface
|
|
294
|
+
(`Check.run(endpoint, context)`), so adding a check is one new module.
|
|
295
|
+
|
|
296
|
+
| File | What it does |
|
|
297
|
+
| --- | --- |
|
|
298
|
+
| `src/apifuzz/spec_loader.py` | Reads a spec from a file or URL, resolves its `$ref`s, validates it, and turns problems into clear errors. |
|
|
299
|
+
| `src/apifuzz/endpoint_model.py` | Turns the spec into a plain list of `Endpoint` objects. |
|
|
300
|
+
| `src/apifuzz/settings.py` | Combines flags, environment/`.env` and the spec into scan settings, including how tokens are sent and which target is confirmed. |
|
|
301
|
+
| `src/apifuzz/session_manager.py` | Logs the two test accounts in, and checks their tokens work and belong to two different users. |
|
|
302
|
+
| `src/apifuzz/http_client.py` | The only code that sends requests to a target. Enforces the allowlist and the request limit, never follows redirects, and records each request and response as evidence. |
|
|
303
|
+
| `src/apifuzz/checks/` | One module per check. `base.py` defines `Check`, `ScanContext` and the registry. |
|
|
304
|
+
| `src/apifuzz/scanner.py` | Runs the chosen checks over the chosen endpoints, and collects findings and coverage. |
|
|
305
|
+
| `src/apifuzz/finding.py` | The `Finding` record, its fingerprint, and the severity scale. |
|
|
306
|
+
| `src/apifuzz/report.py` | Shows results as tables, JSON or a standalone HTML report, redacted. |
|
|
307
|
+
| `src/apifuzz/docker_verify.py` | `--require-docker`: confirms each target is a running local container. |
|
|
308
|
+
| `src/apifuzz/cli.py` | The `apifuzz` command: `endpoints`, `checks`, `scan`, `verify-docker`. |
|
|
309
|
+
| `src/apifuzz/errors.py` | The error types shown to the user as plain messages. |
|
|
310
|
+
| `tests/` | Unit tests against a fake network, plus ground-truth scans of the real lab app in both modes. |
|
|
311
|
+
|
|
312
|
+
## Develop
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
ruff check .
|
|
316
|
+
pytest
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`uv.lock` pins the exact dependency versions; `uv sync --extra dev` installs from it.
|
|
320
|
+
|
|
321
|
+
The tests need no Docker: they run the lab app in-process and require exactly the lab table's
|
|
322
|
+
results from the normal lab and no findings from the hardened one. CI
|
|
323
|
+
([.github/workflows/ci.yml](.github/workflows/ci.yml)) runs them on Linux and Windows for Python
|
|
324
|
+
3.11 to 3.13, and also boots the real container and runs the demo scan.
|
|
325
|
+
|
|
326
|
+
## Scope & ethics
|
|
327
|
+
|
|
328
|
+
Use this only on APIs you own or have written permission to test. The tool is built to make the
|
|
329
|
+
wrong thing hard: `scan` requires `--i-have-authorization`, the HTTP client enforces a host
|
|
330
|
+
allowlist, a spec cannot choose the target, and requests are capped. It detects flaws with
|
|
331
|
+
minimal requests and does not exploit them: probes use marker values, changes are put back, and
|
|
332
|
+
login attempts use an account that doesn't exist. It is not a tool for scanning third-party
|
|
333
|
+
services.
|
|
334
|
+
|
|
335
|
+
## Releasing
|
|
336
|
+
|
|
337
|
+
Publishing to PyPI is automated by [.github/workflows/release.yml](.github/workflows/release.yml)
|
|
338
|
+
using PyPI Trusted Publishing (OIDC), so no API token is stored. One-time setup: register this
|
|
339
|
+
repo as a pending publisher at <https://pypi.org/manage/account/publishing/> (project `apifuzz`,
|
|
340
|
+
workflow `release.yml`, environment `pypi`), and create a GitHub environment named `pypi`. To cut
|
|
341
|
+
a release: bump `version` in `pyproject.toml` (PyPI refuses a re-upload of an existing version),
|
|
342
|
+
then publish a GitHub Release with a tag like `v0.2.0`. The workflow builds, runs `twine check`,
|
|
343
|
+
and uploads. The published version is the one in `pyproject.toml`, not the git tag.
|
|
344
|
+
|
|
345
|
+
## What's next
|
|
346
|
+
|
|
347
|
+
- SARIF 2.1.0 output for GitHub code scanning, and a file of accepted findings.
|
|
348
|
+
- Wider injection coverage: path parameters and headers (today's check covers query-string
|
|
349
|
+
parameters and the JSON body fields of login/search endpoints, including a NoSQL operator-object
|
|
350
|
+
probe).
|