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,20 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""JavaScript language support (tree-sitter)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .base import Language
|
|
9
|
+
from .treesitter import TreeSitterSupport, _load_language
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import tree_sitter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JavaScriptSupport(TreeSitterSupport):
|
|
16
|
+
language = Language.JAVASCRIPT
|
|
17
|
+
extensions = (".js", ".jsx", ".mjs", ".cjs")
|
|
18
|
+
|
|
19
|
+
def _ts_language(self) -> tree_sitter.Language:
|
|
20
|
+
return _load_language("tree_sitter_javascript", "language")
|
codeguard/lang/node.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""``SourceNode`` -- the uniform tree node handed to rules.
|
|
3
|
+
|
|
4
|
+
A rule that targets a single language may reach through to the parser-native
|
|
5
|
+
node via :attr:`SourceNode.native` -- an :class:`ast.AST` for Python, a
|
|
6
|
+
``tree_sitter.Node`` for JavaScript / TypeScript. Rules that span languages
|
|
7
|
+
should stick to the wrapper's own API (:meth:`walk`, :meth:`children`,
|
|
8
|
+
:meth:`child_by_field`, :meth:`text`, :attr:`kind`).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from functools import cached_property
|
|
17
|
+
|
|
18
|
+
from .base import Language, Position
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_ts_node(obj: object) -> bool:
|
|
22
|
+
"""Duck-type a tree_sitter.Node without importing tree_sitter."""
|
|
23
|
+
return hasattr(obj, "type") and hasattr(obj, "start_point") and hasattr(obj, "children")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class SourceNode:
|
|
28
|
+
"""A read-only wrapper over one parser node."""
|
|
29
|
+
|
|
30
|
+
native: object
|
|
31
|
+
language: Language
|
|
32
|
+
source: str
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------
|
|
35
|
+
# Identity
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
@cached_property
|
|
39
|
+
def kind(self) -> str:
|
|
40
|
+
"""A normalized node-type name.
|
|
41
|
+
|
|
42
|
+
``ast`` backend: the lower-cased class name (``"call"``, ``"assign"``).
|
|
43
|
+
tree-sitter backend: the grammar's node type (``"call_expression"``,
|
|
44
|
+
``"member_expression"``).
|
|
45
|
+
"""
|
|
46
|
+
if isinstance(self.native, ast.AST):
|
|
47
|
+
return type(self.native).__name__.lower()
|
|
48
|
+
if _is_ts_node(self.native):
|
|
49
|
+
return str(self.native.type) # type: ignore[attr-defined]
|
|
50
|
+
return type(self.native).__name__
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
# Position (1-indexed line and column)
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@cached_property
|
|
57
|
+
def start(self) -> Position:
|
|
58
|
+
node = self.native
|
|
59
|
+
if isinstance(node, ast.AST):
|
|
60
|
+
return Position(
|
|
61
|
+
line=max(getattr(node, "lineno", 1), 1),
|
|
62
|
+
col=getattr(node, "col_offset", 0) + 1,
|
|
63
|
+
)
|
|
64
|
+
if _is_ts_node(node):
|
|
65
|
+
row, col = node.start_point # type: ignore[attr-defined]
|
|
66
|
+
return Position(line=row + 1, col=col + 1)
|
|
67
|
+
raise TypeError(f"start position unavailable for {node!r}")
|
|
68
|
+
|
|
69
|
+
@cached_property
|
|
70
|
+
def end(self) -> Position | None:
|
|
71
|
+
node = self.native
|
|
72
|
+
if isinstance(node, ast.AST):
|
|
73
|
+
line = getattr(node, "end_lineno", None)
|
|
74
|
+
col = getattr(node, "end_col_offset", None)
|
|
75
|
+
if line is None or col is None:
|
|
76
|
+
return None
|
|
77
|
+
return Position(line=max(line, 1), col=col + 1)
|
|
78
|
+
if _is_ts_node(node):
|
|
79
|
+
row, col = node.end_point # type: ignore[attr-defined]
|
|
80
|
+
return Position(line=row + 1, col=col + 1)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Traversal
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _wrap(self, native: object) -> SourceNode:
|
|
88
|
+
return SourceNode(native=native, language=self.language, source=self.source)
|
|
89
|
+
|
|
90
|
+
def text(self) -> str:
|
|
91
|
+
"""The source slice this node spans (best effort)."""
|
|
92
|
+
if isinstance(self.native, ast.AST):
|
|
93
|
+
segment = ast.get_source_segment(self.source, self.native)
|
|
94
|
+
return segment if segment is not None else ""
|
|
95
|
+
if _is_ts_node(self.native):
|
|
96
|
+
raw = self.native.text # type: ignore[attr-defined]
|
|
97
|
+
return raw.decode("utf-8", "replace") if raw is not None else ""
|
|
98
|
+
return ""
|
|
99
|
+
|
|
100
|
+
def children(self) -> list[SourceNode]:
|
|
101
|
+
if isinstance(self.native, ast.AST):
|
|
102
|
+
return [self._wrap(c) for c in ast.iter_child_nodes(self.native)]
|
|
103
|
+
if _is_ts_node(self.native):
|
|
104
|
+
return [self._wrap(c) for c in self.native.children] # type: ignore[attr-defined]
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
def child_by_field(self, name: str) -> SourceNode | None:
|
|
108
|
+
"""The named child (tree-sitter field), or ``None``.
|
|
109
|
+
|
|
110
|
+
For the ``ast`` backend, *name* is an attribute name; a list-valued
|
|
111
|
+
attribute yields its first element.
|
|
112
|
+
"""
|
|
113
|
+
if _is_ts_node(self.native):
|
|
114
|
+
child = self.native.child_by_field_name(name) # type: ignore[attr-defined]
|
|
115
|
+
return self._wrap(child) if child is not None else None
|
|
116
|
+
if isinstance(self.native, ast.AST):
|
|
117
|
+
value = getattr(self.native, name, None)
|
|
118
|
+
if isinstance(value, ast.AST):
|
|
119
|
+
return self._wrap(value)
|
|
120
|
+
if isinstance(value, list) and value and isinstance(value[0], ast.AST):
|
|
121
|
+
return self._wrap(value[0])
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def walk(self) -> Iterator[SourceNode]:
|
|
125
|
+
"""Yield this node and every descendant, pre-order."""
|
|
126
|
+
if isinstance(self.native, ast.AST):
|
|
127
|
+
for n in ast.walk(self.native):
|
|
128
|
+
yield self._wrap(n)
|
|
129
|
+
return
|
|
130
|
+
if _is_ts_node(self.native):
|
|
131
|
+
stack = [self.native]
|
|
132
|
+
while stack:
|
|
133
|
+
node = stack.pop()
|
|
134
|
+
yield self._wrap(node)
|
|
135
|
+
stack.extend(reversed(node.children)) # type: ignore[attr-defined]
|
|
136
|
+
return
|
|
137
|
+
yield self
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Python language support, backed by the standard library :mod:`ast`."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
|
|
8
|
+
from .base import Language, LanguageSupport, ParseResult, SyntaxErrorInfo
|
|
9
|
+
from .node import SourceNode
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PythonAstSupport(LanguageSupport):
|
|
13
|
+
"""Parse Python with :func:`ast.parse`."""
|
|
14
|
+
|
|
15
|
+
language = Language.PYTHON
|
|
16
|
+
extensions = (".py", ".pyi")
|
|
17
|
+
comment_prefixes = ("#",)
|
|
18
|
+
|
|
19
|
+
def parse(self, source: str, filename: str) -> ParseResult:
|
|
20
|
+
try:
|
|
21
|
+
tree = ast.parse(source, filename=filename)
|
|
22
|
+
except SyntaxError as exc:
|
|
23
|
+
return ParseResult(
|
|
24
|
+
root=None,
|
|
25
|
+
ok=False,
|
|
26
|
+
error=SyntaxErrorInfo(message=exc.msg, line=exc.lineno, col=exc.offset),
|
|
27
|
+
)
|
|
28
|
+
root = SourceNode(native=tree, language=Language.PYTHON, source=source)
|
|
29
|
+
return ParseResult(root=root, ok=True)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Registry of available :class:`~codeguard.lang.base.LanguageSupport` backends."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import Language, LanguageSupport
|
|
9
|
+
from .javascript import JavaScriptSupport
|
|
10
|
+
from .python_ast import PythonAstSupport
|
|
11
|
+
from .typescript import TypeScriptSupport
|
|
12
|
+
|
|
13
|
+
#: All registered language backends, keyed by :class:`Language`.
|
|
14
|
+
LANGUAGES: dict[Language, LanguageSupport] = {
|
|
15
|
+
Language.PYTHON: PythonAstSupport(),
|
|
16
|
+
Language.JAVASCRIPT: JavaScriptSupport(),
|
|
17
|
+
Language.TYPESCRIPT: TypeScriptSupport(),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_EXTENSION_INDEX: dict[str, Language] = {
|
|
21
|
+
ext: lang for lang, support in LANGUAGES.items() for ext in support.extensions
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def language_for_path(path: str | Path) -> Language | None:
|
|
26
|
+
"""Return the language for *path* based on its extension, or ``None``."""
|
|
27
|
+
return _EXTENSION_INDEX.get(Path(path).suffix.lower())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def support_for(language: Language) -> LanguageSupport:
|
|
31
|
+
"""Return the backend for *language*.
|
|
32
|
+
|
|
33
|
+
Raises
|
|
34
|
+
------
|
|
35
|
+
KeyError
|
|
36
|
+
If no backend is registered for *language*.
|
|
37
|
+
"""
|
|
38
|
+
return LANGUAGES[language]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Base class for tree-sitter-backed language support."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from abc import abstractmethod
|
|
7
|
+
from functools import cache
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from .base import LanguageSupport, ParseResult, SyntaxErrorInfo
|
|
12
|
+
from .node import SourceNode
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import tree_sitter
|
|
16
|
+
|
|
17
|
+
_QUERY_DIR = Path(__file__).parent / "queries"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TreeSitterSupport(LanguageSupport):
|
|
21
|
+
"""Parse a language with tree-sitter.
|
|
22
|
+
|
|
23
|
+
Subclasses supply :meth:`_ts_language`. The parser and any ``.scm`` queries
|
|
24
|
+
are created lazily and cached, so a scan that never touches this language
|
|
25
|
+
never imports ``tree_sitter``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
comment_prefixes = ("//", "/*")
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._parser: tree_sitter.Parser | None = None
|
|
32
|
+
self._queries: dict[str, Any] = {}
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def _ts_language(self) -> tree_sitter.Language:
|
|
36
|
+
"""Return the compiled tree-sitter grammar for this language."""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def _parser_obj(self) -> tree_sitter.Parser:
|
|
40
|
+
if self._parser is None:
|
|
41
|
+
import tree_sitter
|
|
42
|
+
|
|
43
|
+
self._parser = tree_sitter.Parser(self._ts_language())
|
|
44
|
+
return self._parser
|
|
45
|
+
|
|
46
|
+
def parse(self, source: str, filename: str) -> ParseResult:
|
|
47
|
+
tree = self._parser_obj.parse(source.encode("utf-8"))
|
|
48
|
+
root = tree.root_node
|
|
49
|
+
|
|
50
|
+
# tree-sitter is error-recovering: a stray token deep in the file still
|
|
51
|
+
# yields a usable tree. Only bail if nothing meaningful parsed.
|
|
52
|
+
meaningful = [c for c in root.children if c.type not in ("ERROR", "comment")]
|
|
53
|
+
if root.has_error and not meaningful:
|
|
54
|
+
err = _first_error(root)
|
|
55
|
+
return ParseResult(
|
|
56
|
+
root=None,
|
|
57
|
+
ok=False,
|
|
58
|
+
error=SyntaxErrorInfo(
|
|
59
|
+
message="could not parse",
|
|
60
|
+
line=(err.start_point[0] + 1) if err else None,
|
|
61
|
+
col=(err.start_point[1] + 1) if err else None,
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
node = SourceNode(native=root, language=self.language, source=source)
|
|
66
|
+
return ParseResult(root=node, ok=True)
|
|
67
|
+
|
|
68
|
+
def query(self, name: str) -> Any | None:
|
|
69
|
+
if name in self._queries:
|
|
70
|
+
return self._queries[name]
|
|
71
|
+
path = _QUERY_DIR / self.language.value / f"{name}.scm"
|
|
72
|
+
if not path.is_file():
|
|
73
|
+
self._queries[name] = None
|
|
74
|
+
return None
|
|
75
|
+
import tree_sitter
|
|
76
|
+
|
|
77
|
+
compiled = tree_sitter.Query(self._ts_language(), path.read_text(encoding="utf-8"))
|
|
78
|
+
self._queries[name] = compiled
|
|
79
|
+
return compiled
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _first_error(node: tree_sitter.Node) -> tree_sitter.Node | None:
|
|
83
|
+
stack = [node]
|
|
84
|
+
while stack:
|
|
85
|
+
n = stack.pop()
|
|
86
|
+
if n.is_error or n.is_missing:
|
|
87
|
+
return n
|
|
88
|
+
stack.extend(n.children)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@cache
|
|
93
|
+
def _load_language(module_name: str, func_name: str) -> tree_sitter.Language:
|
|
94
|
+
import importlib
|
|
95
|
+
|
|
96
|
+
import tree_sitter
|
|
97
|
+
|
|
98
|
+
mod = importlib.import_module(module_name)
|
|
99
|
+
return tree_sitter.Language(getattr(mod, func_name)())
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""TypeScript language support (tree-sitter).
|
|
3
|
+
|
|
4
|
+
The ``.tsx`` grammar is a superset that also parses ``.ts``; using it for both
|
|
5
|
+
keeps things simple and still parses plain TypeScript correctly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from .base import Language
|
|
13
|
+
from .treesitter import TreeSitterSupport, _load_language
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import tree_sitter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TypeScriptSupport(TreeSitterSupport):
|
|
20
|
+
language = Language.TYPESCRIPT
|
|
21
|
+
extensions = (".ts", ".tsx", ".mts", ".cts")
|
|
22
|
+
|
|
23
|
+
def _ts_language(self) -> tree_sitter.Language:
|
|
24
|
+
return _load_language("tree_sitter_typescript", "language_tsx")
|
codeguard/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# PEP 561 — this package ships inline type annotations
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Small helpers for JavaScript / TypeScript rules over a tree-sitter tree.
|
|
3
|
+
|
|
4
|
+
These operate on :class:`~codeguard.lang.node.SourceNode`, so a rule stays a few
|
|
5
|
+
lines: walk the calls, read the callee text, check whether an argument is a
|
|
6
|
+
literal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
|
|
13
|
+
from codeguard.lang.node import SourceNode
|
|
14
|
+
|
|
15
|
+
_PUNCT = {"(", ")", ",", "[", "]", "{", "}", ";"}
|
|
16
|
+
_SECRET_NAME = (
|
|
17
|
+
"password",
|
|
18
|
+
"passwd",
|
|
19
|
+
"pwd",
|
|
20
|
+
"passphrase",
|
|
21
|
+
"secret",
|
|
22
|
+
"apikey",
|
|
23
|
+
"api_key",
|
|
24
|
+
"accesskey",
|
|
25
|
+
"access_key",
|
|
26
|
+
"secretkey",
|
|
27
|
+
"secret_key",
|
|
28
|
+
"privatekey",
|
|
29
|
+
"private_key",
|
|
30
|
+
"token",
|
|
31
|
+
"auth_token",
|
|
32
|
+
"authtoken",
|
|
33
|
+
"client_secret",
|
|
34
|
+
"clientsecret",
|
|
35
|
+
"aws_secret",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def calls(root: SourceNode) -> Iterator[SourceNode]:
|
|
40
|
+
"""Yield every ``call_expression`` node."""
|
|
41
|
+
for node in root.walk():
|
|
42
|
+
if node.kind == "call_expression":
|
|
43
|
+
yield node
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def new_expressions(root: SourceNode) -> Iterator[SourceNode]:
|
|
47
|
+
for node in root.walk():
|
|
48
|
+
if node.kind == "new_expression":
|
|
49
|
+
yield node
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def callee_text(call: SourceNode) -> str:
|
|
53
|
+
"""The called expression as source text (``"eval"``, ``"cp.execSync"``)."""
|
|
54
|
+
fn = call.child_by_field("function") or call.child_by_field("constructor")
|
|
55
|
+
return fn.text() if fn else ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def arguments(call: SourceNode) -> list[SourceNode]:
|
|
59
|
+
"""The argument nodes of a call, punctuation stripped."""
|
|
60
|
+
args_node = call.child_by_field("arguments")
|
|
61
|
+
if args_node is None:
|
|
62
|
+
return []
|
|
63
|
+
return [c for c in args_node.children() if c.kind not in _PUNCT]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_literal(node: SourceNode) -> bool:
|
|
67
|
+
"""True if *node* is a constant: a string / number / boolean literal, a
|
|
68
|
+
template string with no ``${}`` substitutions, or a ``+`` tree of those."""
|
|
69
|
+
kind = node.kind
|
|
70
|
+
if kind in ("string", "number", "true", "false", "regex"):
|
|
71
|
+
return True
|
|
72
|
+
if kind == "template_string":
|
|
73
|
+
return not any(d.kind == "template_substitution" for d in node.walk())
|
|
74
|
+
if kind in ("binary_expression", "parenthesized_expression"):
|
|
75
|
+
children = [c for c in node.children() if c.kind not in ("+", "(", ")")]
|
|
76
|
+
return bool(children) and all(is_literal(c) for c in children)
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def looks_like_secret(name: str) -> bool:
|
|
81
|
+
low = name.lower()
|
|
82
|
+
return any(marker in low for marker in _SECRET_NAME)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Resolve call targets through Python import aliases.
|
|
3
|
+
|
|
4
|
+
Rules that match ``module.function(...)`` calls need to see through the ways an
|
|
5
|
+
import can rename things::
|
|
6
|
+
|
|
7
|
+
import subprocess as sp # sp.run(...) -> ("subprocess", "run")
|
|
8
|
+
from subprocess import run # run(...) -> ("subprocess", "run")
|
|
9
|
+
from os import system as sh # sh(...) -> ("os", "system")
|
|
10
|
+
|
|
11
|
+
:class:`ImportMap` builds these tables once per file; :meth:`ImportMap.resolve_call`
|
|
12
|
+
maps a call node to a canonical ``(module, name)`` pair.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ast
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ImportMap:
|
|
23
|
+
"""Import aliases in one module."""
|
|
24
|
+
|
|
25
|
+
#: local name -> canonical module (``"sp"`` -> ``"subprocess"``)
|
|
26
|
+
module_aliases: dict[str, str] = field(default_factory=dict)
|
|
27
|
+
#: local name -> (canonical module, original attribute)
|
|
28
|
+
symbol_imports: dict[str, tuple[str, str]] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_tree(cls, tree: ast.AST) -> ImportMap:
|
|
32
|
+
m = cls()
|
|
33
|
+
for node in ast.walk(tree):
|
|
34
|
+
if isinstance(node, ast.Import):
|
|
35
|
+
for alias in node.names:
|
|
36
|
+
top = alias.name.split(".")[0]
|
|
37
|
+
m.module_aliases[alias.asname or alias.name] = alias.name
|
|
38
|
+
m.module_aliases.setdefault(top, top)
|
|
39
|
+
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
|
40
|
+
for alias in node.names:
|
|
41
|
+
local = alias.asname or alias.name
|
|
42
|
+
m.symbol_imports[local] = (node.module, alias.name)
|
|
43
|
+
return m
|
|
44
|
+
|
|
45
|
+
def resolve_call(self, func: ast.expr) -> tuple[str | None, str | None]:
|
|
46
|
+
"""Return the canonical ``(module, name)`` a call's *func* refers to.
|
|
47
|
+
|
|
48
|
+
- ``module.attr(...)`` -> ``(canonical_module, attr)``
|
|
49
|
+
- a bare name bound by ``from module import name`` -> ``(module, name)``
|
|
50
|
+
- any other bare name -> ``(None, name)``
|
|
51
|
+
- anything else -> ``(None, None)``
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
54
|
+
base = self.module_aliases.get(func.value.id, func.value.id)
|
|
55
|
+
return base, func.attr
|
|
56
|
+
if isinstance(func, ast.Name):
|
|
57
|
+
if func.id in self.symbol_imports:
|
|
58
|
+
return self.symbol_imports[func.id]
|
|
59
|
+
return None, func.id
|
|
60
|
+
return None, None
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""JavaScript / TypeScript security rules (imported for side-effect)."""
|
|
3
|
+
|
|
4
|
+
from . import cg_sec_101_dynamic_code as _101 # noqa: F401
|
|
5
|
+
from . import cg_sec_102_child_process as _102 # noqa: F401
|
|
6
|
+
from . import cg_sec_103_dom_xss as _103 # noqa: F401
|
|
7
|
+
from . import cg_sec_104_react_dangerous_html as _104 # noqa: F401
|
|
8
|
+
from . import cg_sec_105_hardcoded_secret as _105 # noqa: F401
|
|
9
|
+
from . import cg_sec_106_weak_random as _106 # noqa: F401
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""CG-SEC-101 -- dynamic code execution in JavaScript / TypeScript.
|
|
3
|
+
|
|
4
|
+
Flags:
|
|
5
|
+
- ``eval(x)`` where *x* is not a literal
|
|
6
|
+
- ``new Function(..., body)`` where an argument is not a literal
|
|
7
|
+
- ``setTimeout("...", n)`` / ``setInterval("...", n)`` -- passing a string to a
|
|
8
|
+
timer is an implicit ``eval``
|
|
9
|
+
|
|
10
|
+
CWE-95 (CWE-94), OWASP A03:2021. These are the classic "run a string as code"
|
|
11
|
+
sinks; AI-generated code reaches for them for dynamic dispatch.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from codeguard.engine.context import RuleContext
|
|
17
|
+
from codeguard.engine.finding import Category, Finding, Severity
|
|
18
|
+
from codeguard.engine.registry import REGISTRY
|
|
19
|
+
from codeguard.engine.rule import TreeSitterRule
|
|
20
|
+
from codeguard.lang.base import Language
|
|
21
|
+
from codeguard.lang.node import SourceNode
|
|
22
|
+
from codeguard.rules._jsnodes import arguments, callee_text, calls, is_literal, new_expressions
|
|
23
|
+
|
|
24
|
+
_JS_TS = frozenset({Language.JAVASCRIPT, Language.TYPESCRIPT})
|
|
25
|
+
_TIMERS = frozenset({"setTimeout", "setInterval"})
|
|
26
|
+
_FIX = (
|
|
27
|
+
"Do not execute strings as code. Use a lookup table of functions, dynamic "
|
|
28
|
+
"import(), or pass a function (not a string) to setTimeout/setInterval."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DynamicCodeExecutionRule(TreeSitterRule):
|
|
33
|
+
id = "CG-SEC-101"
|
|
34
|
+
title = "Dynamic code execution (eval / Function / string timer)"
|
|
35
|
+
description = (
|
|
36
|
+
"A string is executed as code via eval(), new Function(), or a string "
|
|
37
|
+
"argument to setTimeout/setInterval. If any part of it is attacker-"
|
|
38
|
+
"controlled this is remote code execution."
|
|
39
|
+
)
|
|
40
|
+
severity = Severity.HIGH
|
|
41
|
+
category = Category.SECURITY
|
|
42
|
+
languages = _JS_TS
|
|
43
|
+
cwe = "CWE-95"
|
|
44
|
+
owasp = "A03:2021 - Injection"
|
|
45
|
+
|
|
46
|
+
def check_tree(self, root: SourceNode, ctx: RuleContext) -> list[Finding]:
|
|
47
|
+
findings: list[Finding] = []
|
|
48
|
+
|
|
49
|
+
for call in calls(root):
|
|
50
|
+
base = callee_text(call).rsplit(".", 1)[-1]
|
|
51
|
+
args = arguments(call)
|
|
52
|
+
if not args:
|
|
53
|
+
continue
|
|
54
|
+
if base == "eval" and not is_literal(args[0]):
|
|
55
|
+
findings.append(self._make_finding(node=call, ctx=ctx, fix_suggestion=_FIX))
|
|
56
|
+
elif base in _TIMERS and args[0].kind in ("string", "template_string"):
|
|
57
|
+
findings.append(
|
|
58
|
+
self._make_finding(
|
|
59
|
+
node=call,
|
|
60
|
+
ctx=ctx,
|
|
61
|
+
description=(
|
|
62
|
+
f"{base}() is called with a string. The string is run through "
|
|
63
|
+
"eval() when the timer fires. Pass a function instead."
|
|
64
|
+
),
|
|
65
|
+
fix_suggestion=_FIX,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
for new_expr in new_expressions(root):
|
|
70
|
+
if callee_text(new_expr).rsplit(".", 1)[-1] != "Function":
|
|
71
|
+
continue
|
|
72
|
+
args = arguments(new_expr)
|
|
73
|
+
if args and not all(is_literal(a) for a in args):
|
|
74
|
+
findings.append(
|
|
75
|
+
self._make_finding(
|
|
76
|
+
node=new_expr,
|
|
77
|
+
ctx=ctx,
|
|
78
|
+
description=(
|
|
79
|
+
"new Function() builds a function from a string body. If the "
|
|
80
|
+
"body is attacker-controlled this is arbitrary code execution."
|
|
81
|
+
),
|
|
82
|
+
fix_suggestion=_FIX,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return findings
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
REGISTRY.register(DynamicCodeExecutionRule())
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""CG-SEC-102 -- shell command injection via child_process.
|
|
3
|
+
|
|
4
|
+
``child_process.exec`` / ``execSync`` run their argument through ``/bin/sh``.
|
|
5
|
+
With a non-literal command that is CWE-78. ``execFile`` / ``spawn`` /
|
|
6
|
+
``spawnSync`` do not use a shell (unless ``shell: true``) and are the fix.
|
|
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 arguments, callee_text, calls, is_literal
|
|
18
|
+
|
|
19
|
+
_JS_TS = frozenset({Language.JAVASCRIPT, Language.TYPESCRIPT})
|
|
20
|
+
_SHELL_FUNCS = frozenset({"exec", "execSync"})
|
|
21
|
+
_FIX = (
|
|
22
|
+
"Use execFile() / spawn() with an argument array and no shell: "
|
|
23
|
+
"execFile('git', ['checkout', branch]). If a shell is unavoidable, validate "
|
|
24
|
+
"every interpolated value."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ChildProcessShellRule(TreeSitterRule):
|
|
29
|
+
id = "CG-SEC-102"
|
|
30
|
+
title = "child_process.exec with a dynamic command"
|
|
31
|
+
description = (
|
|
32
|
+
"child_process.exec / execSync run the command through a shell. If the "
|
|
33
|
+
"command string is not a literal and any part is attacker-controlled, "
|
|
34
|
+
"this is shell command injection."
|
|
35
|
+
)
|
|
36
|
+
severity = Severity.HIGH
|
|
37
|
+
category = Category.SECURITY
|
|
38
|
+
languages = _JS_TS
|
|
39
|
+
cwe = "CWE-78"
|
|
40
|
+
owasp = "A03:2021 - Injection"
|
|
41
|
+
|
|
42
|
+
def check_tree(self, root: SourceNode, ctx: RuleContext) -> list[Finding]:
|
|
43
|
+
findings: list[Finding] = []
|
|
44
|
+
for call in calls(root):
|
|
45
|
+
callee = callee_text(call)
|
|
46
|
+
base = callee.rsplit(".", 1)[-1]
|
|
47
|
+
if base not in _SHELL_FUNCS:
|
|
48
|
+
continue
|
|
49
|
+
# A bare exec() that isn't a member access is probably unrelated.
|
|
50
|
+
if base == callee and "." not in callee:
|
|
51
|
+
continue
|
|
52
|
+
args = arguments(call)
|
|
53
|
+
if args and not is_literal(args[0]):
|
|
54
|
+
findings.append(self._make_finding(node=call, ctx=ctx, fix_suggestion=_FIX))
|
|
55
|
+
return findings
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
REGISTRY.register(ChildProcessShellRule())
|