sarj-python-lint 0.15.0__tar.gz → 0.15.1__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 (54) hide show
  1. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/_secret_names.py +56 -5
  4. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +11 -10
  5. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/.gitignore +0 -0
  6. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/README.md +0 -0
  7. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/_version.py +0 -0
  10. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/py.typed +0 -0
  11. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rule_base.py +0 -0
  12. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  13. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  14. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/_paths.py +0 -0
  15. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/_registry.py +0 -0
  16. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  17. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  18. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  19. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  20. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  21. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  22. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  23. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  24. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  25. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  26. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  27. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  28. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  29. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  30. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  31. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  32. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  33. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  34. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  35. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  36. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  37. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  38. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  39. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  40. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  41. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  42. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  43. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  44. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  45. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  46. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  47. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  48. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  49. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  50. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  51. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  52. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  53. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  54. {sarj_python_lint-0.15.0 → sarj_python_lint-0.15.1}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.15.0
3
+ Version: 0.15.1
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
6
6
  Project-URL: Repository, https://github.com/sarj-ai/standards
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.15.0"
3
+ version = "0.15.1"
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" }]
@@ -11,13 +11,15 @@ misfired on a large false-positive class observed in a real audit:
11
11
  `tokenize`, `tokenizer`, `token_budget`.
12
12
  - Row-id / handle names: `api_key_id`, `*_key_id` — the id of a key row, not the
13
13
  key material.
14
- - Boolean feature / presence / state flags: `password_enabled`,
15
- `token_present`, `password_set`, `password_configured` — a boolean answering
16
- "is it there / was it set", not the credential itself. A `type` discriminator
17
- is the same: `token_type` is `"Bearer"`, `credential_type` is a class name.
14
+ - Boolean feature / presence / state flags, in both word orders: trailing
15
+ (`password_enabled`, `token_present`, `password_set`, `password_configured`)
16
+ and leading (`has_secret`, `hasSecret`, `is_token`, `isToken`) — a boolean
17
+ answering "is it there / was it set", not the credential itself. A `type`
18
+ discriminator is the same: `token_type` is `"Bearer"`, `credential_type` is a
19
+ class name.
18
20
  - Innocent words embedding a secret word: `secretary` (embeds `secret`).
19
21
 
20
- We fix this with two changes:
22
+ We fix this with three changes:
21
23
 
22
24
  1. Match a secret word only as a WHOLE token (after snake_case / camelCase
23
25
  splitting), never a substring. This alone clears `tokenize`, `tokenizer`,
@@ -28,6 +30,10 @@ We fix this with two changes:
28
30
  also present — this clears `token_count`, `api_key_id`, `password_enabled`,
29
31
  while still catching a credential that merely leads with such a word
30
32
  (`valid_token`, `present_token` are secrets, not flags).
33
+ 3. Disqualify an identifier whose LEADING WORD is a boolean predicate (`is`,
34
+ `has`, `was`, ...) — the mirror image of (2), clearing `has_secret` /
35
+ `hasSecret` / `is_token` / `isToken`, which name a boolean answering "does a
36
+ secret exist?" and are neither a leak nor a timing surface.
31
37
  """
32
38
 
33
39
  from __future__ import annotations
@@ -89,6 +95,28 @@ _INNOCUOUS_WORDS = frozenset(
89
95
  }
90
96
  )
91
97
 
98
+ # A LEADING boolean-predicate word marks a flag, not the credential itself:
99
+ # `has_secret`, `hasSecret`, `is_token`, `isToken`, `should_rotate_token`.
100
+ #
101
+ # WHY THE SHARED PREDICATE (both SARJ011 and SARJ012), not just the SARJ011-only
102
+ # auth narrowing the TS port uses: this is the exact mirror of the TRAILING
103
+ # `_INNOCUOUS_WORDS` check above, which already exempts both rules. `has_token`
104
+ # and `token_present` are the same boolean; word order must not decide whether a
105
+ # name counts as a credential. And the SARJ012 case stands on its own — a boolean
106
+ # answering "does a secret exist?" leaks nothing when logged, so the rule was
107
+ # reporting a pure false positive. (As with the trailing form, the exemption keys
108
+ # on the NAME: someone who writes `has_secret=the_actual_secret` still leaks, but
109
+ # that hole predates this and is inherent to a name-only rule.)
110
+ #
111
+ # WHY IT IS SAFE IN THE PERMISSIVE DIRECTION: every member is a copula/auxiliary
112
+ # verb that is never the head noun of a credential. Real secret names are noun
113
+ # phrases — `auth_token`, `api_key`, `signing_secret`, `INTERNAL_ADMIN_TOKEN` —
114
+ # and none begins with `is`/`has`/`was`/`are`/`can`/`should`. Matching is on the
115
+ # whole leading WORD, never a prefix of one, so names that merely start with
116
+ # those letters keep firing: `hash_secret` (`hash` != `has`), `issuer_token`
117
+ # (`issuer` != `is`), `canary_token` (`canary` != `can`).
118
+ _FLAG_PREFIXES = frozenset({"is", "has", "was", "are", "can", "should"})
119
+
92
120
  # camelCase / PascalCase / ALLCAPS / digit run splitter, applied to each
93
121
  # snake/kebab segment. `APIKey` -> ["API", "Key"], `authToken` -> ["auth", "Token"].
94
122
  _CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
@@ -115,6 +143,27 @@ def identifier_tokens(identifier: str) -> list[str]:
115
143
  return tokens
116
144
 
117
145
 
146
+ def leading_word(identifier: str) -> str | None:
147
+ """Return the first *word* of `identifier`, lowercased, or None if it has none.
148
+
149
+ `identifier_tokens` deliberately emits each whole snake/kebab segment before
150
+ its camel parts, so its first entry for the camelCase `hasSecret` is the
151
+ useless `hassecret` rather than `has`. Splitting the leading segment with the
152
+ same camel regex makes `has_secret` and `hasSecret` both yield `has`.
153
+
154
+ Returns:
155
+ The lowercased leading word, or None when `identifier` has no word
156
+ characters at all.
157
+
158
+ """
159
+ for segment in _SEGMENT_RE.split(identifier):
160
+ if not segment:
161
+ continue
162
+ parts = _CAMEL_RE.findall(segment)
163
+ return parts[0].lower() if parts else segment.lower()
164
+ return None
165
+
166
+
118
167
  def is_secret_name(identifier: str) -> bool:
119
168
  """Report whether `identifier` names raw secret material (a credential, not metadata).
120
169
 
@@ -125,6 +174,8 @@ def is_secret_name(identifier: str) -> bool:
125
174
  tokens = identifier_tokens(identifier)
126
175
  if tokens and tokens[-1] in _INNOCUOUS_WORDS:
127
176
  return False
177
+ if leading_word(identifier) in _FLAG_PREFIXES:
178
+ return False
128
179
  if any(tok in _SECRET_WORDS for tok in tokens):
129
180
  return True
130
181
  return _has_api_key(tokens)
@@ -49,10 +49,6 @@ _DESCRIPTOR_WORDS = frozenset({"type", "types", "name", "names", "id", "ids", "k
49
49
  # credential: `TOKEN_TYPE_SYSTEM`, `credential_type`, `grant_kind`.
50
50
  _CATEGORY_WORDS = frozenset({"type", "types", "kind", "kinds"})
51
51
 
52
- # A leading boolean-predicate token marks a flag, not the credential itself:
53
- # `is_token`, `has_secret`, `is_token_strategy`.
54
- _FLAG_PREFIXES = frozenset({"is", "has", "was", "are", "can", "should"})
55
-
56
52
  # Words that make an identifier a secret *only* via an integrity/content hash
57
53
  # (`content_hash`, `metadata_hash`, `row_hash`) rather than an authenticator.
58
54
  # A name that ALSO carries one of these keeps firing (`password_hash`,
@@ -182,10 +178,17 @@ def _is_auth_secret_name(identifier: str, *, crypto_module: bool) -> bool:
182
178
  """Report whether `identifier` names an authenticator (an access-gating secret).
183
179
 
184
180
  Narrows the shared `is_secret_name` for SARJ011: strips category/handle
185
- descriptors, `type`/`kind` discriminators, boolean flags, and integrity-only
186
- hashes, none of which are a timing-attack surface. A name whose only auth
187
- token is the polysemous `signature` needs the module to import crypto
188
- machinery — otherwise it is a function signature, not a MAC.
181
+ descriptors, `type`/`kind` discriminators, and integrity-only hashes, none
182
+ of which are a timing-attack surface. A name whose only auth token is the
183
+ polysemous `signature` needs the module to import crypto machinery —
184
+ otherwise it is a function signature, not a MAC.
185
+
186
+ Boolean flags (`is_token`, `hasSecret`) are NOT handled here: the shared
187
+ `is_secret_name` now rejects a leading flag word for both SARJ011 and
188
+ SARJ012, so the gate above has already returned. A duplicate local check
189
+ used to live here and was dead once that landed — worse, it read
190
+ `tokens[0]`, which is the whole snake segment (`"hassecret"`), so it never
191
+ matched a camelCase flag in the first place.
189
192
 
190
193
  Returns:
191
194
  True when comparing `identifier` in non-constant time leaks an auth secret.
@@ -194,8 +197,6 @@ def _is_auth_secret_name(identifier: str, *, crypto_module: bool) -> bool:
194
197
  if not is_secret_name(identifier):
195
198
  return False
196
199
  tokens = identifier_tokens(identifier)
197
- if tokens and tokens[0] in _FLAG_PREFIXES:
198
- return False
199
200
  if tokens and tokens[-1] in _DESCRIPTOR_WORDS:
200
201
  return False
201
202
  if any(tok in _CATEGORY_WORDS for tok in tokens):