oct
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
40Dependencies
41------------
42- oct.core.parsing (safe_parse - parses the source when the caller passes no tree)
43"""
44
45# OCT-LINT: disable-file=dbg_import reason="Plan 263: the isolation table excludes the secret scanner from the sweep; operator-ruled waiver rather than an import edit (2026-09-14)"
46# OCT-LINT: disable-file=dbg_usage reason="Plan 263: the isolation table excludes the secret scanner from the sweep; operator-ruled waiver rather than a _dbg edit (2026-09-14)"
47
48from __future__ import annotations
49
50import ast
51import math
52import re
53from collections import Counter
54
55from oct.core.parsing import safe_parse
56
57
58# OI-529: word-segment mode splits names on delimiters + camelCase so
59# false positives like ``tokenizer`` (contains ``token``) or
60# ``pwdistance`` (contains ``pwd``) don't trigger. Pattern inserts an
61# underscore between a lowercase/digit and a following uppercase
62# (``apiKey`` → ``api_Key``) so compound patterns like ``api_key`` match
63# via the shared regex below.
64_CAMEL_SPLIT_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
65
66
67# ---------------------------------------------------------------------
68# Secret-indicating variable-name patterns (case-insensitive substring).
69# ---------------------------------------------------------------------
70
71_SECRET_NAME_PATTERNS = {
72 "password", "passwd", "pwd",
73 "secret", "secret_key", "private_key",
74 "api_key", "apikey", "api_secret",
75 "token", "auth_token", "access_token", "refresh_token",
76 "connection_string", "conn_str", "dsn", "database_url", "db_url",
77 "aws_secret", "aws_key", "aws_access",
78 "credentials", "client_secret",
79}
80
81
82def _name_matches_secret(name: str, *, mode: str = "substring") -> bool:
83 """Return True when ``name`` looks secret-shaped.
84
85 OI-529: two matching strategies are supported.
86
87 ``mode="substring"`` (default, back-compat) - any pattern that
88 appears anywhere inside ``name.lower()`` flags the name. This is
89 strict but noisy: ``tokenizer`` (contains ``token``), ``pwdistance``
90 (contains ``pwd``), etc. all flag.
91
92 ``mode="word"`` - camelCase boundaries are converted to underscores
93 (``apiKey`` → ``api_Key``), the name is lowercased, and each pattern
94 must appear delimited by start-of-string, end-of-string, ``_``, or
95 ``-`` - so compound patterns like ``api_key`` still match when the
96 original was ``apiKey`` or ``api-key``. Whole-segment hits still
97 flag (``user_password``) but false positives like ``tokenizer`` and
98 ``pwdistance`` pass clean.
99 """
100 lower = name.lower()
101 if mode == "substring":
102 return any(p in lower for p in _SECRET_NAME_PATTERNS)
103 if mode == "word":
104 normalised = _CAMEL_SPLIT_RE.sub("_", name).lower()
105 for pattern in _SECRET_NAME_PATTERNS:
106 regex = rf"(?:^|[_\-]){re.escape(pattern)}(?:$|[_\-])"
107 if re.search(regex, normalised):
108 return True
109 return False
110 raise ValueError(f"Unknown secret-match mode: {mode!r}")
111
112
113def _is_string_literal(node: ast.expr) -> bool:
114 """True when the AST node is a string constant (hardcoded literal)."""
115 return isinstance(node, ast.Constant) and isinstance(node.value, str)
116
117
118def _is_string_like(node: ast.expr) -> tuple[bool, str]:
119 """Return ``(is_string_like, kind)`` for use by :func:`check_no_hardcoded_secrets`.
120
121 ``kind`` is one of ``"literal"``, ``"f-string"``, or ``"concatenation"``
122 (OI-408). Extended detection catches f-strings and ``"a" + "b"`` patterns
123 in addition to plain string constants.
124 """
125 if isinstance(node, ast.Constant) and isinstance(node.value, str):
126 return True, "literal"
127 if isinstance(node, ast.JoinedStr):
128 return True, "f-string"
129 if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
130 left_ok, _ = _is_string_like(node.left)
131 right_ok, _ = _is_string_like(node.right)
132 if left_ok or right_ok:
133 return True, "concatenation"
134 return False, ""
135
136
137# ---------------------------------------------------------------------
138# FS-507: Shannon entropy detection for high-entropy string literals
139# ---------------------------------------------------------------------
140
141_ENTROPY_DEFAULT_THRESHOLD: float = 4.5
142_DOCSTRING_ENTROPY_DEFAULT: float = 5.0
143_COMMENT_ENTROPY_DEFAULT: float = 5.0
144_ENTROPY_MIN_LENGTH: int = 16
145
146
147def _shannon_entropy(s: str) -> float:
148 """Return the Shannon entropy (bits per character) of *s*.
149
150 H = -sum(p * log2(p)) for each unique character's frequency p.
151 Returns 0.0 for empty strings.
152 """
153 if not s:
154 return 0.0
155 length = len(s)
156 counts = Counter(s)
157 return -sum((c / length) * math.log2(c / length) for c in counts.values())
158
159
160def _collect_docstring_node_ids(tree: ast.Module) -> set[int]:
161 """Return ``id()`` values of ast.Constant nodes that are docstrings."""
162 ids: set[int] = set()
163 for node in ast.walk(tree):
164 if isinstance(node, (ast.Module, ast.ClassDef,
165 ast.FunctionDef, ast.AsyncFunctionDef)):
166 if (node.body
167 and isinstance(node.body[0], ast.Expr)
168 and isinstance(node.body[0].value, ast.Constant)
169 and isinstance(node.body[0].value.value, str)):
170 ids.add(id(node.body[0].value))
171 return ids
172
173
175 tree: ast.Module,
176 field_patterns: list[str],
177) -> set[int]:
178 """Return ``id()`` values of ast.Constant nodes that are keyword
179 arguments with names matching *field_patterns*.
180
181 This identifies strings like ``correct_pattern="..."`` in
182 ``RuleInfo(correct_pattern="...")`` calls - documentation/example
183 fields in namedtuple/dataclass constructors.
184 """
185 if not field_patterns:
186 return set()
187 ids: set[int] = set()
188 pattern_set = set(field_patterns)
189 for node in ast.walk(tree):
190 if isinstance(node, ast.Call):
191 for kw in node.keywords:
192 if (kw.arg in pattern_set
193 and isinstance(kw.value, ast.Constant)
194 and isinstance(kw.value.value, str)):
195 ids.add(id(kw.value))
196 return ids
197
198
200 tree: ast.Module,
201 *,
202 threshold: float = _ENTROPY_DEFAULT_THRESHOLD,
203 docstring_threshold: float = _DOCSTRING_ENTROPY_DEFAULT,
204 comment_threshold: float = _COMMENT_ENTROPY_DEFAULT, # reserved
205 min_length: int = _ENTROPY_MIN_LENGTH,
206 excluded_node_ids: frozenset[int] = frozenset(),
207) -> list[tuple[int, str, float]]:
208 """Walk AST for string constants exceeding the entropy threshold.
209
210 Returns a list of ``(lineno, value_preview, entropy_bits)`` tuples.
211 Only strings longer than *min_length* are checked. The preview is
212 truncated to 24 characters for readability.
213
214 Code strings use ``>=`` (catches at threshold). Docstrings use
215 strict ``>`` (must exceed threshold). ``comment_threshold`` is
216 accepted for forward compatibility but not yet consumed - comments
217 do not appear in the AST.
218
219 Nodes whose ``id()`` is in *excluded_node_ids* are skipped entirely
220 (field-name exclusions from octrc ``entropy_exclusions``).
221 """
222 docstring_ids = _collect_docstring_node_ids(tree)
223 violations: list[tuple[int, str, float]] = []
224 for node in ast.walk(tree):
225 if isinstance(node, ast.Constant) and isinstance(node.value, str):
226 if id(node) in excluded_node_ids:
227 continue
228 value = node.value
229 if len(value) < min_length:
230 continue
231 is_docstring = id(node) in docstring_ids
232 effective_threshold = (
233 docstring_threshold if is_docstring else threshold
234 )
235 entropy = _shannon_entropy(value)
236 # Docstrings: strict > (must exceed threshold)
237 # Code strings: >= (catches at threshold)
238 if is_docstring:
239 flagged = entropy > effective_threshold
240 else:
241 flagged = entropy >= effective_threshold
242 if flagged:
243 preview = value[:24] + ("..." if len(value) > 24 else "")
244 violations.append((node.lineno, preview, entropy))
245 return violations
246
247
249 text: str,
250 tree: ast.Module | None = None,
251 *,
252 mode: str = "substring",
253 code_entropy_threshold: float = _ENTROPY_DEFAULT_THRESHOLD,
254 docstring_entropy_threshold: float = _DOCSTRING_ENTROPY_DEFAULT,
255 comment_entropy_threshold: float = _COMMENT_ENTROPY_DEFAULT,
256 excluded_field_patterns: list[str] | None = None,
257) -> tuple[bool, str]:
258 """Detect string literals bound to secret-shaped names and high-entropy strings.
259
260 Inspects three AST shapes for name-based detection:
261
262 * Direct/annotated assignments - ``password = "literal"``.
263 * Dict literals - ``{"api_key": "literal"}``.
264 * Function default arguments - ``def f(token="literal"): ...``.
265
266 Safe patterns are ignored - calls, subscripts, env lookups.
267
268 FS-507: when *code_entropy_threshold* > 0, also flags any string literal
269 whose Shannon entropy exceeds the threshold (default 4.5 bits/char).
270 Code strings use ``>=``; docstrings use strict ``>``.
271 Set to 0 to disable entropy checking.
272
273 *docstring_entropy_threshold* (default 5.0) sets the bar for
274 docstring constants. *comment_entropy_threshold* (default 5.0) is
275 accepted for forward compatibility but not yet consumed.
276
277 OI-529: ``mode`` forwards to :func:`_name_matches_secret`. Default
278 is ``"substring"`` (legacy behaviour). Pass ``mode="word"`` for
279 segment-aware matching that avoids false positives like
280 ``compass``/``tokenizer``.
281
282 OI-540: *excluded_field_patterns* lists keyword-argument names
283 (e.g. ``["correct_pattern", "example"]``) whose string values are
284 exempt from entropy scanning - documentation fields in
285 namedtuple/dataclass constructors.
286
287 Returns ``(passed, detail_message)``; ``passed`` is False when at least
288 one violation is found.
289 """
290 if tree is None:
291 tree, _ = safe_parse(text)
292 if tree is None:
293 # Plan 176 (175 item 3, remedy A - fail-closed): an unparseable file
294 # cannot be certified secret-free, so the scanner MUST NOT pass it.
295 # This return becomes a normal no_hardcoded_secrets violation, flowing
296 # into secrets_findings and tripping the always-on secrets gate
297 # (exit 5, --force-proof). The message names the file as UNSCANNABLE,
298 # not as containing a secret, so the operator is not misled into
299 # hunting a nonexistent credential.
300 return False, "file is unparseable; secret scan could not run (cannot certify secret-free)"
301
302 violations: list[str] = []
303
304 for node in ast.walk(tree):
305 if isinstance(node, ast.Assign):
306 for target in node.targets:
307 if isinstance(target, ast.Name) and _name_matches_secret(target.id, mode=mode):
308 is_str, kind = _is_string_like(node.value)
309 if is_str:
310 violations.append(
311 f"L{node.lineno}: '{target.id}' assigned {kind}"
312 if kind != "literal"
313 else f"L{node.lineno}: '{target.id}' assigned string literal"
314 )
315
316 if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
317 if _name_matches_secret(node.target.id, mode=mode) and node.value is not None:
318 is_str, kind = _is_string_like(node.value)
319 if is_str:
320 violations.append(
321 f"L{node.lineno}: '{node.target.id}' assigned {kind}"
322 if kind != "literal"
323 else f"L{node.lineno}: '{node.target.id}' assigned string literal"
324 )
325
326 if isinstance(node, ast.Dict):
327 for key, value in zip(node.keys, node.values):
328 if key is None:
329 continue
330 if _is_string_literal(key) and _name_matches_secret(key.value, mode=mode):
331 is_str, kind = _is_string_like(value)
332 if is_str:
333 violations.append(
334 f"L{key.lineno}: dict key '{key.value}' mapped to {kind}"
335 if kind != "literal"
336 else f"L{key.lineno}: dict key '{key.value}' mapped to string literal"
337 )
338
339 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
340 args = node.args
341 n_defaults = len(args.defaults)
342 if n_defaults > 0:
343 defaulted_args = args.args[-n_defaults:]
344 for arg, default in zip(defaulted_args, args.defaults):
345 if _name_matches_secret(arg.arg, mode=mode):
346 is_str, kind = _is_string_like(default)
347 if is_str:
348 violations.append(
349 f"L{node.lineno}: parameter '{arg.arg}' has {kind} default"
350 if kind != "literal"
351 else f"L{node.lineno}: parameter '{arg.arg}' has string literal default"
352 )
353 for arg, default in zip(args.kwonlyargs, args.kw_defaults):
354 if default is not None and _name_matches_secret(arg.arg, mode=mode):
355 is_str, kind = _is_string_like(default)
356 if is_str:
357 violations.append(
358 f"L{node.lineno}: parameter '{arg.arg}' has {kind} default"
359 if kind != "literal"
360 else f"L{node.lineno}: parameter '{arg.arg}' has string literal default"
361 )
362
363 # FS-507: entropy-based detection
364 if code_entropy_threshold > 0 and tree is not None:
366 tree, excluded_field_patterns or [],
367 )
368 for lineno, preview, entropy in _check_entropy_violations(
369 tree,
370 threshold=code_entropy_threshold,
371 docstring_threshold=docstring_entropy_threshold,
372 comment_threshold=comment_entropy_threshold,
373 excluded_node_ids=frozenset(excluded_ids),
374 ):
375 violations.append(
376 f"L{lineno}: high-entropy string ({entropy:.2f} bits/char): \"{preview}\""
377 )
378
379 if violations:
380 return False, "; ".join(violations)
381 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)