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.
Files changed (40) hide show
  1. {deploy_checkup-0.1.1/src/deploy_checkup.egg-info → deploy_checkup-0.2.0}/PKG-INFO +40 -8
  2. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/README.md +39 -7
  3. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/pyproject.toml +1 -1
  4. deploy_checkup-0.2.0/src/deploy_checkup/checks/rate_limit.py +96 -0
  5. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/cli.py +28 -5
  6. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/runner.py +22 -5
  7. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0/src/deploy_checkup.egg-info}/PKG-INFO +40 -8
  8. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup.egg-info/SOURCES.txt +2 -0
  9. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_cli.py +12 -1
  10. deploy_checkup-0.2.0/tests/test_rate_limit.py +92 -0
  11. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_runner.py +33 -1
  12. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/LICENSE +0 -0
  13. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/setup.cfg +0 -0
  14. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/__init__.py +0 -0
  15. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/__main__.py +0 -0
  16. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/__init__.py +0 -0
  17. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/cold_start.py +0 -0
  18. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/cors.py +0 -0
  19. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/exposed_files.py +0 -0
  20. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/headers.py +0 -0
  21. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/health.py +0 -0
  22. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/https.py +0 -0
  23. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/checks/reachable.py +0 -0
  24. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/hosts.py +0 -0
  25. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/models.py +0 -0
  26. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup/report.py +0 -0
  27. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup.egg-info/dependency_links.txt +0 -0
  28. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup.egg-info/entry_points.txt +0 -0
  29. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup.egg-info/requires.txt +0 -0
  30. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/src/deploy_checkup.egg-info/top_level.txt +0 -0
  31. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_cold_start.py +0 -0
  32. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_cors.py +0 -0
  33. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_exposed_files.py +0 -0
  34. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_headers.py +0 -0
  35. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_health.py +0 -0
  36. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_hosts.py +0 -0
  37. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_https.py +0 -0
  38. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_models.py +0 -0
  39. {deploy_checkup-0.1.1 → deploy_checkup-0.2.0}/tests/test_reachable.py +0 -0
  40. {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.1.1
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. For example, in GitHub Actions:
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
- - run: pip install deploy-checkup
95
- - run: deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrender.com --yes-i-own-it
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 full run sends a few dozen ordinary requests.
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 only sends ordinary requests. It doesn't try to break in, and it isn't a replacement for tools like OWASP ZAP.
129
- - CORS is tested on the one API address you give it, and health on one path.
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.1.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. For example, in GitHub Actions:
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
- - run: pip install deploy-checkup
65
- - run: deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrender.com --yes-i-own-it
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 full run sends a few dozen ordinary requests.
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 only sends ordinary requests. It doesn't try to break in, and it isn't a replacement for tools like OWASP ZAP.
99
- - CORS is tested on the one API address you give it, and health on one path.
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.1.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.1.1"
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(http, args.url, args.api, args.health_path, probe)
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(url: str, yes_i_own_it: bool, ask: Callable[[str], str] | None) -> bool:
59
- """Probing checks run only with a flag or a typed "yes"; never by accident."""
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 exposed files like /.env? "
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: fixes_for_host(frontend, detect_host(client, url))}
42
+ results = {url: frontend}
40
43
 
41
- if api_url and api_url != url:
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] = fixes_for_host(api, detect_host(client, api_url))
54
+ results[api_url] = api
51
55
 
52
- return results
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.1.1
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. For example, in GitHub Actions:
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
- - run: pip install deploy-checkup
95
- - run: deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrender.com --yes-i-own-it
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 full run sends a few dozen ordinary requests.
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 only sends ordinary requests. It doesn't try to break in, and it isn't a replacement for tools like OWASP ZAP.
129
- - CORS is tested on the one API address you give it, and health on one path.
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.1.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