Option C Tools
Loading...
Searching...
No Matches
secret_scanner.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/tools/secret_scanner.py
4
5"""
6Purpose
7-------
8OI-514 — standalone hardcoded-secret detection. Extracted from
9:mod:`oct.linter.oct_lint` so :mod:`oct.tools.source_exporter`,
10:mod:`oct.git.quality_gate`, and any future AI review tools no longer
11need to import the linter just to reach the scanner.
12
13The linter continues to re-export the same names for backwards
14compatibility — call sites that already import them from
15``oct.linter.oct_lint`` keep working unchanged.
16
17Responsibilities
18----------------
19- Host :data:`_SECRET_NAME_PATTERNS` — the canonical set of substrings
20 that flag a variable/parameter/key name as "secret-shaped".
21- Provide :func:`_name_matches_secret` for substring matching.
22- Provide :func:`check_no_hardcoded_secrets` — the AST-based scanner.
23- Provide :func:`_is_string_literal` / :func:`_is_string_like` helpers.
24
25Diagnostics
26-----------
27Domain: SECRET-SCAN
28Levels:
29 L2 — scan lifecycle (start / end)
30 L3 — pattern hits (per-line matches)
31 L4 — AST walk details
32
33Contracts
34---------
35- Must not import :mod:`oct.linter.oct_lint` (no circular dependency).
36- Function signatures and return shapes are stable — the linter
37 re-exports them, so any change here is a breaking change there.
38- Name patterns are case-insensitive substring matches.
39"""
40
41from __future__ import annotations
42
43import ast
44import math
45import re
46from collections import Counter
47
48from oct.core.parsing import safe_parse
49
50
51# OI-529: word-segment mode splits names on delimiters + camelCase so
52# false positives like ``tokenizer`` (contains ``token``) or
53# ``pwdistance`` (contains ``pwd``) don't trigger. Pattern inserts an
54# underscore between a lowercase/digit and a following uppercase
55# (``apiKey`` → ``api_Key``) so compound patterns like ``api_key`` match
56# via the shared regex below.
57_CAMEL_SPLIT_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
58
59
60# ---------------------------------------------------------------------
61# Secret-indicating variable-name patterns (case-insensitive substring).
62# ---------------------------------------------------------------------
63
64_SECRET_NAME_PATTERNS = {
65 "password", "passwd", "pwd",
66 "secret", "secret_key", "private_key",
67 "api_key", "apikey", "api_secret",
68 "token", "auth_token", "access_token", "refresh_token",
69 "connection_string", "conn_str", "dsn", "database_url", "db_url",
70 "aws_secret", "aws_key", "aws_access",
71 "credentials", "client_secret",
72}
73
74
75def _name_matches_secret(name: str, *, mode: str = "substring") -> bool:
76 """Return True when ``name`` looks secret-shaped.
77
78 OI-529: two matching strategies are supported.
79
80 ``mode="substring"`` (default, back-compat) — any pattern that
81 appears anywhere inside ``name.lower()`` flags the name. This is
82 strict but noisy: ``tokenizer`` (contains ``token``), ``pwdistance``
83 (contains ``pwd``), etc. all flag.
84
85 ``mode="word"`` — camelCase boundaries are converted to underscores
86 (``apiKey`` → ``api_Key``), the name is lowercased, and each pattern
87 must appear delimited by start-of-string, end-of-string, ``_``, or
88 ``-`` — so compound patterns like ``api_key`` still match when the
89 original was ``apiKey`` or ``api-key``. Whole-segment hits still
90 flag (``user_password``) but false positives like ``tokenizer`` and
91 ``pwdistance`` pass clean.
92 """
93 lower = name.lower()
94 if mode == "substring":
95 return any(p in lower for p in _SECRET_NAME_PATTERNS)
96 if mode == "word":
97 normalised = _CAMEL_SPLIT_RE.sub("_", name).lower()
98 for pattern in _SECRET_NAME_PATTERNS:
99 regex = rf"(?:^|[_\-]){re.escape(pattern)}(?:$|[_\-])"
100 if re.search(regex, normalised):
101 return True
102 return False
103 raise ValueError(f"Unknown secret-match mode: {mode!r}")
104
105
106def _is_string_literal(node: ast.expr) -> bool:
107 """True when the AST node is a string constant (hardcoded literal)."""
108 return isinstance(node, ast.Constant) and isinstance(node.value, str)
109
110
111def _is_string_like(node: ast.expr) -> tuple[bool, str]:
112 """Return ``(is_string_like, kind)`` for use by :func:`check_no_hardcoded_secrets`.
113
114 ``kind`` is one of ``"literal"``, ``"f-string"``, or ``"concatenation"``
115 (OI-408). Extended detection catches f-strings and ``"a" + "b"`` patterns
116 in addition to plain string constants.
117 """
118 if isinstance(node, ast.Constant) and isinstance(node.value, str):
119 return True, "literal"
120 if isinstance(node, ast.JoinedStr):
121 return True, "f-string"
122 if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
123 left_ok, _ = _is_string_like(node.left)
124 right_ok, _ = _is_string_like(node.right)
125 if left_ok or right_ok:
126 return True, "concatenation"
127 return False, ""
128
129
130# ---------------------------------------------------------------------
131# FS-507: Shannon entropy detection for high-entropy string literals
132# ---------------------------------------------------------------------
133
134_ENTROPY_DEFAULT_THRESHOLD: float = 4.5
135_DOCSTRING_ENTROPY_DEFAULT: float = 5.0
136_COMMENT_ENTROPY_DEFAULT: float = 5.0
137_ENTROPY_MIN_LENGTH: int = 16
138
139
140def _shannon_entropy(s: str) -> float:
141 """Return the Shannon entropy (bits per character) of *s*.
142
143 H = -sum(p * log2(p)) for each unique character's frequency p.
144 Returns 0.0 for empty strings.
145 """
146 if not s:
147 return 0.0
148 length = len(s)
149 counts = Counter(s)
150 return -sum((c / length) * math.log2(c / length) for c in counts.values())
151
152
153def _collect_docstring_node_ids(tree: ast.Module) -> set[int]:
154 """Return ``id()`` values of ast.Constant nodes that are docstrings."""
155 ids: set[int] = set()
156 for node in ast.walk(tree):
157 if isinstance(node, (ast.Module, ast.ClassDef,
158 ast.FunctionDef, ast.AsyncFunctionDef)):
159 if (node.body
160 and isinstance(node.body[0], ast.Expr)
161 and isinstance(node.body[0].value, ast.Constant)
162 and isinstance(node.body[0].value.value, str)):
163 ids.add(id(node.body[0].value))
164 return ids
165
166
168 tree: ast.Module,
169 field_patterns: list[str],
170) -> set[int]:
171 """Return ``id()`` values of ast.Constant nodes that are keyword
172 arguments with names matching *field_patterns*.
173
174 This identifies strings like ``correct_pattern="..."`` in
175 ``RuleInfo(correct_pattern="...")`` calls — documentation/example
176 fields in namedtuple/dataclass constructors.
177 """
178 if not field_patterns:
179 return set()
180 ids: set[int] = set()
181 pattern_set = set(field_patterns)
182 for node in ast.walk(tree):
183 if isinstance(node, ast.Call):
184 for kw in node.keywords:
185 if (kw.arg in pattern_set
186 and isinstance(kw.value, ast.Constant)
187 and isinstance(kw.value.value, str)):
188 ids.add(id(kw.value))
189 return ids
190
191
193 tree: ast.Module,
194 *,
195 threshold: float = _ENTROPY_DEFAULT_THRESHOLD,
196 docstring_threshold: float = _DOCSTRING_ENTROPY_DEFAULT,
197 comment_threshold: float = _COMMENT_ENTROPY_DEFAULT, # reserved
198 min_length: int = _ENTROPY_MIN_LENGTH,
199 excluded_node_ids: frozenset[int] = frozenset(),
200) -> list[tuple[int, str, float]]:
201 """Walk AST for string constants exceeding the entropy threshold.
202
203 Returns a list of ``(lineno, value_preview, entropy_bits)`` tuples.
204 Only strings longer than *min_length* are checked. The preview is
205 truncated to 24 characters for readability.
206
207 Code strings use ``>=`` (catches at threshold). Docstrings use
208 strict ``>`` (must exceed threshold). ``comment_threshold`` is
209 accepted for forward compatibility but not yet consumed — comments
210 do not appear in the AST.
211
212 Nodes whose ``id()`` is in *excluded_node_ids* are skipped entirely
213 (field-name exclusions from octrc ``entropy_exclusions``).
214 """
215 docstring_ids = _collect_docstring_node_ids(tree)
216 violations: list[tuple[int, str, float]] = []
217 for node in ast.walk(tree):
218 if isinstance(node, ast.Constant) and isinstance(node.value, str):
219 if id(node) in excluded_node_ids:
220 continue
221 value = node.value
222 if len(value) < min_length:
223 continue
224 is_docstring = id(node) in docstring_ids
225 effective_threshold = (
226 docstring_threshold if is_docstring else threshold
227 )
228 entropy = _shannon_entropy(value)
229 # Docstrings: strict > (must exceed threshold)
230 # Code strings: >= (catches at threshold)
231 if is_docstring:
232 flagged = entropy > effective_threshold
233 else:
234 flagged = entropy >= effective_threshold
235 if flagged:
236 preview = value[:24] + ("..." if len(value) > 24 else "")
237 violations.append((node.lineno, preview, entropy))
238 return violations
239
240
242 text: str,
243 tree: ast.Module | None = None,
244 *,
245 mode: str = "substring",
246 code_entropy_threshold: float = _ENTROPY_DEFAULT_THRESHOLD,
247 docstring_entropy_threshold: float = _DOCSTRING_ENTROPY_DEFAULT,
248 comment_entropy_threshold: float = _COMMENT_ENTROPY_DEFAULT,
249 excluded_field_patterns: list[str] | None = None,
250) -> tuple[bool, str]:
251 """Detect string literals bound to secret-shaped names and high-entropy strings.
252
253 Inspects three AST shapes for name-based detection:
254
255 * Direct/annotated assignments — ``password = "literal"``.
256 * Dict literals — ``{"api_key": "literal"}``.
257 * Function default arguments — ``def f(token="literal"): ...``.
258
259 Safe patterns are ignored — calls, subscripts, env lookups.
260
261 FS-507: when *code_entropy_threshold* > 0, also flags any string literal
262 whose Shannon entropy exceeds the threshold (default 4.5 bits/char).
263 Code strings use ``>=``; docstrings use strict ``>``.
264 Set to 0 to disable entropy checking.
265
266 *docstring_entropy_threshold* (default 5.0) sets the bar for
267 docstring constants. *comment_entropy_threshold* (default 5.0) is
268 accepted for forward compatibility but not yet consumed.
269
270 OI-529: ``mode`` forwards to :func:`_name_matches_secret`. Default
271 is ``"substring"`` (legacy behaviour). Pass ``mode="word"`` for
272 segment-aware matching that avoids false positives like
273 ``compass``/``tokenizer``.
274
275 OI-540: *excluded_field_patterns* lists keyword-argument names
276 (e.g. ``["correct_pattern", "example"]``) whose string values are
277 exempt from entropy scanning — documentation fields in
278 namedtuple/dataclass constructors.
279
280 Returns ``(passed, detail_message)``; ``passed`` is False when at least
281 one violation is found.
282 """
283 if tree is None:
284 tree, _ = safe_parse(text)
285 if tree is None:
286 return True, ""
287
288 violations: list[str] = []
289
290 for node in ast.walk(tree):
291 if isinstance(node, ast.Assign):
292 for target in node.targets:
293 if isinstance(target, ast.Name) and _name_matches_secret(target.id, mode=mode):
294 is_str, kind = _is_string_like(node.value)
295 if is_str:
296 violations.append(
297 f"L{node.lineno}: '{target.id}' assigned {kind}"
298 if kind != "literal"
299 else f"L{node.lineno}: '{target.id}' assigned string literal"
300 )
301
302 if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
303 if _name_matches_secret(node.target.id, mode=mode) and node.value is not None:
304 is_str, kind = _is_string_like(node.value)
305 if is_str:
306 violations.append(
307 f"L{node.lineno}: '{node.target.id}' assigned {kind}"
308 if kind != "literal"
309 else f"L{node.lineno}: '{node.target.id}' assigned string literal"
310 )
311
312 if isinstance(node, ast.Dict):
313 for key, value in zip(node.keys, node.values):
314 if key is None:
315 continue
316 if _is_string_literal(key) and _name_matches_secret(key.value, mode=mode):
317 is_str, kind = _is_string_like(value)
318 if is_str:
319 violations.append(
320 f"L{key.lineno}: dict key '{key.value}' mapped to {kind}"
321 if kind != "literal"
322 else f"L{key.lineno}: dict key '{key.value}' mapped to string literal"
323 )
324
325 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
326 args = node.args
327 n_defaults = len(args.defaults)
328 if n_defaults > 0:
329 defaulted_args = args.args[-n_defaults:]
330 for arg, default in zip(defaulted_args, args.defaults):
331 if _name_matches_secret(arg.arg, mode=mode):
332 is_str, kind = _is_string_like(default)
333 if is_str:
334 violations.append(
335 f"L{node.lineno}: parameter '{arg.arg}' has {kind} default"
336 if kind != "literal"
337 else f"L{node.lineno}: parameter '{arg.arg}' has string literal default"
338 )
339 for arg, default in zip(args.kwonlyargs, args.kw_defaults):
340 if default is not None and _name_matches_secret(arg.arg, mode=mode):
341 is_str, kind = _is_string_like(default)
342 if is_str:
343 violations.append(
344 f"L{node.lineno}: parameter '{arg.arg}' has {kind} default"
345 if kind != "literal"
346 else f"L{node.lineno}: parameter '{arg.arg}' has string literal default"
347 )
348
349 # FS-507: entropy-based detection
350 if code_entropy_threshold > 0 and tree is not None:
352 tree, excluded_field_patterns or [],
353 )
354 for lineno, preview, entropy in _check_entropy_violations(
355 tree,
356 threshold=code_entropy_threshold,
357 docstring_threshold=docstring_entropy_threshold,
358 comment_threshold=comment_entropy_threshold,
359 excluded_node_ids=frozenset(excluded_ids),
360 ):
361 violations.append(
362 f"L{lineno}: high-entropy string ({entropy:.2f} bits/char): \"{preview}\""
363 )
364
365 if violations:
366 return False, "; ".join(violations)
367 return True, ""
bool _is_string_literal(ast.expr node)
set[int] _collect_keyword_excluded_node_ids(ast.Module tree, list[str] field_patterns)
float _shannon_entropy(str s)
set[int] _collect_docstring_node_ids(ast.Module tree)
bool _name_matches_secret(str name, *, str mode="substring")
list[tuple[int, str, float]] _check_entropy_violations(ast.Module tree, *, float threshold=_ENTROPY_DEFAULT_THRESHOLD, float docstring_threshold=_DOCSTRING_ENTROPY_DEFAULT, float comment_threshold=_COMMENT_ENTROPY_DEFAULT, int min_length=_ENTROPY_MIN_LENGTH, frozenset[int] excluded_node_ids=frozenset())
tuple[bool, str] check_no_hardcoded_secrets(str text, ast.Module|None tree=None, *, str mode="substring", float code_entropy_threshold=_ENTROPY_DEFAULT_THRESHOLD, float docstring_entropy_threshold=_DOCSTRING_ENTROPY_DEFAULT, float comment_entropy_threshold=_COMMENT_ENTROPY_DEFAULT, list[str]|None excluded_field_patterns=None)
tuple[bool, str] _is_string_like(ast.expr node)