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.
- codeguard/__init__.py +7 -0
- codeguard/cli/__init__.py +2 -0
- codeguard/cli/_run.py +230 -0
- codeguard/cli/commands.py +390 -0
- codeguard/cli/formatters.py +422 -0
- codeguard/cli/main.py +206 -0
- codeguard/config/__init__.py +16 -0
- codeguard/config/loader.py +86 -0
- codeguard/config/schema.py +172 -0
- codeguard/engine/__init__.py +25 -0
- codeguard/engine/baseline.py +122 -0
- codeguard/engine/context.py +61 -0
- codeguard/engine/discovery.py +160 -0
- codeguard/engine/finding.py +205 -0
- codeguard/engine/fingerprint.py +94 -0
- codeguard/engine/gitdiff.py +80 -0
- codeguard/engine/policy.py +74 -0
- codeguard/engine/registry.py +78 -0
- codeguard/engine/rule.py +195 -0
- codeguard/engine/runner.py +267 -0
- codeguard/engine/suppressions.py +109 -0
- codeguard/lang/__init__.py +37 -0
- codeguard/lang/base.py +80 -0
- codeguard/lang/javascript.py +20 -0
- codeguard/lang/node.py +137 -0
- codeguard/lang/python_ast.py +29 -0
- codeguard/lang/registry.py +38 -0
- codeguard/lang/treesitter.py +99 -0
- codeguard/lang/typescript.py +24 -0
- codeguard/py.typed +1 -0
- codeguard/rules/__init__.py +6 -0
- codeguard/rules/_jsnodes.py +82 -0
- codeguard/rules/_pyimports.py +60 -0
- codeguard/rules/javascript/__init__.py +9 -0
- codeguard/rules/javascript/cg_sec_101_dynamic_code.py +89 -0
- codeguard/rules/javascript/cg_sec_102_child_process.py +58 -0
- codeguard/rules/javascript/cg_sec_103_dom_xss.py +67 -0
- codeguard/rules/javascript/cg_sec_104_react_dangerous_html.py +54 -0
- codeguard/rules/javascript/cg_sec_105_hardcoded_secret.py +73 -0
- codeguard/rules/javascript/cg_sec_106_weak_random.py +83 -0
- codeguard/rules/meta/__init__.py +55 -0
- codeguard/rules/security/__init__.py +8 -0
- codeguard/rules/security/cg_sec_001_sql_injection.py +110 -0
- codeguard/rules/security/cg_sec_002_hardcoded_secrets.py +184 -0
- codeguard/rules/security/cg_sec_003_eval_exec.py +104 -0
- codeguard/rules/security/cg_sec_004_unsafe_deserialization.py +156 -0
- codeguard/rules/security/cg_sec_005_shell_injection.py +157 -0
- codeguard_cli-2.0.0.dist-info/METADATA +210 -0
- codeguard_cli-2.0.0.dist-info/RECORD +52 -0
- codeguard_cli-2.0.0.dist-info/WHEEL +4 -0
- codeguard_cli-2.0.0.dist-info/entry_points.txt +2 -0
- 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())
|