codeguard-cli 2.0.0__py3-none-any.whl

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 (52) hide show
  1. codeguard/__init__.py +7 -0
  2. codeguard/cli/__init__.py +2 -0
  3. codeguard/cli/_run.py +230 -0
  4. codeguard/cli/commands.py +390 -0
  5. codeguard/cli/formatters.py +422 -0
  6. codeguard/cli/main.py +206 -0
  7. codeguard/config/__init__.py +16 -0
  8. codeguard/config/loader.py +86 -0
  9. codeguard/config/schema.py +172 -0
  10. codeguard/engine/__init__.py +25 -0
  11. codeguard/engine/baseline.py +122 -0
  12. codeguard/engine/context.py +61 -0
  13. codeguard/engine/discovery.py +160 -0
  14. codeguard/engine/finding.py +205 -0
  15. codeguard/engine/fingerprint.py +94 -0
  16. codeguard/engine/gitdiff.py +80 -0
  17. codeguard/engine/policy.py +74 -0
  18. codeguard/engine/registry.py +78 -0
  19. codeguard/engine/rule.py +195 -0
  20. codeguard/engine/runner.py +267 -0
  21. codeguard/engine/suppressions.py +109 -0
  22. codeguard/lang/__init__.py +37 -0
  23. codeguard/lang/base.py +80 -0
  24. codeguard/lang/javascript.py +20 -0
  25. codeguard/lang/node.py +137 -0
  26. codeguard/lang/python_ast.py +29 -0
  27. codeguard/lang/registry.py +38 -0
  28. codeguard/lang/treesitter.py +99 -0
  29. codeguard/lang/typescript.py +24 -0
  30. codeguard/py.typed +1 -0
  31. codeguard/rules/__init__.py +6 -0
  32. codeguard/rules/_jsnodes.py +82 -0
  33. codeguard/rules/_pyimports.py +60 -0
  34. codeguard/rules/javascript/__init__.py +9 -0
  35. codeguard/rules/javascript/cg_sec_101_dynamic_code.py +89 -0
  36. codeguard/rules/javascript/cg_sec_102_child_process.py +58 -0
  37. codeguard/rules/javascript/cg_sec_103_dom_xss.py +67 -0
  38. codeguard/rules/javascript/cg_sec_104_react_dangerous_html.py +54 -0
  39. codeguard/rules/javascript/cg_sec_105_hardcoded_secret.py +73 -0
  40. codeguard/rules/javascript/cg_sec_106_weak_random.py +83 -0
  41. codeguard/rules/meta/__init__.py +55 -0
  42. codeguard/rules/security/__init__.py +8 -0
  43. codeguard/rules/security/cg_sec_001_sql_injection.py +110 -0
  44. codeguard/rules/security/cg_sec_002_hardcoded_secrets.py +184 -0
  45. codeguard/rules/security/cg_sec_003_eval_exec.py +104 -0
  46. codeguard/rules/security/cg_sec_004_unsafe_deserialization.py +156 -0
  47. codeguard/rules/security/cg_sec_005_shell_injection.py +157 -0
  48. codeguard_cli-2.0.0.dist-info/METADATA +210 -0
  49. codeguard_cli-2.0.0.dist-info/RECORD +52 -0
  50. codeguard_cli-2.0.0.dist-info/WHEEL +4 -0
  51. codeguard_cli-2.0.0.dist-info/entry_points.txt +2 -0
  52. codeguard_cli-2.0.0.dist-info/licenses/LICENSE +184 -0
@@ -0,0 +1,67 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """CG-SEC-103 -- DOM-based XSS sink assigned a non-literal.
3
+
4
+ Flags ``el.innerHTML = x`` / ``el.outerHTML = x`` / ``el.insertAdjacentHTML(pos, x)``
5
+ / ``document.write(x)`` where the HTML is not a string literal. CWE-79.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from codeguard.engine.context import RuleContext
11
+ from codeguard.engine.finding import Category, Finding, Severity
12
+ from codeguard.engine.registry import REGISTRY
13
+ from codeguard.engine.rule import TreeSitterRule
14
+ from codeguard.lang.base import Language
15
+ from codeguard.lang.node import SourceNode
16
+ from codeguard.rules._jsnodes import arguments, callee_text, calls, is_literal
17
+
18
+ _JS_TS = frozenset({Language.JAVASCRIPT, Language.TYPESCRIPT})
19
+ _SINK_PROPS = frozenset({"innerHTML", "outerHTML"})
20
+ _SINK_CALLS = frozenset({"write", "writeln", "insertAdjacentHTML"})
21
+ _FIX = (
22
+ "Set textContent, or sanitize the HTML with a library like DOMPurify before "
23
+ "assigning it. Prefer DOM APIs (createElement / append) over HTML strings."
24
+ )
25
+
26
+
27
+ class DomXssRule(TreeSitterRule):
28
+ id = "CG-SEC-103"
29
+ title = "DOM XSS sink assigned a non-literal value"
30
+ description = (
31
+ "A non-literal value is written to innerHTML / outerHTML / document.write "
32
+ "/ insertAdjacentHTML. If it contains attacker-controlled data the browser "
33
+ "will execute injected script."
34
+ )
35
+ severity = Severity.HIGH
36
+ category = Category.SECURITY
37
+ languages = _JS_TS
38
+ cwe = "CWE-79"
39
+ owasp = "A03:2021 - Injection"
40
+
41
+ def check_tree(self, root: SourceNode, ctx: RuleContext) -> list[Finding]:
42
+ findings: list[Finding] = []
43
+
44
+ for node in root.walk():
45
+ if node.kind == "assignment_expression":
46
+ left = node.child_by_field("left")
47
+ right = node.child_by_field("right")
48
+ if left is None or right is None or left.kind != "member_expression":
49
+ continue
50
+ prop = left.child_by_field("property")
51
+ if prop and prop.text() in _SINK_PROPS and not is_literal(right):
52
+ findings.append(self._make_finding(node=node, ctx=ctx, fix_suggestion=_FIX))
53
+
54
+ for call in calls(root):
55
+ callee = callee_text(call)
56
+ base = callee.rsplit(".", 1)[-1]
57
+ if base not in _SINK_CALLS or "." not in callee:
58
+ continue
59
+ args = arguments(call)
60
+ html_arg = args[-1] if base == "insertAdjacentHTML" else (args[0] if args else None)
61
+ if html_arg is not None and not is_literal(html_arg):
62
+ findings.append(self._make_finding(node=call, ctx=ctx, fix_suggestion=_FIX))
63
+
64
+ return findings
65
+
66
+
67
+ REGISTRY.register(DomXssRule())
@@ -0,0 +1,54 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """CG-SEC-104 -- React dangerouslySetInnerHTML with a non-literal value.
3
+
4
+ ``<div dangerouslySetInnerHTML={{ __html: value }} />`` bypasses React's XSS
5
+ escaping. If ``value`` is not a literal, the HTML must be sanitized first.
6
+ CWE-79.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from codeguard.engine.context import RuleContext
12
+ from codeguard.engine.finding import Category, Finding, Severity
13
+ from codeguard.engine.registry import REGISTRY
14
+ from codeguard.engine.rule import TreeSitterRule
15
+ from codeguard.lang.base import Language
16
+ from codeguard.lang.node import SourceNode
17
+ from codeguard.rules._jsnodes import is_literal
18
+
19
+ _JS_TS = frozenset({Language.JAVASCRIPT, Language.TYPESCRIPT})
20
+ _FIX = (
21
+ "Sanitize the HTML with DOMPurify before passing it to __html, or render the "
22
+ "value as text instead of raw HTML."
23
+ )
24
+
25
+
26
+ class ReactDangerousHtmlRule(TreeSitterRule):
27
+ id = "CG-SEC-104"
28
+ title = "dangerouslySetInnerHTML with a non-literal value"
29
+ description = (
30
+ "dangerouslySetInnerHTML injects raw HTML, bypassing React's escaping. "
31
+ "The __html value here is not a literal; if it is attacker-controlled "
32
+ "this is a cross-site scripting vulnerability."
33
+ )
34
+ severity = Severity.HIGH
35
+ category = Category.SECURITY
36
+ languages = _JS_TS
37
+ cwe = "CWE-79"
38
+ owasp = "A03:2021 - Injection"
39
+
40
+ def check_tree(self, root: SourceNode, ctx: RuleContext) -> list[Finding]:
41
+ findings: list[Finding] = []
42
+ for node in root.walk():
43
+ if node.kind != "pair":
44
+ continue
45
+ key = node.child_by_field("key")
46
+ value = node.child_by_field("value")
47
+ if key is None or value is None or key.text().strip("'\"") != "__html":
48
+ continue
49
+ if not is_literal(value):
50
+ findings.append(self._make_finding(node=node, ctx=ctx, fix_suggestion=_FIX))
51
+ return findings
52
+
53
+
54
+ REGISTRY.register(ReactDangerousHtmlRule())
@@ -0,0 +1,73 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """CG-SEC-105 -- hardcoded secret in JavaScript / TypeScript.
3
+
4
+ A non-trivial string literal assigned to an identifier whose name reads like a
5
+ credential (``password``, ``apiKey``, ``token``, ...). CWE-798. Mirrors the
6
+ Python rule CG-SEC-002; confidence 0.9 because placeholder values in examples
7
+ trip it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from codeguard.engine.context import RuleContext
13
+ from codeguard.engine.finding import Category, Finding, Severity
14
+ from codeguard.engine.registry import REGISTRY
15
+ from codeguard.engine.rule import TreeSitterRule
16
+ from codeguard.lang.base import Language
17
+ from codeguard.lang.node import SourceNode
18
+ from codeguard.rules._jsnodes import looks_like_secret
19
+
20
+ _JS_TS = frozenset({Language.JAVASCRIPT, Language.TYPESCRIPT})
21
+ _MIN_LEN = 3
22
+ _FIX = "Load secrets from process.env or a secrets manager; never commit them."
23
+
24
+ # (node kind, name field, value field)
25
+ _BINDINGS = (
26
+ ("variable_declarator", "name", "value"),
27
+ ("assignment_expression", "left", "right"),
28
+ ("pair", "key", "value"),
29
+ ("public_field_definition", "name", "value"),
30
+ )
31
+
32
+
33
+ class HardcodedSecretRule(TreeSitterRule):
34
+ id = "CG-SEC-105"
35
+ title = "Hardcoded secret"
36
+ description = (
37
+ "A string literal is assigned to an identifier whose name indicates a "
38
+ "credential (password, API key, token, ...). Hardcoded secrets get "
39
+ "committed and are trivially discoverable."
40
+ )
41
+ severity = Severity.HIGH
42
+ category = Category.SECURITY
43
+ languages = _JS_TS
44
+ cwe = "CWE-798"
45
+ owasp = "A07:2021 - Identification and Authentication Failures"
46
+
47
+ def check_tree(self, root: SourceNode, ctx: RuleContext) -> list[Finding]:
48
+ findings: list[Finding] = []
49
+ for node in root.walk():
50
+ for kind, name_field, value_field in _BINDINGS:
51
+ if node.kind != kind:
52
+ continue
53
+ name = node.child_by_field(name_field)
54
+ value = node.child_by_field(value_field)
55
+ if name is None or value is None:
56
+ continue
57
+ if value.kind != "string" or len(value.text()) - 2 < _MIN_LEN:
58
+ continue
59
+ ident = name.text().strip("'\"")
60
+ if looks_like_secret(ident):
61
+ findings.append(
62
+ self._make_finding(
63
+ node=node,
64
+ ctx=ctx,
65
+ description=f"{self.description} (identifier: {ident!r})",
66
+ fix_suggestion=_FIX,
67
+ confidence=0.9,
68
+ )
69
+ )
70
+ return findings
71
+
72
+
73
+ REGISTRY.register(HardcodedSecretRule())
@@ -0,0 +1,83 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """CG-SEC-106 -- Math.random() used for something security-sensitive.
3
+
4
+ ``Math.random()`` is not cryptographically secure. Using it to build a token,
5
+ session id, password, nonce, salt, OTP, or API key is CWE-338. The rule only
6
+ fires when the surrounding binding name signals a security use, to keep the
7
+ false-positive rate low (confidence 0.8).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from codeguard.engine.context import RuleContext
15
+ from codeguard.engine.finding import Category, Finding, Severity
16
+ from codeguard.engine.registry import REGISTRY
17
+ from codeguard.engine.rule import TreeSitterRule
18
+ from codeguard.lang.base import Language
19
+ from codeguard.lang.node import SourceNode
20
+ from codeguard.rules._jsnodes import callee_text, calls
21
+
22
+ _JS_TS = frozenset({Language.JAVASCRIPT, Language.TYPESCRIPT})
23
+ _SECURITY_WORD = re.compile(
24
+ r"(?i)(token|secret|password|passwd|nonce|salt|otp|apikey|api_key|"
25
+ r"session|session_id|sessionid|csrf|uuid|guid|random_?id|verification|"
26
+ r"reset_?code|auth)"
27
+ )
28
+ _NAME_BEARING = {
29
+ "variable_declarator": "name",
30
+ "assignment_expression": "left",
31
+ "pair": "key",
32
+ "public_field_definition": "name",
33
+ }
34
+ _FIX = (
35
+ "Use a cryptographically secure source: crypto.randomBytes(),"
36
+ " crypto.randomUUID(), or the Web Crypto API (crypto.getRandomValues())."
37
+ )
38
+
39
+
40
+ class WeakRandomRule(TreeSitterRule):
41
+ id = "CG-SEC-106"
42
+ title = "Math.random() used for a security value"
43
+ description = (
44
+ "Math.random() is not cryptographically secure. It is being used to "
45
+ "produce a value whose name indicates a token, secret, id, nonce, or "
46
+ "similar -- an attacker can predict the output."
47
+ )
48
+ severity = Severity.MEDIUM
49
+ category = Category.SECURITY
50
+ languages = _JS_TS
51
+ cwe = "CWE-338"
52
+ owasp = "A02:2021 - Cryptographic Failures"
53
+
54
+ def check_tree(self, root: SourceNode, ctx: RuleContext) -> list[Finding]:
55
+ findings: list[Finding] = []
56
+ for call in calls(root):
57
+ if callee_text(call) != "Math.random":
58
+ continue
59
+ name = _enclosing_binding_name(call)
60
+ if name and _SECURITY_WORD.search(name):
61
+ findings.append(
62
+ self._make_finding(node=call, ctx=ctx, fix_suggestion=_FIX, confidence=0.8)
63
+ )
64
+ return findings
65
+
66
+
67
+ def _enclosing_binding_name(node: SourceNode, *, depth: int = 6) -> str | None:
68
+ """Walk up to *depth* ancestors looking for a binding, return its name text."""
69
+ native = node.native
70
+ for _ in range(depth):
71
+ parent = getattr(native, "parent", None)
72
+ if parent is None:
73
+ return None
74
+ field = _NAME_BEARING.get(parent.type)
75
+ if field is not None:
76
+ target = parent.child_by_field_name(field)
77
+ if target is not None and target.text is not None:
78
+ return str(target.text.decode("utf-8", "replace"))
79
+ native = parent
80
+ return None
81
+
82
+
83
+ REGISTRY.register(WeakRandomRule())
@@ -0,0 +1,55 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Meta rules -- emitted by the suppression engine, not by AST analysis.
3
+
4
+ CG-META-001 / CG-META-002 are registered here so ``list-rules`` and ``explain``
5
+ can describe them and so they can be disabled or remapped like any other rule.
6
+ The findings themselves are produced by
7
+ :class:`codeguard.engine.suppressions.SuppressionSet` via the runner.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from codeguard.engine.context import RuleContext
13
+ from codeguard.engine.finding import Category, Finding, Severity
14
+ from codeguard.engine.registry import REGISTRY
15
+ from codeguard.engine.rule import Rule
16
+ from codeguard.engine.suppressions import META_EXPIRED, META_MISSING_REASON
17
+ from codeguard.lang.base import Language
18
+
19
+ _ALL_LANGS = frozenset(Language)
20
+
21
+
22
+ class _MetaRule(Rule):
23
+ severity = Severity.LOW
24
+ category = Category.META
25
+ languages = _ALL_LANGS
26
+
27
+ def analyze(self, ctx: RuleContext) -> list[Finding]:
28
+ return [] # emitted by the runner from parsed suppressions
29
+
30
+
31
+ class SuppressionMissingReasonRule(_MetaRule):
32
+ id = META_MISSING_REASON
33
+ title = "Suppression comment has no reason"
34
+ description = (
35
+ "A `# codeguard: ignore[...]` comment does not include `reason: ...`. "
36
+ "Unexplained suppressions rot: require a short reason so reviewers know "
37
+ "why the finding was waived."
38
+ )
39
+ help_uri = "https://mevichitra.github.io/codeguard/suppressions/"
40
+
41
+
42
+ class ExpiredSuppressionRule(_MetaRule):
43
+ id = META_EXPIRED
44
+ title = "Suppression has expired"
45
+ description = (
46
+ "A `# codeguard: ignore[...] until=YYYY-MM-DD` comment is past its date. "
47
+ "The underlying finding is active again -- fix it, or renew the "
48
+ "suppression with a new date and reason."
49
+ )
50
+ severity = Severity.MEDIUM
51
+ help_uri = "https://mevichitra.github.io/codeguard/suppressions/"
52
+
53
+
54
+ REGISTRY.register(SuppressionMissingReasonRule())
55
+ REGISTRY.register(ExpiredSuppressionRule())
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Security rules — imported for side-effect (registers rules into REGISTRY)."""
3
+
4
+ from . import cg_sec_001_sql_injection as _001 # noqa: F401
5
+ from . import cg_sec_002_hardcoded_secrets as _002 # noqa: F401
6
+ from . import cg_sec_003_eval_exec as _003 # noqa: F401
7
+ from . import cg_sec_004_unsafe_deserialization as _004 # noqa: F401
8
+ from . import cg_sec_005_shell_injection as _005 # noqa: F401
@@ -0,0 +1,110 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """CG-SEC-001 — SQL query built with string formatting.
3
+
4
+ Detects f-strings, %-formatting, .format(), and string concatenation used
5
+ as the first argument to cursor.execute() / executemany() / executescript().
6
+
7
+ Why this matters
8
+ ----------------
9
+ Building SQL queries by interpolating variables into strings is the textbook
10
+ SQL injection vector (CWE-89, OWASP A03:2021). AI models produce this pattern
11
+ frequently because it is syntactically simple and mirrors common tutorial code.
12
+
13
+ The fix is always the same: use parameterized queries.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+
20
+ from codeguard.engine.finding import Category, Finding, Severity
21
+ from codeguard.engine.registry import REGISTRY
22
+ from codeguard.engine.rule import AstRule
23
+
24
+ _SQL_METHODS = frozenset({"execute", "executemany", "executescript"})
25
+
26
+ _FIX = (
27
+ "Use parameterized queries instead of string interpolation: "
28
+ 'cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))'
29
+ )
30
+
31
+
32
+ class SQLStringFormattingRule(AstRule):
33
+ """Detect SQL queries built via string formatting."""
34
+
35
+ id = "CG-SEC-001"
36
+ title = "SQL query built with string formatting"
37
+ description = (
38
+ "The SQL query passed to execute() is constructed using string interpolation "
39
+ "(f-string, %-format, .format(), or concatenation). This allows SQL injection "
40
+ "if any interpolated value originates from user input."
41
+ )
42
+ severity = Severity.HIGH
43
+ category = Category.SECURITY
44
+ cwe = "CWE-89"
45
+ owasp = "A03:2021 - Injection"
46
+
47
+ def check_ast(self, tree: ast.AST, source: str, filename: str) -> list[Finding]:
48
+ """Walk the AST looking for execute()/executemany() calls with dynamic SQL."""
49
+ findings: list[Finding] = []
50
+
51
+ for node in ast.walk(tree):
52
+ if not isinstance(node, ast.Call):
53
+ continue
54
+ if not self._is_sql_call(node):
55
+ continue
56
+ if node.args and self._is_dynamic_string(node.args[0]):
57
+ findings.append(
58
+ self._make_finding(
59
+ node=node,
60
+ filename=filename,
61
+ fix_suggestion=_FIX,
62
+ )
63
+ )
64
+
65
+ return findings
66
+
67
+ # ------------------------------------------------------------------
68
+ # Helpers
69
+ # ------------------------------------------------------------------
70
+
71
+ @staticmethod
72
+ def _is_sql_call(node: ast.Call) -> bool:
73
+ """Return True if the call is a known SQL-execution method."""
74
+ if isinstance(node.func, ast.Attribute):
75
+ return node.func.attr in _SQL_METHODS
76
+ return False
77
+
78
+ @staticmethod
79
+ def _is_dynamic_string(node: ast.AST) -> bool:
80
+ """Return True if *node* produces a string via formatting or concatenation."""
81
+ # f"SELECT ... {var} ..."
82
+ if isinstance(node, ast.JoinedStr):
83
+ return True
84
+ # "SELECT ..." % var
85
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mod):
86
+ return True
87
+ # "SELECT ...".format(var)
88
+ if (
89
+ isinstance(node, ast.Call)
90
+ and isinstance(node.func, ast.Attribute)
91
+ and node.func.attr == "format"
92
+ ):
93
+ return True
94
+ # "SELECT " + var or var + " FROM ..." -- dynamic unless the whole
95
+ # concatenation tree is string literals (left-associative, so walk it).
96
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
97
+ return not _is_constant_concat(node)
98
+ return False
99
+
100
+
101
+ def _is_constant_concat(node: ast.AST) -> bool:
102
+ """True if *node* is a string literal or an ``+`` tree of string literals."""
103
+ if isinstance(node, ast.Constant):
104
+ return isinstance(node.value, str)
105
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
106
+ return _is_constant_concat(node.left) and _is_constant_concat(node.right)
107
+ return False
108
+
109
+
110
+ REGISTRY.register(SQLStringFormattingRule())
@@ -0,0 +1,184 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """CG-SEC-002 — Hardcoded secret in variable assignment.
3
+
4
+ Detects string literals assigned to variables whose names suggest they hold
5
+ a secret: password, api_key, token, secret, etc.
6
+
7
+ Why this matters
8
+ ----------------
9
+ Hardcoded credentials are one of the most common security mistakes in
10
+ AI-generated code (CWE-798, OWASP A07:2021). LLMs routinely generate
11
+ example code with placeholder strings like "admin123" or "my_secret_key"
12
+ that end up committed to version control.
13
+
14
+ This rule has intentionally conservative matching: it only fires when the
15
+ *variable name* matches known secret-naming patterns AND the value is a
16
+ non-empty string literal (not an env-var lookup or config read).
17
+
18
+ Confidence is set to 0.9 because the rule can fire on intentional test
19
+ fixtures using placeholder values. Use ``# codeguard: ignore[CG-SEC-002]``
20
+ in tests if needed, or use a non-secret-looking variable name.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import ast
26
+ import re
27
+
28
+ from codeguard.engine.finding import Category, Finding, Severity
29
+ from codeguard.engine.registry import REGISTRY
30
+ from codeguard.engine.rule import AstRule
31
+
32
+ # Variable names that suggest secret storage.
33
+ # Deliberately conservative — only clear semantic markers.
34
+ _SECRET_NAME_RE = re.compile(
35
+ r"(?i)(\b|_)("
36
+ r"password|passwd|pwd|passphrase"
37
+ r"|secret|api_?key|private_?key|access_?key|secret_?key"
38
+ r"|auth_?token|bearer_?token|refresh_?token|session_?token|csrf_?token"
39
+ r"|client_?secret|app_?secret"
40
+ r"|db_?pass(?:word)?|database_?password"
41
+ r"|smtp_?pass(?:word)?"
42
+ r"|aws_?secret|stripe_?(?:secret_?)?key|twilio_?token|github_?token"
43
+ r")(\b|_)"
44
+ )
45
+
46
+ _FIX = (
47
+ "Load secrets from environment variables or a secrets manager: "
48
+ "os.environ['MY_SECRET'] or a library like python-decouple / hvac."
49
+ )
50
+
51
+ # Minimum length to avoid flagging empty-string defaults
52
+ _MIN_SECRET_LEN = 1
53
+
54
+
55
+ class HardcodedSecretsRule(AstRule):
56
+ """Detect string literals assigned to secret-named variables."""
57
+
58
+ id = "CG-SEC-002"
59
+ title = "Hardcoded secret"
60
+ description = (
61
+ "A string literal is assigned to a variable whose name indicates it holds "
62
+ "a secret (password, API key, token, etc.). Hardcoded credentials get "
63
+ "committed to version control and are trivially discoverable."
64
+ )
65
+ severity = Severity.HIGH
66
+ category = Category.SECURITY
67
+ cwe = "CWE-798"
68
+ owasp = "A07:2021 - Identification and Authentication Failures"
69
+
70
+ def check_ast(self, tree: ast.AST, source: str, filename: str) -> list[Finding]:
71
+ """Scan assignments for secret-named variables with string literals."""
72
+ findings: list[Finding] = []
73
+
74
+ for node in ast.walk(tree):
75
+ # Simple assignment: password = "hunter2"
76
+ if isinstance(node, ast.Assign):
77
+ for target in node.targets:
78
+ # Handle tuple/list unpacking: user, password = "admin", "hunter2"
79
+ if isinstance(target, (ast.Tuple, ast.List)):
80
+ findings.extend(self._check_unpacked(target, node.value, node, filename))
81
+ else:
82
+ name = self._target_name(target)
83
+ if name and _SECRET_NAME_RE.search(name):
84
+ if self._is_secret_literal(node.value):
85
+ findings.append(
86
+ self._make_finding(
87
+ node=node,
88
+ filename=filename,
89
+ description=(f"{self.description} (variable: {name!r})"),
90
+ fix_suggestion=_FIX,
91
+ confidence=0.9,
92
+ )
93
+ )
94
+
95
+ # Annotated assignment: password: str = "hunter2"
96
+ elif isinstance(node, ast.AnnAssign):
97
+ name = self._target_name(node.target)
98
+ if name and _SECRET_NAME_RE.search(name) and node.value is not None:
99
+ if self._is_secret_literal(node.value):
100
+ findings.append(
101
+ self._make_finding(
102
+ node=node,
103
+ filename=filename,
104
+ description=(f"{self.description} (variable: {name!r})"),
105
+ fix_suggestion=_FIX,
106
+ confidence=0.9,
107
+ )
108
+ )
109
+
110
+ return findings
111
+
112
+ # ------------------------------------------------------------------
113
+ # Helpers
114
+ # ------------------------------------------------------------------
115
+
116
+ def _check_unpacked(
117
+ self,
118
+ target: ast.Tuple | ast.List,
119
+ value: ast.AST,
120
+ node: ast.AST,
121
+ filename: str,
122
+ ) -> list[Finding]:
123
+ """Check each element of a tuple/list unpacking assignment.
124
+
125
+ Handles patterns like:
126
+ user, password = "admin", "hunter2"
127
+ [username, api_key] = get_credentials()
128
+ """
129
+ findings: list[Finding] = []
130
+
131
+ # Only inspect element-by-element when the RHS is also a tuple/list
132
+ # literal so we can match targets to values positionally.
133
+ if isinstance(value, (ast.Tuple, ast.List)):
134
+ for tgt, val in zip(target.elts, value.elts, strict=False):
135
+ name = self._target_name(tgt)
136
+ if name and _SECRET_NAME_RE.search(name):
137
+ if self._is_secret_literal(val):
138
+ findings.append(
139
+ self._make_finding(
140
+ node=node,
141
+ filename=filename,
142
+ description=(f"{self.description} (variable: {name!r})"),
143
+ fix_suggestion=_FIX,
144
+ confidence=0.9,
145
+ )
146
+ )
147
+ else:
148
+ # RHS is not a literal tuple — we can't match positionally,
149
+ # so flag any secret-named target in the unpacking.
150
+ for tgt in target.elts:
151
+ name = self._target_name(tgt)
152
+ if name and _SECRET_NAME_RE.search(name):
153
+ findings.append(
154
+ self._make_finding(
155
+ node=node,
156
+ filename=filename,
157
+ description=(f"{self.description} (variable: {name!r})"),
158
+ fix_suggestion=_FIX,
159
+ confidence=0.7,
160
+ )
161
+ )
162
+
163
+ return findings
164
+
165
+ @staticmethod
166
+ def _target_name(target: ast.AST) -> str | None:
167
+ """Extract the simple name from a Name or Attribute target, or None."""
168
+ if isinstance(target, ast.Name):
169
+ return target.id
170
+ if isinstance(target, ast.Attribute):
171
+ return target.attr
172
+ return None
173
+
174
+ @staticmethod
175
+ def _is_secret_literal(node: ast.AST) -> bool:
176
+ """Return True if *node* is a non-empty string constant."""
177
+ return (
178
+ isinstance(node, ast.Constant)
179
+ and isinstance(node.value, str)
180
+ and len(node.value) >= _MIN_SECRET_LEN
181
+ )
182
+
183
+
184
+ REGISTRY.register(HardcodedSecretsRule())