deploy-checkup 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deploy_checkup/__init__.py +0 -0
- deploy_checkup/__main__.py +3 -0
- deploy_checkup/checks/__init__.py +0 -0
- deploy_checkup/checks/cold_start.py +37 -0
- deploy_checkup/checks/cors.py +85 -0
- deploy_checkup/checks/exposed_files.py +65 -0
- deploy_checkup/checks/headers.py +140 -0
- deploy_checkup/checks/health.py +106 -0
- deploy_checkup/checks/https.py +79 -0
- deploy_checkup/checks/reachable.py +59 -0
- deploy_checkup/cli.py +83 -0
- deploy_checkup/hosts.py +27 -0
- deploy_checkup/models.py +19 -0
- deploy_checkup/report.py +72 -0
- deploy_checkup/runner.py +60 -0
- deploy_checkup-0.1.0.dist-info/METADATA +178 -0
- deploy_checkup-0.1.0.dist-info/RECORD +21 -0
- deploy_checkup-0.1.0.dist-info/WHEEL +5 -0
- deploy_checkup-0.1.0.dist-info/entry_points.txt +2 -0
- deploy_checkup-0.1.0.dist-info/licenses/LICENSE +21 -0
- deploy_checkup-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from statistics import median
|
|
2
|
+
|
|
3
|
+
from deploy_checkup.models import Finding, Level
|
|
4
|
+
|
|
5
|
+
CHECK = "cold_start"
|
|
6
|
+
COLD_MS = 5000 # a first request this slow usually means the server was asleep
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_cold_start(times_ms: list[float]) -> Finding:
|
|
10
|
+
"""Compare the first response time with the ones after it."""
|
|
11
|
+
first = round(times_ms[0])
|
|
12
|
+
warm = round(median(times_ms[1:]))
|
|
13
|
+
|
|
14
|
+
if first >= COLD_MS and first > 3 * warm:
|
|
15
|
+
return Finding(
|
|
16
|
+
check=CHECK,
|
|
17
|
+
level=Level.INFO,
|
|
18
|
+
title=f"First request took {first / 1000:.1f} s, later ones {warm} ms: free tier waking up?",
|
|
19
|
+
why=(
|
|
20
|
+
"Free hosting plans often put an app to sleep when nobody visits, "
|
|
21
|
+
"and the next visitor waits while it starts again."
|
|
22
|
+
),
|
|
23
|
+
fix=(
|
|
24
|
+
"Say so in your README so visitors know to wait, or use a plan that "
|
|
25
|
+
"keeps the server running."
|
|
26
|
+
),
|
|
27
|
+
render_fix=(
|
|
28
|
+
"Render's free tier sleeps after 15 minutes without visitors. Say so in "
|
|
29
|
+
"your README, or move the service to a paid instance type to keep it awake."
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return Finding(
|
|
34
|
+
check=CHECK,
|
|
35
|
+
level=Level.PASS,
|
|
36
|
+
title=f"No cold start this time (first request {first} ms)",
|
|
37
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from deploy_checkup.models import Finding, Level
|
|
4
|
+
|
|
5
|
+
CHECK = "cors"
|
|
6
|
+
MADE_UP_SITE = "https://deploy-checkup-test.invalid" # .invalid can never be a real website
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_cors(client: httpx.Client, api_url: str, frontend_url: str) -> list[Finding]:
|
|
10
|
+
"""Ask the API as your frontend, then as a made-up site, and compare the answers."""
|
|
11
|
+
frontend = _origin(frontend_url)
|
|
12
|
+
try:
|
|
13
|
+
as_frontend = client.get(api_url, headers={"Origin": frontend})
|
|
14
|
+
as_stranger = client.get(api_url, headers={"Origin": MADE_UP_SITE})
|
|
15
|
+
except httpx.RequestError:
|
|
16
|
+
return [] # the other checks already report connection problems
|
|
17
|
+
|
|
18
|
+
return [
|
|
19
|
+
_check_own_frontend(as_frontend, frontend),
|
|
20
|
+
_check_other_sites(as_stranger),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _origin(url: str) -> str:
|
|
25
|
+
"""The part of a URL a browser sends as Origin: scheme, host and (if unusual) port."""
|
|
26
|
+
parsed = httpx.URL(url)
|
|
27
|
+
origin = f"{parsed.scheme}://{parsed.host}"
|
|
28
|
+
if parsed.port is not None:
|
|
29
|
+
origin += f":{parsed.port}"
|
|
30
|
+
return origin
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _check_own_frontend(response: httpx.Response, frontend: str) -> Finding:
|
|
34
|
+
allowed = response.headers.get("access-control-allow-origin")
|
|
35
|
+
if allowed in (frontend, "*"):
|
|
36
|
+
return Finding(check=CHECK, level=Level.PASS, title=f"API allows your frontend ({frontend})")
|
|
37
|
+
|
|
38
|
+
return Finding(
|
|
39
|
+
check=CHECK,
|
|
40
|
+
level=Level.WARNING,
|
|
41
|
+
title=f"API doesn't allow your frontend ({frontend})",
|
|
42
|
+
why=(
|
|
43
|
+
"Browsers will block your frontend from reading the API's answers, "
|
|
44
|
+
"so pages that load data will break."
|
|
45
|
+
),
|
|
46
|
+
fix=(
|
|
47
|
+
f"Add {frontend} to your API's allowed origins (often set through an "
|
|
48
|
+
"environment variable), then redeploy."
|
|
49
|
+
),
|
|
50
|
+
render_fix=(
|
|
51
|
+
f"Add {frontend} to your API's allowed origins, for example through an "
|
|
52
|
+
"environment variable in the Render dashboard, then redeploy."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _check_other_sites(response: httpx.Response) -> Finding:
|
|
58
|
+
allowed = response.headers.get("access-control-allow-origin")
|
|
59
|
+
with_cookies = response.headers.get("access-control-allow-credentials", "").lower() == "true"
|
|
60
|
+
|
|
61
|
+
if allowed is None:
|
|
62
|
+
return Finding(check=CHECK, level=Level.PASS, title="API blocks other websites")
|
|
63
|
+
|
|
64
|
+
if allowed == MADE_UP_SITE and with_cookies:
|
|
65
|
+
return Finding(
|
|
66
|
+
check=CHECK,
|
|
67
|
+
level=Level.FAIL,
|
|
68
|
+
title="API lets any website read it with your visitors' cookies",
|
|
69
|
+
why=(
|
|
70
|
+
"A stranger's site can call your API from a logged-in visitor's browser "
|
|
71
|
+
"and read their private data."
|
|
72
|
+
),
|
|
73
|
+
fix=(
|
|
74
|
+
"List your real frontend address as the only allowed origin. "
|
|
75
|
+
"Never combine 'allow every origin' with allow_credentials."
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return Finding(
|
|
80
|
+
check=CHECK,
|
|
81
|
+
level=Level.WARNING,
|
|
82
|
+
title=f"API accepts requests from any website (Access-Control-Allow-Origin: {allowed})",
|
|
83
|
+
why="Any site can read your API's answers. That's fine for public data, risky for anything else.",
|
|
84
|
+
fix="If the API isn't meant to be public, allow only your frontend's address.",
|
|
85
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from deploy_checkup.models import Finding, Level
|
|
6
|
+
|
|
7
|
+
CHECK = "exposed_files"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _looks_like_env(body: str) -> bool:
|
|
11
|
+
return re.search(r"^[A-Z][A-Z0-9_]*=", body, re.MULTILINE) is not None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _looks_like_git_config(body: str) -> bool:
|
|
15
|
+
return "[core]" in body
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Each path, and how to recognise the real file (so a friendly 200 page isn't a false alarm).
|
|
19
|
+
PATHS = {
|
|
20
|
+
"/.env": _looks_like_env,
|
|
21
|
+
"/.git/config": _looks_like_git_config,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_exposed_files(client: httpx.Client, url: str) -> list[Finding]:
|
|
26
|
+
"""Ask for a few files that should never be public, and check what comes back."""
|
|
27
|
+
findings = []
|
|
28
|
+
for path, looks_real in PATHS.items():
|
|
29
|
+
try:
|
|
30
|
+
response = client.get(httpx.URL(url).join(path))
|
|
31
|
+
except httpx.RequestError:
|
|
32
|
+
return [] # the other checks already report connection problems
|
|
33
|
+
|
|
34
|
+
is_html = "text/html" in response.headers.get("content-type", "")
|
|
35
|
+
if response.status_code == 200 and not is_html and looks_real(response.text):
|
|
36
|
+
findings.append(_exposed(path))
|
|
37
|
+
else:
|
|
38
|
+
findings.append(Finding(check=CHECK, level=Level.PASS, title=f"{path} is not exposed"))
|
|
39
|
+
return findings
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _exposed(path: str) -> Finding:
|
|
43
|
+
return Finding(
|
|
44
|
+
check=CHECK,
|
|
45
|
+
level=Level.FAIL,
|
|
46
|
+
title=f"{path} is publicly downloadable",
|
|
47
|
+
why=(
|
|
48
|
+
"Anyone can download this file. It usually holds passwords, API keys or "
|
|
49
|
+
"your code's history, which attackers collect automatically."
|
|
50
|
+
),
|
|
51
|
+
fix=(
|
|
52
|
+
f"Stop serving {path} (don't copy it into your build or static folder), "
|
|
53
|
+
"then change every secret it contained: assume they're already leaked."
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def skipped() -> Finding:
|
|
59
|
+
return Finding(
|
|
60
|
+
check=CHECK,
|
|
61
|
+
level=Level.INFO,
|
|
62
|
+
title="Exposed files check skipped (needs your permission)",
|
|
63
|
+
why="It asks for files like /.env, which only the site's owner should do.",
|
|
64
|
+
fix="Run again and answer yes, or pass --yes-i-own-it in CI.",
|
|
65
|
+
)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from deploy_checkup.models import Finding, Level
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
CHECK = "headers"
|
|
6
|
+
SIX_MONTHS= 15_552_000 #seconds
|
|
7
|
+
|
|
8
|
+
def check_headers(client: httpx.Client , url:str)->list[Finding]:
|
|
9
|
+
"""Check the security of the headers a browser uses to protect visitors."""
|
|
10
|
+
try:
|
|
11
|
+
response = client.get(url, follow_redirects=True)
|
|
12
|
+
|
|
13
|
+
except httpx.RequestError:
|
|
14
|
+
return []
|
|
15
|
+
|
|
16
|
+
headers = response.headers
|
|
17
|
+
return [
|
|
18
|
+
_check_hsts(headers),
|
|
19
|
+
_check_content_type_options(headers),
|
|
20
|
+
_check_csp(headers),
|
|
21
|
+
_check_frame_options(headers),
|
|
22
|
+
_check_referrer_policy(headers),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check_hsts(headers: httpx.Headers)->Finding:
|
|
27
|
+
value = headers.get("strict-transport-security")
|
|
28
|
+
if value is None:
|
|
29
|
+
return Finding(
|
|
30
|
+
check=CHECK,
|
|
31
|
+
level=Level.WARNING,
|
|
32
|
+
title="Strict-Transport-Security is missing",
|
|
33
|
+
why=(
|
|
34
|
+
"Without it, a visitor's browser may still try plain HTTP first, "
|
|
35
|
+
"which someone on the same Wi-Fi can intercept."
|
|
36
|
+
),
|
|
37
|
+
fix="Send the header Strict-Transport-Security: max-age=31536000 from your app.",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
max_age = _parse_max_age(value)
|
|
41
|
+
if max_age is None or max_age < SIX_MONTHS:
|
|
42
|
+
return Finding(
|
|
43
|
+
check=CHECK,
|
|
44
|
+
level=Level.WARNING,
|
|
45
|
+
title=f"Strict-Transport-Security is too short ({value})",
|
|
46
|
+
why="Browsers forget the HTTPS-only rule quickly, so the protection keeps lapsing.",
|
|
47
|
+
fix="Set max-age to at least 15552000 (6 months); 31536000 (1 year) is common.",
|
|
48
|
+
)
|
|
49
|
+
return Finding(
|
|
50
|
+
check=CHECK,
|
|
51
|
+
level=Level.PASS,
|
|
52
|
+
title=f"Strict-Transport-Security is set (max-age={max_age})",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_max_age(value:str)->int|None:
|
|
57
|
+
"""Return the max-age number from the HSTS value, or None if it's missing or invalid"""
|
|
58
|
+
for part in value.split(";"):
|
|
59
|
+
name,_,number = part.strip().partition("=")
|
|
60
|
+
if name.lower() == "max-age":
|
|
61
|
+
try:
|
|
62
|
+
return int(number.strip('"'))
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_content_type_options(headers: httpx.Headers)->Finding:
|
|
69
|
+
value = headers.get("x-content-type-options")
|
|
70
|
+
if value is None or value.strip().lower()!="nosniff":
|
|
71
|
+
return Finding(
|
|
72
|
+
check=CHECK,
|
|
73
|
+
level=Level.WARNING,
|
|
74
|
+
title="X-Content-Type-Options is not nosniff",
|
|
75
|
+
why=(
|
|
76
|
+
"The browser may guess a file's type, so an uploaded file "
|
|
77
|
+
"could be run as a script."
|
|
78
|
+
),
|
|
79
|
+
fix="Send the header X-Content-Type-Options: nosniff from your app.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return Finding(check=CHECK, level=Level.PASS, title="X-Content-Type-Options is nosniff")
|
|
83
|
+
|
|
84
|
+
def _check_csp(headers: httpx.Headers)-> Finding:
|
|
85
|
+
if headers.get("content-security-policy") is None:
|
|
86
|
+
return Finding(
|
|
87
|
+
check=CHECK,
|
|
88
|
+
level=Level.WARNING,
|
|
89
|
+
title="Content-Security-Policy is missing",
|
|
90
|
+
why=(
|
|
91
|
+
"If an attacker manages to inject a script into your page, the browser "
|
|
92
|
+
"will run it. A policy tells the browser which scripts are allowed."
|
|
93
|
+
),
|
|
94
|
+
fix=(
|
|
95
|
+
"Start with Content-Security-Policy: default-src 'self' and loosen it "
|
|
96
|
+
"only for the outside sources your frontend really uses."
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
return Finding(check=CHECK , level=Level.PASS,title="Content-Security-Policy is set")
|
|
100
|
+
|
|
101
|
+
def _check_frame_options(headers: httpx.Headers) -> Finding:
|
|
102
|
+
value = headers.get("x-frame-options", "").strip().upper()
|
|
103
|
+
csp = headers.get("content-security-policy","").strip().lower()
|
|
104
|
+
|
|
105
|
+
if value in ("DENY","SAMEORIGIN"):
|
|
106
|
+
return Finding(check=CHECK , level=Level.PASS , title=f"X-Frame-Options is {value}")
|
|
107
|
+
|
|
108
|
+
if "frame-ancestors" in csp:
|
|
109
|
+
return Finding(
|
|
110
|
+
check=CHECK,
|
|
111
|
+
level=Level.PASS,
|
|
112
|
+
title="X-Frame-Options isn't needed (CSP sets frame-ancestors)",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return Finding(
|
|
116
|
+
check=CHECK,
|
|
117
|
+
level=Level.WARNING,
|
|
118
|
+
title="X-Frame-Options is missing",
|
|
119
|
+
why=(
|
|
120
|
+
"Another site can load yours inside an invisible frame and trick "
|
|
121
|
+
"visitors into clicking buttons they can't see."
|
|
122
|
+
),
|
|
123
|
+
fix="Send X-Frame-Options: DENY, or add frame-ancestors 'none' to your CSP.",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _check_referrer_policy(headers: httpx.Headers)->Finding:
|
|
128
|
+
value = headers.get("referrer-policy")
|
|
129
|
+
if value is None:
|
|
130
|
+
return Finding(
|
|
131
|
+
check=CHECK,
|
|
132
|
+
level=Level.INFO,
|
|
133
|
+
title="Referrer-Policy is not set",
|
|
134
|
+
why=(
|
|
135
|
+
"Modern browsers already default to a safe policy, but setting it "
|
|
136
|
+
"makes the behaviour the same in every browser."
|
|
137
|
+
),
|
|
138
|
+
fix="Send Referrer-Policy: strict-origin-when-cross-origin.",
|
|
139
|
+
)
|
|
140
|
+
return Finding(check=CHECK, level=Level.PASS, title=f"Referrer-Policy is {value}")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from statistics import median
|
|
2
|
+
from time import perf_counter
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from deploy_checkup.checks.cold_start import check_cold_start
|
|
7
|
+
from deploy_checkup.models import Finding, Level
|
|
8
|
+
|
|
9
|
+
CHECK = "health"
|
|
10
|
+
ATTEMPTS = 3
|
|
11
|
+
SLOW_MS = 1000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_health(client: httpx.Client, api_url: str, path: str = "/health") -> list[Finding]:
|
|
15
|
+
"""Call the health path a few times: does it exist, and how fast does it answer?"""
|
|
16
|
+
health_url = httpx.URL(api_url).join(path)
|
|
17
|
+
times_ms = []
|
|
18
|
+
|
|
19
|
+
for _ in range(ATTEMPTS):
|
|
20
|
+
start = perf_counter()
|
|
21
|
+
try:
|
|
22
|
+
response = client.get(health_url)
|
|
23
|
+
except httpx.RequestError:
|
|
24
|
+
return [_unreachable(health_url)]
|
|
25
|
+
elapsed_ms = (perf_counter() - start) * 1000
|
|
26
|
+
|
|
27
|
+
if response.status_code == 404:
|
|
28
|
+
return [_missing(health_url)]
|
|
29
|
+
if response.status_code >= 400:
|
|
30
|
+
return [_unhealthy(health_url, response.status_code)]
|
|
31
|
+
|
|
32
|
+
times_ms.append(elapsed_ms)
|
|
33
|
+
|
|
34
|
+
# The first request may include waking the server up, so judge speed on the rest.
|
|
35
|
+
return [_latency(times_ms[1:]), check_cold_start(times_ms)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _unreachable(health_url: httpx.URL) -> Finding:
|
|
39
|
+
return Finding(
|
|
40
|
+
check=CHECK,
|
|
41
|
+
level=Level.FAIL,
|
|
42
|
+
title=f"Couldn't connect to the API at {health_url.host}",
|
|
43
|
+
why="Your frontend can't load any data if the API doesn't answer.",
|
|
44
|
+
fix="Check the --api address is right, then look at your API's deploy status and logs.",
|
|
45
|
+
render_fix=(
|
|
46
|
+
"Check the --api address is right, then open the API service in the Render "
|
|
47
|
+
"dashboard and look at the Events and Logs tabs."
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _missing(health_url: httpx.URL) -> Finding:
|
|
53
|
+
return Finding(
|
|
54
|
+
check=CHECK,
|
|
55
|
+
level=Level.WARNING,
|
|
56
|
+
title=f"No health endpoint found at {health_url.path}",
|
|
57
|
+
why=(
|
|
58
|
+
"Hosts use a health check to know when a new deploy is ready and "
|
|
59
|
+
"to restart your app if it stops responding."
|
|
60
|
+
),
|
|
61
|
+
fix=(
|
|
62
|
+
f"Add a GET {health_url.path} route that returns 200 quickly, and point "
|
|
63
|
+
"your host's health check at it."
|
|
64
|
+
),
|
|
65
|
+
render_fix=(
|
|
66
|
+
f"Add a GET {health_url.path} route that returns 200 quickly, then set it as "
|
|
67
|
+
"the Health Check Path in the service's Settings on Render."
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _unhealthy(health_url: httpx.URL, status: int) -> Finding:
|
|
73
|
+
return Finding(
|
|
74
|
+
check=CHECK,
|
|
75
|
+
level=Level.FAIL,
|
|
76
|
+
title=f"Health endpoint {health_url.path} answered with status {status}",
|
|
77
|
+
why="Your app reports itself as broken, so your host may keep restarting it.",
|
|
78
|
+
fix="Check your API's logs to see why the health route is failing.",
|
|
79
|
+
render_fix="Open the service's Logs tab on Render to see why the health route is failing.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _latency(times_ms: list[float]) -> Finding:
|
|
84
|
+
"""Judge already-measured response times. Kept separate so tests can pass in numbers."""
|
|
85
|
+
typical = round(median(times_ms))
|
|
86
|
+
if typical < SLOW_MS:
|
|
87
|
+
return Finding(
|
|
88
|
+
check=CHECK,
|
|
89
|
+
level=Level.PASS,
|
|
90
|
+
title=f"API answered in {typical} ms (median of {len(times_ms)})",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return Finding(
|
|
94
|
+
check=CHECK,
|
|
95
|
+
level=Level.WARNING,
|
|
96
|
+
title=f"API is slow: {typical} ms (median of {len(times_ms)})",
|
|
97
|
+
why="Every page that loads data waits this long, and visitors notice anything over a second.",
|
|
98
|
+
fix=(
|
|
99
|
+
"Look for slow database calls in the health route, and check whether the "
|
|
100
|
+
"server is short of CPU or memory."
|
|
101
|
+
),
|
|
102
|
+
render_fix=(
|
|
103
|
+
"Check the Metrics tab on Render for CPU or memory limits, and look for "
|
|
104
|
+
"slow database calls in the health route."
|
|
105
|
+
),
|
|
106
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from deploy_checkup.models import Finding, Level
|
|
4
|
+
|
|
5
|
+
CHECK = "https"
|
|
6
|
+
PERMANENT_REDIRECTS = (301, 308)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_https(client: httpx.Client, url: str) -> list[Finding]:
|
|
10
|
+
"""Check that the plain http:// address sends visitors to https://."""
|
|
11
|
+
http_url = httpx.URL(url).copy_with(scheme="http")
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
response = client.get(http_url, follow_redirects=True)
|
|
15
|
+
except httpx.RequestError:
|
|
16
|
+
if not _answers(client, url):
|
|
17
|
+
return [] # the whole site is down; the reachable or health check says so
|
|
18
|
+
return [
|
|
19
|
+
Finding(
|
|
20
|
+
check=CHECK,
|
|
21
|
+
level=Level.INFO,
|
|
22
|
+
title="Plain HTTP isn't served at all",
|
|
23
|
+
why="Nothing can be sent unencrypted, because the http:// address doesn't answer.",
|
|
24
|
+
)
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
if response.url.scheme == "http":
|
|
28
|
+
return [
|
|
29
|
+
Finding(
|
|
30
|
+
check=CHECK,
|
|
31
|
+
level=Level.FAIL,
|
|
32
|
+
title="Your site is served over plain HTTP",
|
|
33
|
+
why=(
|
|
34
|
+
"Visitors who type your address without https:// get an unencrypted page. "
|
|
35
|
+
"Anyone on the same Wi-Fi can read or change what they see and send."
|
|
36
|
+
),
|
|
37
|
+
fix=(
|
|
38
|
+
"Turn on your host's 'redirect HTTP to HTTPS' setting, or redirect in "
|
|
39
|
+
"your app, and make sure your domain has a TLS certificate."
|
|
40
|
+
),
|
|
41
|
+
render_fix=(
|
|
42
|
+
"Render redirects to HTTPS automatically on its own addresses. If you use a "
|
|
43
|
+
"custom domain, check it shows as Verified with a certificate under "
|
|
44
|
+
"Settings > Custom Domains."
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
first_hop = response.history[0].status_code
|
|
50
|
+
if first_hop in PERMANENT_REDIRECTS:
|
|
51
|
+
return [
|
|
52
|
+
Finding(
|
|
53
|
+
check=CHECK,
|
|
54
|
+
level=Level.PASS,
|
|
55
|
+
title=f"Plain HTTP redirects to HTTPS ({first_hop})",
|
|
56
|
+
)
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
Finding(
|
|
61
|
+
check=CHECK,
|
|
62
|
+
level=Level.WARNING,
|
|
63
|
+
title=f"Plain HTTP redirects to HTTPS, but only temporarily ({first_hop})",
|
|
64
|
+
why=(
|
|
65
|
+
"Browsers don't remember a temporary redirect, so every visit still starts "
|
|
66
|
+
"with an unencrypted request."
|
|
67
|
+
),
|
|
68
|
+
fix="Make the redirect permanent: use status 301 or 308 instead of 302 or 307.",
|
|
69
|
+
)
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _answers(client: httpx.Client, url: str) -> bool:
|
|
74
|
+
"""True if the site answers at all on its normal address."""
|
|
75
|
+
try:
|
|
76
|
+
client.get(url)
|
|
77
|
+
except httpx.RequestError:
|
|
78
|
+
return False
|
|
79
|
+
return True
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from deploy_checkup.models import Finding, Level
|
|
4
|
+
|
|
5
|
+
CHECK = "reachable"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def check_reachable(client: httpx.Client, url: str) -> list[Finding]:
|
|
9
|
+
"""Report whether the URL answers, and how."""
|
|
10
|
+
try:
|
|
11
|
+
response = client.get(url)
|
|
12
|
+
except httpx.RequestError:
|
|
13
|
+
return [
|
|
14
|
+
Finding(
|
|
15
|
+
check=CHECK,
|
|
16
|
+
level=Level.FAIL,
|
|
17
|
+
title="Couldn't connect to the site",
|
|
18
|
+
why="Nobody can use your app if the address doesn't answer.",
|
|
19
|
+
fix="Check the URL is right, then look at your host's deploy status and logs.",
|
|
20
|
+
render_fix=(
|
|
21
|
+
"Check the URL is right, then open the service in the Render "
|
|
22
|
+
"dashboard and look at the Events and Logs tabs."
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
status = response.status_code
|
|
28
|
+
title = f"Site answered with status {status}"
|
|
29
|
+
|
|
30
|
+
if status >= 500:
|
|
31
|
+
return [
|
|
32
|
+
Finding(
|
|
33
|
+
check=CHECK,
|
|
34
|
+
level=Level.FAIL,
|
|
35
|
+
title=title,
|
|
36
|
+
why="Your server hit an error, so visitors see a broken page instead of your app.",
|
|
37
|
+
fix=(
|
|
38
|
+
"Check your server's logs. A 502 or 503 usually means the app "
|
|
39
|
+
"crashed on startup or is still deploying."
|
|
40
|
+
),
|
|
41
|
+
render_fix=(
|
|
42
|
+
"Open the service's Logs tab on Render. A 502 or 503 usually means "
|
|
43
|
+
"the app crashed on startup or is still deploying."
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
if status >= 400:
|
|
49
|
+
return [
|
|
50
|
+
Finding(
|
|
51
|
+
check=CHECK,
|
|
52
|
+
level=Level.WARNING,
|
|
53
|
+
title=title,
|
|
54
|
+
why="Visitors to this address get an error page instead of your app.",
|
|
55
|
+
fix="Check the URL is the page you meant to test, for example your home page.",
|
|
56
|
+
)
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
return [Finding(check=CHECK, level=Level.PASS, title=title)]
|
deploy_checkup/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from deploy_checkup.report import print_report, to_json
|
|
9
|
+
from deploy_checkup.runner import exit_code, make_client, run_checks
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(
|
|
13
|
+
argv: list[str] | None = None,
|
|
14
|
+
client: httpx.Client | None = None,
|
|
15
|
+
ask: Callable[[str], str] | None = None,
|
|
16
|
+
) -> int:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
description="Check whether your deployed app is set up safely.",
|
|
19
|
+
epilog="Only test apps you own or have permission to test.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"-V",
|
|
23
|
+
"--version",
|
|
24
|
+
action="version",
|
|
25
|
+
version=version("deploy-checkup"),
|
|
26
|
+
help="show the program's version number",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("url", help="your frontend's address")
|
|
29
|
+
parser.add_argument("--api", help="your API's address, if it is separate")
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--health-path",
|
|
32
|
+
default="/health",
|
|
33
|
+
help="the API's health check path (default: /health)",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--yes-i-own-it",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="skip the permission question and run the probing checks (for CI)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--json",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="print the findings as JSON instead of a coloured report",
|
|
44
|
+
)
|
|
45
|
+
args = parser.parse_args(argv)
|
|
46
|
+
|
|
47
|
+
probe = _has_permission(args.url, args.yes_i_own_it, ask)
|
|
48
|
+
with client or make_client() as http:
|
|
49
|
+
results = run_checks(http, args.url, args.api, args.health_path, probe)
|
|
50
|
+
|
|
51
|
+
if args.json:
|
|
52
|
+
print(to_json(results))
|
|
53
|
+
else:
|
|
54
|
+
print_report(results)
|
|
55
|
+
return exit_code(results)
|
|
56
|
+
|
|
57
|
+
|
|
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."""
|
|
60
|
+
if yes_i_own_it:
|
|
61
|
+
return True
|
|
62
|
+
if ask is None:
|
|
63
|
+
if not sys.stdin.isatty():
|
|
64
|
+
return False # nobody is there to answer, for example in CI
|
|
65
|
+
ask = _ask_on_stderr
|
|
66
|
+
try:
|
|
67
|
+
answer = ask(
|
|
68
|
+
f"Also check {url} for exposed files like /.env? "
|
|
69
|
+
"Only say yes if you own this site or have permission to test it. [y/N] "
|
|
70
|
+
)
|
|
71
|
+
except EOFError:
|
|
72
|
+
return False # input ran out without an answer, so treat it as "no"
|
|
73
|
+
return answer.strip().lower() in ("y", "yes")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ask_on_stderr(prompt: str) -> str:
|
|
77
|
+
"""Like input(), but the question goes to stderr so it never mixes into --json output."""
|
|
78
|
+
print(prompt, end="", file=sys.stderr, flush=True)
|
|
79
|
+
return input()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
raise SystemExit(main())
|
deploy_checkup/hosts.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from dataclasses import replace
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from deploy_checkup.models import Finding
|
|
6
|
+
|
|
7
|
+
RENDER = "Render"
|
|
8
|
+
UNKNOWN = "unknown host"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def detect_host(client: httpx.Client, url: str) -> str:
|
|
12
|
+
"""Guess where the site is hosted from the headers its responses carry."""
|
|
13
|
+
try:
|
|
14
|
+
response = client.get(url)
|
|
15
|
+
except httpx.RequestError:
|
|
16
|
+
return UNKNOWN
|
|
17
|
+
|
|
18
|
+
if "x-render-origin-server" in response.headers:
|
|
19
|
+
return RENDER
|
|
20
|
+
return UNKNOWN
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fixes_for_host(findings: list[Finding], host: str) -> list[Finding]:
|
|
24
|
+
"""On Render, swap in the Render-specific fix wherever a check wrote one."""
|
|
25
|
+
if host != RENDER:
|
|
26
|
+
return findings
|
|
27
|
+
return [replace(f, fix=f.render_fix) if f.render_fix else f for f in findings]
|
deploy_checkup/models.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Level(str, Enum):
|
|
6
|
+
PASS = "pass"
|
|
7
|
+
WARNING = "warning"
|
|
8
|
+
FAIL = "fail"
|
|
9
|
+
INFO = "info"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Finding:
|
|
14
|
+
check: str
|
|
15
|
+
level: Level
|
|
16
|
+
title: str
|
|
17
|
+
why: str = ""
|
|
18
|
+
fix: str = "" # works on any host
|
|
19
|
+
render_fix: str = "" # replaces fix when the site is on Render
|
deploy_checkup/report.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.markup import escape
|
|
6
|
+
|
|
7
|
+
from deploy_checkup.models import Finding, Level
|
|
8
|
+
|
|
9
|
+
COLOURS = {
|
|
10
|
+
Level.PASS: "green",
|
|
11
|
+
Level.WARNING: "yellow",
|
|
12
|
+
Level.FAIL: "red",
|
|
13
|
+
Level.INFO: "blue",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def count_levels(results: dict[str, list[Finding]]) -> dict[Level, int]:
|
|
18
|
+
"""How many findings there are at each level, across every address."""
|
|
19
|
+
counts = {level: 0 for level in Level}
|
|
20
|
+
for findings in results.values():
|
|
21
|
+
for finding in findings:
|
|
22
|
+
counts[finding.level] += 1
|
|
23
|
+
return counts
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def print_report(results: dict[str, list[Finding]], console: Console | None = None) -> None:
|
|
27
|
+
"""Print each address's findings, then a one-line summary."""
|
|
28
|
+
console = console or Console()
|
|
29
|
+
|
|
30
|
+
for target, findings in results.items():
|
|
31
|
+
console.rule(escape(target))
|
|
32
|
+
for finding in findings:
|
|
33
|
+
colour = COLOURS[finding.level]
|
|
34
|
+
label = finding.level.value.upper()
|
|
35
|
+
console.print(f"[bold {colour}]{label:<7}[/] {escape(finding.title)}")
|
|
36
|
+
if finding.why:
|
|
37
|
+
console.print(f" [dim]Why:[/] {escape(finding.why)}")
|
|
38
|
+
if finding.fix:
|
|
39
|
+
console.print(f" [dim]Fix:[/] {escape(finding.fix)}")
|
|
40
|
+
console.print()
|
|
41
|
+
|
|
42
|
+
counts = count_levels(results)
|
|
43
|
+
summary = ", ".join(
|
|
44
|
+
f"[{COLOURS[level]}]{counts[level]} {level.value}[/]" for level in Level
|
|
45
|
+
)
|
|
46
|
+
console.print(f"Summary: {summary}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def to_json(results: dict[str, list[Finding]]) -> str:
|
|
50
|
+
"""The same findings as JSON, for scripts, CI and other tools."""
|
|
51
|
+
report = {
|
|
52
|
+
"tool": "deploy-checkup",
|
|
53
|
+
"version": version("deploy-checkup"),
|
|
54
|
+
"targets": [
|
|
55
|
+
{
|
|
56
|
+
"url": target,
|
|
57
|
+
"findings": [
|
|
58
|
+
{
|
|
59
|
+
"check": f.check,
|
|
60
|
+
"level": f.level.value,
|
|
61
|
+
"title": f.title,
|
|
62
|
+
"why": f.why,
|
|
63
|
+
"fix": f.fix,
|
|
64
|
+
}
|
|
65
|
+
for f in findings
|
|
66
|
+
],
|
|
67
|
+
}
|
|
68
|
+
for target, findings in results.items()
|
|
69
|
+
],
|
|
70
|
+
"summary": {level.value: count for level, count in count_levels(results).items()},
|
|
71
|
+
}
|
|
72
|
+
return json.dumps(report, indent=2)
|
deploy_checkup/runner.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from deploy_checkup.checks import exposed_files
|
|
6
|
+
from deploy_checkup.checks.cors import check_cors
|
|
7
|
+
from deploy_checkup.checks.headers import check_headers
|
|
8
|
+
from deploy_checkup.checks.health import check_health
|
|
9
|
+
from deploy_checkup.checks.https import check_https
|
|
10
|
+
from deploy_checkup.checks.reachable import check_reachable
|
|
11
|
+
from deploy_checkup.hosts import detect_host, fixes_for_host
|
|
12
|
+
from deploy_checkup.models import Finding, Level
|
|
13
|
+
|
|
14
|
+
USER_AGENT = f"DeployCheckup/{version('deploy-checkup')}"
|
|
15
|
+
TIMEOUT = httpx.Timeout(60.0) # Render's free tier can take ~50 s to wake up
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_client() -> httpx.Client:
|
|
19
|
+
"""A polite client: honest User-Agent and a time limit on every request."""
|
|
20
|
+
return httpx.Client(timeout=TIMEOUT, headers={"User-Agent": USER_AGENT})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_checks(
|
|
24
|
+
client: httpx.Client,
|
|
25
|
+
url: str,
|
|
26
|
+
api_url: str | None = None,
|
|
27
|
+
health_path: str = "/health",
|
|
28
|
+
probe: bool = False,
|
|
29
|
+
) -> dict[str, list[Finding]]:
|
|
30
|
+
"""Run every check and group the findings by the address they're about.
|
|
31
|
+
|
|
32
|
+
probe=True also runs the probing checks, which only the site's owner should run.
|
|
33
|
+
"""
|
|
34
|
+
frontend = check_reachable(client, url) + check_https(client, url) + check_headers(client, url)
|
|
35
|
+
if probe:
|
|
36
|
+
frontend += exposed_files.check_exposed_files(client, url)
|
|
37
|
+
else:
|
|
38
|
+
frontend.append(exposed_files.skipped())
|
|
39
|
+
results = {url: fixes_for_host(frontend, detect_host(client, url))}
|
|
40
|
+
|
|
41
|
+
if api_url and api_url != url:
|
|
42
|
+
# Health goes first: its first request is the one that can catch a cold start.
|
|
43
|
+
api = (
|
|
44
|
+
check_health(client, api_url, health_path)
|
|
45
|
+
+ check_https(client, api_url)
|
|
46
|
+
+ check_cors(client, api_url, url)
|
|
47
|
+
)
|
|
48
|
+
if probe:
|
|
49
|
+
api += exposed_files.check_exposed_files(client, api_url)
|
|
50
|
+
results[api_url] = fixes_for_host(api, detect_host(client, api_url))
|
|
51
|
+
|
|
52
|
+
return results
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def exit_code(results: dict[str, list[Finding]]) -> int:
|
|
56
|
+
"""1 if anything failed, otherwise 0, so CI can stop a broken deploy."""
|
|
57
|
+
for findings in results.values():
|
|
58
|
+
if any(finding.level == Level.FAIL for finding in findings):
|
|
59
|
+
return 1
|
|
60
|
+
return 0
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: deploy-checkup
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Check whether your deployed app is set up safely, in plain language, with fixes for your host.
|
|
5
|
+
Author: Nandhagopal
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Nandhagopal2912/deploy-checkup
|
|
8
|
+
Project-URL: Issues, https://github.com/Nandhagopal2912/deploy-checkup/issues
|
|
9
|
+
Keywords: deploy,security,cors,headers,https,render,cli
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
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 :: Internet :: WWW/HTTP
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: httpx<1,>=0.28
|
|
24
|
+
Requires-Dist: rich<16,>=14
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
27
|
+
Requires-Dist: build; extra == "dev"
|
|
28
|
+
Requires-Dist: twine; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# deploy-checkup
|
|
32
|
+
|
|
33
|
+
[](https://github.com/Nandhagopal2912/deploy-checkup/actions/workflows/ci.yml)
|
|
34
|
+
|
|
35
|
+
Tells you, in plain language, whether your deployed app is set up safely, and exactly how to fix what isn't on your host.
|
|
36
|
+
|
|
37
|
+
> **Only test apps you own or have permission to test.** Against someone else's site, unusual requests can look like an attack and may be illegal.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
Requires Python 3.10 or newer.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pipx install deploy-checkup
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`pipx` installs command-line tools in their own environment ([how to install pipx](https://pipx.pypa.io/stable/installation/)). `pip install deploy-checkup` works too.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrender.com
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
| Option | Meaning |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `url` | Your frontend's address (required) |
|
|
58
|
+
| `--api` | Your API's address, if it is separate |
|
|
59
|
+
| `--health-path` | The API's health check path (default `/health`) |
|
|
60
|
+
| `--yes-i-own-it` | Run the probing checks without asking (for CI) |
|
|
61
|
+
| `--json` | Print the findings as JSON instead of a coloured report |
|
|
62
|
+
| `-V`, `--version` | Show the version |
|
|
63
|
+
| `-h`, `--help` | Show help |
|
|
64
|
+
|
|
65
|
+
Example output:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
──────────── https://your-frontend.onrender.com ────────────
|
|
69
|
+
PASS Site answered with status 200
|
|
70
|
+
PASS Plain HTTP redirects to HTTPS (301)
|
|
71
|
+
WARNING Content-Security-Policy is missing
|
|
72
|
+
Why: If an attacker manages to inject a script into your page, the browser will run it. ...
|
|
73
|
+
Fix: Start with Content-Security-Policy: default-src 'self' and loosen it ...
|
|
74
|
+
|
|
75
|
+
Summary: 6 pass, 1 warning, 0 fail, 0 info
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Each finding says what was found, why it matters, and how to fix it.
|
|
79
|
+
|
|
80
|
+
| Level | Meaning |
|
|
81
|
+
|---|---|
|
|
82
|
+
| **PASS** | Checked, and it's set up well. |
|
|
83
|
+
| **WARNING** | Probably worth fixing, but your app may be fine without it. Used whenever deploy-checkup can't be sure. |
|
|
84
|
+
| **FAIL** | Something is clearly broken or unsafe. Fix this first. |
|
|
85
|
+
| **INFO** | Good to know, such as a check that was skipped or a hint that may not apply. |
|
|
86
|
+
|
|
87
|
+
The command exits with code `1` if anything fails and `0` otherwise.
|
|
88
|
+
|
|
89
|
+
### Use it in CI
|
|
90
|
+
|
|
91
|
+
Run it after each deploy, so a broken setup fails the pipeline. For example, in GitHub Actions:
|
|
92
|
+
|
|
93
|
+
```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
|
+
```
|
|
97
|
+
|
|
98
|
+
`--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.
|
|
99
|
+
|
|
100
|
+
## What it checks
|
|
101
|
+
|
|
102
|
+
| Check | Runs on | What it asks |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| **reachable** | frontend | Does the address answer, and with what status? |
|
|
105
|
+
| **https** | frontend and API | Does plain `http://` redirect to `https://`, and permanently (301/308)? |
|
|
106
|
+
| **headers** | frontend | Are the browser security headers set? Strict-Transport-Security (at least 6 months), X-Content-Type-Options (`nosniff`), Content-Security-Policy, X-Frame-Options (or CSP `frame-ancestors`), Referrer-Policy |
|
|
107
|
+
| **cors** | API | Asks the API as your frontend, then as a made-up site (`deploy-checkup-test.invalid`). Is your frontend allowed and everyone else blocked? |
|
|
108
|
+
| **health** | API | Calls the health path 3 times. Does it exist, and what's the median response time? |
|
|
109
|
+
| **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
|
+
| **exposed_files** | frontend and API | Can anyone download `/.env` or `/.git/config`? **Probing:** runs only after you confirm you own the site. |
|
|
111
|
+
|
|
112
|
+
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
|
+
|
|
114
|
+
**Fix hints match your host.** When a response carries Render's `x-render-origin-server` header, fixes point to the exact place in the Render dashboard. On any other host you get general advice.
|
|
115
|
+
|
|
116
|
+
### Probing checks ask first
|
|
117
|
+
|
|
118
|
+
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
|
+
|
|
120
|
+
### Polite by design
|
|
121
|
+
|
|
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.
|
|
123
|
+
|
|
124
|
+
### What it doesn't do
|
|
125
|
+
|
|
126
|
+
deploy-checkup is a quick first look, not a full security audit:
|
|
127
|
+
|
|
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.
|
|
130
|
+
- Cold start detection is a guess from a few timings.
|
|
131
|
+
- Fix hints are written for Render first; other hosts get general advice.
|
|
132
|
+
|
|
133
|
+
## JSON output
|
|
134
|
+
|
|
135
|
+
`--json` prints the same findings for scripts and CI. Only the JSON goes to standard output; the permission question goes to standard error, so you can save the report safely:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
deploy-checkup https://your-frontend.onrender.com --api https://your-api.onrender.com --json > report.json
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"tool": "deploy-checkup",
|
|
144
|
+
"version": "0.1.0",
|
|
145
|
+
"targets": [
|
|
146
|
+
{
|
|
147
|
+
"url": "https://your-api.onrender.com",
|
|
148
|
+
"findings": [
|
|
149
|
+
{"check": "health", "level": "pass", "title": "API answered in 108 ms (median of 2)", "why": "", "fix": ""}
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
],
|
|
153
|
+
"summary": {"pass": 16, "warning": 0, "fail": 0, "info": 0}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The exit code is the same as for the coloured report.
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
git clone https://github.com/Nandhagopal2912/deploy-checkup.git
|
|
163
|
+
cd deploy-checkup
|
|
164
|
+
python -m venv .venv
|
|
165
|
+
.venv\Scripts\activate # Windows; on macOS/Linux: source .venv/bin/activate
|
|
166
|
+
pip install -e ".[dev]"
|
|
167
|
+
pytest
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`-e` installs in editable mode, so code changes take effect without reinstalling. Re-run the install after changing `dependencies` or `[project.scripts]` in `pyproject.toml`.
|
|
171
|
+
|
|
172
|
+
Tests never touch the network. Each check receives an `httpx.Client`, and tests pass one built on `httpx.MockTransport`, which answers requests with fake responses. CI runs them on Python 3.10 and 3.13.
|
|
173
|
+
|
|
174
|
+
[`demo/`](https://github.com/Nandhagopal2912/deploy-checkup/tree/main/demo) holds a deliberately broken app with known flaws, used to prove each check catches what it should. The project's plan is in the [roadmap](https://github.com/Nandhagopal2912/deploy-checkup/blob/main/docs/Deploy%20Doctor%20Roadmap.pdf).
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
[MIT](https://github.com/Nandhagopal2912/deploy-checkup/blob/main/LICENSE)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
deploy_checkup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
deploy_checkup/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
deploy_checkup/cli.py,sha256=0tRVmYpru6b4fAPdCzDD2qqkxPR2rQr6SCLpTeUnZYU,2683
|
|
4
|
+
deploy_checkup/hosts.py,sha256=rtOsEct6oe6efgLpUN1AbgrGZ-mJkoczA20EhpaRaFk,748
|
|
5
|
+
deploy_checkup/models.py,sha256=7GlO-S2y9jvpZj2KPCVocnjrLYSk79w9muw23H3uDUA,372
|
|
6
|
+
deploy_checkup/report.py,sha256=Cmv3mrWjuRwc8YMqWQj4w7lv5TYIB_7BVrEcRV5XVEQ,2353
|
|
7
|
+
deploy_checkup/runner.py,sha256=sOpzlKIzyrl-LGKTZfnKv8ApCU-f_asqr-49abmtHeA,2232
|
|
8
|
+
deploy_checkup/checks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
deploy_checkup/checks/cold_start.py,sha256=86ZeCOxe1JYpHY56JrVI6LakloXgdE0VmWgCnhyrFsw,1331
|
|
10
|
+
deploy_checkup/checks/cors.py,sha256=l_yacNx87sbozoV6WIwlRX0mg6swvMYMMPic8NKHsic,3262
|
|
11
|
+
deploy_checkup/checks/exposed_files.py,sha256=x8B54c5Wh4WviZN85FJrzGiudcUpSkhSXQE6KRyK9nk,2088
|
|
12
|
+
deploy_checkup/checks/headers.py,sha256=dsRj5CgW4RffabpzolQLNCwCvPsUiVk-4ZsptW_MP8o,5170
|
|
13
|
+
deploy_checkup/checks/health.py,sha256=ud0bEbiXwMsHbPIlVhhNl43-M2z6A-DMBsZSnjkNnxE,3811
|
|
14
|
+
deploy_checkup/checks/https.py,sha256=8dXkTQNGV9EQHxaXMGc-GGmeaJw9ZOihqtX-OAWPXus,2737
|
|
15
|
+
deploy_checkup/checks/reachable.py,sha256=wkmKiQUP5jodYzRfEOmeeDx47hQr_1WCU_Bi0b_hkCw,2024
|
|
16
|
+
deploy_checkup-0.1.0.dist-info/licenses/LICENSE,sha256=T05KEB9aLILkU72gnV6cl_GeYeM3XH48jVChayziiE8,1070
|
|
17
|
+
deploy_checkup-0.1.0.dist-info/METADATA,sha256=vBRh2yrsV_xtJW8l_IAmufGYTzC7oyy9hPJ0Rfq0agE,8039
|
|
18
|
+
deploy_checkup-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
19
|
+
deploy_checkup-0.1.0.dist-info/entry_points.txt,sha256=khu9uZERFBtIG91pPm9mMAGCT_R6rmbKNhpJUN4lIAY,59
|
|
20
|
+
deploy_checkup-0.1.0.dist-info/top_level.txt,sha256=YDLxIPeyvP8VG-O7x7oAVzdluCQfOHxbrAC6rbq1u5Q,15
|
|
21
|
+
deploy_checkup-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nandha Gopal
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
deploy_checkup
|