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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: djcheckup
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: DJ Checkup is a security scanner for Django sites.
5
5
  Author: Stuart Maxwell
6
6
  Author-email: Stuart Maxwell <stuart@amanzi.nz>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "djcheckup"
3
- version = "0.1.2"
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.run:app"
12
+ djcheckup = "djcheckup.cli:app"
13
13
 
14
14
  [project.urls]
15
15
  Homepage = "https://github.com/stuartmaxwell/djcheckup"
@@ -0,0 +1,5 @@
1
+ """DJ Checkup CLI package."""
2
+
3
+ from djcheckup.run import run_checks
4
+
5
+ __all__ = ["run_checks"]
@@ -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: httpx.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
- 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
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
- logger.debug("Response body contains expected content.")
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
- 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
256
+ return response.status_code == self.status_code
293
257
 
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
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
- 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
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.output_json import output_results_as_json
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
- """Use Rich to display output."""
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