sarj-python-lint 0.2.0__tar.gz → 0.4.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.
Files changed (22) hide show
  1. {sarj_python_lint-0.2.0 → sarj_python_lint-0.4.0}/PKG-INFO +9 -7
  2. {sarj_python_lint-0.2.0 → sarj_python_lint-0.4.0}/README.md +3 -1
  3. {sarj_python_lint-0.2.0 → sarj_python_lint-0.4.0}/pyproject.toml +10 -10
  4. {sarj_python_lint-0.2.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/__init__.py +1 -1
  5. sarj_python_lint-0.4.0/src/sarj_python_lint/rules/__init__.py +35 -0
  6. sarj_python_lint-0.4.0/src/sarj_python_lint/rules/no_fat_try_blocks.py +82 -0
  7. sarj_python_lint-0.4.0/src/sarj_python_lint/rules/no_secret_in_log.py +116 -0
  8. sarj_python_lint-0.4.0/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +124 -0
  9. sarj_python_lint-0.4.0/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +75 -0
  10. sarj_python_lint-0.4.0/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +95 -0
  11. sarj_python_lint-0.4.0/src/sarj_python_lint/rules/prefer_discriminated_union.py +336 -0
  12. sarj_python_lint-0.4.0/src/sarj_python_lint/rules/prefer_str_enum.py +280 -0
  13. sarj_python_lint-0.4.0/src/sarj_python_lint/rules/pydantic_at_boundaries.py +239 -0
  14. sarj_python_lint-0.2.0/src/sarj_python_lint/rules/__init__.py +0 -19
  15. sarj_python_lint-0.2.0/src/sarj_python_lint/rules/prefer_discriminated_union.py +0 -148
  16. sarj_python_lint-0.2.0/src/sarj_python_lint/rules/prefer_str_enum.py +0 -107
  17. {sarj_python_lint-0.2.0 → sarj_python_lint-0.4.0}/.gitignore +0 -0
  18. {sarj_python_lint-0.2.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/__main__.py +0 -0
  19. {sarj_python_lint-0.2.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/py.typed +0 -0
  20. {sarj_python_lint-0.2.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rule_base.py +0 -0
  21. {sarj_python_lint-0.2.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  22. {sarj_python_lint-0.2.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
- Project-URL: Homepage, https://github.com/sarj-ai/linting/tree/main/packages/python
6
- Project-URL: Repository, https://github.com/sarj-ai/linting
7
- Project-URL: Issues, https://github.com/sarj-ai/linting/issues
5
+ Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
6
+ Project-URL: Repository, https://github.com/sarj-ai/standards
7
+ Project-URL: Issues, https://github.com/sarj-ai/standards/issues
8
8
  Author: sarj-ai
9
9
  License: MIT
10
10
  Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: License :: OSI Approved :: MIT License
13
13
  Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
15
  Classifier: Topic :: Software Development :: Quality Assurance
16
- Requires-Python: >=3.13
16
+ Requires-Python: >=3.14
17
17
  Description-Content-Type: text/markdown
18
18
 
19
19
  # sarj-python-lint
@@ -27,13 +27,15 @@ uv tool install sarj-python-lint
27
27
  ## Pre-commit
28
28
 
29
29
  ```yaml
30
- - repo: https://github.com/sarj-ai/linting
30
+ - repo: https://github.com/sarj-ai/standards
31
31
  rev: python-v0.2.0
32
32
  hooks:
33
33
  - id: sarj-no-sequential-await
34
34
  - id: sarj-inefficient-string-concat-in-loop
35
35
  - id: sarj-prefer-discriminated-union
36
36
  - id: sarj-prefer-str-enum
37
+ - id: sarj-no-fat-try-blocks
38
+ - id: sarj-pydantic-at-boundaries
37
39
  ```
38
40
 
39
41
  ## CLI
@@ -9,13 +9,15 @@ uv tool install sarj-python-lint
9
9
  ## Pre-commit
10
10
 
11
11
  ```yaml
12
- - repo: https://github.com/sarj-ai/linting
12
+ - repo: https://github.com/sarj-ai/standards
13
13
  rev: python-v0.2.0
14
14
  hooks:
15
15
  - id: sarj-no-sequential-await
16
16
  - id: sarj-inefficient-string-concat-in-loop
17
17
  - id: sarj-prefer-discriminated-union
18
18
  - id: sarj-prefer-str-enum
19
+ - id: sarj-no-fat-try-blocks
20
+ - id: sarj-pydantic-at-boundaries
19
21
  ```
20
22
 
21
23
  ## CLI
@@ -1,17 +1,17 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
7
7
  license = { text = "MIT" }
8
- requires-python = ">=3.13"
8
+ requires-python = ">=3.14"
9
9
  classifiers = [
10
10
  "Development Status :: 4 - Beta",
11
11
  "Intended Audience :: Developers",
12
12
  "License :: OSI Approved :: MIT License",
13
13
  "Programming Language :: Python :: 3",
14
- "Programming Language :: Python :: 3.13",
14
+ "Programming Language :: Python :: 3.14",
15
15
  "Topic :: Software Development :: Quality Assurance",
16
16
  ]
17
17
  dependencies = []
@@ -20,16 +20,16 @@ dependencies = []
20
20
  sarj-python-lint = "sarj_python_lint.__main__:main"
21
21
 
22
22
  [project.urls]
23
- Homepage = "https://github.com/sarj-ai/linting/tree/main/packages/python"
24
- Repository = "https://github.com/sarj-ai/linting"
25
- Issues = "https://github.com/sarj-ai/linting/issues"
23
+ Homepage = "https://github.com/sarj-ai/standards/tree/main/packages/python"
24
+ Repository = "https://github.com/sarj-ai/standards"
25
+ Issues = "https://github.com/sarj-ai/standards/issues"
26
26
 
27
27
  [dependency-groups]
28
28
  dev = [
29
- "pytest>=8.0",
30
- "pytest-benchmark>=4.0",
31
- "ruff>=0.6",
32
- "basedpyright>=1.20",
29
+ "pytest>=9.0",
30
+ "pytest-benchmark>=5.2",
31
+ "ruff>=0.15",
32
+ "basedpyright>=1.39",
33
33
  ]
34
34
 
35
35
  [build-system]
@@ -1,3 +1,3 @@
1
1
  """sarj-python-lint — custom Python + SQL lint rules."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from sarj_python_lint.rule_base import Rule
4
+ from sarj_python_lint.rules.inefficient_string_concat_in_loop import (
5
+ InefficientStringConcatInLoop,
6
+ )
7
+ from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
8
+ from sarj_python_lint.rules.no_secret_in_log import NoSecretInLog
9
+ from sarj_python_lint.rules.no_sentinel_return_on_except import NoSentinelReturnOnExcept
10
+ from sarj_python_lint.rules.no_sequential_await import NoSequentialAwait
11
+ from sarj_python_lint.rules.no_unreachable_after_terminal import (
12
+ NoUnreachableAfterTerminal,
13
+ )
14
+ from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
15
+ PreferConstantTimeSecretCompare,
16
+ )
17
+ from sarj_python_lint.rules.prefer_discriminated_union import PreferDiscriminatedUnion
18
+ from sarj_python_lint.rules.prefer_str_enum import PreferStrEnum
19
+ from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
20
+
21
+
22
+ REGISTRY: dict[str, type[Rule]] = {
23
+ NoSequentialAwait.id: NoSequentialAwait,
24
+ InefficientStringConcatInLoop.id: InefficientStringConcatInLoop,
25
+ PreferDiscriminatedUnion.id: PreferDiscriminatedUnion,
26
+ PreferStrEnum.id: PreferStrEnum,
27
+ NoFatTryBlocks.id: NoFatTryBlocks,
28
+ PydanticAtBoundaries.id: PydanticAtBoundaries,
29
+ NoSentinelReturnOnExcept.id: NoSentinelReturnOnExcept,
30
+ NoUnreachableAfterTerminal.id: NoUnreachableAfterTerminal,
31
+ PreferConstantTimeSecretCompare.id: PreferConstantTimeSecretCompare,
32
+ NoSecretInLog.id: NoSecretInLog,
33
+ }
34
+
35
+ __all__ = ["REGISTRY"]
@@ -0,0 +1,82 @@
1
+ """SARJ007: `try` block whose body has more than 3 top-level statements.
2
+
3
+ A fat `try` body obscures which statement is actually expected to raise and
4
+ widens the blast radius of the `except` handlers: unrelated failures get
5
+ caught (and often swallowed or mis-reported) by handlers written for a
6
+ different operation. Keep the `try` skinny — isolate the throwing
7
+ statement(s) and move the non-throwing setup and follow-up work outside.
8
+
9
+ Only the top-level statements of the `try` body are counted; statements
10
+ nested inside an `if` / `with` / loop within the body count as the single
11
+ compound statement that contains them. Nested `try` blocks are checked
12
+ independently. `try*` (PEP 654 except-groups) is held to the same limit.
13
+
14
+ This is a direct Python port of the org's ESLint restriction
15
+ `TryStatement > BlockStatement[body.length > 3]` in eslint.strict.mjs.
16
+
17
+ Instead of:
18
+ try:
19
+ payload = build_payload(order)
20
+ response = client.send(payload)
21
+ record = parse(response)
22
+ store.save(record)
23
+ except HTTPError:
24
+ ...
25
+
26
+ Prefer:
27
+ payload = build_payload(order)
28
+ try:
29
+ response = client.send(payload)
30
+ except HTTPError:
31
+ ...
32
+ record = parse(response)
33
+ store.save(record)
34
+
35
+ References:
36
+ - https://docs.python.org/3/tutorial/errors.html#handling-exceptions
37
+ - https://docs.python.org/3/library/ast.html#ast.Try
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import ast
43
+ from pathlib import Path
44
+
45
+ from sarj_python_lint.rule_base import Diagnostic, Rule
46
+
47
+ _MAX_TRY_BODY_STATEMENTS = 3
48
+
49
+
50
+ class NoFatTryBlocks(Rule):
51
+ """Try body longer than 3 statements — isolate the throwing statement(s)."""
52
+
53
+ id = "no-fat-try-blocks"
54
+ code = "SARJ007"
55
+ description = "Try block body exceeds 3 statements — keep try blocks skinny."
56
+
57
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
58
+ try:
59
+ tree = ast.parse(source, filename=str(path))
60
+ except SyntaxError:
61
+ return []
62
+ diags: list[Diagnostic] = []
63
+ for node in ast.walk(tree):
64
+ if not isinstance(node, (ast.Try, ast.TryStar)):
65
+ continue
66
+ if len(node.body) <= _MAX_TRY_BODY_STATEMENTS:
67
+ continue
68
+ diags.append(
69
+ Diagnostic(
70
+ path=path,
71
+ line=node.lineno,
72
+ col=node.col_offset + 1,
73
+ code=self.code,
74
+ message=(
75
+ f"try block has {len(node.body)} statements "
76
+ f"(max {_MAX_TRY_BODY_STATEMENTS}) — try blocks should "
77
+ "isolate the throwing statement(s); move non-throwing "
78
+ "work outside the try."
79
+ ),
80
+ )
81
+ )
82
+ return diags
@@ -0,0 +1,116 @@
1
+ """SARJ012: detect secrets passed by keyword argument to a logging call.
2
+
3
+ Logging a secret value (token, password, api key, jwt, credential, etc.) by
4
+ keyword argument leaks it into log sinks — files, stdout, log aggregators —
5
+ where it persists far beyond its intended lifetime and is readable by anyone
6
+ with log access. Prefer redaction (`token_prefix=token[:6]`) or omission.
7
+
8
+ We are deliberately precise: only the keyword-argument form
9
+ (`logger.info("x", token=token)`) is flagged. F-strings are too noisy to detect
10
+ reliably, so they're out of scope.
11
+
12
+ References:
13
+ - https://owasp.org/www-community/vulnerabilities/Information_exposure_through_log_files
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+ import re
20
+ from pathlib import Path
21
+
22
+ from sarj_python_lint.rule_base import Diagnostic, Rule
23
+
24
+ # Logging method names (the `.attr` of the call's func).
25
+ _LOG_METHODS = frozenset(
26
+ {"debug", "info", "warning", "warn", "error", "exception", "critical"}
27
+ )
28
+
29
+ # Names that look like a logger object. We're permissive on the object but
30
+ # require it to plausibly be a logger to avoid flagging unrelated `.info(...)`
31
+ # calls.
32
+ _LOGGER_NAMES = frozenset({"logger", "log", "logging"})
33
+
34
+ # A keyword name leaks a secret if it CONTAINS a secret word (so `AuthToken`,
35
+ # `api_key`, `userPassword` all match) UNLESS it also carries a redaction marker
36
+ # (`token_prefix`, `password_hash`, `secret_masked`) — those are the intended
37
+ # safe forms, not the raw value.
38
+ _SECRET_WORD_RE = re.compile(
39
+ r"token|secret|password|passwd|api_?key|jwt|credential|authorization",
40
+ re.IGNORECASE,
41
+ )
42
+ _REDACTION_RE = re.compile(
43
+ r"prefix|suffix|redact|mask|hash|hint|_len|length",
44
+ re.IGNORECASE,
45
+ )
46
+
47
+
48
+ def _is_secret_keyword(name: str) -> bool:
49
+ """True if the keyword name names a raw secret (not a redacted derivative)."""
50
+ if _REDACTION_RE.search(name):
51
+ return False
52
+ return _SECRET_WORD_RE.search(name) is not None
53
+
54
+
55
+ class NoSecretInLog(Rule):
56
+ """Secret passed by keyword argument to a logging call."""
57
+
58
+ id = "no-secret-in-log"
59
+ code = "SARJ012"
60
+ description = "Secret passed by keyword to a logging call — redact or omit."
61
+
62
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
63
+ try:
64
+ tree = ast.parse(source, filename=str(path))
65
+ except SyntaxError:
66
+ return []
67
+ diags: list[Diagnostic] = []
68
+ for node in ast.walk(tree):
69
+ if not isinstance(node, ast.Call):
70
+ continue
71
+ if not _is_logging_call(node):
72
+ continue
73
+ for kw in node.keywords:
74
+ # `**kwargs` has arg=None — nothing to inspect.
75
+ if kw.arg is None:
76
+ continue
77
+ if _is_secret_keyword(kw.arg):
78
+ diags.append(
79
+ Diagnostic(
80
+ path=path,
81
+ line=getattr(kw.value, "lineno", node.lineno),
82
+ col=getattr(kw.value, "col_offset", node.col_offset) + 1,
83
+ code=self.code,
84
+ message=(
85
+ f"Secret keyword `{kw.arg}` passed to a logging "
86
+ "call leaks it to log sinks — redact "
87
+ "(e.g. `token_prefix=token[:6]`) or omit it."
88
+ ),
89
+ )
90
+ )
91
+ return diags
92
+
93
+
94
+ def _is_logging_call(node: ast.Call) -> bool:
95
+ """Return True if `node` looks like `logger.<level>(...)`.
96
+
97
+ Precise on the method name (must be a known log level) and conservative on
98
+ the object: it must be a Name in {logger, log, logging} or an Attribute
99
+ ending in one of those (e.g. `self.logger`, `self.log`).
100
+ """
101
+ func = node.func
102
+ if not isinstance(func, ast.Attribute):
103
+ return False
104
+ if func.attr not in _LOG_METHODS:
105
+ return False
106
+ return _looks_like_logger(func.value)
107
+
108
+
109
+ def _looks_like_logger(value: ast.AST) -> bool:
110
+ """Return True if `value` names a logger object (case-insensitively)."""
111
+ if isinstance(value, ast.Name):
112
+ return value.id.lower() in _LOGGER_NAMES
113
+ if isinstance(value, ast.Attribute):
114
+ # e.g. `self.logger`, `cls.log`
115
+ return value.attr.lower() in _LOGGER_NAMES
116
+ return False
@@ -0,0 +1,124 @@
1
+ """SARJ009: detect exception handlers that silently swallow via a sentinel return.
2
+
3
+ An `except` block whose final statement is `return <sentinel>` (None, False,
4
+ empty collection, empty string) and which never re-raises silently discards the
5
+ error. Callers then can't distinguish "no result" from "something broke", which
6
+ hides bugs and corrupts idempotency decisions.
7
+
8
+ Prefer re-raising, or returning a typed result (e.g. a Result/Optional that the
9
+ caller must explicitly handle).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ from pathlib import Path
16
+
17
+ from sarj_python_lint.rule_base import Diagnostic, Rule
18
+
19
+
20
+ class NoSentinelReturnOnExcept(Rule):
21
+ """Exception handler that swallows the error by returning a sentinel."""
22
+
23
+ id = "no-sentinel-return-on-except"
24
+ code = "SARJ009"
25
+ description = (
26
+ "`except` handler returns a sentinel and never re-raises — "
27
+ "the exception is silently swallowed."
28
+ )
29
+
30
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
31
+ try:
32
+ tree = ast.parse(source, filename=str(path))
33
+ except SyntaxError:
34
+ return []
35
+ diags: list[Diagnostic] = []
36
+ for node in ast.walk(tree):
37
+ if not isinstance(node, ast.ExceptHandler):
38
+ continue
39
+ if not node.body:
40
+ continue
41
+ last = node.body[-1]
42
+ if not isinstance(last, ast.Return):
43
+ continue
44
+ # Bare `return` (value is None) is semantically `return None` — a
45
+ # sentinel. Only skip when there IS a value that isn't a sentinel.
46
+ if last.value is not None and not _is_sentinel(last.value):
47
+ continue
48
+ if _handler_reraises(node):
49
+ continue
50
+ diags.append(
51
+ Diagnostic(
52
+ path=path,
53
+ line=last.lineno,
54
+ col=last.col_offset + 1,
55
+ code=self.code,
56
+ message=(
57
+ "Exception is swallowed by returning a sentinel — "
58
+ "re-raise, or return a typed result and handle it "
59
+ "explicitly."
60
+ ),
61
+ )
62
+ )
63
+ return diags
64
+
65
+
66
+ def _is_sentinel(value: ast.expr) -> bool:
67
+ """True if `value` is a sentinel: None, False, empty collection/str, set()."""
68
+ if isinstance(value, ast.Constant):
69
+ # None, False, or empty string. Note: True / non-empty str / numbers
70
+ # are meaningful and must not be flagged.
71
+ if value.value is None or value.value is False:
72
+ return True
73
+ if isinstance(value.value, str) and value.value == "":
74
+ return True
75
+ return False
76
+ # Empty list / dict / set / tuple literals.
77
+ if isinstance(value, ast.List):
78
+ return len(value.elts) == 0
79
+ if isinstance(value, ast.Tuple):
80
+ return len(value.elts) == 0
81
+ if isinstance(value, ast.Set):
82
+ # `set()` is a call, not a Set node; `{}` is a Dict. A Set node always
83
+ # has at least one element, so it's never empty — but be explicit.
84
+ return len(value.elts) == 0
85
+ if isinstance(value, ast.Dict):
86
+ return len(value.keys) == 0
87
+ # `set()` call with no args.
88
+ if isinstance(value, ast.Call):
89
+ func = value.func
90
+ if (
91
+ isinstance(func, ast.Name)
92
+ and func.id == "set"
93
+ and not value.args
94
+ and not value.keywords
95
+ ):
96
+ return True
97
+ return False
98
+ return False
99
+
100
+
101
+ def _handler_reraises(handler: ast.ExceptHandler) -> bool:
102
+ """True if the handler body contains a `raise`, ignoring nested functions.
103
+
104
+ A `raise` inside a nested def/lambda doesn't re-raise for *this* handler, so
105
+ we stop walking at function/lambda boundaries.
106
+ """
107
+ for stmt in handler.body:
108
+ if _contains_raise(stmt):
109
+ return True
110
+ return False
111
+
112
+
113
+ def _contains_raise(node: ast.AST) -> bool:
114
+ """Walk `node`, returning True on a `raise`, but not crossing nested defs."""
115
+ # A `raise` inside a nested def/lambda doesn't re-raise for *this* handler,
116
+ # so a node that IS a function/lambda contributes no re-raise.
117
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
118
+ return False
119
+ if isinstance(node, ast.Raise):
120
+ return True
121
+ for child in ast.iter_child_nodes(node):
122
+ if _contains_raise(child):
123
+ return True
124
+ return False
@@ -0,0 +1,75 @@
1
+ """SARJ010: detect unreachable code after a terminal statement.
2
+
3
+ A terminal statement (`return`, `raise`, `break`, `continue`) ends control
4
+ flow for the enclosing block. Any statement that immediately follows it in the
5
+ same statement list can never execute — it is dead code, almost always a
6
+ logic error (e.g. a `return` placed before cleanup, or a stray statement after
7
+ a `break`).
8
+
9
+ This is a pure structural check: for every statement-list field on every node
10
+ (the `body`/`orelse`/`finalbody` lists of Module, FunctionDef, If, For, While,
11
+ With, Try, ExceptHandler, etc.), if a terminal appears before the last element
12
+ of that list, the statement immediately after it is unreachable.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ from pathlib import Path
19
+ from typing import cast
20
+
21
+ from sarj_python_lint.rule_base import Diagnostic, Rule
22
+
23
+ # Statements that terminate control flow for their enclosing block.
24
+ _TERMINALS = (ast.Return, ast.Raise, ast.Break, ast.Continue)
25
+
26
+ # Fields on AST nodes that hold a list of statements (a block body).
27
+ _BLOCK_FIELDS = ("body", "orelse", "finalbody")
28
+
29
+
30
+ class NoUnreachableAfterTerminal(Rule):
31
+ """Code following a `return`/`raise`/`break`/`continue` is unreachable."""
32
+
33
+ id = "no-unreachable-after-terminal"
34
+ code = "SARJ010"
35
+ description = (
36
+ "Unreachable code after a terminal statement "
37
+ "(`return`/`raise`/`break`/`continue`)."
38
+ )
39
+
40
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
41
+ try:
42
+ tree = ast.parse(source, filename=str(path))
43
+ except SyntaxError:
44
+ return []
45
+ diags: list[Diagnostic] = []
46
+ for node in ast.walk(tree):
47
+ for field in _BLOCK_FIELDS:
48
+ raw = getattr(node, field, None)
49
+ if not isinstance(raw, list):
50
+ continue
51
+ # `getattr` is untyped; the block fields (`body`/`orelse`/
52
+ # `finalbody`) only ever hold statements, so cast to a concrete
53
+ # element type to keep the `.lineno`/`.col_offset` access below
54
+ # well-typed under strict checking.
55
+ stmts = cast("list[ast.stmt]", raw)
56
+ # Find the first terminal that is not the last element; the
57
+ # statement immediately after it is unreachable.
58
+ for i in range(len(stmts) - 1):
59
+ if isinstance(stmts[i], _TERMINALS):
60
+ unreachable = stmts[i + 1]
61
+ diags.append(
62
+ Diagnostic(
63
+ path=path,
64
+ line=unreachable.lineno,
65
+ col=unreachable.col_offset + 1,
66
+ code=self.code,
67
+ message=(
68
+ "Unreachable code — this statement follows a "
69
+ "`return`/`raise`/`break`/`continue` and can "
70
+ "never execute."
71
+ ),
72
+ )
73
+ )
74
+ break # one diag per statement list (the first)
75
+ return diags
@@ -0,0 +1,95 @@
1
+ """SARJ011: detect `==`/`!=` comparisons on secret-like values.
2
+
3
+ Comparing secrets (tokens, signatures, HMACs, password hashes, API keys) with
4
+ `==`/`!=` is timing-attack-prone: short-circuiting on the first differing byte
5
+ leaks information about how many leading bytes matched. Use
6
+ `hmac.compare_digest(a, b)`, which compares in constant time.
7
+
8
+ References:
9
+ - https://docs.python.org/3/library/hmac.html#hmac.compare_digest
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import re
16
+ from pathlib import Path
17
+
18
+ from sarj_python_lint.rule_base import Diagnostic, Rule
19
+
20
+ # Identifiers that look like secrets. Matched against Name.id / Attribute.attr.
21
+ _SECRET_RE = re.compile(
22
+ r"token|secret|signature|api_?key|hmac|digest|password|passwd|hash",
23
+ re.IGNORECASE,
24
+ )
25
+
26
+
27
+ class PreferConstantTimeSecretCompare(Rule):
28
+ """Direct `==`/`!=` on a secret-like value — prefer hmac.compare_digest."""
29
+
30
+ id = "prefer-constant-time-secret-compare"
31
+ code = "SARJ011"
32
+ description = (
33
+ "Direct `==`/`!=` on a secret — prefer `hmac.compare_digest(a, b)`."
34
+ )
35
+
36
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
37
+ try:
38
+ tree = ast.parse(source, filename=str(path))
39
+ except SyntaxError:
40
+ return []
41
+ diags: list[Diagnostic] = []
42
+ for node in ast.walk(tree):
43
+ if not isinstance(node, ast.Compare):
44
+ continue
45
+ # Only single-operator comparisons using == or != (Eq/NotEq).
46
+ # Chained comparisons (a == b == c) and is/is not don't apply.
47
+ if len(node.ops) != 1:
48
+ continue
49
+ if not isinstance(node.ops[0], (ast.Eq, ast.NotEq)):
50
+ continue
51
+ operands = [node.left, *node.comparators]
52
+ # Skip presence checks: None/True/False, numbers, empty string "".
53
+ if any(_is_excluded_operand(op) for op in operands):
54
+ continue
55
+ if not any(_is_secret_operand(op) for op in operands):
56
+ continue
57
+ diags.append(
58
+ Diagnostic(
59
+ path=path,
60
+ line=node.lineno,
61
+ col=node.col_offset + 1,
62
+ code=self.code,
63
+ message=(
64
+ "Direct `==`/`!=` on a secret-like value is "
65
+ "timing-attack-prone — prefer "
66
+ "`hmac.compare_digest(a, b)`."
67
+ ),
68
+ )
69
+ )
70
+ return diags
71
+
72
+
73
+ def _is_secret_operand(node: ast.AST) -> bool:
74
+ """True if the operand's identifier matches the secret-name pattern."""
75
+ if isinstance(node, ast.Name):
76
+ return _SECRET_RE.search(node.id) is not None
77
+ if isinstance(node, ast.Attribute):
78
+ return _SECRET_RE.search(node.attr) is not None
79
+ return False
80
+
81
+
82
+ def _is_excluded_operand(node: ast.AST) -> bool:
83
+ """True for presence/identity-style operands we never want to flag.
84
+
85
+ Covers `None`/`True`/`False`, numeric literals, and the empty string `""`.
86
+ """
87
+ if isinstance(node, ast.Constant):
88
+ value = node.value
89
+ if value is None or isinstance(value, bool):
90
+ return True
91
+ if isinstance(value, (int, float, complex)):
92
+ return True
93
+ if isinstance(value, str) and value == "":
94
+ return True
95
+ return False