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.
@@ -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()