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.
- hackscan/__init__.py +3 -0
- hackscan/analyzers/__init__.py +1 -0
- hackscan/analyzers/ast_pass.py +154 -0
- hackscan/analyzers/suppressions.py +63 -0
- hackscan/analyzers/taint_pass.py +808 -0
- hackscan/cli.py +326 -0
- hackscan/config.py +184 -0
- hackscan/core/__init__.py +1 -0
- hackscan/core/dedupe.py +147 -0
- hackscan/core/fingerprint.py +76 -0
- hackscan/core/models.py +270 -0
- hackscan/core/pipeline.py +164 -0
- hackscan/core/redact.py +84 -0
- hackscan/core/taxonomy.py +111 -0
- hackscan/importers/__init__.py +1 -0
- hackscan/importers/common.py +198 -0
- hackscan/importers/gitleaks.py +104 -0
- hackscan/importers/runner.py +161 -0
- hackscan/importers/sarif.py +164 -0
- hackscan/plugins/__init__.py +5 -0
- hackscan/plugins/base.py +261 -0
- hackscan/plugins/loader.py +98 -0
- hackscan/plugins/rules/__init__.py +1 -0
- hackscan/plugins/rules/cmdi.py +80 -0
- hackscan/plugins/rules/codei.py +50 -0
- hackscan/plugins/rules/sqli.py +127 -0
- hackscan/plugins/rules/weak_crypto.py +53 -0
- hackscan/plugins/scopes.py +263 -0
- hackscan/sarif/__init__.py +1 -0
- hackscan/sarif/generator.py +205 -0
- hackscan/taint/__init__.py +1 -0
- hackscan/taint/sanitizers.py +37 -0
- hackscan/taint/sources.py +99 -0
- hackscan-0.1.0.dist-info/METADATA +181 -0
- hackscan-0.1.0.dist-info/RECORD +38 -0
- hackscan-0.1.0.dist-info/WHEEL +4 -0
- hackscan-0.1.0.dist-info/entry_points.txt +2 -0
- hackscan-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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 []
|
hackscan/plugins/base.py
ADDED
|
@@ -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)
|