codeguard-cli 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. codeguard/__init__.py +7 -0
  2. codeguard/cli/__init__.py +2 -0
  3. codeguard/cli/_run.py +230 -0
  4. codeguard/cli/commands.py +390 -0
  5. codeguard/cli/formatters.py +422 -0
  6. codeguard/cli/main.py +206 -0
  7. codeguard/config/__init__.py +16 -0
  8. codeguard/config/loader.py +86 -0
  9. codeguard/config/schema.py +172 -0
  10. codeguard/engine/__init__.py +25 -0
  11. codeguard/engine/baseline.py +122 -0
  12. codeguard/engine/context.py +61 -0
  13. codeguard/engine/discovery.py +160 -0
  14. codeguard/engine/finding.py +205 -0
  15. codeguard/engine/fingerprint.py +94 -0
  16. codeguard/engine/gitdiff.py +80 -0
  17. codeguard/engine/policy.py +74 -0
  18. codeguard/engine/registry.py +78 -0
  19. codeguard/engine/rule.py +195 -0
  20. codeguard/engine/runner.py +267 -0
  21. codeguard/engine/suppressions.py +109 -0
  22. codeguard/lang/__init__.py +37 -0
  23. codeguard/lang/base.py +80 -0
  24. codeguard/lang/javascript.py +20 -0
  25. codeguard/lang/node.py +137 -0
  26. codeguard/lang/python_ast.py +29 -0
  27. codeguard/lang/registry.py +38 -0
  28. codeguard/lang/treesitter.py +99 -0
  29. codeguard/lang/typescript.py +24 -0
  30. codeguard/py.typed +1 -0
  31. codeguard/rules/__init__.py +6 -0
  32. codeguard/rules/_jsnodes.py +82 -0
  33. codeguard/rules/_pyimports.py +60 -0
  34. codeguard/rules/javascript/__init__.py +9 -0
  35. codeguard/rules/javascript/cg_sec_101_dynamic_code.py +89 -0
  36. codeguard/rules/javascript/cg_sec_102_child_process.py +58 -0
  37. codeguard/rules/javascript/cg_sec_103_dom_xss.py +67 -0
  38. codeguard/rules/javascript/cg_sec_104_react_dangerous_html.py +54 -0
  39. codeguard/rules/javascript/cg_sec_105_hardcoded_secret.py +73 -0
  40. codeguard/rules/javascript/cg_sec_106_weak_random.py +83 -0
  41. codeguard/rules/meta/__init__.py +55 -0
  42. codeguard/rules/security/__init__.py +8 -0
  43. codeguard/rules/security/cg_sec_001_sql_injection.py +110 -0
  44. codeguard/rules/security/cg_sec_002_hardcoded_secrets.py +184 -0
  45. codeguard/rules/security/cg_sec_003_eval_exec.py +104 -0
  46. codeguard/rules/security/cg_sec_004_unsafe_deserialization.py +156 -0
  47. codeguard/rules/security/cg_sec_005_shell_injection.py +157 -0
  48. codeguard_cli-2.0.0.dist-info/METADATA +210 -0
  49. codeguard_cli-2.0.0.dist-info/RECORD +52 -0
  50. codeguard_cli-2.0.0.dist-info/WHEEL +4 -0
  51. codeguard_cli-2.0.0.dist-info/entry_points.txt +2 -0
  52. codeguard_cli-2.0.0.dist-info/licenses/LICENSE +184 -0
@@ -0,0 +1,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,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """All CodeGuard rules — importing this module loads every built-in rule."""
3
+
4
+ from . import javascript as _javascript # noqa: F401
5
+ from . import meta as _meta # noqa: F401
6
+ from . import security as _security # noqa: F401
@@ -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())