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,104 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""CG-SEC-003 — eval() / exec() called with a non-literal argument.
|
|
3
|
+
|
|
4
|
+
Detects calls to ``eval``, ``exec``, or ``compile`` where the first argument
|
|
5
|
+
is not a string literal — meaning the code being executed is dynamic and may
|
|
6
|
+
be attacker-controlled.
|
|
7
|
+
|
|
8
|
+
Why this matters
|
|
9
|
+
----------------
|
|
10
|
+
``eval()`` and ``exec()`` execute arbitrary Python. When called with
|
|
11
|
+
user-controlled or externally-sourced input they allow remote code execution
|
|
12
|
+
(CWE-78, CWE-95, OWASP A03:2021).
|
|
13
|
+
|
|
14
|
+
AI models frequently generate ``eval(user_input)`` or ``exec(command)``
|
|
15
|
+
patterns because they look like concise solutions to dynamic-dispatch problems.
|
|
16
|
+
|
|
17
|
+
False-positive guidance
|
|
18
|
+
-----------------------
|
|
19
|
+
``eval("1 + 1")`` with a *literal* string is intentionally excluded — that is
|
|
20
|
+
a code smell but not the dangerous case. If your codebase legitimately calls
|
|
21
|
+
``eval`` on trusted, internally-constructed strings, suppress with:
|
|
22
|
+
``# codeguard: ignore[CG-SEC-003]``
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import ast
|
|
28
|
+
|
|
29
|
+
from codeguard.engine.finding import Category, Finding, Severity
|
|
30
|
+
from codeguard.engine.registry import REGISTRY
|
|
31
|
+
from codeguard.engine.rule import AstRule
|
|
32
|
+
|
|
33
|
+
_DANGEROUS_BUILTINS = frozenset({"eval", "exec", "compile"})
|
|
34
|
+
|
|
35
|
+
_FIX = (
|
|
36
|
+
"Avoid eval/exec on dynamic input. Use a safe dispatch mechanism "
|
|
37
|
+
"(a dict of callables, importlib, or ast.literal_eval for data parsing)."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EvalExecRule(AstRule):
|
|
42
|
+
"""Detect eval/exec/compile called with a non-literal argument."""
|
|
43
|
+
|
|
44
|
+
id = "CG-SEC-003"
|
|
45
|
+
title = "eval() / exec() on dynamic input"
|
|
46
|
+
description = (
|
|
47
|
+
"eval(), exec(), or compile() is called with a non-literal argument. "
|
|
48
|
+
"If the argument can be influenced by external input this is a remote "
|
|
49
|
+
"code execution vulnerability."
|
|
50
|
+
)
|
|
51
|
+
severity = Severity.HIGH
|
|
52
|
+
category = Category.SECURITY
|
|
53
|
+
cwe = "CWE-95"
|
|
54
|
+
owasp = "A03:2021 - Injection"
|
|
55
|
+
|
|
56
|
+
def check_ast(self, tree: ast.AST, source: str, filename: str) -> list[Finding]:
|
|
57
|
+
"""Find eval/exec/compile calls with dynamic first arguments."""
|
|
58
|
+
findings: list[Finding] = []
|
|
59
|
+
|
|
60
|
+
for node in ast.walk(tree):
|
|
61
|
+
if not isinstance(node, ast.Call):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
func_name = self._func_name(node)
|
|
65
|
+
if func_name not in _DANGEROUS_BUILTINS:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# No arguments at all — unusual but not our concern
|
|
69
|
+
if not node.args:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
first_arg = node.args[0]
|
|
73
|
+
# Safe case: literal string — eval("1+1") is a code smell, not RCE
|
|
74
|
+
if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
findings.append(
|
|
78
|
+
self._make_finding(
|
|
79
|
+
node=node,
|
|
80
|
+
filename=filename,
|
|
81
|
+
description=(
|
|
82
|
+
f"{func_name}() is called with a non-literal argument. "
|
|
83
|
+
"If this value is attacker-controlled it enables arbitrary "
|
|
84
|
+
"code execution."
|
|
85
|
+
),
|
|
86
|
+
fix_suggestion=_FIX,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return findings
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# Helpers
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _func_name(node: ast.Call) -> str:
|
|
98
|
+
"""Return the bare function name for simple Name calls, else ''."""
|
|
99
|
+
if isinstance(node.func, ast.Name):
|
|
100
|
+
return node.func.id
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
REGISTRY.register(EvalExecRule())
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""CG-SEC-004 — Unsafe deserialization via pickle or yaml.load.
|
|
3
|
+
|
|
4
|
+
Detects:
|
|
5
|
+
- ``pickle.loads(...)`` / ``pickle.load(...)``
|
|
6
|
+
- ``_pickle.loads(...)`` (CPython internal alias)
|
|
7
|
+
- ``marshal.loads(...)``
|
|
8
|
+
- ``yaml.load(...)`` without ``Loader=SafeLoader`` (or ``Loader=yaml.SafeLoader``)
|
|
9
|
+
|
|
10
|
+
Why this matters
|
|
11
|
+
----------------
|
|
12
|
+
Deserializing data from untrusted sources using pickle or unsafe yaml is a
|
|
13
|
+
critical vulnerability (CWE-502, OWASP A08:2021). Pickle can execute
|
|
14
|
+
arbitrary Python during deserialization; yaml.load() with the default
|
|
15
|
+
(FullLoader or older unsafe Loader) can instantiate arbitrary Python objects.
|
|
16
|
+
|
|
17
|
+
AI models commonly generate ``pickle.loads(data)`` and ``yaml.load(config)``
|
|
18
|
+
because the documentation examples often omit the Loader parameter.
|
|
19
|
+
|
|
20
|
+
False-positive guidance
|
|
21
|
+
-----------------------
|
|
22
|
+
``pickle.loads`` on *internally-generated, trusted* data is still risky (any
|
|
23
|
+
attacker who can modify your data store gets RCE) but you can suppress if
|
|
24
|
+
you've audited the data provenance:
|
|
25
|
+
``# codeguard: ignore[CG-SEC-004]``
|
|
26
|
+
|
|
27
|
+
``yaml.load`` with ``Loader=yaml.SafeLoader`` or ``Loader=SafeLoader`` is safe
|
|
28
|
+
and will NOT be flagged.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import ast
|
|
34
|
+
|
|
35
|
+
from codeguard.engine.finding import Category, Finding, Severity
|
|
36
|
+
from codeguard.engine.registry import REGISTRY
|
|
37
|
+
from codeguard.engine.rule import AstRule
|
|
38
|
+
from codeguard.rules._pyimports import ImportMap
|
|
39
|
+
|
|
40
|
+
# (module, method) pairs that are always unsafe
|
|
41
|
+
_UNSAFE_CALLS: frozenset[tuple[str, str]] = frozenset(
|
|
42
|
+
{
|
|
43
|
+
("pickle", "loads"),
|
|
44
|
+
("pickle", "load"),
|
|
45
|
+
("_pickle", "loads"),
|
|
46
|
+
("_pickle", "load"),
|
|
47
|
+
("marshal", "loads"),
|
|
48
|
+
("marshal", "load"),
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_SAFE_LOADERS = frozenset({"SafeLoader", "CSafeLoader"})
|
|
53
|
+
|
|
54
|
+
_FIX_PICKLE = (
|
|
55
|
+
"Never unpickle data from untrusted sources. "
|
|
56
|
+
"Consider JSON, msgpack, or protobuf for cross-process serialization."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
_FIX_YAML = (
|
|
60
|
+
"Pass Loader=yaml.SafeLoader: yaml.load(data, Loader=yaml.SafeLoader), "
|
|
61
|
+
"or use yaml.safe_load(data)."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class UnsafeDeserializationRule(AstRule):
|
|
66
|
+
"""Detect pickle.loads, marshal.loads, and yaml.load without SafeLoader."""
|
|
67
|
+
|
|
68
|
+
id = "CG-SEC-004"
|
|
69
|
+
title = "Unsafe deserialization"
|
|
70
|
+
description = (
|
|
71
|
+
"Deserializing data with pickle, marshal, or yaml.load (without SafeLoader) "
|
|
72
|
+
"can execute arbitrary code if the data source is attacker-controlled."
|
|
73
|
+
)
|
|
74
|
+
severity = Severity.HIGH
|
|
75
|
+
category = Category.SECURITY
|
|
76
|
+
cwe = "CWE-502"
|
|
77
|
+
owasp = "A08:2021 - Software and Data Integrity Failures"
|
|
78
|
+
|
|
79
|
+
def check_ast(self, tree: ast.AST, source: str, filename: str) -> list[Finding]:
|
|
80
|
+
"""Find unsafe deserialization calls."""
|
|
81
|
+
findings: list[Finding] = []
|
|
82
|
+
imports = ImportMap.from_tree(tree)
|
|
83
|
+
|
|
84
|
+
for node in ast.walk(tree):
|
|
85
|
+
if not isinstance(node, ast.Call):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
module, method = imports.resolve_call(node.func)
|
|
89
|
+
if not module or not method:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
# pickle.loads / marshal.loads — always unsafe
|
|
93
|
+
if (module, method) in _UNSAFE_CALLS:
|
|
94
|
+
findings.append(
|
|
95
|
+
self._make_finding(
|
|
96
|
+
node=node,
|
|
97
|
+
filename=filename,
|
|
98
|
+
description=(
|
|
99
|
+
f"{module}.{method}() deserializes arbitrary Python objects. "
|
|
100
|
+
"This is a critical vulnerability if the data is attacker-controlled."
|
|
101
|
+
),
|
|
102
|
+
fix_suggestion=_FIX_PICKLE,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# yaml.load — only unsafe without SafeLoader
|
|
107
|
+
elif module == "yaml" and method == "load":
|
|
108
|
+
if not self._has_safe_loader(node):
|
|
109
|
+
findings.append(
|
|
110
|
+
self._make_finding(
|
|
111
|
+
node=node,
|
|
112
|
+
filename=filename,
|
|
113
|
+
description=(
|
|
114
|
+
"yaml.load() without Loader=SafeLoader can instantiate "
|
|
115
|
+
"arbitrary Python objects from the YAML input."
|
|
116
|
+
),
|
|
117
|
+
fix_suggestion=_FIX_YAML,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return findings
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# Helpers
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _has_safe_loader(node: ast.Call) -> bool:
|
|
129
|
+
"""Return True if Loader=SafeLoader (or equivalent) is present."""
|
|
130
|
+
# Keyword argument: yaml.load(data, Loader=yaml.SafeLoader)
|
|
131
|
+
for kw in node.keywords:
|
|
132
|
+
if kw.arg == "Loader":
|
|
133
|
+
val = kw.value
|
|
134
|
+
loader_name = (
|
|
135
|
+
val.attr
|
|
136
|
+
if isinstance(val, ast.Attribute)
|
|
137
|
+
else (val.id if isinstance(val, ast.Name) else "")
|
|
138
|
+
)
|
|
139
|
+
if loader_name in _SAFE_LOADERS:
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
# Positional argument: yaml.load(data, yaml.SafeLoader)
|
|
143
|
+
if len(node.args) >= 2:
|
|
144
|
+
arg = node.args[1]
|
|
145
|
+
loader_name = (
|
|
146
|
+
arg.attr
|
|
147
|
+
if isinstance(arg, ast.Attribute)
|
|
148
|
+
else (arg.id if isinstance(arg, ast.Name) else "")
|
|
149
|
+
)
|
|
150
|
+
if loader_name in _SAFE_LOADERS:
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
REGISTRY.register(UnsafeDeserializationRule())
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""CG-SEC-005 — a non-literal command run through a shell.
|
|
3
|
+
|
|
4
|
+
Two cases, both CWE-78 / OWASP A03:2021:
|
|
5
|
+
|
|
6
|
+
- ``subprocess.run`` / ``call`` / ``Popen`` / ``check_output`` / ``check_call``
|
|
7
|
+
with ``shell=True`` and a non-literal command.
|
|
8
|
+
- ``os.system`` / ``os.popen`` / ``subprocess.getoutput`` /
|
|
9
|
+
``subprocess.getstatusoutput`` with a non-literal command — these always use
|
|
10
|
+
a shell, there is no ``shell=`` keyword to check.
|
|
11
|
+
|
|
12
|
+
Import aliases are resolved, so ``from os import system`` and
|
|
13
|
+
``import subprocess as sp`` are covered.
|
|
14
|
+
|
|
15
|
+
Why this matters
|
|
16
|
+
----------------
|
|
17
|
+
When a command string is interpreted by the OS shell and any part of it is
|
|
18
|
+
attacker-controlled, the attacker can inject arbitrary shell commands.
|
|
19
|
+
|
|
20
|
+
AI models routinely generate ``subprocess.run(f"git {user_arg}", shell=True)``
|
|
21
|
+
because it looks clean and concise. It is not.
|
|
22
|
+
|
|
23
|
+
Safe patterns NOT flagged
|
|
24
|
+
-------------------------
|
|
25
|
+
- ``subprocess.run("ls -la", shell=True)`` — literal string, no injection vector
|
|
26
|
+
- ``subprocess.run(["git", "status"])`` — list form without shell=True
|
|
27
|
+
- ``subprocess.run(cmd, shell=False)`` — shell disabled
|
|
28
|
+
|
|
29
|
+
False-positive guidance
|
|
30
|
+
-----------------------
|
|
31
|
+
If the command is constructed from entirely trusted, validated values you can
|
|
32
|
+
suppress: ``# codeguard: ignore[CG-SEC-005]``
|
|
33
|
+
|
|
34
|
+
Better: switch to the list form (``shell=False``).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import ast
|
|
40
|
+
|
|
41
|
+
from codeguard.engine.finding import Category, Finding, Severity
|
|
42
|
+
from codeguard.engine.registry import REGISTRY
|
|
43
|
+
from codeguard.engine.rule import AstRule
|
|
44
|
+
from codeguard.rules._pyimports import ImportMap
|
|
45
|
+
|
|
46
|
+
# subprocess entry points where a shell is only used when shell=True is passed.
|
|
47
|
+
_SHELL_OPTIONAL: frozenset[tuple[str, str]] = frozenset(
|
|
48
|
+
{
|
|
49
|
+
("subprocess", "run"),
|
|
50
|
+
("subprocess", "call"),
|
|
51
|
+
("subprocess", "Popen"),
|
|
52
|
+
("subprocess", "check_output"),
|
|
53
|
+
("subprocess", "check_call"),
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Calls that ALWAYS run their argument through a shell -- no shell= keyword.
|
|
58
|
+
_ALWAYS_SHELL: frozenset[tuple[str, str]] = frozenset(
|
|
59
|
+
{
|
|
60
|
+
("os", "system"),
|
|
61
|
+
("os", "popen"),
|
|
62
|
+
("subprocess", "getoutput"),
|
|
63
|
+
("subprocess", "getstatusoutput"),
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_FIX = (
|
|
68
|
+
"Pass the command as a list with shell=False: "
|
|
69
|
+
"subprocess.run(['git', 'status'], shell=False). "
|
|
70
|
+
"If a shell is required, validate and sanitize every interpolated value with shlex.quote()."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ShellInjectionRule(AstRule):
|
|
75
|
+
"""Detect subprocess calls with shell=True and a non-literal command."""
|
|
76
|
+
|
|
77
|
+
id = "CG-SEC-005"
|
|
78
|
+
title = "subprocess with shell=True and dynamic command"
|
|
79
|
+
description = (
|
|
80
|
+
"subprocess is called with shell=True and the command argument is not a "
|
|
81
|
+
"string literal. If any part of the command is attacker-controlled, this "
|
|
82
|
+
"enables shell command injection."
|
|
83
|
+
)
|
|
84
|
+
severity = Severity.HIGH
|
|
85
|
+
category = Category.SECURITY
|
|
86
|
+
cwe = "CWE-78"
|
|
87
|
+
owasp = "A03:2021 - Injection"
|
|
88
|
+
|
|
89
|
+
def check_ast(self, tree: ast.AST, source: str, filename: str) -> list[Finding]:
|
|
90
|
+
"""Find shell command injection via subprocess / os.system."""
|
|
91
|
+
findings: list[Finding] = []
|
|
92
|
+
imports = ImportMap.from_tree(tree)
|
|
93
|
+
|
|
94
|
+
for node in ast.walk(tree):
|
|
95
|
+
if not isinstance(node, ast.Call):
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
target = imports.resolve_call(node.func)
|
|
99
|
+
always_shell = target in _ALWAYS_SHELL
|
|
100
|
+
shell_optional = target in _SHELL_OPTIONAL
|
|
101
|
+
|
|
102
|
+
if not always_shell and not shell_optional:
|
|
103
|
+
continue
|
|
104
|
+
if shell_optional and not self._has_shell_true(node):
|
|
105
|
+
continue
|
|
106
|
+
if self._command_is_literal(node):
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
module, method = target
|
|
110
|
+
findings.append(
|
|
111
|
+
self._make_finding(
|
|
112
|
+
node=node,
|
|
113
|
+
filename=filename,
|
|
114
|
+
description=(
|
|
115
|
+
f"{module}.{method}() runs a non-literal command through a shell. "
|
|
116
|
+
"If any part of the command is attacker-controlled, this enables "
|
|
117
|
+
"shell command injection."
|
|
118
|
+
),
|
|
119
|
+
fix_suggestion=_FIX,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return findings
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# Helpers
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _has_shell_true(node: ast.Call) -> bool:
|
|
131
|
+
"""Return True if shell=True is explicitly passed."""
|
|
132
|
+
for kw in node.keywords:
|
|
133
|
+
if kw.arg == "shell":
|
|
134
|
+
val = kw.value
|
|
135
|
+
# shell=True or shell=1
|
|
136
|
+
if isinstance(val, ast.Constant) and val.value:
|
|
137
|
+
return True
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _command_is_literal(node: ast.Call) -> bool:
|
|
142
|
+
"""Return True if the command arg is a plain string literal (safe case)."""
|
|
143
|
+
if not node.args:
|
|
144
|
+
# All keyword args — look for args=
|
|
145
|
+
for kw in node.keywords:
|
|
146
|
+
if kw.arg == "args":
|
|
147
|
+
return isinstance(kw.value, ast.Constant)
|
|
148
|
+
return True # no command at all — not our concern
|
|
149
|
+
|
|
150
|
+
first = node.args[0]
|
|
151
|
+
# List of literals is fine too: ["ls", "-la"]
|
|
152
|
+
if isinstance(first, ast.List):
|
|
153
|
+
return all(isinstance(elt, ast.Constant) for elt in first.elts)
|
|
154
|
+
return isinstance(first, ast.Constant)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
REGISTRY.register(ShellInjectionRule())
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: codeguard-cli
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Fast, offline multi-language static analysis for security anti-patterns (Python, JS, TS)
|
|
5
|
+
Project-URL: Homepage, https://github.com/mevichitra/codeguard
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/mevichitra/codeguard/issues
|
|
7
|
+
Project-URL: Security, https://github.com/mevichitra/codeguard/security
|
|
8
|
+
Project-URL: Documentation, https://github.com/mevichitra/codeguard/tree/main/docs
|
|
9
|
+
Author: CodeGuard Contributors
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ast,linting,sarif,sast,security,static-analysis
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: click>=8.1
|
|
26
|
+
Requires-Dist: pathspec>=0.12
|
|
27
|
+
Requires-Dist: platformdirs>=4.0
|
|
28
|
+
Requires-Dist: rich>=13.0
|
|
29
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
30
|
+
Requires-Dist: tree-sitter-javascript>=0.23
|
|
31
|
+
Requires-Dist: tree-sitter-typescript>=0.23
|
|
32
|
+
Requires-Dist: tree-sitter>=0.23
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
35
|
+
Requires-Dist: mypy>=1.7; extra == 'dev'
|
|
36
|
+
Requires-Dist: pip-audit>=2.7; extra == 'dev'
|
|
37
|
+
Requires-Dist: pre-commit>=3.6; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest-cov>=4.1; extra == 'dev'
|
|
39
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
40
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
41
|
+
Requires-Dist: towncrier>=24.8; extra == 'dev'
|
|
42
|
+
Provides-Extra: docs
|
|
43
|
+
Requires-Dist: mkdocs-gen-files>=0.5; extra == 'docs'
|
|
44
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
|
|
47
|
+
# CodeGuard
|
|
48
|
+
|
|
49
|
+
Fast, offline static analysis that finds security anti-patterns in **Python, JavaScript, and TypeScript** — and drops into every gate of your workflow (editor, pre-commit, PR/CI, scheduled audit) from a single config file.
|
|
50
|
+
|
|
51
|
+
**Status: 2.0 (beta).** Rule IDs, the `Finding`/JSON schema, config keys, and exit codes are a stable contract from 2.0. See [migration notes](docs/migration-v2.md) if you used the 0.1 alpha.
|
|
52
|
+
|
|
53
|
+
[](LICENSE)
|
|
54
|
+
[](https://python.org)
|
|
55
|
+
[](https://github.com/mevichitra/codeguard/actions)
|
|
56
|
+
[](https://pypi.org/project/codeguard-cli/)
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## What it does
|
|
61
|
+
|
|
62
|
+
CodeGuard scans **Python, JavaScript, and TypeScript** and reports security findings. Each finding has a stable rule ID, severity, CWE/OWASP mapping, and a plain-English fix suggestion.
|
|
63
|
+
|
|
64
|
+
Current rules (see [docs/rules/](docs/rules/) for detail):
|
|
65
|
+
|
|
66
|
+
| ID | What it catches | Severity | CWE | Languages |
|
|
67
|
+
|---|---|---|---|---|
|
|
68
|
+
| CG-SEC-001 | SQL built with f-strings / `%` / `.format()` | HIGH | CWE-89 | Python |
|
|
69
|
+
| CG-SEC-002 | Hardcoded passwords, API keys, tokens | HIGH | CWE-798 | Python |
|
|
70
|
+
| CG-SEC-003 | `eval()` / `exec()` on non-literal input | HIGH | CWE-95 | Python |
|
|
71
|
+
| CG-SEC-004 | `pickle.loads` / `yaml.load` without SafeLoader | HIGH | CWE-502 | Python |
|
|
72
|
+
| CG-SEC-005 | `subprocess(..., shell=True)` with non-literal args | HIGH | CWE-78 | Python |
|
|
73
|
+
| CG-SEC-101 | `eval` / `new Function` / string timers on dynamic input | HIGH | CWE-95 | JS, TS |
|
|
74
|
+
| CG-SEC-102 | `child_process.exec` with a dynamic command | HIGH | CWE-78 | JS, TS |
|
|
75
|
+
| CG-SEC-103 | `innerHTML` / `document.write` assigned a non-literal | HIGH | CWE-79 | JS, TS |
|
|
76
|
+
| CG-SEC-104 | `dangerouslySetInnerHTML` with a non-literal value | HIGH | CWE-79 | JS, TS |
|
|
77
|
+
| CG-SEC-105 | Hardcoded passwords, API keys, tokens | HIGH | CWE-798 | JS, TS |
|
|
78
|
+
| CG-SEC-106 | `Math.random()` used for a token / secret / id | MEDIUM | CWE-338 | JS, TS |
|
|
79
|
+
|
|
80
|
+
### What it does not do (yet)
|
|
81
|
+
|
|
82
|
+
- Baseline / diff scanning, a CI-native command, packaged distribution (planned for v2.0)
|
|
83
|
+
- AI-generated-code detection (deferred to a post-2.0 experimental module)
|
|
84
|
+
- Web dashboard or REST API
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Install
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pipx install codeguard-cli # or: uv tool install codeguard-cli
|
|
92
|
+
pip install codeguard-cli # into the current environment
|
|
93
|
+
|
|
94
|
+
# or from source:
|
|
95
|
+
git clone https://github.com/mevichitra/codeguard
|
|
96
|
+
cd codeguard
|
|
97
|
+
pip install -e ".[dev]"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The PyPI project is `codeguard-cli`; the installed command is `codeguard`.
|
|
101
|
+
|
|
102
|
+
Requires Python 3.10+. No database, no Redis, no Docker needed.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Usage
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Scan a file or directory
|
|
110
|
+
codeguard scan myproject/
|
|
111
|
+
|
|
112
|
+
# Output as JSON
|
|
113
|
+
codeguard scan myproject/ --format json
|
|
114
|
+
|
|
115
|
+
# Output as SARIF (for GitHub code scanning)
|
|
116
|
+
codeguard scan myproject/ --format sarif > results.sarif
|
|
117
|
+
|
|
118
|
+
# Only run specific rules
|
|
119
|
+
codeguard scan myproject/ --rule CG-SEC-001 --rule CG-SEC-002
|
|
120
|
+
|
|
121
|
+
# Only report HIGH and above
|
|
122
|
+
codeguard scan myproject/ --severity high
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Example output (human format):
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
myproject/auth.py:12:4 [CG-SEC-001] HIGH SQL query built with string formatting
|
|
129
|
+
→ Use parameterized queries: cursor.execute("SELECT ... WHERE id = %s", (user_id,))
|
|
130
|
+
|
|
131
|
+
myproject/config.py:5:0 [CG-SEC-002] HIGH Hardcoded secret: password
|
|
132
|
+
→ Load secrets from environment variables or a secrets manager.
|
|
133
|
+
|
|
134
|
+
2 findings (2 high, 0 medium, 0 low)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Exit codes: `0` = no findings, `1` = findings found, `2` = error.
|
|
138
|
+
|
|
139
|
+
### Inline suppression
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
query = f"SELECT * FROM users WHERE id = {uid}" # codeguard: ignore[CG-SEC-001]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Suppressed findings still appear with `suppressed: true` in JSON/SARIF output.
|
|
146
|
+
|
|
147
|
+
### Config file
|
|
148
|
+
|
|
149
|
+
Place a `codeguard.toml` in your project root:
|
|
150
|
+
|
|
151
|
+
```toml
|
|
152
|
+
[codeguard]
|
|
153
|
+
exclude = ["tests/", "migrations/"]
|
|
154
|
+
severity = "medium" # ignore findings below this level
|
|
155
|
+
|
|
156
|
+
[codeguard.rules]
|
|
157
|
+
disabled = ["CG-SEC-002"] # not yet
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
_(Config file support is on the roadmap; not yet implemented.)_
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## CI integration
|
|
165
|
+
|
|
166
|
+
### GitHub Actions
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
- name: Run CodeGuard
|
|
170
|
+
run: |
|
|
171
|
+
pip install codeguard-cli
|
|
172
|
+
codeguard scan src/ --format sarif > codeguard.sarif
|
|
173
|
+
|
|
174
|
+
- name: Upload SARIF
|
|
175
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
176
|
+
with:
|
|
177
|
+
sarif_file: codeguard.sarif
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Contributing
|
|
183
|
+
|
|
184
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add a rule or file a bug.
|
|
185
|
+
Adding a rule requires: one rule file + two test fixtures (vulnerable + safe). See the [rule authoring guide](CONTRIBUTING.md#adding-a-rule).
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Project status and roadmap
|
|
190
|
+
|
|
191
|
+
**2.0 (beta).** Rule IDs, the `Finding` / JSON schema, config keys, and the exit-code
|
|
192
|
+
contract are stable. Full details in [the changelog](CHANGELOG.md) and
|
|
193
|
+
[docs/](https://mevichitra.github.io/codeguard/).
|
|
194
|
+
|
|
195
|
+
On the roadmap:
|
|
196
|
+
|
|
197
|
+
1. Light intraprocedural taint tracking (source → sink, sanitizer-aware) to cut false positives further
|
|
198
|
+
2. A Semgrep-compatible YAML rule subset for custom rules
|
|
199
|
+
3. Autofix (`--fix`) for the safe-fix rules
|
|
200
|
+
4. Standalone single-file binaries and a Homebrew tap
|
|
201
|
+
5. An LSP server for editor integration
|
|
202
|
+
6. Optional, offline AI-assisted triage (confidence + rationale, no code leaves the machine)
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
Apache-2.0. See [LICENSE](LICENSE).
|
|
209
|
+
|
|
210
|
+
Contributions are accepted under the [Developer Certificate of Origin](https://developercertificate.org/) (DCO). Sign your commits with `git commit -s`.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
codeguard/__init__.py,sha256=rSpNWh22tyLJb077nuoPh0_u-88k3arwSFUtAktUpzU,205
|
|
2
|
+
codeguard/py.typed,sha256=hnBZ2f5aEYic_FIpxxOxTCC1g1rZ2U4caFMSikgm170,57
|
|
3
|
+
codeguard/cli/__init__.py,sha256=RWO3W_psF33Drjj6RmfHeDVJE_Ws6lN6n9sN5tnoOA4,67
|
|
4
|
+
codeguard/cli/_run.py,sha256=Vj63yfWXhhhchXqtZjEU5wwmGMipbQy-wS0L1oWrcTE,8313
|
|
5
|
+
codeguard/cli/commands.py,sha256=OyUocW-INriCV_fvubxOHGhTtTMKlA_VtdY38Gdxfkw,13679
|
|
6
|
+
codeguard/cli/formatters.py,sha256=CKsN8tV_LDRIeyKzCkWZUuunyA3lNyxQZDpLnI0JVRs,14869
|
|
7
|
+
codeguard/cli/main.py,sha256=jTUR1ZCI7sl6IjwpTPhy2KMvNyFXgB7tiTh_LAWiPV4,6004
|
|
8
|
+
codeguard/config/__init__.py,sha256=Evh6UYwdh4FBhp6HYMEHp-XwwM9ei8zttZeWM8i04z4,393
|
|
9
|
+
codeguard/config/loader.py,sha256=3R4ElUqOX9TuN-bAZa-70QwjlNqqnLkWuqEKorOgzCA,2627
|
|
10
|
+
codeguard/config/schema.py,sha256=m5CliVIMFy6TCKFHaj0SjmgD3fufEVaNwttzQCxGQEY,6813
|
|
11
|
+
codeguard/engine/__init__.py,sha256=W3eQbTLlHaQPEiCkpdvzIjh4Xlvz75wbz28kxwvnPow,556
|
|
12
|
+
codeguard/engine/baseline.py,sha256=zzEVkLAMQO2ezftWkt361D_-Aqiz9HOFU4Rx26pi1io,4577
|
|
13
|
+
codeguard/engine/context.py,sha256=Eaum1v8lGjCc0tFoNuJPoYnjsvuLOaU0p5M3uM7LTn8,1766
|
|
14
|
+
codeguard/engine/discovery.py,sha256=mUONVr4TmcK_5MkfkPtQhdZ1x7nivU3KnDODrWzequI,4806
|
|
15
|
+
codeguard/engine/finding.py,sha256=s-9DH5jAS75iaqXj99D5hYykyHXD8N5fSehrGMwkCkc,6301
|
|
16
|
+
codeguard/engine/fingerprint.py,sha256=kGOq2hvov-gZSvLkm5KPDIF5V2vAUnCai14jKBrJeMs,3178
|
|
17
|
+
codeguard/engine/gitdiff.py,sha256=NJOXPLK7LZ30gWcxBGoQXIQbJ1Uk1MKcVYRzv2IBUKE,2418
|
|
18
|
+
codeguard/engine/policy.py,sha256=Zv9iNGNFA3xnOEm_gZB59emLchD4dIObVxrxsz8Frz4,2574
|
|
19
|
+
codeguard/engine/registry.py,sha256=161GqYUkZZvTb8q5CG4HjT-LDkdLyS3aIDxFXQYJDQU,2439
|
|
20
|
+
codeguard/engine/rule.py,sha256=Mk_K_z2fJSUGLO7GzhuUvKyb9g3zPanEEg0f1hLDqLg,6657
|
|
21
|
+
codeguard/engine/runner.py,sha256=SFM5_ls2UkXzSwtzi4QU5I71sGSMO28Vjzd9B38qmQg,9949
|
|
22
|
+
codeguard/engine/suppressions.py,sha256=MsN8yqLbOMzxvkYoSzlj0Zywo_cLRnP8knk-ewCbUx4,4053
|
|
23
|
+
codeguard/lang/__init__.py,sha256=OE27PN1n9kIZsCSc9u6R4LT6zvXjznuCGIQPXzi9ZZQ,1160
|
|
24
|
+
codeguard/lang/base.py,sha256=WKVolBMOrPh-lbuABuHFnFsnkgHtmSJ7A4NKk-v-d4E,2266
|
|
25
|
+
codeguard/lang/javascript.py,sha256=-WavoLvHOvsHv0zSAGKne_HUEMfTyhz1oNc_Gg9Gv14,536
|
|
26
|
+
codeguard/lang/node.py,sha256=Nv7sw3TIovgQyJBdRQXtnU_de9sgdYE48ngbqCmhMw0,5341
|
|
27
|
+
codeguard/lang/python_ast.py,sha256=KGjOntGqygKVPuspHEWrJw2zrniOLeq7VZmAtd03JQM,933
|
|
28
|
+
codeguard/lang/registry.py,sha256=w949r0nZbBmymDPAEm7_Bh6iBy4jm39pgPbkAqcHsPw,1156
|
|
29
|
+
codeguard/lang/treesitter.py,sha256=rZh4Fxs5rNj_UEiwlzTaaU2hRAMX_du3uBRsKFpVVOE,3151
|
|
30
|
+
codeguard/lang/typescript.py,sha256=LIJSnmXOWgljOQNbs07IJ6DsKoaexivwW-WJnZimpJY,686
|
|
31
|
+
codeguard/rules/__init__.py,sha256=iuF7srUvwALv71dWwcWtzwcQ01rLlRbKctZKOADiniI,264
|
|
32
|
+
codeguard/rules/_jsnodes.py,sha256=mSPAMrgwBANF6ayuHsQGi0FB_A7v2eTZlXr9Bofg1pE,2439
|
|
33
|
+
codeguard/rules/_pyimports.py,sha256=tiPgyb3sDmmja87MNWUb-L8FqchFoy6qcjjn8m0yLig,2475
|
|
34
|
+
codeguard/rules/javascript/__init__.py,sha256=_pyPrFeDzfgih0BhL4xMYKGUCSnOqC0g2-kPJeIyUcc,479
|
|
35
|
+
codeguard/rules/javascript/cg_sec_101_dynamic_code.py,sha256=HAxCUM1wkRSKzD-dKPXHq9IZD1OUhNQZ47I-L5cUtU0,3505
|
|
36
|
+
codeguard/rules/javascript/cg_sec_102_child_process.py,sha256=ThorafEt6U6SW4s6ArwiSTnO7OHSMl07CG3lnn8Q7sM,2238
|
|
37
|
+
codeguard/rules/javascript/cg_sec_103_dom_xss.py,sha256=AJs-qtPyT_uGlDNs08rDnE-omb2VRAoGsB4pVluYNqE,2728
|
|
38
|
+
codeguard/rules/javascript/cg_sec_104_react_dangerous_html.py,sha256=XA5Vyy9x9Jt_lwOJUJi6rD7roOhiFN1Wcy3iB-93HDY,1985
|
|
39
|
+
codeguard/rules/javascript/cg_sec_105_hardcoded_secret.py,sha256=kR6nOPOx2WXbtEi052qb5TNE5CAJ6TFT--kvyZkOGl8,2750
|
|
40
|
+
codeguard/rules/javascript/cg_sec_106_weak_random.py,sha256=JeSDbFmQvcvuQMCYLk9GsqcYZ78k9ZqotYhz52WYUJU,3028
|
|
41
|
+
codeguard/rules/meta/__init__.py,sha256=KWvs-SmJg5BmvOPZWLZVNIB1x6R86p7_vOmoSRe9w70,1964
|
|
42
|
+
codeguard/rules/security/__init__.py,sha256=2XrbMlU0eXPO3ZgHPt_ccPqkjEhkJllbfirnSkGsSPM,438
|
|
43
|
+
codeguard/rules/security/cg_sec_001_sql_injection.py,sha256=49QCPIiwXNTTa48Oh7knhSlHt6eHUspIdWzTfCJSEKw,4025
|
|
44
|
+
codeguard/rules/security/cg_sec_002_hardcoded_secrets.py,sha256=aLlKaCTDuYDON6CWEy18UNG4qPeQF_R5kD8PHOJZvlA,7423
|
|
45
|
+
codeguard/rules/security/cg_sec_003_eval_exec.py,sha256=EiP_oak51iwURYu6mR5KEDI0csO-pxUohiMu6Q-7m2Y,3625
|
|
46
|
+
codeguard/rules/security/cg_sec_004_unsafe_deserialization.py,sha256=kYSftvwI49pYraOPBtReqcejLAoxUxresY4hihqD-nw,5559
|
|
47
|
+
codeguard/rules/security/cg_sec_005_shell_injection.py,sha256=tUn6m2bmZ1zGpWXDRJOJQm-DALcUmPpefWlLiu2qcAM,5590
|
|
48
|
+
codeguard_cli-2.0.0.dist-info/METADATA,sha256=vD1jmTm0uCvlrShjwGyuwihMdCAkgZ4mO2b5sLqFT9Q,7496
|
|
49
|
+
codeguard_cli-2.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
50
|
+
codeguard_cli-2.0.0.dist-info/entry_points.txt,sha256=XOpCnyTVSj3IGv_BjHUBz6VvLV1HTWqFYi4M6FOHxw4,53
|
|
51
|
+
codeguard_cli-2.0.0.dist-info/licenses/LICENSE,sha256=8MbEbZBqWFGR5HLQoPl6VQcU5WOT027U_9PD0bTsXmo,10316
|
|
52
|
+
codeguard_cli-2.0.0.dist-info/RECORD,,
|