deploy-checkup 0.1.1__tar.gz → 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.
- {deploy_checkup-0.1.1/src/deploy_checkup.egg-info → deploy_checkup-0.2.0}/PKG-INFO +40 -8
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/README.md +39 -7
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/pyproject.toml +1 -1
- deploy_checkup-0.2.0/src/deploy_checkup/checks/rate_limit.py +96 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/cli.py +28 -5
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/runner.py +22 -5
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0/src/deploy_checkup.egg-info}/PKG-INFO +40 -8
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup.egg-info/SOURCES.txt +2 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_cli.py +12 -1
- deploy_checkup-0.2.0/tests/test_rate_limit.py +92 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_runner.py +33 -1
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/LICENSE +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/setup.cfg +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/__init__.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/__main__.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/__init__.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/cold_start.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/cors.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/exposed_files.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/headers.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/health.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/https.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/reachable.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/hosts.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/models.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/report.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup.egg-info/dependency_links.txt +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup.egg-info/entry_points.txt +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup.egg-info/requires.txt +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup.egg-info/top_level.txt +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_cold_start.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_cors.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_exposed_files.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_headers.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_health.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_hosts.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_https.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_models.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_reachable.py +0 -0
- {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_report.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deploy-checkup
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Check whether your deployed app is set up safely, in plain language, with fixes for your host.
|
|
5
5
|
Author: Nandhagopal
|
|
6
6
|
License-Expression: MIT
|
|
@@ -58,6 +58,8 @@ deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrende
|
|
|
58
58
|
| `--api` | Your API's address, if it is separate |
|
|
59
59
|
| `--health-path` | The API's health check path (default `/health`) |
|
|
60
60
|
| `--yes-i-own-it` | Run the probing checks without asking (for CI) |
|
|
61
|
+
| `--active` | Also run the active rate-limit test (needs your permission too) |
|
|
62
|
+
| `--rate-limit-path` | The path the rate-limit test sends its burst to (default: the health path) |
|
|
61
63
|
| `--json` | Print the findings as JSON instead of a coloured report |
|
|
62
64
|
| `-V`, `--version` | Show the version |
|
|
63
65
|
| `-h`, `--help` | Show help |
|
|
@@ -88,11 +90,28 @@ The command exits with code `1` if anything fails and `0` otherwise.
|
|
|
88
90
|
|
|
89
91
|
### Use it in CI
|
|
90
92
|
|
|
91
|
-
Run it after each deploy, so a broken setup fails the pipeline.
|
|
93
|
+
Run it after each deploy, so a broken setup fails the pipeline. In GitHub Actions, use the action:
|
|
92
94
|
|
|
93
95
|
```yaml
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
+
- uses: Nandhagopal2912/deploy-checkup@v0.2.0
|
|
97
|
+
with:
|
|
98
|
+
url: https://your-frontend.onrender.com
|
|
99
|
+
api: https://your-api.onrender.com
|
|
100
|
+
i-own-it: "true"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| Input | Meaning |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `url` | Your frontend's address (required) |
|
|
106
|
+
| `api` | Your API's address, if it is separate |
|
|
107
|
+
| `health-path` | The API's health check path (default `/health`) |
|
|
108
|
+
| `i-own-it` | `"true"` also runs the probing checks. Only for apps you own or have permission to test. |
|
|
109
|
+
|
|
110
|
+
Anywhere else, install and run the command directly:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install deploy-checkup
|
|
114
|
+
deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrender.com --yes-i-own-it
|
|
96
115
|
```
|
|
97
116
|
|
|
98
117
|
`--yes-i-own-it` runs the probing checks without the question nobody can answer in CI. Leave it out to run only the gentle checks.
|
|
@@ -108,6 +127,7 @@ Run it after each deploy, so a broken setup fails the pipeline. For example, in
|
|
|
108
127
|
| **health** | API | Calls the health path 3 times. Does it exist, and what's the median response time? |
|
|
109
128
|
| **cold_start** | API | Was the first of those requests much slower than the rest (a free tier waking up)? Reported as a hint, not a verdict. |
|
|
110
129
|
| **exposed_files** | frontend and API | Can anyone download `/.env` or `/.git/config`? **Probing:** runs only after you confirm you own the site. |
|
|
130
|
+
| **rate_limit** | API (or frontend) | Once the rate limit blocks you, does a fake `X-Forwarded-For` get you unblocked? **Active:** runs only with `--active` and your permission. |
|
|
111
131
|
|
|
112
132
|
The headers check skips the API on purpose: a JSON API doesn't need CSP or X-Frame-Options, and flagging them would be a false alarm.
|
|
113
133
|
|
|
@@ -117,16 +137,28 @@ The headers check skips the API on purpose: a JSON API doesn't need CSP or X-Fra
|
|
|
117
137
|
|
|
118
138
|
Most checks send ordinary requests any browser could send. The exposed files check asks for paths like `/.env`, which on someone else's site can look like an attack. So deploy-checkup asks *"Do you own this site?"* before running it. If nobody can answer (in CI, for example), it is skipped and the report says so, unless you pass `--yes-i-own-it`. A leaked file's contents are never shown in the report.
|
|
119
139
|
|
|
140
|
+
### The active rate-limit test
|
|
141
|
+
|
|
142
|
+
Many apps limit how often one visitor can call them, and identify the visitor by the `X-Forwarded-For` header. But visitors can write that header themselves. If the app trusts the first entry, anyone can bypass the limit by sending a different fake IP each time.
|
|
143
|
+
|
|
144
|
+
With `--active`, deploy-checkup:
|
|
145
|
+
|
|
146
|
+
1. sends normal requests until the app answers `429 Too Many Requests` (at most 60),
|
|
147
|
+
2. sends one request with a fake `X-Forwarded-For: 203.0.113.7` (an address reserved for documentation, never a real visitor),
|
|
148
|
+
3. sends one more normal request, to confirm you're still blocked.
|
|
149
|
+
|
|
150
|
+
If the fake request gets through while you're still blocked, the limit can be bypassed. The test defaults to the health path, because it's cheap to call; never point `--rate-limit-path` at an endpoint that costs money per request. Afterwards, your own IP may stay blocked on that app for up to a minute.
|
|
151
|
+
|
|
120
152
|
### Polite by design
|
|
121
153
|
|
|
122
|
-
Every request uses a 60-second timeout (Render's free tier can take about 50 seconds to wake up) and identifies itself as `User-Agent: DeployCheckup/<version>`, so site owners can see what called them. A
|
|
154
|
+
Every request uses a 60-second timeout (Render's free tier can take about 50 seconds to wake up) and identifies itself as `User-Agent: DeployCheckup/<version>`, so site owners can see what called them. A normal run sends a few dozen ordinary requests; `--active` adds at most 62 more.
|
|
123
155
|
|
|
124
156
|
### What it doesn't do
|
|
125
157
|
|
|
126
158
|
deploy-checkup is a quick first look, not a full security audit:
|
|
127
159
|
|
|
128
|
-
- It
|
|
129
|
-
- CORS is tested on the one API address you give it, and
|
|
160
|
+
- It doesn't try to break in, and it isn't a replacement for tools like OWASP ZAP. Apart from the opt-in rate-limit test, it only sends ordinary requests.
|
|
161
|
+
- CORS is tested on the one API address you give it, health on one path, and the rate limit on one path with one fake header (`X-Forwarded-For`).
|
|
130
162
|
- Cold start detection is a guess from a few timings.
|
|
131
163
|
- Fix hints are written for Render first; other hosts get general advice.
|
|
132
164
|
|
|
@@ -141,7 +173,7 @@ deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrende
|
|
|
141
173
|
```json
|
|
142
174
|
{
|
|
143
175
|
"tool": "deploy-checkup",
|
|
144
|
-
"version": "0.
|
|
176
|
+
"version": "0.2.0",
|
|
145
177
|
"targets": [
|
|
146
178
|
{
|
|
147
179
|
"url": "https://your-api.onrender.com",
|
|
@@ -28,6 +28,8 @@ deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrende
|
|
|
28
28
|
| `--api` | Your API's address, if it is separate |
|
|
29
29
|
| `--health-path` | The API's health check path (default `/health`) |
|
|
30
30
|
| `--yes-i-own-it` | Run the probing checks without asking (for CI) |
|
|
31
|
+
| `--active` | Also run the active rate-limit test (needs your permission too) |
|
|
32
|
+
| `--rate-limit-path` | The path the rate-limit test sends its burst to (default: the health path) |
|
|
31
33
|
| `--json` | Print the findings as JSON instead of a coloured report |
|
|
32
34
|
| `-V`, `--version` | Show the version |
|
|
33
35
|
| `-h`, `--help` | Show help |
|
|
@@ -58,11 +60,28 @@ The command exits with code `1` if anything fails and `0` otherwise.
|
|
|
58
60
|
|
|
59
61
|
### Use it in CI
|
|
60
62
|
|
|
61
|
-
Run it after each deploy, so a broken setup fails the pipeline.
|
|
63
|
+
Run it after each deploy, so a broken setup fails the pipeline. In GitHub Actions, use the action:
|
|
62
64
|
|
|
63
65
|
```yaml
|
|
64
|
-
-
|
|
65
|
-
|
|
66
|
+
- uses: Nandhagopal2912/deploy-checkup@v0.2.0
|
|
67
|
+
with:
|
|
68
|
+
url: https://your-frontend.onrender.com
|
|
69
|
+
api: https://your-api.onrender.com
|
|
70
|
+
i-own-it: "true"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
| Input | Meaning |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `url` | Your frontend's address (required) |
|
|
76
|
+
| `api` | Your API's address, if it is separate |
|
|
77
|
+
| `health-path` | The API's health check path (default `/health`) |
|
|
78
|
+
| `i-own-it` | `"true"` also runs the probing checks. Only for apps you own or have permission to test. |
|
|
79
|
+
|
|
80
|
+
Anywhere else, install and run the command directly:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install deploy-checkup
|
|
84
|
+
deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrender.com --yes-i-own-it
|
|
66
85
|
```
|
|
67
86
|
|
|
68
87
|
`--yes-i-own-it` runs the probing checks without the question nobody can answer in CI. Leave it out to run only the gentle checks.
|
|
@@ -78,6 +97,7 @@ Run it after each deploy, so a broken setup fails the pipeline. For example, in
|
|
|
78
97
|
| **health** | API | Calls the health path 3 times. Does it exist, and what's the median response time? |
|
|
79
98
|
| **cold_start** | API | Was the first of those requests much slower than the rest (a free tier waking up)? Reported as a hint, not a verdict. |
|
|
80
99
|
| **exposed_files** | frontend and API | Can anyone download `/.env` or `/.git/config`? **Probing:** runs only after you confirm you own the site. |
|
|
100
|
+
| **rate_limit** | API (or frontend) | Once the rate limit blocks you, does a fake `X-Forwarded-For` get you unblocked? **Active:** runs only with `--active` and your permission. |
|
|
81
101
|
|
|
82
102
|
The headers check skips the API on purpose: a JSON API doesn't need CSP or X-Frame-Options, and flagging them would be a false alarm.
|
|
83
103
|
|
|
@@ -87,16 +107,28 @@ The headers check skips the API on purpose: a JSON API doesn't need CSP or X-Fra
|
|
|
87
107
|
|
|
88
108
|
Most checks send ordinary requests any browser could send. The exposed files check asks for paths like `/.env`, which on someone else's site can look like an attack. So deploy-checkup asks *"Do you own this site?"* before running it. If nobody can answer (in CI, for example), it is skipped and the report says so, unless you pass `--yes-i-own-it`. A leaked file's contents are never shown in the report.
|
|
89
109
|
|
|
110
|
+
### The active rate-limit test
|
|
111
|
+
|
|
112
|
+
Many apps limit how often one visitor can call them, and identify the visitor by the `X-Forwarded-For` header. But visitors can write that header themselves. If the app trusts the first entry, anyone can bypass the limit by sending a different fake IP each time.
|
|
113
|
+
|
|
114
|
+
With `--active`, deploy-checkup:
|
|
115
|
+
|
|
116
|
+
1. sends normal requests until the app answers `429 Too Many Requests` (at most 60),
|
|
117
|
+
2. sends one request with a fake `X-Forwarded-For: 203.0.113.7` (an address reserved for documentation, never a real visitor),
|
|
118
|
+
3. sends one more normal request, to confirm you're still blocked.
|
|
119
|
+
|
|
120
|
+
If the fake request gets through while you're still blocked, the limit can be bypassed. The test defaults to the health path, because it's cheap to call; never point `--rate-limit-path` at an endpoint that costs money per request. Afterwards, your own IP may stay blocked on that app for up to a minute.
|
|
121
|
+
|
|
90
122
|
### Polite by design
|
|
91
123
|
|
|
92
|
-
Every request uses a 60-second timeout (Render's free tier can take about 50 seconds to wake up) and identifies itself as `User-Agent: DeployCheckup/<version>`, so site owners can see what called them. A
|
|
124
|
+
Every request uses a 60-second timeout (Render's free tier can take about 50 seconds to wake up) and identifies itself as `User-Agent: DeployCheckup/<version>`, so site owners can see what called them. A normal run sends a few dozen ordinary requests; `--active` adds at most 62 more.
|
|
93
125
|
|
|
94
126
|
### What it doesn't do
|
|
95
127
|
|
|
96
128
|
deploy-checkup is a quick first look, not a full security audit:
|
|
97
129
|
|
|
98
|
-
- It
|
|
99
|
-
- CORS is tested on the one API address you give it, and
|
|
130
|
+
- It doesn't try to break in, and it isn't a replacement for tools like OWASP ZAP. Apart from the opt-in rate-limit test, it only sends ordinary requests.
|
|
131
|
+
- CORS is tested on the one API address you give it, health on one path, and the rate limit on one path with one fake header (`X-Forwarded-For`).
|
|
100
132
|
- Cold start detection is a guess from a few timings.
|
|
101
133
|
- Fix hints are written for Render first; other hosts get general advice.
|
|
102
134
|
|
|
@@ -111,7 +143,7 @@ deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrende
|
|
|
111
143
|
```json
|
|
112
144
|
{
|
|
113
145
|
"tool": "deploy-checkup",
|
|
114
|
-
"version": "0.
|
|
146
|
+
"version": "0.2.0",
|
|
115
147
|
"targets": [
|
|
116
148
|
{
|
|
117
149
|
"url": "https://your-api.onrender.com",
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "deploy-checkup"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Check whether your deployed app is set up safely, in plain language, with fixes for your host."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from deploy_checkup.models import Finding, Level, could_not_finish
|
|
4
|
+
|
|
5
|
+
CHECK = "rate_limit"
|
|
6
|
+
MAX_REQUESTS = 60 # never send more than this to find the limit
|
|
7
|
+
FAKE_IP = "203.0.113.7" # TEST-NET-3: reserved for documentation, never a real visitor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_rate_limit(client: httpx.Client, url: str, path: str) -> list[Finding]:
|
|
11
|
+
"""Hit the rate limit, then pretend to be someone else with a fake X-Forwarded-For.
|
|
12
|
+
|
|
13
|
+
If the fake header gets through while a normal request is still blocked, anyone
|
|
14
|
+
can bypass the limit by writing a new fake IP into every request.
|
|
15
|
+
"""
|
|
16
|
+
target = httpx.URL(url).join(path)
|
|
17
|
+
try:
|
|
18
|
+
for sent in range(1, MAX_REQUESTS + 1):
|
|
19
|
+
if client.get(target).status_code == 429:
|
|
20
|
+
break
|
|
21
|
+
else:
|
|
22
|
+
return [_no_limit_found(target.path)]
|
|
23
|
+
|
|
24
|
+
spoofed = client.get(target, headers={"X-Forwarded-For": FAKE_IP})
|
|
25
|
+
again = client.get(target)
|
|
26
|
+
except httpx.RequestError as error:
|
|
27
|
+
return [could_not_finish(CHECK, error)]
|
|
28
|
+
|
|
29
|
+
if spoofed.status_code == 429:
|
|
30
|
+
return [
|
|
31
|
+
Finding(
|
|
32
|
+
check=CHECK,
|
|
33
|
+
level=Level.PASS,
|
|
34
|
+
title=(
|
|
35
|
+
f"Rate limit on {target.path} can't be bypassed with a fake "
|
|
36
|
+
f"X-Forwarded-For (limited after {sent} requests)"
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
]
|
|
40
|
+
if again.status_code == 429:
|
|
41
|
+
return [_bypassable(target.path)]
|
|
42
|
+
return [_inconclusive(target.path)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _bypassable(path: str) -> Finding:
|
|
46
|
+
return Finding(
|
|
47
|
+
check=CHECK,
|
|
48
|
+
level=Level.FAIL,
|
|
49
|
+
title=f"Rate limit on {path} can be bypassed with a fake X-Forwarded-For",
|
|
50
|
+
why=(
|
|
51
|
+
"Anyone can send unlimited requests by writing a different fake IP into each one, "
|
|
52
|
+
"so the limit doesn't protect you from abuse or from surprise bills."
|
|
53
|
+
),
|
|
54
|
+
fix=(
|
|
55
|
+
"Read the visitor's IP from the right of X-Forwarded-For, skipping exactly as many "
|
|
56
|
+
"entries as your host's proxies add. Never trust the first entry: the visitor wrote it."
|
|
57
|
+
),
|
|
58
|
+
render_fix=(
|
|
59
|
+
"On Render a request passes Cloudflare, Render's load balancer and an internal proxy, "
|
|
60
|
+
"so the visitor is 3rd from the right of X-Forwarded-For (measured October 2026). "
|
|
61
|
+
"Take that entry, never the first one."
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _no_limit_found(path: str) -> Finding:
|
|
67
|
+
return Finding(
|
|
68
|
+
check=CHECK,
|
|
69
|
+
level=Level.INFO,
|
|
70
|
+
title=f"No rate limit hit on {path} after {MAX_REQUESTS} requests",
|
|
71
|
+
why="Either there's no limit, or it's higher than this test goes.",
|
|
72
|
+
fix=(
|
|
73
|
+
"If this endpoint does real work, such as sending email or calling a paid API, "
|
|
74
|
+
"add a per-visitor rate limit. Use --rate-limit-path to test a limited endpoint."
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _inconclusive(path: str) -> Finding:
|
|
80
|
+
return Finding(
|
|
81
|
+
check=CHECK,
|
|
82
|
+
level=Level.WARNING,
|
|
83
|
+
title=f"Couldn't tell whether the rate limit on {path} can be bypassed",
|
|
84
|
+
why="The limit reset during the test, so the fake X-Forwarded-For result doesn't prove anything.",
|
|
85
|
+
fix="Run the test again in a minute.",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def skipped() -> Finding:
|
|
90
|
+
return Finding(
|
|
91
|
+
check=CHECK,
|
|
92
|
+
level=Level.INFO,
|
|
93
|
+
title="Rate limit test skipped (needs your permission)",
|
|
94
|
+
why="It sends a burst of requests and fake headers, which only the site's owner should do.",
|
|
95
|
+
fix="Run again and answer yes, or pass --yes-i-own-it together with --active.",
|
|
96
|
+
)
|
|
@@ -37,6 +37,15 @@ def main(
|
|
|
37
37
|
action="store_true",
|
|
38
38
|
help="skip the permission question and run the probing checks (for CI)",
|
|
39
39
|
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--active",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="also run the active rate-limit test (sends up to 62 requests; needs your permission)",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--rate-limit-path",
|
|
47
|
+
help="the path the active test sends its burst to (default: the health path)",
|
|
48
|
+
)
|
|
40
49
|
parser.add_argument(
|
|
41
50
|
"--json",
|
|
42
51
|
action="store_true",
|
|
@@ -44,9 +53,17 @@ def main(
|
|
|
44
53
|
)
|
|
45
54
|
args = parser.parse_args(argv)
|
|
46
55
|
|
|
47
|
-
probe = _has_permission(args.url, args.yes_i_own_it, ask)
|
|
56
|
+
probe = _has_permission(args.url, args.yes_i_own_it, args.active, ask)
|
|
48
57
|
with client or make_client() as http:
|
|
49
|
-
results = run_checks(
|
|
58
|
+
results = run_checks(
|
|
59
|
+
http,
|
|
60
|
+
args.url,
|
|
61
|
+
args.api,
|
|
62
|
+
args.health_path,
|
|
63
|
+
probe,
|
|
64
|
+
args.active,
|
|
65
|
+
args.rate_limit_path,
|
|
66
|
+
)
|
|
50
67
|
|
|
51
68
|
if args.json:
|
|
52
69
|
print(to_json(results))
|
|
@@ -55,17 +72,23 @@ def main(
|
|
|
55
72
|
return exit_code(results)
|
|
56
73
|
|
|
57
74
|
|
|
58
|
-
def _has_permission(
|
|
59
|
-
|
|
75
|
+
def _has_permission(
|
|
76
|
+
url: str, yes_i_own_it: bool, active: bool, ask: Callable[[str], str] | None
|
|
77
|
+
) -> bool:
|
|
78
|
+
"""Probing and active checks run only with a flag or a typed "yes"; never by accident."""
|
|
60
79
|
if yes_i_own_it:
|
|
61
80
|
return True
|
|
62
81
|
if ask is None:
|
|
63
82
|
if not sys.stdin.isatty():
|
|
64
83
|
return False # nobody is there to answer, for example in CI
|
|
65
84
|
ask = _ask_on_stderr
|
|
85
|
+
|
|
86
|
+
what = "exposed files like /.env"
|
|
87
|
+
if active:
|
|
88
|
+
what += " and run the rate-limit test (a burst of up to 62 requests)"
|
|
66
89
|
try:
|
|
67
90
|
answer = ask(
|
|
68
|
-
f"Also check {url} for
|
|
91
|
+
f"Also check {url} for {what}? "
|
|
69
92
|
"Only say yes if you own this site or have permission to test it. [y/N] "
|
|
70
93
|
)
|
|
71
94
|
except EOFError:
|
|
@@ -2,7 +2,7 @@ from importlib.metadata import version
|
|
|
2
2
|
|
|
3
3
|
import httpx
|
|
4
4
|
|
|
5
|
-
from deploy_checkup.checks import exposed_files
|
|
5
|
+
from deploy_checkup.checks import exposed_files, rate_limit
|
|
6
6
|
from deploy_checkup.checks.cors import check_cors
|
|
7
7
|
from deploy_checkup.checks.headers import check_headers
|
|
8
8
|
from deploy_checkup.checks.health import check_health
|
|
@@ -26,19 +26,23 @@ def run_checks(
|
|
|
26
26
|
api_url: str | None = None,
|
|
27
27
|
health_path: str = "/health",
|
|
28
28
|
probe: bool = False,
|
|
29
|
+
active: bool = False,
|
|
30
|
+
rate_limit_path: str | None = None,
|
|
29
31
|
) -> dict[str, list[Finding]]:
|
|
30
32
|
"""Run every check and group the findings by the address they're about.
|
|
31
33
|
|
|
32
34
|
probe=True also runs the probing checks, which only the site's owner should run.
|
|
35
|
+
active=True adds the active checks; they also need probe=True (the owner's permission).
|
|
33
36
|
"""
|
|
34
37
|
frontend = check_reachable(client, url) + check_https(client, url) + check_headers(client, url)
|
|
35
38
|
if probe:
|
|
36
39
|
frontend += exposed_files.check_exposed_files(client, url)
|
|
37
40
|
else:
|
|
38
41
|
frontend.append(exposed_files.skipped())
|
|
39
|
-
results = {url:
|
|
42
|
+
results = {url: frontend}
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
has_api = api_url is not None and api_url != url
|
|
45
|
+
if has_api:
|
|
42
46
|
# Health goes first: its first request is the one that can catch a cold start.
|
|
43
47
|
api = (
|
|
44
48
|
check_health(client, api_url, health_path)
|
|
@@ -47,10 +51,23 @@ def run_checks(
|
|
|
47
51
|
)
|
|
48
52
|
if probe:
|
|
49
53
|
api += exposed_files.check_exposed_files(client, api_url)
|
|
50
|
-
results[api_url] =
|
|
54
|
+
results[api_url] = api
|
|
51
55
|
|
|
52
|
-
|
|
56
|
+
if active:
|
|
57
|
+
# Last, so the burst of requests can't slow down or block the other checks.
|
|
58
|
+
target = api_url if has_api else url
|
|
59
|
+
if probe:
|
|
60
|
+
results[target] += rate_limit.check_rate_limit(
|
|
61
|
+
client, target, rate_limit_path or health_path
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
results[target].append(rate_limit.skipped())
|
|
53
65
|
|
|
66
|
+
return {
|
|
67
|
+
address: fixes_for_host(findings, detect_host(client, address))
|
|
68
|
+
for address, findings in results.items()
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
|
|
55
72
|
def exit_code(results: dict[str, list[Finding]]) -> int:
|
|
56
73
|
"""1 if anything failed, otherwise 0, so CI can stop a broken deploy."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deploy-checkup
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Check whether your deployed app is set up safely, in plain language, with fixes for your host.
|
|
5
5
|
Author: Nandhagopal
|
|
6
6
|
License-Expression: MIT
|
|
@@ -58,6 +58,8 @@ deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrende
|
|
|
58
58
|
| `--api` | Your API's address, if it is separate |
|
|
59
59
|
| `--health-path` | The API's health check path (default `/health`) |
|
|
60
60
|
| `--yes-i-own-it` | Run the probing checks without asking (for CI) |
|
|
61
|
+
| `--active` | Also run the active rate-limit test (needs your permission too) |
|
|
62
|
+
| `--rate-limit-path` | The path the rate-limit test sends its burst to (default: the health path) |
|
|
61
63
|
| `--json` | Print the findings as JSON instead of a coloured report |
|
|
62
64
|
| `-V`, `--version` | Show the version |
|
|
63
65
|
| `-h`, `--help` | Show help |
|
|
@@ -88,11 +90,28 @@ The command exits with code `1` if anything fails and `0` otherwise.
|
|
|
88
90
|
|
|
89
91
|
### Use it in CI
|
|
90
92
|
|
|
91
|
-
Run it after each deploy, so a broken setup fails the pipeline.
|
|
93
|
+
Run it after each deploy, so a broken setup fails the pipeline. In GitHub Actions, use the action:
|
|
92
94
|
|
|
93
95
|
```yaml
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
+
- uses: Nandhagopal2912/deploy-checkup@v0.2.0
|
|
97
|
+
with:
|
|
98
|
+
url: https://your-frontend.onrender.com
|
|
99
|
+
api: https://your-api.onrender.com
|
|
100
|
+
i-own-it: "true"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| Input | Meaning |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `url` | Your frontend's address (required) |
|
|
106
|
+
| `api` | Your API's address, if it is separate |
|
|
107
|
+
| `health-path` | The API's health check path (default `/health`) |
|
|
108
|
+
| `i-own-it` | `"true"` also runs the probing checks. Only for apps you own or have permission to test. |
|
|
109
|
+
|
|
110
|
+
Anywhere else, install and run the command directly:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install deploy-checkup
|
|
114
|
+
deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrender.com --yes-i-own-it
|
|
96
115
|
```
|
|
97
116
|
|
|
98
117
|
`--yes-i-own-it` runs the probing checks without the question nobody can answer in CI. Leave it out to run only the gentle checks.
|
|
@@ -108,6 +127,7 @@ Run it after each deploy, so a broken setup fails the pipeline. For example, in
|
|
|
108
127
|
| **health** | API | Calls the health path 3 times. Does it exist, and what's the median response time? |
|
|
109
128
|
| **cold_start** | API | Was the first of those requests much slower than the rest (a free tier waking up)? Reported as a hint, not a verdict. |
|
|
110
129
|
| **exposed_files** | frontend and API | Can anyone download `/.env` or `/.git/config`? **Probing:** runs only after you confirm you own the site. |
|
|
130
|
+
| **rate_limit** | API (or frontend) | Once the rate limit blocks you, does a fake `X-Forwarded-For` get you unblocked? **Active:** runs only with `--active` and your permission. |
|
|
111
131
|
|
|
112
132
|
The headers check skips the API on purpose: a JSON API doesn't need CSP or X-Frame-Options, and flagging them would be a false alarm.
|
|
113
133
|
|
|
@@ -117,16 +137,28 @@ The headers check skips the API on purpose: a JSON API doesn't need CSP or X-Fra
|
|
|
117
137
|
|
|
118
138
|
Most checks send ordinary requests any browser could send. The exposed files check asks for paths like `/.env`, which on someone else's site can look like an attack. So deploy-checkup asks *"Do you own this site?"* before running it. If nobody can answer (in CI, for example), it is skipped and the report says so, unless you pass `--yes-i-own-it`. A leaked file's contents are never shown in the report.
|
|
119
139
|
|
|
140
|
+
### The active rate-limit test
|
|
141
|
+
|
|
142
|
+
Many apps limit how often one visitor can call them, and identify the visitor by the `X-Forwarded-For` header. But visitors can write that header themselves. If the app trusts the first entry, anyone can bypass the limit by sending a different fake IP each time.
|
|
143
|
+
|
|
144
|
+
With `--active`, deploy-checkup:
|
|
145
|
+
|
|
146
|
+
1. sends normal requests until the app answers `429 Too Many Requests` (at most 60),
|
|
147
|
+
2. sends one request with a fake `X-Forwarded-For: 203.0.113.7` (an address reserved for documentation, never a real visitor),
|
|
148
|
+
3. sends one more normal request, to confirm you're still blocked.
|
|
149
|
+
|
|
150
|
+
If the fake request gets through while you're still blocked, the limit can be bypassed. The test defaults to the health path, because it's cheap to call; never point `--rate-limit-path` at an endpoint that costs money per request. Afterwards, your own IP may stay blocked on that app for up to a minute.
|
|
151
|
+
|
|
120
152
|
### Polite by design
|
|
121
153
|
|
|
122
|
-
Every request uses a 60-second timeout (Render's free tier can take about 50 seconds to wake up) and identifies itself as `User-Agent: DeployCheckup/<version>`, so site owners can see what called them. A
|
|
154
|
+
Every request uses a 60-second timeout (Render's free tier can take about 50 seconds to wake up) and identifies itself as `User-Agent: DeployCheckup/<version>`, so site owners can see what called them. A normal run sends a few dozen ordinary requests; `--active` adds at most 62 more.
|
|
123
155
|
|
|
124
156
|
### What it doesn't do
|
|
125
157
|
|
|
126
158
|
deploy-checkup is a quick first look, not a full security audit:
|
|
127
159
|
|
|
128
|
-
- It
|
|
129
|
-
- CORS is tested on the one API address you give it, and
|
|
160
|
+
- It doesn't try to break in, and it isn't a replacement for tools like OWASP ZAP. Apart from the opt-in rate-limit test, it only sends ordinary requests.
|
|
161
|
+
- CORS is tested on the one API address you give it, health on one path, and the rate limit on one path with one fake header (`X-Forwarded-For`).
|
|
130
162
|
- Cold start detection is a guess from a few timings.
|
|
131
163
|
- Fix hints are written for Render first; other hosts get general advice.
|
|
132
164
|
|
|
@@ -141,7 +173,7 @@ deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrende
|
|
|
141
173
|
```json
|
|
142
174
|
{
|
|
143
175
|
"tool": "deploy-checkup",
|
|
144
|
-
"version": "0.
|
|
176
|
+
"version": "0.2.0",
|
|
145
177
|
"targets": [
|
|
146
178
|
{
|
|
147
179
|
"url": "https://your-api.onrender.com",
|
|
@@ -21,6 +21,7 @@ src/deploy_checkup/checks/exposed_files.py
|
|
|
21
21
|
src/deploy_checkup/checks/headers.py
|
|
22
22
|
src/deploy_checkup/checks/health.py
|
|
23
23
|
src/deploy_checkup/checks/https.py
|
|
24
|
+
src/deploy_checkup/checks/rate_limit.py
|
|
24
25
|
src/deploy_checkup/checks/reachable.py
|
|
25
26
|
tests/test_cli.py
|
|
26
27
|
tests/test_cold_start.py
|
|
@@ -31,6 +32,7 @@ tests/test_health.py
|
|
|
31
32
|
tests/test_hosts.py
|
|
32
33
|
tests/test_https.py
|
|
33
34
|
tests/test_models.py
|
|
35
|
+
tests/test_rate_limit.py
|
|
34
36
|
tests/test_reachable.py
|
|
35
37
|
tests/test_report.py
|
|
36
38
|
tests/test_runner.py
|
|
@@ -105,4 +105,15 @@ def test_permission_question_goes_to_stderr(capsys, monkeypatch):
|
|
|
105
105
|
captured = capsys.readouterr()
|
|
106
106
|
assert answer == "y"
|
|
107
107
|
assert captured.err == "Own it? "
|
|
108
|
-
assert captured.out == ""
|
|
108
|
+
assert captured.out == ""
|
|
109
|
+
|
|
110
|
+
def test_active_question_mentions_the_burst(capsys):
|
|
111
|
+
questions = []
|
|
112
|
+
|
|
113
|
+
def remember(prompt):
|
|
114
|
+
questions.append(prompt)
|
|
115
|
+
return "n"
|
|
116
|
+
|
|
117
|
+
main(["https://example.com", "--active"], client=fake_client(healthy_site), ask=remember)
|
|
118
|
+
|
|
119
|
+
assert "rate-limit test" in questions[0]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from deploy_checkup.checks.rate_limit import FAKE_IP, MAX_REQUESTS, check_rate_limit
|
|
6
|
+
from deploy_checkup.models import Level
|
|
7
|
+
|
|
8
|
+
URL = "https://api.example.com"
|
|
9
|
+
LIMIT = 5
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def limiter(trusts_first_entry):
|
|
13
|
+
"""A fake API with a 5-request limit per visitor.
|
|
14
|
+
|
|
15
|
+
trusts_first_entry=True copies the bug: it believes the first X-Forwarded-For entry.
|
|
16
|
+
"""
|
|
17
|
+
hits = defaultdict(int)
|
|
18
|
+
|
|
19
|
+
def handler(request):
|
|
20
|
+
forwarded = request.headers.get("x-forwarded-for")
|
|
21
|
+
visitor = forwarded.split(",")[0] if trusts_first_entry and forwarded else "real-visitor"
|
|
22
|
+
hits[visitor] += 1
|
|
23
|
+
return httpx.Response(429 if hits[visitor] > LIMIT else 200)
|
|
24
|
+
|
|
25
|
+
return handler
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check(handler):
|
|
29
|
+
return check_rate_limit(httpx.Client(transport=httpx.MockTransport(handler)), URL, "/health")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_limit_that_trusts_the_first_entry_fails():
|
|
33
|
+
[finding] = check(limiter(trusts_first_entry=True))
|
|
34
|
+
|
|
35
|
+
assert finding.level == Level.FAIL
|
|
36
|
+
assert "/health" in finding.title
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_limit_that_ignores_fake_entries_passes():
|
|
40
|
+
[finding] = check(limiter(trusts_first_entry=False))
|
|
41
|
+
|
|
42
|
+
assert finding.level == Level.PASS
|
|
43
|
+
assert f"limited after {LIMIT + 1} requests" in finding.title
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_no_limit_stops_at_the_cap():
|
|
47
|
+
calls = []
|
|
48
|
+
|
|
49
|
+
def handler(request):
|
|
50
|
+
calls.append(request)
|
|
51
|
+
return httpx.Response(200)
|
|
52
|
+
|
|
53
|
+
[finding] = check(handler)
|
|
54
|
+
|
|
55
|
+
assert finding.level == Level.INFO
|
|
56
|
+
assert len(calls) == MAX_REQUESTS
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_fake_ip_is_a_documentation_address():
|
|
60
|
+
seen = []
|
|
61
|
+
api = limiter(trusts_first_entry=False)
|
|
62
|
+
|
|
63
|
+
def handler(request):
|
|
64
|
+
seen.append(request.headers.get("x-forwarded-for"))
|
|
65
|
+
return api(request)
|
|
66
|
+
|
|
67
|
+
check(handler)
|
|
68
|
+
|
|
69
|
+
assert FAKE_IP.startswith("203.0.113.")
|
|
70
|
+
assert seen.count(FAKE_IP) == 1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_limit_resetting_mid_test_is_inconclusive():
|
|
74
|
+
calls = []
|
|
75
|
+
|
|
76
|
+
def handler(request):
|
|
77
|
+
calls.append(request)
|
|
78
|
+
return httpx.Response(429 if len(calls) == LIMIT + 1 else 200)
|
|
79
|
+
|
|
80
|
+
[finding] = check(handler)
|
|
81
|
+
|
|
82
|
+
assert finding.level == Level.WARNING
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_network_error_is_reported_not_hidden():
|
|
86
|
+
def handler(request):
|
|
87
|
+
raise httpx.ReadTimeout("timed out", request=request)
|
|
88
|
+
|
|
89
|
+
[finding] = check(handler)
|
|
90
|
+
|
|
91
|
+
assert finding.level == Level.INFO
|
|
92
|
+
assert "Couldn't finish the rate_limit check" in finding.title
|
|
@@ -47,4 +47,36 @@ def test_same_address_for_frontend_and_api_keeps_frontend_findings():
|
|
|
47
47
|
results = run_checks(client, "https://example.com", "https://example.com")
|
|
48
48
|
|
|
49
49
|
assert list(results) == ["https://example.com"]
|
|
50
|
-
assert any(f.check == "headers" for f in results["https://example.com"])
|
|
50
|
+
assert any(f.check == "headers" for f in results["https://example.com"])
|
|
51
|
+
|
|
52
|
+
def test_active_test_runs_on_the_api_with_permission():
|
|
53
|
+
client = fake_client(lambda request: httpx.Response(200))
|
|
54
|
+
|
|
55
|
+
results = run_checks(
|
|
56
|
+
client, "https://example.com", "https://api.example.com", probe=True, active=True
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
assert any(f.check == "rate_limit" for f in results["https://api.example.com"])
|
|
60
|
+
assert not any(f.check == "rate_limit" for f in results["https://example.com"])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_active_test_is_skipped_without_permission():
|
|
64
|
+
calls = []
|
|
65
|
+
|
|
66
|
+
def handler(request):
|
|
67
|
+
calls.append(request)
|
|
68
|
+
return httpx.Response(200)
|
|
69
|
+
|
|
70
|
+
results = run_checks(fake_client(handler), "https://example.com", active=True)
|
|
71
|
+
|
|
72
|
+
[skipped] = [f for f in results["https://example.com"] if f.check == "rate_limit"]
|
|
73
|
+
assert skipped.level == Level.INFO
|
|
74
|
+
assert len(calls) < 20
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_no_active_test_unless_asked():
|
|
78
|
+
client = fake_client(lambda request: httpx.Response(200))
|
|
79
|
+
|
|
80
|
+
results = run_checks(client, "https://example.com", probe=True)
|
|
81
|
+
|
|
82
|
+
assert not any(f.check == "rate_limit" for f in results["https://example.com"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|