djcheckup 0.1.2__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {djcheckup-0.1.2 → djcheckup-0.2.0}/PKG-INFO +1 -1
- {djcheckup-0.1.2 → djcheckup-0.2.0}/pyproject.toml +2 -2
- djcheckup-0.2.0/src/djcheckup/__init__.py +5 -0
- {djcheckup-0.1.2 → djcheckup-0.2.0}/src/djcheckup/checks.py +7 -62
- djcheckup-0.1.2/src/djcheckup/run.py → djcheckup-0.2.0/src/djcheckup/cli.py +1 -2
- djcheckup-0.1.2/src/djcheckup/output_rich.py → djcheckup-0.2.0/src/djcheckup/outputs.py +22 -2
- djcheckup-0.2.0/src/djcheckup/run.py +29 -0
- djcheckup-0.1.2/src/djcheckup/__init__.py +0 -12
- djcheckup-0.1.2/src/djcheckup/output_json.py +0 -28
- {djcheckup-0.1.2 → djcheckup-0.2.0}/LICENSE +0 -0
- {djcheckup-0.1.2 → djcheckup-0.2.0}/README.md +0 -0
- {djcheckup-0.1.2 → djcheckup-0.2.0}/src/djcheckup/check_defs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "djcheckup"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "DJ Checkup is a security scanner for Django sites."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Stuart Maxwell", email = "stuart@amanzi.nz" }]
|
|
@@ -9,7 +9,7 @@ dependencies = ["httpx>=0.28.1", "rich>=14.1.0", "typer>=0.17.4"]
|
|
|
9
9
|
license = { file = "LICENSE" }
|
|
10
10
|
|
|
11
11
|
[project.scripts]
|
|
12
|
-
djcheckup = "djcheckup.
|
|
12
|
+
djcheckup = "djcheckup.cli:app"
|
|
13
13
|
|
|
14
14
|
[project.urls]
|
|
15
15
|
Homepage = "https://github.com/stuartmaxwell/djcheckup"
|
|
@@ -8,8 +8,6 @@ from typing import Literal
|
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
10
10
|
|
|
11
|
-
from djcheckup import logger
|
|
12
|
-
|
|
13
11
|
|
|
14
12
|
class SeverityWeight(Enum):
|
|
15
13
|
"""Severity weight for the security score."""
|
|
@@ -55,7 +53,7 @@ class CheckResponse:
|
|
|
55
53
|
class SiteCheckResult:
|
|
56
54
|
"""The result of a site check."""
|
|
57
55
|
|
|
58
|
-
url:
|
|
56
|
+
url: str
|
|
59
57
|
check_results: list[CheckResponse]
|
|
60
58
|
|
|
61
59
|
|
|
@@ -82,8 +80,6 @@ class _BaseCheck(ABC):
|
|
|
82
80
|
dependent_result = previous_results.get(self.depends_on)
|
|
83
81
|
|
|
84
82
|
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
83
|
return CheckResponse(
|
|
88
84
|
name=self.name,
|
|
89
85
|
result=CheckResult.SKIPPED,
|
|
@@ -116,16 +112,10 @@ class HeaderCheck(_BaseCheck):
|
|
|
116
112
|
headers = {k.lower(): v for k, v in context.headers.items()}
|
|
117
113
|
actual_value = headers.get(self.header_name.lower())
|
|
118
114
|
if actual_value is None:
|
|
119
|
-
logger.info(f"Header '{self.header_name}' not found: {context.headers}")
|
|
120
115
|
return False
|
|
121
116
|
|
|
122
117
|
# Compare header values case-insensitively if header_value is provided
|
|
123
|
-
|
|
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
|
|
118
|
+
return not (self.header_value and actual_value.lower() != self.header_value.lower())
|
|
129
119
|
|
|
130
120
|
|
|
131
121
|
@dataclass
|
|
@@ -146,7 +136,6 @@ class ContentCheck(_BaseCheck):
|
|
|
146
136
|
if self.path:
|
|
147
137
|
# Append the path to the url
|
|
148
138
|
new_url = httpx.URL(context.url).join(self.path)
|
|
149
|
-
logger.info(f"Making request to {new_url}")
|
|
150
139
|
|
|
151
140
|
response = context.client.get(new_url)
|
|
152
141
|
response_content = response.text
|
|
@@ -155,15 +144,9 @@ class ContentCheck(_BaseCheck):
|
|
|
155
144
|
response_content = context.content
|
|
156
145
|
|
|
157
146
|
if response_content == "":
|
|
158
|
-
logger.info("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
147
|
return False
|
|
164
148
|
|
|
165
|
-
|
|
166
|
-
return True
|
|
149
|
+
return self.content in response_content
|
|
167
150
|
|
|
168
151
|
|
|
169
152
|
@dataclass
|
|
@@ -176,22 +159,16 @@ class CookieCheck(_BaseCheck):
|
|
|
176
159
|
def check(self, context: SiteCheckContext) -> bool:
|
|
177
160
|
"""Check if a specific cookie is present and optionally if it matches a given value."""
|
|
178
161
|
if not context.cookies:
|
|
179
|
-
logger.info("No response cookies to check.")
|
|
180
162
|
return False
|
|
181
163
|
|
|
182
164
|
for cookie in context.cookies:
|
|
183
165
|
if cookie.name == self.cookie_name:
|
|
184
166
|
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
167
|
return True
|
|
189
168
|
|
|
190
169
|
if not self.cookie_value:
|
|
191
|
-
logger.debug(f"Cookie '{self.cookie_name}' is present.")
|
|
192
170
|
return True
|
|
193
171
|
|
|
194
|
-
logger.debug(f"Cookie '{self.cookie_name}' is not present.")
|
|
195
172
|
return False
|
|
196
173
|
|
|
197
174
|
|
|
@@ -204,14 +181,12 @@ class CookieHttpOnlyCheck(_BaseCheck):
|
|
|
204
181
|
def check(self, context: SiteCheckContext) -> bool:
|
|
205
182
|
"""Check if a specific cookie is marked as HttpOnly."""
|
|
206
183
|
if not context.cookies:
|
|
207
|
-
logger.info("No response cookies to check.")
|
|
208
184
|
return False
|
|
209
185
|
|
|
210
186
|
for cookie in context.cookies:
|
|
211
187
|
if cookie.name == self.cookie_name:
|
|
212
188
|
return cookie.has_nonstandard_attr("HttpOnly")
|
|
213
189
|
|
|
214
|
-
logger.info(f"Cookie '{self.cookie_name}' was not found.")
|
|
215
190
|
return False
|
|
216
191
|
|
|
217
192
|
|
|
@@ -225,14 +200,12 @@ class CookieSameSiteCheck(_BaseCheck):
|
|
|
225
200
|
def check(self, context: SiteCheckContext) -> bool:
|
|
226
201
|
"""Check if a specific cookie has a SameSite attribute."""
|
|
227
202
|
if not context.cookies:
|
|
228
|
-
logger.info("No response cookies to check.")
|
|
229
203
|
return False
|
|
230
204
|
|
|
231
205
|
for cookie in context.cookies:
|
|
232
206
|
if cookie.name == self.cookie_name:
|
|
233
207
|
return cookie.get_nonstandard_attr("SameSite") == self.samesite_value
|
|
234
208
|
|
|
235
|
-
logger.info(f"Cookie '{self.cookie_name}' was not found.")
|
|
236
209
|
return False
|
|
237
210
|
|
|
238
211
|
|
|
@@ -248,15 +221,12 @@ class CookieSecureCheck(_BaseCheck):
|
|
|
248
221
|
def check(self, context: SiteCheckContext) -> bool:
|
|
249
222
|
"""Check if a specific cookie is marked as Secure."""
|
|
250
223
|
if not context.cookies:
|
|
251
|
-
logger.info("No response cookies to check.")
|
|
252
224
|
return False
|
|
253
225
|
|
|
254
226
|
for cookie in context.cookies:
|
|
255
227
|
if cookie.name == self.cookie_name:
|
|
256
|
-
logger.debug(f"Found cookie '{self.cookie_name}': {cookie}, with attributes {cookie.__dict__}")
|
|
257
228
|
return cookie.secure
|
|
258
229
|
|
|
259
|
-
logger.info(f"Cookie '{self.cookie_name}' was not found.")
|
|
260
230
|
return False
|
|
261
231
|
|
|
262
232
|
|
|
@@ -279,24 +249,13 @@ class PathCheck(_BaseCheck):
|
|
|
279
249
|
"""Makes a request to the specified path and checks the response."""
|
|
280
250
|
# Append the path to the url using the urlib module
|
|
281
251
|
new_url = httpx.URL(context.url).join(self.path)
|
|
282
|
-
logger.info(f"Making request to {new_url}")
|
|
283
252
|
|
|
284
253
|
response = context.client.get(new_url)
|
|
285
|
-
logger.info(f"Response status code: {response.status_code}")
|
|
286
254
|
|
|
287
255
|
if self.status_code:
|
|
288
|
-
|
|
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
|
|
256
|
+
return response.status_code == self.status_code
|
|
293
257
|
|
|
294
|
-
|
|
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
|
|
258
|
+
return bool(httpx.codes.is_success(response.status_code))
|
|
300
259
|
|
|
301
260
|
|
|
302
261
|
@dataclass
|
|
@@ -316,22 +275,15 @@ class SchemeCheck(_BaseCheck):
|
|
|
316
275
|
"""Check if the scheme of the URL in the request matches the final scheme."""
|
|
317
276
|
# If the start scheme matches the original URL scheme, then we don't need a new request.
|
|
318
277
|
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
278
|
return True
|
|
321
279
|
|
|
322
280
|
# Need to create a new URL with the specified start scheme
|
|
323
281
|
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
282
|
|
|
326
283
|
# And make a request to the new URL
|
|
327
284
|
response = context.client.get(new_url)
|
|
328
285
|
|
|
329
|
-
|
|
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
|
|
286
|
+
return response.url.scheme == self.end_scheme
|
|
335
287
|
|
|
336
288
|
|
|
337
289
|
class SiteChecker:
|
|
@@ -358,8 +310,6 @@ class SiteChecker:
|
|
|
358
310
|
This connects to the URL using httpx to check connectivity and then saves the page content, headers, and cookies
|
|
359
311
|
into a context object for the remaining checks.
|
|
360
312
|
"""
|
|
361
|
-
logger.info(f"Running first check for {self.url}")
|
|
362
|
-
|
|
363
313
|
check_name = "Can I connect to your site?"
|
|
364
314
|
severity_weight = SeverityWeight.HIGH
|
|
365
315
|
success_message = "Connected to your site successfully."
|
|
@@ -368,10 +318,8 @@ class SiteChecker:
|
|
|
368
318
|
try:
|
|
369
319
|
response = self.client.get(self.url)
|
|
370
320
|
response.raise_for_status()
|
|
371
|
-
logger.debug(f"Successfully connected to {self.url}")
|
|
372
321
|
|
|
373
322
|
except httpx.RequestError:
|
|
374
|
-
logger.exception(f"Error connecting to {self.url}")
|
|
375
323
|
return CheckResponse(
|
|
376
324
|
name=check_name,
|
|
377
325
|
result=CheckResult.FAILURE,
|
|
@@ -388,8 +336,6 @@ class SiteChecker:
|
|
|
388
336
|
response_url=response.url,
|
|
389
337
|
)
|
|
390
338
|
|
|
391
|
-
logger.info(f"Finished first check for {self.url}")
|
|
392
|
-
|
|
393
339
|
return CheckResponse(
|
|
394
340
|
name=check_name,
|
|
395
341
|
result=CheckResult.SUCCESS,
|
|
@@ -401,7 +347,7 @@ class SiteChecker:
|
|
|
401
347
|
"""Run all checks."""
|
|
402
348
|
# First, run the first check
|
|
403
349
|
first_check = self.run_first_check()
|
|
404
|
-
site_check_results = SiteCheckResult(url=self.url, check_results=[first_check])
|
|
350
|
+
site_check_results = SiteCheckResult(url=str(self.url), check_results=[first_check])
|
|
405
351
|
|
|
406
352
|
if first_check.result == CheckResult.FAILURE:
|
|
407
353
|
return site_check_results
|
|
@@ -413,7 +359,6 @@ class SiteChecker:
|
|
|
413
359
|
result = check.run(self.context, previous_results)
|
|
414
360
|
site_check_results.check_results.append(result)
|
|
415
361
|
previous_results[check.check_id] = result
|
|
416
|
-
logger.debug(f"Finished running check: {check.name}")
|
|
417
362
|
|
|
418
363
|
# Close the HTTP client
|
|
419
364
|
self.client.close()
|
|
@@ -7,8 +7,7 @@ import typer
|
|
|
7
7
|
|
|
8
8
|
from djcheckup.check_defs import all_checks
|
|
9
9
|
from djcheckup.checks import SiteChecker
|
|
10
|
-
from djcheckup.
|
|
11
|
-
from djcheckup.output_rich import rich_output
|
|
10
|
+
from djcheckup.outputs import output_results_as_json, rich_output
|
|
12
11
|
|
|
13
12
|
app = typer.Typer()
|
|
14
13
|
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Output formats for the site check results."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict
|
|
2
5
|
|
|
3
6
|
from rich.console import Console
|
|
4
7
|
from rich.markdown import Markdown
|
|
5
8
|
from rich.panel import Panel
|
|
6
9
|
from rich.table import Table
|
|
7
10
|
|
|
8
|
-
from djcheckup.checks import CheckResult, SiteCheckResult
|
|
11
|
+
from djcheckup.checks import CheckResult, SeverityWeight, SiteCheckResult
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
def rich_output(check_results: SiteCheckResult) -> None:
|
|
@@ -30,3 +33,20 @@ def rich_output(check_results: SiteCheckResult) -> None:
|
|
|
30
33
|
table.add_row(result.name, display_result, Markdown(result.message))
|
|
31
34
|
|
|
32
35
|
console.print(Panel(table))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def json_encoder(obj: object) -> str | int:
|
|
39
|
+
"""Custom JSON encoder for non-serialisable types."""
|
|
40
|
+
if isinstance(obj, SeverityWeight):
|
|
41
|
+
return obj.value
|
|
42
|
+
|
|
43
|
+
if isinstance(obj, CheckResult):
|
|
44
|
+
return obj.value
|
|
45
|
+
|
|
46
|
+
msg = f"Object of type {type(obj).__name__} is not JSON serialisable"
|
|
47
|
+
raise TypeError(msg)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def output_results_as_json(results: SiteCheckResult) -> str:
|
|
51
|
+
"""Convert the results to JSON format."""
|
|
52
|
+
return json.dumps(asdict(results), default=json_encoder)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Run the DJ Checkup security scanner."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal, overload
|
|
4
|
+
|
|
5
|
+
from djcheckup.check_defs import all_checks
|
|
6
|
+
from djcheckup.checks import SiteChecker, SiteCheckResult
|
|
7
|
+
from djcheckup.outputs import output_results_as_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@overload
|
|
11
|
+
def run_checks(url: str, output_format: Literal["object"] = "object") -> SiteCheckResult: ...
|
|
12
|
+
@overload
|
|
13
|
+
def run_checks(url: str, output_format: Literal["json"]) -> str: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_checks(url: str, output_format: Literal["object", "json"] = "object") -> str | SiteCheckResult:
|
|
17
|
+
"""Run the DJ Checkup tool against a specific URL and returns a JSON string."""
|
|
18
|
+
checker = SiteChecker(url)
|
|
19
|
+
results = checker.run_checks(all_checks)
|
|
20
|
+
|
|
21
|
+
if output_format not in {"object", "json"}:
|
|
22
|
+
msg = f"Invalid output_format: {output_format!r}. Must be 'object' or 'json'."
|
|
23
|
+
raise ValueError(msg)
|
|
24
|
+
|
|
25
|
+
if output_format == "json":
|
|
26
|
+
return output_results_as_json(results)
|
|
27
|
+
|
|
28
|
+
# Default to object output
|
|
29
|
+
return str(results)
|
|
@@ -1,12 +0,0 @@
|
|
|
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.2"
|
|
@@ -1,28 +0,0 @@
|
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|