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.
Files changed (53) hide show
  1. apifuzz-0.2.0/.dockerignore +4 -0
  2. apifuzz-0.2.0/.env.example +44 -0
  3. apifuzz-0.2.0/.gitattributes +2 -0
  4. apifuzz-0.2.0/.github/workflows/ci.yml +74 -0
  5. apifuzz-0.2.0/.github/workflows/release.yml +44 -0
  6. apifuzz-0.2.0/.gitignore +30 -0
  7. apifuzz-0.2.0/LICENSE +21 -0
  8. apifuzz-0.2.0/Makefile +19 -0
  9. apifuzz-0.2.0/PKG-INFO +350 -0
  10. apifuzz-0.2.0/README.md +319 -0
  11. apifuzz-0.2.0/docker-compose.yml +20 -0
  12. apifuzz-0.2.0/lab/Dockerfile +18 -0
  13. apifuzz-0.2.0/lab/app.py +362 -0
  14. apifuzz-0.2.0/lab/demo.ps1 +15 -0
  15. apifuzz-0.2.0/lab/openapi.json +420 -0
  16. apifuzz-0.2.0/pyproject.toml +65 -0
  17. apifuzz-0.2.0/src/apifuzz/__init__.py +13 -0
  18. apifuzz-0.2.0/src/apifuzz/__main__.py +5 -0
  19. apifuzz-0.2.0/src/apifuzz/checks/__init__.py +9 -0
  20. apifuzz-0.2.0/src/apifuzz/checks/auth_required.py +62 -0
  21. apifuzz-0.2.0/src/apifuzz/checks/base.py +152 -0
  22. apifuzz-0.2.0/src/apifuzz/checks/bola.py +337 -0
  23. apifuzz-0.2.0/src/apifuzz/checks/injection.py +289 -0
  24. apifuzz-0.2.0/src/apifuzz/checks/mass_assignment.py +283 -0
  25. apifuzz-0.2.0/src/apifuzz/checks/rate_limit.py +168 -0
  26. apifuzz-0.2.0/src/apifuzz/cli.py +425 -0
  27. apifuzz-0.2.0/src/apifuzz/docker_verify.py +168 -0
  28. apifuzz-0.2.0/src/apifuzz/endpoint_model.py +180 -0
  29. apifuzz-0.2.0/src/apifuzz/errors.py +37 -0
  30. apifuzz-0.2.0/src/apifuzz/finding.py +55 -0
  31. apifuzz-0.2.0/src/apifuzz/http_client.py +221 -0
  32. apifuzz-0.2.0/src/apifuzz/report.py +261 -0
  33. apifuzz-0.2.0/src/apifuzz/scanner.py +97 -0
  34. apifuzz-0.2.0/src/apifuzz/session_manager.py +146 -0
  35. apifuzz-0.2.0/src/apifuzz/settings.py +270 -0
  36. apifuzz-0.2.0/src/apifuzz/spec_loader.py +138 -0
  37. apifuzz-0.2.0/tests/conftest.py +80 -0
  38. apifuzz-0.2.0/tests/data/sample_openapi.json +113 -0
  39. apifuzz-0.2.0/tests/test_bola.py +336 -0
  40. apifuzz-0.2.0/tests/test_cli.py +71 -0
  41. apifuzz-0.2.0/tests/test_cli_options.py +159 -0
  42. apifuzz-0.2.0/tests/test_docker_verify.py +144 -0
  43. apifuzz-0.2.0/tests/test_edge_cases.py +182 -0
  44. apifuzz-0.2.0/tests/test_endpoint_model.py +153 -0
  45. apifuzz-0.2.0/tests/test_http_client.py +166 -0
  46. apifuzz-0.2.0/tests/test_injection.py +198 -0
  47. apifuzz-0.2.0/tests/test_mass_assignment.py +274 -0
  48. apifuzz-0.2.0/tests/test_phase1_complete.py +196 -0
  49. apifuzz-0.2.0/tests/test_phase1_framework.py +382 -0
  50. apifuzz-0.2.0/tests/test_rate_limit.py +157 -0
  51. apifuzz-0.2.0/tests/test_review_fixes.py +430 -0
  52. apifuzz-0.2.0/tests/test_spec_loader.py +150 -0
  53. apifuzz-0.2.0/uv.lock +1094 -0
@@ -0,0 +1,4 @@
1
+ # Keep the build context small and free of secrets: the lab image only needs the lab app and its spec.
2
+ *
3
+ !lab/app.py
4
+ !lab/openapi.json
@@ -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,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
@@ -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
@@ -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).