sarj-python-lint 0.2.0__tar.gz → 0.3.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.
- {sarj_python_lint-0.2.0 → sarj_python_lint-0.3.0}/PKG-INFO +9 -7
- {sarj_python_lint-0.2.0 → sarj_python_lint-0.3.0}/README.md +3 -1
- {sarj_python_lint-0.2.0 → sarj_python_lint-0.3.0}/pyproject.toml +10 -10
- {sarj_python_lint-0.2.0 → sarj_python_lint-0.3.0}/src/sarj_python_lint/__init__.py +1 -1
- sarj_python_lint-0.3.0/src/sarj_python_lint/rules/__init__.py +35 -0
- sarj_python_lint-0.3.0/src/sarj_python_lint/rules/no_fat_try_blocks.py +82 -0
- sarj_python_lint-0.3.0/src/sarj_python_lint/rules/no_secret_in_log.py +116 -0
- sarj_python_lint-0.3.0/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +124 -0
- sarj_python_lint-0.3.0/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +75 -0
- sarj_python_lint-0.3.0/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +95 -0
- sarj_python_lint-0.3.0/src/sarj_python_lint/rules/prefer_discriminated_union.py +336 -0
- sarj_python_lint-0.3.0/src/sarj_python_lint/rules/prefer_str_enum.py +288 -0
- sarj_python_lint-0.3.0/src/sarj_python_lint/rules/pydantic_at_boundaries.py +230 -0
- sarj_python_lint-0.2.0/src/sarj_python_lint/rules/__init__.py +0 -19
- sarj_python_lint-0.2.0/src/sarj_python_lint/rules/prefer_discriminated_union.py +0 -148
- sarj_python_lint-0.2.0/src/sarj_python_lint/rules/prefer_str_enum.py +0 -107
- {sarj_python_lint-0.2.0 → sarj_python_lint-0.3.0}/.gitignore +0 -0
- {sarj_python_lint-0.2.0 → sarj_python_lint-0.3.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.2.0 → sarj_python_lint-0.3.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.2.0 → sarj_python_lint-0.3.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.2.0 → sarj_python_lint-0.3.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.2.0 → sarj_python_lint-0.3.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.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
|
-
Project-URL: Homepage, https://github.com/sarj-ai/
|
|
6
|
-
Project-URL: Repository, https://github.com/sarj-ai/
|
|
7
|
-
Project-URL: Issues, https://github.com/sarj-ai/
|
|
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.
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
15
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
-
Requires-Python: >=3.
|
|
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/
|
|
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/
|
|
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.
|
|
3
|
+
version = "0.3.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.
|
|
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.
|
|
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/
|
|
24
|
-
Repository = "https://github.com/sarj-ai/
|
|
25
|
-
Issues = "https://github.com/sarj-ai/
|
|
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>=
|
|
30
|
-
"pytest-benchmark>=
|
|
31
|
-
"ruff>=0.
|
|
32
|
-
"basedpyright>=1.
|
|
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]
|
|
@@ -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
|