djcheckup 0.1.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.
- djcheckup-0.1.0/PKG-INFO +115 -0
- djcheckup-0.1.0/README.md +101 -0
- djcheckup-0.1.0/pyproject.toml +29 -0
- djcheckup-0.1.0/src/djcheckup/__init__.py +12 -0
- djcheckup-0.1.0/src/djcheckup/check_defs.py +337 -0
- djcheckup-0.1.0/src/djcheckup/checks.py +420 -0
- djcheckup-0.1.0/src/djcheckup/output_json.py +28 -0
- djcheckup-0.1.0/src/djcheckup/output_rich.py +32 -0
- djcheckup-0.1.0/src/djcheckup/run.py +36 -0
djcheckup-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: djcheckup
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DJ Checkup is a security scanner for Django sites.
|
|
5
|
+
Author: Stuart Maxwell
|
|
6
|
+
Author-email: Stuart Maxwell <stuart@amanzi.nz>
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: rich>=14.1.0
|
|
9
|
+
Requires-Dist: typer>=0.17.4
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Project-URL: Homepage, https://github.com/stuartmaxwell/djcheckup
|
|
12
|
+
Project-URL: Repository, https://github.com/stuartmaxwell/djcheckup
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# DJ Checkup
|
|
16
|
+
|
|
17
|
+
## Overview
|
|
18
|
+
|
|
19
|
+
DJ Checkup is a security scanner for Django sites. This package provides a command-line interface to run the security
|
|
20
|
+
checks against your Django site. These are the same checks that are used by the DJ Checkup website at
|
|
21
|
+
<https://djcheckup.com>.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
This works best when installed with `uv tool` or `pipx`.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# With uv:
|
|
29
|
+
uv tool install djcheckup
|
|
30
|
+
|
|
31
|
+
# Or with pipx:
|
|
32
|
+
pipx install djcheckup
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
You can also run the tool without installing it:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# With uvx:
|
|
39
|
+
uvx djcheckup https://yourdjangosite.com
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
Run the `djcheckup` command-line utility with the URL of your Django site.
|
|
45
|
+
|
|
46
|
+
You'll see a nicely formatted report in your terminal:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
|
50
|
+
│ DJ Checkup Results for https://djcheckup.com │
|
|
51
|
+
│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
|
|
52
|
+
│ ┃ Check ┃ Result ┃ Message ┃ │
|
|
53
|
+
│ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │
|
|
54
|
+
│ │ Can I connect to your site? │ 🟢 Success │ Connected to your site successfully. │ │
|
|
55
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
56
|
+
│ │ Is your Django admin site exposed at the default │ 🟢 Success │ Your Django admin site is not exposed at the │ │
|
|
57
|
+
│ │ URL? │ │ default URL. This reduces the risk of automated │ │
|
|
58
|
+
│ │ │ │ attacks. │ │
|
|
59
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
60
|
+
│ │ Is a CSRF cookie set? │ 🟢 Success │ CSRF cookie detected. Your site is protected │ │
|
|
61
|
+
│ │ │ │ against cross-site request forgery attacks. │ │
|
|
62
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
63
|
+
│ │ Is the CSRF cookie HttpOnly? │ 🟢 Success │ Your CSRF cookie is marked as HttpOnly, which │ │
|
|
64
|
+
│ │ │ │ helps prevent some XSS attacks. │ │
|
|
65
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
66
|
+
│ │ Is the CSRF cookie SameSite=Lax? │ 🟢 Success │ CSRF cookie is marked as SameSite=Lax, which │ │
|
|
67
|
+
│ │ │ │ helps prevent CSRF attacks via cross-site │ │
|
|
68
|
+
│ │ │ │ requests. │ │
|
|
69
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
70
|
+
│ │ Is the CSRF cookie Secure? │ 🟢 Success │ CSRF cookie is marked as Secure. It will only be │ │
|
|
71
|
+
│ │ │ │ sent over HTTPS. │ │
|
|
72
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
73
|
+
│ │ Does your site return a 404 for non-existent │ 🟢 Success │ Your site correctly returns a 404 error for │ │
|
|
74
|
+
│ │ pages? │ │ non-existent pages. │ │
|
|
75
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
76
|
+
│ │ Is Django DEBUG mode disabled? │ 🟢 Success │ Django DEBUG mode is disabled. This is essential │ │
|
|
77
|
+
│ │ │ │ for production security. │ │
|
|
78
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
79
|
+
│ │ Is the Strict-Transport-Security (HSTS) header │ 🟢 Success │ Strict-Transport-Security header is set. Your │ │
|
|
80
|
+
│ │ set? │ │ site enforces HTTPS for all visitors. │ │
|
|
81
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
82
|
+
│ │ Does your site redirect all HTTP traffic to │ 🟢 Success │ All HTTP traffic is redirected to HTTPS. This is │ │
|
|
83
|
+
│ │ HTTPS? │ │ essential for security. │ │
|
|
84
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
85
|
+
│ │ Is your site accessible via HTTPS? │ 🟢 Success │ Your site is accessible via HTTPS. All sensitive │ │
|
|
86
|
+
│ │ │ │ data is encrypted in transit. │ │
|
|
87
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
88
|
+
│ │ Is your login page exposed at a default or │ 🟢 Success │ Login page is not exposed at the default URL. │ │
|
|
89
|
+
│ │ guessable URL? │ │ This reduces the risk of automated attacks. │ │
|
|
90
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
91
|
+
│ │ Is the sessionid cookie set? │ 🔴 Failure │ No sessionid cookie was found. This is normal if │ │
|
|
92
|
+
│ │ │ │ your site does not use sessions on this page. If │ │
|
|
93
|
+
│ │ │ │ your application relies on sessions for │ │
|
|
94
|
+
│ │ │ │ authentication or user data, ensure Django's │ │
|
|
95
|
+
│ │ │ │ session middleware is enabled and configured │ │
|
|
96
|
+
│ │ │ │ correctly. │ │
|
|
97
|
+
│ │ │ │ │ │
|
|
98
|
+
│ │ │ │ Reference: │ │
|
|
99
|
+
│ │ │ │ │ │
|
|
100
|
+
│ │ │ │ • Django Sessions │ │
|
|
101
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
102
|
+
│ │ Is the sessionid cookie HttpOnly? │ 🟡 Skipped │ Check skipped due to failed or missing │ │
|
|
103
|
+
│ │ │ │ dependency: sessionid_cookie_check │ │
|
|
104
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
105
|
+
│ │ Is the sessionid cookie Secure? │ 🟡 Skipped │ Check skipped due to failed or missing │ │
|
|
106
|
+
│ │ │ │ dependency: sessionid_cookie_check │ │
|
|
107
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
108
|
+
│ │ Is the sessionid cookie SameSite=Lax? │ 🟡 Skipped │ Check skipped due to failed or missing │ │
|
|
109
|
+
│ │ │ │ dependency: sessionid_cookie_check │ │
|
|
110
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
111
|
+
│ │ Is the X-Frame-Options header set? │ 🟢 Success │ X-Frame-Options header is set. Your site is │ │
|
|
112
|
+
│ │ │ │ protected against clickjacking attacks. │ │
|
|
113
|
+
│ └──────────────────────────────────────────────────┴────────────┴──────────────────────────────────────────────────┘ │
|
|
114
|
+
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
115
|
+
```
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# DJ Checkup
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
DJ Checkup is a security scanner for Django sites. This package provides a command-line interface to run the security
|
|
6
|
+
checks against your Django site. These are the same checks that are used by the DJ Checkup website at
|
|
7
|
+
<https://djcheckup.com>.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
This works best when installed with `uv tool` or `pipx`.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# With uv:
|
|
15
|
+
uv tool install djcheckup
|
|
16
|
+
|
|
17
|
+
# Or with pipx:
|
|
18
|
+
pipx install djcheckup
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
You can also run the tool without installing it:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# With uvx:
|
|
25
|
+
uvx djcheckup https://yourdjangosite.com
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
Run the `djcheckup` command-line utility with the URL of your Django site.
|
|
31
|
+
|
|
32
|
+
You'll see a nicely formatted report in your terminal:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
|
36
|
+
│ DJ Checkup Results for https://djcheckup.com │
|
|
37
|
+
│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
|
|
38
|
+
│ ┃ Check ┃ Result ┃ Message ┃ │
|
|
39
|
+
│ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │
|
|
40
|
+
│ │ Can I connect to your site? │ 🟢 Success │ Connected to your site successfully. │ │
|
|
41
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
42
|
+
│ │ Is your Django admin site exposed at the default │ 🟢 Success │ Your Django admin site is not exposed at the │ │
|
|
43
|
+
│ │ URL? │ │ default URL. This reduces the risk of automated │ │
|
|
44
|
+
│ │ │ │ attacks. │ │
|
|
45
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
46
|
+
│ │ Is a CSRF cookie set? │ 🟢 Success │ CSRF cookie detected. Your site is protected │ │
|
|
47
|
+
│ │ │ │ against cross-site request forgery attacks. │ │
|
|
48
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
49
|
+
│ │ Is the CSRF cookie HttpOnly? │ 🟢 Success │ Your CSRF cookie is marked as HttpOnly, which │ │
|
|
50
|
+
│ │ │ │ helps prevent some XSS attacks. │ │
|
|
51
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
52
|
+
│ │ Is the CSRF cookie SameSite=Lax? │ 🟢 Success │ CSRF cookie is marked as SameSite=Lax, which │ │
|
|
53
|
+
│ │ │ │ helps prevent CSRF attacks via cross-site │ │
|
|
54
|
+
│ │ │ │ requests. │ │
|
|
55
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
56
|
+
│ │ Is the CSRF cookie Secure? │ 🟢 Success │ CSRF cookie is marked as Secure. It will only be │ │
|
|
57
|
+
│ │ │ │ sent over HTTPS. │ │
|
|
58
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
59
|
+
│ │ Does your site return a 404 for non-existent │ 🟢 Success │ Your site correctly returns a 404 error for │ │
|
|
60
|
+
│ │ pages? │ │ non-existent pages. │ │
|
|
61
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
62
|
+
│ │ Is Django DEBUG mode disabled? │ 🟢 Success │ Django DEBUG mode is disabled. This is essential │ │
|
|
63
|
+
│ │ │ │ for production security. │ │
|
|
64
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
65
|
+
│ │ Is the Strict-Transport-Security (HSTS) header │ 🟢 Success │ Strict-Transport-Security header is set. Your │ │
|
|
66
|
+
│ │ set? │ │ site enforces HTTPS for all visitors. │ │
|
|
67
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
68
|
+
│ │ Does your site redirect all HTTP traffic to │ 🟢 Success │ All HTTP traffic is redirected to HTTPS. This is │ │
|
|
69
|
+
│ │ HTTPS? │ │ essential for security. │ │
|
|
70
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
71
|
+
│ │ Is your site accessible via HTTPS? │ 🟢 Success │ Your site is accessible via HTTPS. All sensitive │ │
|
|
72
|
+
│ │ │ │ data is encrypted in transit. │ │
|
|
73
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
74
|
+
│ │ Is your login page exposed at a default or │ 🟢 Success │ Login page is not exposed at the default URL. │ │
|
|
75
|
+
│ │ guessable URL? │ │ This reduces the risk of automated attacks. │ │
|
|
76
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
77
|
+
│ │ Is the sessionid cookie set? │ 🔴 Failure │ No sessionid cookie was found. This is normal if │ │
|
|
78
|
+
│ │ │ │ your site does not use sessions on this page. If │ │
|
|
79
|
+
│ │ │ │ your application relies on sessions for │ │
|
|
80
|
+
│ │ │ │ authentication or user data, ensure Django's │ │
|
|
81
|
+
│ │ │ │ session middleware is enabled and configured │ │
|
|
82
|
+
│ │ │ │ correctly. │ │
|
|
83
|
+
│ │ │ │ │ │
|
|
84
|
+
│ │ │ │ Reference: │ │
|
|
85
|
+
│ │ │ │ │ │
|
|
86
|
+
│ │ │ │ • Django Sessions │ │
|
|
87
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
88
|
+
│ │ Is the sessionid cookie HttpOnly? │ 🟡 Skipped │ Check skipped due to failed or missing │ │
|
|
89
|
+
│ │ │ │ dependency: sessionid_cookie_check │ │
|
|
90
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
91
|
+
│ │ Is the sessionid cookie Secure? │ 🟡 Skipped │ Check skipped due to failed or missing │ │
|
|
92
|
+
│ │ │ │ dependency: sessionid_cookie_check │ │
|
|
93
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
94
|
+
│ │ Is the sessionid cookie SameSite=Lax? │ 🟡 Skipped │ Check skipped due to failed or missing │ │
|
|
95
|
+
│ │ │ │ dependency: sessionid_cookie_check │ │
|
|
96
|
+
│ ├──────────────────────────────────────────────────┼────────────┼──────────────────────────────────────────────────┤ │
|
|
97
|
+
│ │ Is the X-Frame-Options header set? │ 🟢 Success │ X-Frame-Options header is set. Your site is │ │
|
|
98
|
+
│ │ │ │ protected against clickjacking attacks. │ │
|
|
99
|
+
│ └──────────────────────────────────────────────────┴────────────┴──────────────────────────────────────────────────┘ │
|
|
100
|
+
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
101
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "djcheckup"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "DJ Checkup is a security scanner for Django sites."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Stuart Maxwell", email = "stuart@amanzi.nz" }]
|
|
7
|
+
requires-python = ">=3.13"
|
|
8
|
+
dependencies = ["httpx>=0.28.1", "rich>=14.1.0", "typer>=0.17.4"]
|
|
9
|
+
|
|
10
|
+
[project.scripts]
|
|
11
|
+
djcheckup = "djcheckup.run:app"
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://github.com/stuartmaxwell/djcheckup"
|
|
15
|
+
Repository = "https://github.com/stuartmaxwell/djcheckup"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.8.13,<0.9.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[tool.ruff]
|
|
22
|
+
line-length = 120
|
|
23
|
+
target-version = "py313"
|
|
24
|
+
|
|
25
|
+
[tool.ruff.lint]
|
|
26
|
+
select = ["ALL"]
|
|
27
|
+
|
|
28
|
+
[tool.ruff.lint.pydocstyle]
|
|
29
|
+
convention = "google" # Accepts: "google", "numpy", or "pep257".
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""DJ Checkup CLI package."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from rich.logging import RichHandler
|
|
6
|
+
|
|
7
|
+
FORMAT = "%(message)s"
|
|
8
|
+
logging.basicConfig(level=logging.WARNING, format=FORMAT, datefmt="[%X]", handlers=[RichHandler()])
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("rich")
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Check definitions."""
|
|
2
|
+
|
|
3
|
+
from djcheckup.checks import (
|
|
4
|
+
ContentCheck,
|
|
5
|
+
CookieCheck,
|
|
6
|
+
CookieHttpOnlyCheck,
|
|
7
|
+
CookieSameSiteCheck,
|
|
8
|
+
CookieSecureCheck,
|
|
9
|
+
HeaderCheck,
|
|
10
|
+
PathCheck,
|
|
11
|
+
SchemeCheck,
|
|
12
|
+
SeverityWeight,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
admin_check = PathCheck(
|
|
16
|
+
check_id="admin_check",
|
|
17
|
+
name="Is your Django admin site exposed at the default URL?",
|
|
18
|
+
path="/admin",
|
|
19
|
+
success=False,
|
|
20
|
+
severity=SeverityWeight.HIGH,
|
|
21
|
+
success_message="""
|
|
22
|
+
Your Django admin site is not exposed at the default URL. This reduces the risk of automated attacks.
|
|
23
|
+
""",
|
|
24
|
+
failure_message="""
|
|
25
|
+
Your Django admin site is accessible at the default URL (`/admin`).
|
|
26
|
+
|
|
27
|
+
Best practice is to restrict admin access to trusted IPs, use strong authentication, and change the admin URL to a
|
|
28
|
+
non-default, unguessable value. Never expose the admin over HTTP; always use HTTPS.
|
|
29
|
+
|
|
30
|
+
Reference:
|
|
31
|
+
- [Django Admin Docs](https://docs.djangoproject.com/en/stable/ref/contrib/admin/#adminsite-objects)
|
|
32
|
+
""",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
csrf_check = CookieCheck(
|
|
37
|
+
check_id="csrf_check",
|
|
38
|
+
name="Is a CSRF cookie set?",
|
|
39
|
+
cookie_name="csrftoken",
|
|
40
|
+
success=True,
|
|
41
|
+
severity=SeverityWeight.LOW,
|
|
42
|
+
success_message="CSRF cookie detected. Your site is protected against cross-site request forgery attacks.",
|
|
43
|
+
failure_message="""
|
|
44
|
+
No CSRF cookie was found. This is expected if your site does not render any forms that require CSRF protection on this
|
|
45
|
+
page. If you have forms that perform POST, PUT, or DELETE requests, ensure Django's CSRF middleware is enabled and not
|
|
46
|
+
bypassed.
|
|
47
|
+
|
|
48
|
+
Reference:
|
|
49
|
+
- [Django CSRF Protection](https://docs.djangoproject.com/en/stable/ref/csrf/)
|
|
50
|
+
""",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
csrf_httponly_check = CookieHttpOnlyCheck(
|
|
55
|
+
check_id="csrf_httponly_check",
|
|
56
|
+
depends_on="csrf_check",
|
|
57
|
+
name="Is the CSRF cookie HttpOnly?",
|
|
58
|
+
cookie_name="csrftoken",
|
|
59
|
+
success=False,
|
|
60
|
+
severity=SeverityWeight.MEDIUM,
|
|
61
|
+
success_message="Your CSRF cookie is marked as HttpOnly, which helps prevent some XSS attacks.",
|
|
62
|
+
failure_message="""
|
|
63
|
+
Your CSRF cookie is not marked as HttpOnly. While Django does not set this by default
|
|
64
|
+
(to allow JavaScript access for some use cases), setting HttpOnly can help mitigate certain XSS risks.
|
|
65
|
+
Consider your application's needs before enabling this.
|
|
66
|
+
|
|
67
|
+
**Note:** This check will be skipped if the `csrf_check` check fails.
|
|
68
|
+
|
|
69
|
+
Reference:
|
|
70
|
+
- [Django CSRF Cookie Settings](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-httponly)
|
|
71
|
+
""",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
csrf_samesite_check = CookieSameSiteCheck(
|
|
76
|
+
check_id="csrf_samesite_check",
|
|
77
|
+
depends_on="csrf_check",
|
|
78
|
+
name="Is the CSRF cookie SameSite=Lax?",
|
|
79
|
+
cookie_name="csrftoken",
|
|
80
|
+
samesite_value="Lax",
|
|
81
|
+
success=True,
|
|
82
|
+
severity=SeverityWeight.HIGH,
|
|
83
|
+
success_message="CSRF cookie is marked as SameSite=Lax, which helps prevent CSRF attacks via cross-site requests.",
|
|
84
|
+
failure_message="""
|
|
85
|
+
Your CSRF cookie is not marked as SameSite=Lax. This increases the risk of CSRF attacks.
|
|
86
|
+
Set `CSRF_COOKIE_SAMESITE = 'Lax'` in your Django settings.
|
|
87
|
+
|
|
88
|
+
**Note:** This check will be skipped if the `csrf_check` check fails.
|
|
89
|
+
|
|
90
|
+
Reference:
|
|
91
|
+
- [Django CSRF Settings](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-samesite)
|
|
92
|
+
""",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
csrf_secure_check = CookieSecureCheck(
|
|
97
|
+
check_id="csrf_secure_check",
|
|
98
|
+
depends_on="csrf_check",
|
|
99
|
+
name="Is the CSRF cookie Secure?",
|
|
100
|
+
cookie_name="csrftoken",
|
|
101
|
+
success=True,
|
|
102
|
+
severity=SeverityWeight.HIGH,
|
|
103
|
+
success_message="CSRF cookie is marked as Secure. It will only be sent over HTTPS.",
|
|
104
|
+
failure_message="""
|
|
105
|
+
Your CSRF cookie is not marked as Secure. This means it could be sent over unencrypted HTTP,
|
|
106
|
+
exposing it to interception. Set `CSRF_COOKIE_SECURE = True` in your Django settings.
|
|
107
|
+
|
|
108
|
+
**Note:** This check will be skipped if the `csrf_check` check fails.
|
|
109
|
+
|
|
110
|
+
Reference:
|
|
111
|
+
- [Django CSRF Settings](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-secure)
|
|
112
|
+
""",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
debug_404_check = PathCheck(
|
|
117
|
+
check_id="debug_404_check",
|
|
118
|
+
name="Does your site return a 404 for non-existent pages?",
|
|
119
|
+
path="/a/b/c/d/e/f/g/h/i/j/xyz/",
|
|
120
|
+
status_code=404,
|
|
121
|
+
success=True,
|
|
122
|
+
severity=SeverityWeight.MEDIUM,
|
|
123
|
+
success_message="Your site correctly returns a 404 error for non-existent pages.",
|
|
124
|
+
failure_message="""
|
|
125
|
+
Your site does not return a 404 error for non-existent pages. This may indicate a misconfiguration or a custom error
|
|
126
|
+
handler that does not set the correct status code.
|
|
127
|
+
Ensure your web server and Django app return 404 for missing resources.
|
|
128
|
+
|
|
129
|
+
Reference:
|
|
130
|
+
- [Django Custom Error Views](https://docs.djangoproject.com/en/stable/topics/http/views/#customizing-error-views)
|
|
131
|
+
""",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
debug_check = ContentCheck(
|
|
136
|
+
check_id="debug_check",
|
|
137
|
+
depends_on="debug_404_check",
|
|
138
|
+
name="Is Django DEBUG mode disabled?",
|
|
139
|
+
path="/a/b/c/d/e/f/g/h/i/j/xyz/",
|
|
140
|
+
content="DEBUG = True",
|
|
141
|
+
success=False,
|
|
142
|
+
severity=SeverityWeight.CRITICAL,
|
|
143
|
+
success_message="Django DEBUG mode is disabled. This is essential for production security.",
|
|
144
|
+
failure_message="""
|
|
145
|
+
Your site appears to have Django's DEBUG mode enabled. This exposes sensitive information and should never be used in
|
|
146
|
+
production. Set `DEBUG = False` in your settings.
|
|
147
|
+
|
|
148
|
+
Reference:
|
|
149
|
+
- [Django Deployment Checklist](https://docs.djangoproject.com/en/stable/howto/deployment/checklist/#debug)
|
|
150
|
+
""",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
hsts_header_check = HeaderCheck(
|
|
155
|
+
check_id="hsts_header_check",
|
|
156
|
+
name="Is the Strict-Transport-Security (HSTS) header set?",
|
|
157
|
+
header_name="Strict-Transport-Security",
|
|
158
|
+
success=True,
|
|
159
|
+
severity=SeverityWeight.HIGH,
|
|
160
|
+
success_message="Strict-Transport-Security header is set. Your site enforces HTTPS for all visitors.",
|
|
161
|
+
failure_message="""
|
|
162
|
+
Your site does not send the Strict-Transport-Security (HSTS) header. This means browsers may allow users to visit your
|
|
163
|
+
site over HTTP, exposing them to downgrade attacks.
|
|
164
|
+
Set this header in your web server or via Django's `SECURE_HSTS_SECONDS` setting.
|
|
165
|
+
|
|
166
|
+
Reference:
|
|
167
|
+
- [Django Security Settings](https://docs.djangoproject.com/en/stable/ref/settings/#secure-hsts-seconds)
|
|
168
|
+
- [MDN: Strict-Transport-Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security)
|
|
169
|
+
""",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
http_check = SchemeCheck(
|
|
174
|
+
check_id="http_check",
|
|
175
|
+
name="Does your site redirect all HTTP traffic to HTTPS?",
|
|
176
|
+
start_scheme="http",
|
|
177
|
+
end_scheme="https",
|
|
178
|
+
success=True,
|
|
179
|
+
severity=SeverityWeight.HIGH,
|
|
180
|
+
success_message="All HTTP traffic is redirected to HTTPS. This is essential for security.",
|
|
181
|
+
failure_message="""
|
|
182
|
+
Your site does not redirect HTTP traffic to HTTPS. This exposes users to man-in-the-middle attacks. Ensure your web
|
|
183
|
+
server or CDN is configured to redirect all HTTP requests to HTTPS.
|
|
184
|
+
|
|
185
|
+
Reference:
|
|
186
|
+
- [Django Security Settings](https://docs.djangoproject.com/en/stable/ref/settings/#secure-ssl-redirect)
|
|
187
|
+
- [Google Web Fundamentals](https://developers.google.com/web/fundamentals/security/encrypt-in-transit/enable-https)
|
|
188
|
+
""",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
https_check = SchemeCheck(
|
|
193
|
+
check_id="https_check",
|
|
194
|
+
name="Is your site accessible via HTTPS?",
|
|
195
|
+
start_scheme="https",
|
|
196
|
+
end_scheme="https",
|
|
197
|
+
success=True,
|
|
198
|
+
severity=SeverityWeight.HIGH,
|
|
199
|
+
success_message="Your site is accessible via HTTPS. All sensitive data is encrypted in transit.",
|
|
200
|
+
failure_message="""
|
|
201
|
+
Your site is not accessible via HTTPS. This exposes all traffic to interception and tampering. Obtain and install a
|
|
202
|
+
valid TLS certificate and configure your web server to serve HTTPS.
|
|
203
|
+
|
|
204
|
+
Reference:
|
|
205
|
+
- [Django Security Settings](https://docs.djangoproject.com/en/stable/ref/settings/#secure-proxy-ssl-header)
|
|
206
|
+
- [Cloudflare SSL](https://support.cloudflare.com/hc/en-us/articles/360023792171-Getting-Started-with-Cloudflare-SSL)
|
|
207
|
+
""",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
login_check = PathCheck(
|
|
212
|
+
check_id="login_check",
|
|
213
|
+
name="Is your login page exposed at a default or guessable URL?",
|
|
214
|
+
path="/accounts/login/",
|
|
215
|
+
success=False,
|
|
216
|
+
severity=SeverityWeight.MEDIUM,
|
|
217
|
+
success_message="Login page is not exposed at the default URL. This reduces the risk of automated attacks.",
|
|
218
|
+
failure_message="""
|
|
219
|
+
Your login page is accessible at the default URL (`/accounts/login/`). Consider changing it to a non-default path and
|
|
220
|
+
always require HTTPS for login forms. Use strong authentication and rate limiting to prevent brute-force attacks.
|
|
221
|
+
""",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
sessionid_cookie_check = CookieCheck(
|
|
226
|
+
check_id="sessionid_cookie_check",
|
|
227
|
+
name="Is the sessionid cookie set?",
|
|
228
|
+
cookie_name="sessionid",
|
|
229
|
+
success=True,
|
|
230
|
+
severity=SeverityWeight.LOW,
|
|
231
|
+
success_message="Session cookie is set. User sessions are being managed securely.",
|
|
232
|
+
failure_message="""
|
|
233
|
+
No sessionid cookie was found. This is normal if your site does not use sessions on this page. If your application
|
|
234
|
+
relies on sessions for authentication or user data, ensure Django's session middleware is enabled and configured
|
|
235
|
+
correctly.
|
|
236
|
+
|
|
237
|
+
Reference:
|
|
238
|
+
- [Django Sessions](https://docs.djangoproject.com/en/stable/topics/http/sessions/)
|
|
239
|
+
""",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
sessionid_httponly_check = CookieHttpOnlyCheck(
|
|
244
|
+
check_id="sessionid_httponly_check",
|
|
245
|
+
depends_on="sessionid_cookie_check",
|
|
246
|
+
name="Is the sessionid cookie HttpOnly?",
|
|
247
|
+
cookie_name="sessionid",
|
|
248
|
+
success=True,
|
|
249
|
+
severity=SeverityWeight.HIGH,
|
|
250
|
+
success_message="Sessionid cookie is marked as HttpOnly. This helps prevent access from JavaScript.",
|
|
251
|
+
failure_message="""
|
|
252
|
+
Your sessionid cookie is not marked as HttpOnly. This increases the risk of session theft via XSS.
|
|
253
|
+
Set `SESSION_COOKIE_HTTPONLY = True` in your Django settings.
|
|
254
|
+
|
|
255
|
+
**Note:** This check will be skipped if the `sessionid_cookie_check` check fails.
|
|
256
|
+
|
|
257
|
+
Reference:
|
|
258
|
+
- [Django Session Security](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-httponly)
|
|
259
|
+
""",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
sessionid_samesite_check = CookieSameSiteCheck(
|
|
264
|
+
check_id="sessionid_samesite_check",
|
|
265
|
+
depends_on="sessionid_cookie_check",
|
|
266
|
+
name="Is the sessionid cookie SameSite=Lax?",
|
|
267
|
+
cookie_name="sessionid",
|
|
268
|
+
samesite_value="Lax",
|
|
269
|
+
success=True,
|
|
270
|
+
severity=SeverityWeight.HIGH,
|
|
271
|
+
success_message="Sessionid cookie is marked as SameSite=Lax. This helps prevent CSRF attacks.",
|
|
272
|
+
failure_message="""
|
|
273
|
+
Your sessionid cookie is not marked as SameSite=Lax. This increases the risk of CSRF attacks.
|
|
274
|
+
Set `SESSION_COOKIE_SAMESITE = 'Lax'` in your Django settings.
|
|
275
|
+
|
|
276
|
+
Reference:
|
|
277
|
+
- [Django Session Security](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-samesite)
|
|
278
|
+
""",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
sessionid_secure_check = CookieSecureCheck(
|
|
283
|
+
check_id="sessionid_secure_check",
|
|
284
|
+
depends_on="sessionid_cookie_check",
|
|
285
|
+
name="Is the sessionid cookie Secure?",
|
|
286
|
+
cookie_name="sessionid",
|
|
287
|
+
success=True,
|
|
288
|
+
severity=SeverityWeight.HIGH,
|
|
289
|
+
success_message="Sessionid cookie is marked as Secure. It will only be sent over HTTPS.",
|
|
290
|
+
failure_message="""
|
|
291
|
+
Your sessionid cookie is not marked as Secure. This means it could be sent over unencrypted HTTP, exposing it to
|
|
292
|
+
interception. Set `SESSION_COOKIE_SECURE = True` in your Django settings.
|
|
293
|
+
|
|
294
|
+
**Note:** This check will be skipped if the `sessionid_cookie_check` check fails.
|
|
295
|
+
|
|
296
|
+
Reference:
|
|
297
|
+
- [Django Session Security](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-secure)
|
|
298
|
+
""",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
xframe_header_check = HeaderCheck(
|
|
303
|
+
check_id="xframe_header_check",
|
|
304
|
+
name="Is the X-Frame-Options header set?",
|
|
305
|
+
header_name="X-Frame-Options",
|
|
306
|
+
success=True,
|
|
307
|
+
severity=SeverityWeight.HIGH,
|
|
308
|
+
success_message="X-Frame-Options header is set. Your site is protected against clickjacking attacks.",
|
|
309
|
+
failure_message="""
|
|
310
|
+
Your site does not send the X-Frame-Options header. This allows your site to be embedded in iframes,
|
|
311
|
+
making it vulnerable to clickjacking. Set this header in your web server or via Django's `XFrameOptionsMiddleware`.
|
|
312
|
+
|
|
313
|
+
Reference:
|
|
314
|
+
- [Django Clickjacking Protection](https://docs.djangoproject.com/en/stable/ref/clickjacking/)
|
|
315
|
+
- [MDN: X-Frame-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options)
|
|
316
|
+
""",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
all_checks = [
|
|
321
|
+
admin_check,
|
|
322
|
+
csrf_check,
|
|
323
|
+
csrf_httponly_check,
|
|
324
|
+
csrf_samesite_check,
|
|
325
|
+
csrf_secure_check,
|
|
326
|
+
debug_404_check,
|
|
327
|
+
debug_check,
|
|
328
|
+
hsts_header_check,
|
|
329
|
+
http_check,
|
|
330
|
+
https_check,
|
|
331
|
+
login_check,
|
|
332
|
+
sessionid_cookie_check,
|
|
333
|
+
sessionid_httponly_check,
|
|
334
|
+
sessionid_secure_check,
|
|
335
|
+
sessionid_samesite_check,
|
|
336
|
+
xframe_header_check,
|
|
337
|
+
]
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Check types."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from http.cookiejar import CookieJar
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from djcheckup import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SeverityWeight(Enum):
|
|
15
|
+
"""Severity weight for the security score."""
|
|
16
|
+
|
|
17
|
+
NONE = 0
|
|
18
|
+
LOW = 5
|
|
19
|
+
MEDIUM = 15
|
|
20
|
+
HIGH = 30
|
|
21
|
+
CRITICAL = 50
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CheckResult(Enum):
|
|
25
|
+
"""Possible results of a check."""
|
|
26
|
+
|
|
27
|
+
SUCCESS = "success"
|
|
28
|
+
FAILURE = "failure"
|
|
29
|
+
SKIPPED = "skipped"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SiteCheckContext:
|
|
34
|
+
"""Context object passed to all checks."""
|
|
35
|
+
|
|
36
|
+
url: httpx.URL
|
|
37
|
+
client: httpx.Client
|
|
38
|
+
headers: httpx.Headers
|
|
39
|
+
cookies: CookieJar
|
|
40
|
+
content: str
|
|
41
|
+
response_url: httpx.URL
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class CheckResponse:
|
|
46
|
+
"""The response from an individual check."""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
result: CheckResult
|
|
50
|
+
severity_score: SeverityWeight
|
|
51
|
+
message: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class SiteCheckResult:
|
|
56
|
+
"""The result of a site check."""
|
|
57
|
+
|
|
58
|
+
url: httpx.URL
|
|
59
|
+
check_results: list[CheckResponse]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(kw_only=True)
|
|
63
|
+
class _BaseCheck(ABC):
|
|
64
|
+
"""Base class for all checks."""
|
|
65
|
+
|
|
66
|
+
check_id: str
|
|
67
|
+
name: str
|
|
68
|
+
success: bool
|
|
69
|
+
severity: SeverityWeight
|
|
70
|
+
success_message: str
|
|
71
|
+
failure_message: str
|
|
72
|
+
depends_on: str | None = None
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def check(self, context: SiteCheckContext) -> bool:
|
|
76
|
+
"""Run the check and return the result."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
def run(self, context: SiteCheckContext, previous_results: dict[str, CheckResponse]) -> CheckResponse:
|
|
80
|
+
"""Run the header check."""
|
|
81
|
+
if self.depends_on:
|
|
82
|
+
dependent_result = previous_results.get(self.depends_on)
|
|
83
|
+
|
|
84
|
+
if not dependent_result or dependent_result.result == CheckResult.FAILURE:
|
|
85
|
+
logger.info(f"Skipping check '{self.name}' because it depends on failed check '{self.depends_on}'.")
|
|
86
|
+
|
|
87
|
+
return CheckResponse(
|
|
88
|
+
name=self.name,
|
|
89
|
+
result=CheckResult.SKIPPED,
|
|
90
|
+
severity_score=SeverityWeight.NONE,
|
|
91
|
+
message=f"Check skipped due to failed or missing dependency: {self.depends_on}",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
check_result = self.check(context)
|
|
95
|
+
|
|
96
|
+
result = CheckResult.SUCCESS if check_result == self.success else CheckResult.FAILURE
|
|
97
|
+
|
|
98
|
+
return CheckResponse(
|
|
99
|
+
name=self.name,
|
|
100
|
+
result=result,
|
|
101
|
+
severity_score=self.severity,
|
|
102
|
+
message=self.success_message if result == CheckResult.SUCCESS else self.failure_message,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class HeaderCheck(_BaseCheck):
|
|
108
|
+
"""A header check."""
|
|
109
|
+
|
|
110
|
+
header_name: str
|
|
111
|
+
header_value: str = ""
|
|
112
|
+
|
|
113
|
+
def check(self, context: SiteCheckContext) -> bool:
|
|
114
|
+
"""Check if a specific header is present and optionally if it matches a given value."""
|
|
115
|
+
# Normalize header names to lowercase for case-insensitive comparison
|
|
116
|
+
headers = {k.lower(): v for k, v in context.headers.items()}
|
|
117
|
+
actual_value = headers.get(self.header_name.lower())
|
|
118
|
+
if actual_value is None:
|
|
119
|
+
logger.info(f"Header '{self.header_name}' not found: {context.headers}")
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
# Compare header values case-insensitively if header_value is provided
|
|
123
|
+
if self.header_value and actual_value.lower() != self.header_value.lower():
|
|
124
|
+
logger.info(f"Header '{self.header_name}' value '{actual_value}' doesn't match '{self.header_value}'.")
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
logger.debug(f"Header '{self.header_name}' is present with value '{actual_value}'.")
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class ContentCheck(_BaseCheck):
|
|
133
|
+
"""A content check.
|
|
134
|
+
|
|
135
|
+
Checks for specific content in the response.
|
|
136
|
+
Takes an optional path to check a different URL.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
content: str
|
|
140
|
+
path: str = ""
|
|
141
|
+
|
|
142
|
+
def check(self, context: SiteCheckContext) -> bool:
|
|
143
|
+
"""Check if the response body contains the expected content."""
|
|
144
|
+
response_content: str = ""
|
|
145
|
+
|
|
146
|
+
if self.path:
|
|
147
|
+
# Append the path to the url
|
|
148
|
+
new_url = httpx.URL(context.url).join(self.path)
|
|
149
|
+
logger.info(f"Making request to {new_url}")
|
|
150
|
+
|
|
151
|
+
response = context.client.get(new_url)
|
|
152
|
+
response_content = response.text
|
|
153
|
+
|
|
154
|
+
else:
|
|
155
|
+
response_content = context.content
|
|
156
|
+
|
|
157
|
+
if response_content == "":
|
|
158
|
+
logger.warning("No response content to check.")
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
if self.content not in response_content:
|
|
162
|
+
logger.debug("Response body does not contain expected content.")
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
logger.debug("Response body contains expected content.")
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class CookieCheck(_BaseCheck):
|
|
171
|
+
"""A cookie check."""
|
|
172
|
+
|
|
173
|
+
cookie_name: str
|
|
174
|
+
cookie_value: str = ""
|
|
175
|
+
|
|
176
|
+
def check(self, context: SiteCheckContext) -> bool:
|
|
177
|
+
"""Check if a specific cookie is present and optionally if it matches a given value."""
|
|
178
|
+
if not context.cookies:
|
|
179
|
+
logger.warning("No response cookies to check.")
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
for cookie in context.cookies:
|
|
183
|
+
if cookie.name == self.cookie_name:
|
|
184
|
+
if self.cookie_value and cookie.value == self.cookie_value:
|
|
185
|
+
logger.info(
|
|
186
|
+
f"Cookie '{self.cookie_name}' value '{cookie.value}' doesn't match '{self.cookie_value}'.",
|
|
187
|
+
)
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
if not self.cookie_value:
|
|
191
|
+
logger.debug(f"Cookie '{self.cookie_name}' is present.")
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
logger.debug(f"Cookie '{self.cookie_name}' is not present.")
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class CookieHttpOnlyCheck(_BaseCheck):
|
|
200
|
+
"""A cookie HttpOnly check."""
|
|
201
|
+
|
|
202
|
+
cookie_name: str
|
|
203
|
+
|
|
204
|
+
def check(self, context: SiteCheckContext) -> bool:
|
|
205
|
+
"""Check if a specific cookie is marked as HttpOnly."""
|
|
206
|
+
if not context.cookies:
|
|
207
|
+
logger.warning("No response cookies to check.")
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
for cookie in context.cookies:
|
|
211
|
+
if cookie.name == self.cookie_name:
|
|
212
|
+
return cookie.has_nonstandard_attr("HttpOnly")
|
|
213
|
+
|
|
214
|
+
logger.info(f"Cookie '{self.cookie_name}' was not found.")
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class CookieSameSiteCheck(_BaseCheck):
|
|
220
|
+
"""A cookie SameSite check."""
|
|
221
|
+
|
|
222
|
+
cookie_name: str
|
|
223
|
+
samesite_value: Literal["Strict", "Lax", "None"]
|
|
224
|
+
|
|
225
|
+
def check(self, context: SiteCheckContext) -> bool:
|
|
226
|
+
"""Check if a specific cookie has a SameSite attribute."""
|
|
227
|
+
if not context.cookies:
|
|
228
|
+
logger.warning("No response cookies to check.")
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
for cookie in context.cookies:
|
|
232
|
+
if cookie.name == self.cookie_name:
|
|
233
|
+
return cookie.get_nonstandard_attr("SameSite") == self.samesite_value
|
|
234
|
+
|
|
235
|
+
logger.info(f"Cookie '{self.cookie_name}' was not found.")
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class CookieSecureCheck(_BaseCheck):
|
|
241
|
+
"""A cookie secure check.
|
|
242
|
+
|
|
243
|
+
Checks if the specified cookie is marked as Secure.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
cookie_name: str
|
|
247
|
+
|
|
248
|
+
def check(self, context: SiteCheckContext) -> bool:
|
|
249
|
+
"""Check if a specific cookie is marked as Secure."""
|
|
250
|
+
if not context.cookies:
|
|
251
|
+
logger.warning("No response cookies to check.")
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
for cookie in context.cookies:
|
|
255
|
+
if cookie.name == self.cookie_name:
|
|
256
|
+
logger.debug(f"Found cookie '{self.cookie_name}': {cookie}, with attributes {cookie.__dict__}")
|
|
257
|
+
return cookie.secure
|
|
258
|
+
|
|
259
|
+
logger.info(f"Cookie '{self.cookie_name}' was not found.")
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass
|
|
264
|
+
class PathCheck(_BaseCheck):
|
|
265
|
+
"""A path check.
|
|
266
|
+
|
|
267
|
+
Checks if the specified path returns a successful response.
|
|
268
|
+
|
|
269
|
+
Takes an optional status code to check against if you want to test for a specific status code.
|
|
270
|
+
|
|
271
|
+
Returns True if the path is accessible or the status code matches.
|
|
272
|
+
Returns False if the path is not accessible or the status code does not match.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
path: str
|
|
276
|
+
status_code: int | None = None
|
|
277
|
+
|
|
278
|
+
def check(self, context: SiteCheckContext) -> bool:
|
|
279
|
+
"""Makes a request to the specified path and checks the response."""
|
|
280
|
+
# Append the path to the url using the urlib module
|
|
281
|
+
new_url = httpx.URL(context.url).join(self.path)
|
|
282
|
+
logger.info(f"Making request to {new_url}")
|
|
283
|
+
|
|
284
|
+
response = context.client.get(new_url)
|
|
285
|
+
logger.info(f"Response status code: {response.status_code}")
|
|
286
|
+
|
|
287
|
+
if self.status_code:
|
|
288
|
+
if response.status_code == self.status_code:
|
|
289
|
+
logger.debug(f"Request to {new_url} returned expected status code {self.status_code}.")
|
|
290
|
+
return True
|
|
291
|
+
logger.debug(f"Request to {new_url} returned unexpected status code {response.status_code}.")
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
if httpx.codes.is_success(response.status_code):
|
|
295
|
+
logger.debug(f"Request to {new_url} succeeded.")
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
logger.debug(f"Request to {new_url} failed with status code {response.status_code}.")
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@dataclass
|
|
303
|
+
class SchemeCheck(_BaseCheck):
|
|
304
|
+
"""A scheme check.
|
|
305
|
+
|
|
306
|
+
Checks if the URL scheme (http or https) that the request starts with matches the final scheme.
|
|
307
|
+
|
|
308
|
+
`start_scheme` is the scheme to start with.
|
|
309
|
+
`end_scheme` is the expected scheme after any redirects.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
start_scheme: Literal["http", "https"]
|
|
313
|
+
end_scheme: Literal["http", "https"]
|
|
314
|
+
|
|
315
|
+
def check(self, context: SiteCheckContext) -> bool:
|
|
316
|
+
"""Check if the scheme of the URL in the request matches the final scheme."""
|
|
317
|
+
# If the start scheme matches the original URL scheme, then we don't need a new request.
|
|
318
|
+
if context.url.scheme == self.start_scheme and context.response_url.scheme == self.end_scheme:
|
|
319
|
+
logger.debug(f"URL '{context.url}' scheme matches expected scheme '{self.end_scheme}'.")
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
# Need to create a new URL with the specified start scheme
|
|
323
|
+
new_url = context.url.copy_with(scheme=self.start_scheme)
|
|
324
|
+
logger.debug(f"Created new URL '{new_url}' with scheme '{self.end_scheme}'.")
|
|
325
|
+
|
|
326
|
+
# And make a request to the new URL
|
|
327
|
+
response = context.client.get(new_url)
|
|
328
|
+
|
|
329
|
+
if response.url.scheme == self.end_scheme:
|
|
330
|
+
logger.debug(f"Request to {new_url} succeeded.")
|
|
331
|
+
return True
|
|
332
|
+
|
|
333
|
+
logger.debug(f"URL '{context.url}' matches scheme '{self.start_scheme}' to '{self.end_scheme}'.")
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class SiteChecker:
|
|
338
|
+
"""Run all the checks for a given site."""
|
|
339
|
+
|
|
340
|
+
def __init__(self, url: str) -> None:
|
|
341
|
+
"""Initialize the SiteChecker with a URL."""
|
|
342
|
+
self.url: httpx.URL = httpx.URL(url)
|
|
343
|
+
self.user_agent: str = "DJCheckupBot/1.0 (+https://djcheckup.com/bot-info)"
|
|
344
|
+
self.timeout: float = 10.0
|
|
345
|
+
self.client: httpx.Client = httpx.Client(
|
|
346
|
+
headers={"User-Agent": self.user_agent},
|
|
347
|
+
timeout=self.timeout,
|
|
348
|
+
follow_redirects=True,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def close(self) -> None:
|
|
352
|
+
"""Close the HTTP client and release resources."""
|
|
353
|
+
self.client.close()
|
|
354
|
+
|
|
355
|
+
def run_first_check(self) -> CheckResponse:
|
|
356
|
+
"""Run the first check.
|
|
357
|
+
|
|
358
|
+
This connects to the URL using httpx to check connectivity and then saves the page content, headers, and cookies
|
|
359
|
+
into a context object for the remaining checks.
|
|
360
|
+
"""
|
|
361
|
+
logger.info(f"Running first check for {self.url}")
|
|
362
|
+
|
|
363
|
+
check_name = "Can I connect to your site?"
|
|
364
|
+
severity_weight = SeverityWeight.HIGH
|
|
365
|
+
success_message = "Connected to your site successfully."
|
|
366
|
+
error_message = "Unable to connect to your site and no further checks can be performed."
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
response = self.client.get(self.url)
|
|
370
|
+
response.raise_for_status()
|
|
371
|
+
logger.debug(f"Successfully connected to {self.url}")
|
|
372
|
+
|
|
373
|
+
except httpx.RequestError:
|
|
374
|
+
logger.exception(f"Error connecting to {self.url}")
|
|
375
|
+
return CheckResponse(
|
|
376
|
+
name=check_name,
|
|
377
|
+
result=CheckResult.FAILURE,
|
|
378
|
+
severity_score=severity_weight,
|
|
379
|
+
message=error_message,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
self.context = SiteCheckContext(
|
|
383
|
+
url=self.url,
|
|
384
|
+
client=self.client,
|
|
385
|
+
headers=response.headers,
|
|
386
|
+
cookies=response.cookies.jar,
|
|
387
|
+
content=response.text,
|
|
388
|
+
response_url=response.url,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
logger.info(f"Finished first check for {self.url}")
|
|
392
|
+
|
|
393
|
+
return CheckResponse(
|
|
394
|
+
name=check_name,
|
|
395
|
+
result=CheckResult.SUCCESS,
|
|
396
|
+
severity_score=severity_weight,
|
|
397
|
+
message=success_message,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def run_checks(self, checks: list[_BaseCheck]) -> SiteCheckResult:
|
|
401
|
+
"""Run all checks."""
|
|
402
|
+
# First, run the first check
|
|
403
|
+
first_check = self.run_first_check()
|
|
404
|
+
site_check_results = SiteCheckResult(url=self.url, check_results=[first_check])
|
|
405
|
+
|
|
406
|
+
if first_check.result == CheckResult.FAILURE:
|
|
407
|
+
return site_check_results
|
|
408
|
+
|
|
409
|
+
# If the first check passes, run all other checks
|
|
410
|
+
previous_results: dict[str, CheckResponse] = {"first_check": first_check}
|
|
411
|
+
|
|
412
|
+
for check in checks:
|
|
413
|
+
result = check.run(self.context, previous_results)
|
|
414
|
+
site_check_results.check_results.append(result)
|
|
415
|
+
previous_results[check.check_id] = result
|
|
416
|
+
logger.debug(f"Finished running check: {check.name}")
|
|
417
|
+
|
|
418
|
+
# Close the HTTP client
|
|
419
|
+
self.client.close()
|
|
420
|
+
return site_check_results
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Output the results as JSON."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from djcheckup.checks import CheckResult, SeverityWeight, SiteCheckResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def custom_encoder(obj: object) -> str | int:
|
|
12
|
+
"""Custom JSON encoder for non-serializable types."""
|
|
13
|
+
if isinstance(obj, SeverityWeight):
|
|
14
|
+
return obj.value
|
|
15
|
+
|
|
16
|
+
if isinstance(obj, CheckResult):
|
|
17
|
+
return obj.value
|
|
18
|
+
|
|
19
|
+
if isinstance(obj, httpx.URL):
|
|
20
|
+
return str(obj)
|
|
21
|
+
|
|
22
|
+
msg = f"Object of type {type(obj).__name__} is not JSON serializable"
|
|
23
|
+
raise TypeError(msg)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def output_results_as_json(results: SiteCheckResult) -> str:
|
|
27
|
+
"""Convert the results to JSON format."""
|
|
28
|
+
return json.dumps(asdict(results), default=custom_encoder)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Use Rich to display output."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.markdown import Markdown
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from djcheckup.checks import CheckResult, SiteCheckResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def rich_output(check_results: SiteCheckResult) -> None:
|
|
12
|
+
"""Display results using Rich."""
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
table = Table(title=f"DJ Checkup Results for {check_results.url}", show_lines=True)
|
|
16
|
+
|
|
17
|
+
table.add_column("Check", justify="left")
|
|
18
|
+
table.add_column("Result", justify="left")
|
|
19
|
+
table.add_column("Message", justify="left")
|
|
20
|
+
|
|
21
|
+
for result in check_results.check_results:
|
|
22
|
+
emoji = ""
|
|
23
|
+
if result.result == CheckResult.SUCCESS:
|
|
24
|
+
emoji = "🟢 "
|
|
25
|
+
elif result.result == CheckResult.FAILURE:
|
|
26
|
+
emoji = "🔴 "
|
|
27
|
+
elif result.result == CheckResult.SKIPPED:
|
|
28
|
+
emoji = "🟡 "
|
|
29
|
+
display_result = emoji + result.result.value.capitalize()
|
|
30
|
+
table.add_row(result.name, display_result, Markdown(result.message))
|
|
31
|
+
|
|
32
|
+
console.print(Panel(table))
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import rich
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from djcheckup.check_defs import all_checks
|
|
9
|
+
from djcheckup.checks import SiteChecker
|
|
10
|
+
from djcheckup.output_json import output_results_as_json
|
|
11
|
+
from djcheckup.output_rich import rich_output
|
|
12
|
+
|
|
13
|
+
app = typer.Typer()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command()
|
|
17
|
+
def run_checks(
|
|
18
|
+
url: Annotated[str, typer.Argument(help="The URL to check.")],
|
|
19
|
+
*,
|
|
20
|
+
output_json: Annotated[
|
|
21
|
+
bool,
|
|
22
|
+
typer.Option(help="Prints the results in JSON format."),
|
|
23
|
+
] = False,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Run the DJ Checkup tool against a specific URL."""
|
|
26
|
+
checker = SiteChecker(url)
|
|
27
|
+
results = checker.run_checks(all_checks)
|
|
28
|
+
|
|
29
|
+
if output_json:
|
|
30
|
+
rich.print_json(output_results_as_json(results))
|
|
31
|
+
else:
|
|
32
|
+
rich_output(results)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
app()
|