hackscan 0.1.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.
@@ -0,0 +1,164 @@
1
+ """SARIF 2.1.0 import (Semgrep, Bandit, CodeQL, or any SARIF producer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from hackscan.core.models import Evidence, Finding, Severity
10
+ from hackscan.core.taxonomy import classify, normalize_cwes
11
+ from hackscan.importers.common import (
12
+ SEVERITY_FROM_LEVEL,
13
+ ImportResult,
14
+ ReportError,
15
+ SourceIndex,
16
+ make_region,
17
+ normalize_path,
18
+ severity_from_score,
19
+ )
20
+
21
+ # Tool driver names -> source label used in findings.
22
+ KNOWN_DRIVERS = {
23
+ "semgrep": "semgrep",
24
+ "semgrep oss": "semgrep",
25
+ "semgrep pro": "semgrep",
26
+ "bandit": "bandit",
27
+ "codeql": "codeql",
28
+ }
29
+ DEFAULT_CONFIDENCE = {"semgrep": 60, "bandit": 60, "codeql": 75}
30
+ BANDIT_CONFIDENCE = {"HIGH": 80, "MEDIUM": 60, "LOW": 40}
31
+ BANDIT_SEVERITY = {"HIGH": Severity.HIGH, "MEDIUM": Severity.MEDIUM, "LOW": Severity.LOW}
32
+
33
+
34
+ def load_sarif(path: Path) -> dict[str, Any]:
35
+ try:
36
+ data = json.loads(path.read_text(encoding="utf-8-sig"))
37
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
38
+ raise ReportError(f"{path}: not a readable SARIF/JSON file: {exc}") from exc
39
+ if not isinstance(data, dict) or not isinstance(data.get("runs"), list):
40
+ raise ReportError(f"{path}: not a SARIF log (missing `runs`)")
41
+ return data
42
+
43
+
44
+ def import_sarif(
45
+ data: dict[str, Any], root: Path, index: SourceIndex, label: str | None = None
46
+ ) -> ImportResult:
47
+ """Convert every result of every run. `label` forces the source label."""
48
+ result = ImportResult(tool=label or "sarif")
49
+ for run in data.get("runs", []):
50
+ if not isinstance(run, dict):
51
+ continue
52
+ driver = run.get("tool", {}).get("driver", {}) or {}
53
+ source = label or KNOWN_DRIVERS.get(str(driver.get("name", "")).lower(), "sarif")
54
+ result.tool = source
55
+ rules = _rules_by_id(driver)
56
+ base_uris = {
57
+ k: v.get("uri")
58
+ for k, v in (run.get("originalUriBaseIds") or {}).items()
59
+ if isinstance(v, dict)
60
+ }
61
+ for raw in run.get("results", []) or []:
62
+ try:
63
+ finding = _convert(raw, source, rules, base_uris, root, index, result.warnings)
64
+ except (AttributeError, KeyError, TypeError, ValueError) as exc:
65
+ result.warnings.append(f"{source}: skipped malformed result: {exc}")
66
+ continue
67
+ if finding is not None:
68
+ result.findings.append(finding)
69
+ return result
70
+
71
+
72
+ def _rules_by_id(driver: dict[str, Any]) -> dict[str, dict[str, Any]]:
73
+ rules = {}
74
+ for rule in driver.get("rules", []) or []:
75
+ if isinstance(rule, dict) and rule.get("id"):
76
+ rules[str(rule["id"])] = rule
77
+ return rules
78
+
79
+
80
+ def _convert(
81
+ raw: dict[str, Any],
82
+ source: str,
83
+ rules: dict[str, dict[str, Any]],
84
+ base_uris: dict[str, str | None],
85
+ root: Path,
86
+ index: SourceIndex,
87
+ warn: list[str],
88
+ ) -> Finding | None:
89
+ rule_key = str(raw.get("ruleId") or raw.get("rule", {}).get("id") or "unknown")
90
+ rule = rules.get(rule_key, {})
91
+ locations = raw.get("locations") or []
92
+ if not locations:
93
+ warn.append(f"{source}: result for {rule_key} has no location; skipped")
94
+ return None
95
+ physical = locations[0].get("physicalLocation", {})
96
+ artifact = physical.get("artifactLocation", {})
97
+ uri = artifact.get("uri")
98
+ if not uri:
99
+ warn.append(f"{source}: result for {rule_key} has no file; skipped")
100
+ return None
101
+ rel = normalize_path(uri, root, base_uris.get(artifact.get("uriBaseId", "")))
102
+ if rel is None:
103
+ warn.append(f"{source}: {uri} is outside the scan root; skipped")
104
+ return None
105
+ reg = physical.get("region", {}) or {}
106
+ region = make_region(
107
+ rel,
108
+ reg.get("startLine"),
109
+ reg.get("startColumn"),
110
+ reg.get("endLine"),
111
+ reg.get("endColumn"),
112
+ )
113
+
114
+ props = {**(rule.get("properties") or {}), **(raw.get("properties") or {})}
115
+ cwes = normalize_cwes([*_strings(props.get("tags")), *_strings(props.get("cwe"))])
116
+ rule_id = f"{source}:{rule_key}"
117
+ severity, confidence = _severity_confidence(raw, rule, props, source)
118
+ message = (raw.get("message") or {}).get("text") or rule.get("shortDescription", {}).get(
119
+ "text", rule_key
120
+ )
121
+ snippet = (reg.get("snippet") or {}).get("text") or index.line_text(rel, region.start_line)
122
+ sink, function = index.enrich(region)
123
+ return Finding(
124
+ id="",
125
+ vuln_class=classify(rule_id, cwes),
126
+ rule_id=rule_id,
127
+ severity=severity,
128
+ location=region,
129
+ message=str(message).strip(),
130
+ snippet=str(snippet).rstrip("\n"),
131
+ sink=sink,
132
+ function=function,
133
+ cwe=cwes,
134
+ sources=(source,),
135
+ confidence=confidence,
136
+ evidence=(Evidence(source, "tool_message", str(message).strip()),),
137
+ )
138
+
139
+
140
+ def _severity_confidence(
141
+ raw: dict[str, Any], rule: dict[str, Any], props: dict[str, Any], source: str
142
+ ) -> tuple[Severity, int]:
143
+ confidence = DEFAULT_CONFIDENCE.get(source, 50)
144
+ score = props.get("security-severity")
145
+ if source == "bandit":
146
+ severity = BANDIT_SEVERITY.get(str(props.get("issue_severity", "")).upper())
147
+ confidence = BANDIT_CONFIDENCE.get(str(props.get("issue_confidence", "")).upper(), 60)
148
+ if severity is not None:
149
+ return severity, confidence
150
+ if score is not None:
151
+ try:
152
+ return severity_from_score(float(score)), confidence
153
+ except (TypeError, ValueError):
154
+ pass
155
+ level = raw.get("level") or (rule.get("defaultConfiguration") or {}).get("level") or "warning"
156
+ return SEVERITY_FROM_LEVEL.get(str(level), Severity.MEDIUM), confidence
157
+
158
+
159
+ def _strings(value: Any) -> list[str]:
160
+ if isinstance(value, str):
161
+ return [value]
162
+ if isinstance(value, list):
163
+ return [str(v) for v in value]
164
+ return []
@@ -0,0 +1,5 @@
1
+ """Rule plugin API: built-in rules and user plugins share the same interface."""
2
+
3
+ from hackscan.plugins.base import FileContext, Match, RulePlugin
4
+
5
+ __all__ = ["FileContext", "Match", "RulePlugin"]
@@ -0,0 +1,261 @@
1
+ """Rule plugin interface and the per-file context rules query.
2
+
3
+ A rule declares which AST node types it inspects and yields `Match`es for sink nodes.
4
+ The AST pass turns matches into `Finding`s (location, sink text, enclosing function,
5
+ vuln_class), so rules only decide *whether* a node is a candidate and how confident.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import re
12
+ from abc import ABC, abstractmethod
13
+ from collections.abc import Iterable, Iterator
14
+ from dataclasses import dataclass, field
15
+
16
+ from hackscan.core.models import Severity
17
+ from hackscan.plugins.scopes import ScopeIndex
18
+
19
+ _NEWLINE_RE = re.compile(r"\r\n|\r|\n")
20
+
21
+ FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda
22
+
23
+ SQL_KEYWORD_RE = re.compile(
24
+ r"^\s*(select|insert|update|delete|with|create|drop|alter|replace|merge|truncate|grant)\b"
25
+ r"|\b(from|where|order\s+by|group\s+by|values|into|join|set)\b",
26
+ re.IGNORECASE,
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Match:
32
+ node: ast.AST # the sink (reported location)
33
+ message: str
34
+ confidence: int | None = None # None -> rule default
35
+ # The value flowing into the sink (e.g. the query argument). When set and the rule's
36
+ # class is modeled by the taint pass, taint decides confirm/suppress/keep.
37
+ arg: ast.AST | None = None
38
+
39
+
40
+ class RulePlugin(ABC):
41
+ rule_id: str
42
+ name: str
43
+ description: str
44
+ severity: Severity
45
+ cwe: tuple[str, ...] = ()
46
+ default_confidence: int = 50
47
+ node_types: tuple[type[ast.AST], ...] = (ast.Call,)
48
+
49
+ @abstractmethod
50
+ def check(self, node: ast.AST, ctx: FileContext) -> Iterable[Match]:
51
+ """Yield matches for `node` (one of `node_types`)."""
52
+
53
+
54
+ @dataclass
55
+ class FileContext:
56
+ """Parsed file plus the lookups rules need: scoped name resolution and source text.
57
+
58
+ Data-flow questions (is this variable constant/sanitized/tainted?) are deliberately
59
+ *not* answered here: rules report candidates, the taint pass (M2) decides.
60
+ """
61
+
62
+ path: str # POSIX, relative to scan root
63
+ source: str
64
+ tree: ast.Module
65
+ lines: list[str] = field(init=False)
66
+ scopes: ScopeIndex = field(init=False)
67
+
68
+ def __post_init__(self) -> None:
69
+ self.lines = _NEWLINE_RE.split(self.source)
70
+ self.scopes = ScopeIndex(self.tree)
71
+
72
+ # -- names -------------------------------------------------------------------------
73
+
74
+ def resolve_all(self, expr: ast.AST) -> frozenset[str]:
75
+ """Every dotted name `expr` may refer to (see `plugins.scopes`); empty if unknown."""
76
+ if isinstance(expr, ast.Name):
77
+ return self.scopes.resolve_name(expr.id, expr)
78
+ if isinstance(expr, ast.Attribute):
79
+ return frozenset(f"{base}.{expr.attr}" for base in self.resolve_all(expr.value))
80
+ return frozenset()
81
+
82
+ def call_names(self, call: ast.Call) -> frozenset[str]:
83
+ return self.resolve_all(call.func)
84
+
85
+ def resolve(self, expr: ast.AST) -> str | None:
86
+ """Dotted name of `expr`, resolved in the scope where `expr` appears.
87
+
88
+ `sp.run` after `import subprocess as sp` -> `subprocess.run`;
89
+ `system` after `from os import system` -> `os.system`; builtins stay bare.
90
+ Local values (parameters, assignments) resolve to None: `os.system` where `os`
91
+ is a parameter is not the `os` module. When several names are possible (star
92
+ imports, import fallbacks) this returns one deterministically; rules matching sinks
93
+ should use `call_names` / `resolve_all`.
94
+ """
95
+ names = self.resolve_all(expr)
96
+ return min(names) if names else None
97
+
98
+ def call_name(self, call: ast.Call) -> str | None:
99
+ return self.resolve(call.func)
100
+
101
+ # -- scopes ------------------------------------------------------------------------
102
+
103
+ def enclosing_function(self, node: ast.AST) -> FunctionNode | None:
104
+ return self.scopes.enclosing_function(node) # type: ignore[return-value]
105
+
106
+ def assignments_before(self, name: str, node: ast.AST) -> list[ast.expr]:
107
+ """Values assigned to local `name` textually before `node` in the same scope.
108
+
109
+ Flow-insensitive: use only to *find* candidate values (e.g. a literal algorithm
110
+ name), never to dismiss a finding.
111
+ """
112
+ scope = self.enclosing_function(node) or self.tree
113
+ line = getattr(node, "lineno", 0)
114
+ values: list[ast.expr] = []
115
+ for stmt in _walk_same_scope(scope):
116
+ if getattr(stmt, "lineno", line) >= line:
117
+ continue
118
+ if isinstance(stmt, ast.Assign):
119
+ if any(_binds(t, name) for t in stmt.targets):
120
+ values.append(stmt.value)
121
+ elif (
122
+ isinstance(stmt, (ast.AugAssign, ast.AnnAssign))
123
+ and _binds(stmt.target, name)
124
+ and stmt.value is not None
125
+ ):
126
+ values.append(stmt.value)
127
+ return values
128
+
129
+ def binding_values(self, name: str, node: ast.AST) -> list[ast.expr]:
130
+ """Values assigned to `name` in the scope it is bound in, as seen from `node`:
131
+ local assignments before `node`, else all module-level assignments (module code
132
+ runs before the function is called). Flow-insensitive: for *finding* candidates.
133
+ """
134
+ local = self.assignments_before(name, node)
135
+ if local or self.enclosing_function(node) is None:
136
+ return local
137
+ values: list[ast.expr] = []
138
+ for stmt in _walk_same_scope(self.tree):
139
+ if isinstance(stmt, ast.Assign) and any(_binds(t, name) for t in stmt.targets):
140
+ values.append(stmt.value)
141
+ return values
142
+
143
+ # -- source text -------------------------------------------------------------------
144
+
145
+ def segment(self, node: ast.AST) -> str:
146
+ return ast.get_source_segment(self.source, node) or ""
147
+
148
+ def char_column(self, line: int, byte_offset: int) -> int:
149
+ """Convert an AST UTF-8 byte offset to a 1-based character column."""
150
+ text = self.lines[line - 1] if 0 < line <= len(self.lines) else ""
151
+ prefix = text.encode("utf-8")[:byte_offset].decode("utf-8", errors="replace")
152
+ return len(prefix) + 1
153
+
154
+
155
+ # -- expression classification helpers (shared by rules) ----------------------------------
156
+
157
+
158
+ def is_constant(expr: ast.AST) -> bool:
159
+ """True for expressions whose value is fixed at write time."""
160
+ if isinstance(expr, ast.Constant):
161
+ return True
162
+ if isinstance(expr, ast.JoinedStr):
163
+ return all(isinstance(v, ast.Constant) for v in expr.values)
164
+ if isinstance(expr, (ast.Tuple, ast.List, ast.Set)):
165
+ return all(is_constant(e) for e in expr.elts)
166
+ if isinstance(expr, ast.BinOp):
167
+ return is_constant(expr.left) and is_constant(expr.right)
168
+ return False
169
+
170
+
171
+ def _is_str_literal(expr: ast.AST) -> bool:
172
+ return isinstance(expr, ast.JoinedStr) or (
173
+ isinstance(expr, ast.Constant) and isinstance(expr.value, str)
174
+ )
175
+
176
+
177
+ def _flatten_add(expr: ast.AST) -> Iterator[ast.AST]:
178
+ if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Add):
179
+ yield from _flatten_add(expr.left)
180
+ yield from _flatten_add(expr.right)
181
+ else:
182
+ yield expr
183
+
184
+
185
+ def is_dynamic_string(expr: ast.AST) -> bool:
186
+ """String built from non-constant parts: f-string, `%`, `+`, `.format()`, `.join()`."""
187
+ if isinstance(expr, ast.JoinedStr):
188
+ return not is_constant(expr)
189
+ if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Mod):
190
+ return _is_str_literal(expr.left) and not is_constant(expr.right)
191
+ if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Add):
192
+ parts = list(_flatten_add(expr))
193
+ has_str = any(_is_str_literal(p) or is_dynamic_string(p) for p in parts)
194
+ return has_str and not all(is_constant(p) for p in parts)
195
+ if (
196
+ isinstance(expr, ast.Call)
197
+ and isinstance(expr.func, ast.Attribute)
198
+ and expr.func.attr in {"format", "join", "format_map"}
199
+ and _is_str_literal(expr.func.value)
200
+ ):
201
+ args = [*expr.args, *(k.value for k in expr.keywords)]
202
+ return any(not is_constant(a) for a in args)
203
+ return False
204
+
205
+
206
+ def is_string_formatting(expr: ast.AST) -> bool:
207
+ """Looser than `is_dynamic_string`: also `%`/`.format()` applied to a template
208
+ variable (`template % user`, `template.format(user)`). Only meaningful at sinks
209
+ that expect a string, since `a % b` is also integer modulo."""
210
+ if is_dynamic_string(expr):
211
+ return True
212
+ if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Mod):
213
+ return not is_constant(expr.right)
214
+ if (
215
+ isinstance(expr, ast.Call)
216
+ and isinstance(expr.func, ast.Attribute)
217
+ and expr.func.attr in {"format", "format_map"}
218
+ ):
219
+ return any(not is_constant(a) for a in [*expr.args, *(k.value for k in expr.keywords)])
220
+ return False
221
+
222
+
223
+ def has_sql_text(expr: ast.AST) -> bool:
224
+ """Any string literal inside `expr` that looks like SQL."""
225
+ return any(
226
+ isinstance(node, ast.Constant)
227
+ and isinstance(node.value, str)
228
+ and SQL_KEYWORD_RE.search(node.value)
229
+ for node in ast.walk(expr)
230
+ )
231
+
232
+
233
+ def keyword(call: ast.Call, name: str) -> ast.expr | None:
234
+ return next((k.value for k in call.keywords if k.arg == name), None)
235
+
236
+
237
+ def first_arg(call: ast.Call, kw: str | None = None) -> ast.expr | None:
238
+ if call.args and not isinstance(call.args[0], ast.Starred):
239
+ return call.args[0]
240
+ return keyword(call, kw) if kw else None
241
+
242
+
243
+ # -- internals ----------------------------------------------------------------------------
244
+
245
+
246
+ def _walk_same_scope(scope: ast.AST) -> Iterator[ast.AST]:
247
+ """Walk `scope` without descending into nested functions or classes."""
248
+ stack = list(ast.iter_child_nodes(scope))
249
+ while stack:
250
+ node = stack.pop()
251
+ yield node
252
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
253
+ stack.extend(ast.iter_child_nodes(node))
254
+
255
+
256
+ def _binds(target: ast.AST, name: str) -> bool:
257
+ if isinstance(target, ast.Name):
258
+ return target.id == name
259
+ if isinstance(target, (ast.Tuple, ast.List)):
260
+ return any(_binds(t, name) for t in target.elts)
261
+ return False
@@ -0,0 +1,98 @@
1
+ """Built-in rule registry and user plugin discovery.
2
+
3
+ User plugins are `.py` files in a directory passed via `--plugins`; every concrete
4
+ `RulePlugin` subclass they define is instantiated. Loading a plugin executes its code, so
5
+ it is strictly opt-in.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import importlib.util
12
+ import inspect
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from hackscan.core.models import Severity
17
+ from hackscan.plugins.base import RulePlugin
18
+ from hackscan.plugins.rules.cmdi import CommandInjection
19
+ from hackscan.plugins.rules.codei import CodeInjection
20
+ from hackscan.plugins.rules.sqli import SqlInjection
21
+ from hackscan.plugins.rules.weak_crypto import WeakHash
22
+
23
+ BUILTIN_RULES: tuple[type[RulePlugin], ...] = (
24
+ SqlInjection,
25
+ CommandInjection,
26
+ CodeInjection,
27
+ WeakHash,
28
+ )
29
+
30
+
31
+ class PluginError(Exception):
32
+ pass
33
+
34
+
35
+ def builtin_plugins() -> list[RulePlugin]:
36
+ return [cls() for cls in BUILTIN_RULES]
37
+
38
+
39
+ def load_plugins(directory: Path) -> list[RulePlugin]:
40
+ """Instantiate every concrete RulePlugin defined in `directory/*.py` (sorted by file)."""
41
+ if not directory.is_dir():
42
+ raise PluginError(f"plugin directory not found: {directory}")
43
+ plugins: list[RulePlugin] = []
44
+ for path in sorted(directory.glob("*.py")):
45
+ module_name = f"hackscan_user_plugin_{path.stem}"
46
+ spec = importlib.util.spec_from_file_location(module_name, path)
47
+ if spec is None or spec.loader is None:
48
+ raise PluginError(f"cannot load plugin file: {path}")
49
+ module = importlib.util.module_from_spec(spec)
50
+ sys.modules[module_name] = module
51
+ try:
52
+ spec.loader.exec_module(module)
53
+ except Exception as exc: # surface any import-time failure with the file name
54
+ raise PluginError(f"error loading plugin {path.name}: {exc}") from exc
55
+ for _, obj in inspect.getmembers(module, inspect.isclass):
56
+ if (
57
+ issubclass(obj, RulePlugin)
58
+ and obj is not RulePlugin
59
+ and obj.__module__ == module_name
60
+ and not inspect.isabstract(obj)
61
+ ):
62
+ plugins.append(_validated(obj(), path))
63
+ return plugins
64
+
65
+
66
+ def _validated(plugin: RulePlugin, path: Path) -> RulePlugin:
67
+ cls = type(plugin).__name__
68
+ for attr in ("rule_id", "name", "description"):
69
+ value = getattr(plugin, attr, None)
70
+ if not isinstance(value, str) or not value.strip():
71
+ raise PluginError(f"{path.name}: {cls}.{attr} must be a non-empty string")
72
+ if not isinstance(getattr(plugin, "severity", None), Severity):
73
+ raise PluginError(f"{path.name}: {cls}.severity must be a hackscan Severity")
74
+ node_types = getattr(plugin, "node_types", None)
75
+ if (
76
+ not isinstance(node_types, tuple)
77
+ or not node_types
78
+ or not all(isinstance(t, type) and issubclass(t, ast.AST) for t in node_types)
79
+ ):
80
+ raise PluginError(f"{path.name}: {cls}.node_types must be a tuple of ast node classes")
81
+ if not isinstance(plugin.cwe, tuple) or not all(isinstance(c, str) for c in plugin.cwe):
82
+ raise PluginError(f"{path.name}: {cls}.cwe must be a tuple of strings")
83
+ if not isinstance(plugin.default_confidence, int) or not 0 <= plugin.default_confidence <= 100:
84
+ raise PluginError(f"{path.name}: {cls}.default_confidence must be an int 0-100")
85
+ if plugin.rule_id.startswith("HS-"):
86
+ raise PluginError(f"{path.name}: rule id prefix `HS-` is reserved for built-in rules")
87
+ return plugin
88
+
89
+
90
+ def resolve_plugins(extra_dir: Path | None = None) -> list[RulePlugin]:
91
+ plugins = builtin_plugins()
92
+ if extra_dir is not None:
93
+ plugins.extend(load_plugins(extra_dir))
94
+ ids = [p.rule_id for p in plugins]
95
+ duplicates = sorted({i for i in ids if ids.count(i) > 1})
96
+ if duplicates:
97
+ raise PluginError(f"duplicate rule ids: {', '.join(duplicates)}")
98
+ return plugins
@@ -0,0 +1 @@
1
+ """Built-in detection rules."""
@@ -0,0 +1,80 @@
1
+ """Command injection: non-constant commands run through a shell.
2
+
3
+ Any non-constant command at a shell sink is a candidate; the taint pass decides whether
4
+ it is attacker-controlled, constant or sanitized.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from collections.abc import Iterable
11
+
12
+ from hackscan.core.models import Severity
13
+ from hackscan.plugins.base import (
14
+ FileContext,
15
+ Match,
16
+ RulePlugin,
17
+ first_arg,
18
+ is_constant,
19
+ is_dynamic_string,
20
+ keyword,
21
+ )
22
+
23
+ # Always interpreted by a shell.
24
+ SHELL_FUNCTIONS = frozenset(
25
+ {
26
+ "os.system",
27
+ "os.popen",
28
+ "subprocess.getoutput",
29
+ "subprocess.getstatusoutput",
30
+ "commands.getoutput",
31
+ "commands.getstatusoutput",
32
+ "asyncio.create_subprocess_shell",
33
+ }
34
+ )
35
+ # Shell only when called with shell=True (or a non-constant shell flag).
36
+ SUBPROCESS_FUNCTIONS = frozenset(
37
+ {
38
+ "subprocess.call",
39
+ "subprocess.run",
40
+ "subprocess.Popen",
41
+ "subprocess.check_call",
42
+ "subprocess.check_output",
43
+ }
44
+ )
45
+
46
+
47
+ class CommandInjection(RulePlugin):
48
+ rule_id = "HS-CMDI-001"
49
+ name = "command-injection"
50
+ description = "A non-constant command string is executed through a system shell."
51
+ severity = Severity.HIGH
52
+ cwe = ("CWE-78",)
53
+ default_confidence = 70
54
+
55
+ def check(self, node: ast.AST, ctx: FileContext) -> Iterable[Match]:
56
+ assert isinstance(node, ast.Call)
57
+ names = ctx.call_names(node)
58
+ if shell := sorted(names & SHELL_FUNCTIONS):
59
+ via = shell[0]
60
+ elif (proc := sorted(names & SUBPROCESS_FUNCTIONS)) and _shell_enabled(node):
61
+ via = f"{proc[0]}(shell=True)"
62
+ else:
63
+ return
64
+ command = first_arg(node, "args") or first_arg(node, "cmd")
65
+ if command is None or is_constant(command):
66
+ return
67
+ if is_dynamic_string(command):
68
+ message = f"Command built from non-constant parts is executed via {via}."
69
+ yield Match(node, message, arg=command)
70
+ else:
71
+ label = ctx.segment(command)
72
+ message = f"Non-constant command `{label}` is executed via {via}."
73
+ yield Match(node, message, confidence=50, arg=command)
74
+
75
+
76
+ def _shell_enabled(call: ast.Call) -> bool:
77
+ shell = keyword(call, "shell")
78
+ if shell is None:
79
+ return False
80
+ return not (isinstance(shell, ast.Constant) and not shell.value)
@@ -0,0 +1,50 @@
1
+ """Code injection: non-constant input to eval/exec/compile.
2
+
3
+ Any non-constant argument is a candidate; the taint pass decides.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from collections.abc import Iterable
10
+
11
+ from hackscan.core.models import Severity
12
+ from hackscan.plugins.base import (
13
+ FileContext,
14
+ Match,
15
+ RulePlugin,
16
+ first_arg,
17
+ is_constant,
18
+ is_dynamic_string,
19
+ )
20
+
21
+ CODE_FUNCTIONS = frozenset(
22
+ {"eval", "exec", "compile", "builtins.eval", "builtins.exec", "builtins.compile"}
23
+ )
24
+
25
+
26
+ class CodeInjection(RulePlugin):
27
+ rule_id = "HS-CODEI-001"
28
+ name = "code-injection"
29
+ description = "Non-constant input is evaluated as Python code."
30
+ severity = Severity.HIGH
31
+ cwe = ("CWE-95",)
32
+ default_confidence = 70
33
+
34
+ def check(self, node: ast.AST, ctx: FileContext) -> Iterable[Match]:
35
+ assert isinstance(node, ast.Call)
36
+ matched = sorted(ctx.call_names(node) & CODE_FUNCTIONS)
37
+ if not matched:
38
+ return
39
+ name = matched[0]
40
+ code = first_arg(node, "source")
41
+ if code is None or is_constant(code):
42
+ return
43
+ func = name.rsplit(".", 1)[-1]
44
+ if is_dynamic_string(code):
45
+ message = f"String built from non-constant parts is passed to {func}()."
46
+ yield Match(node, message, arg=code)
47
+ else:
48
+ label = ctx.segment(code)
49
+ message = f"Non-constant `{label}` is passed to {func}()."
50
+ yield Match(node, message, confidence=55, arg=code)