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,111 @@
|
|
|
1
|
+
"""Canonical vulnerability classes, mapped from rule ids and CWEs.
|
|
2
|
+
|
|
3
|
+
`vuln_class` is the vendor-independent identity used for fingerprinting and dedupe, so
|
|
4
|
+
the same issue reported by HackScan, Semgrep and Bandit lands in the same class.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
|
|
12
|
+
SQLI = "sqli"
|
|
13
|
+
CMDI = "cmdi"
|
|
14
|
+
CODEI = "codei"
|
|
15
|
+
WEAK_CRYPTO = "weak_crypto"
|
|
16
|
+
SECRET = "secret"
|
|
17
|
+
|
|
18
|
+
KNOWN_CLASSES = frozenset({SQLI, CMDI, CODEI, WEAK_CRYPTO, SECRET})
|
|
19
|
+
|
|
20
|
+
# Classes the own taint engine models; only these may be suppressed by taint.
|
|
21
|
+
TAINT_CLASSES = frozenset({SQLI, CMDI, CODEI})
|
|
22
|
+
|
|
23
|
+
OTHER_PREFIX = "other:"
|
|
24
|
+
|
|
25
|
+
_CWE_TO_CLASS = {
|
|
26
|
+
"CWE-89": SQLI,
|
|
27
|
+
"CWE-564": SQLI,
|
|
28
|
+
"CWE-77": CMDI,
|
|
29
|
+
"CWE-78": CMDI,
|
|
30
|
+
"CWE-88": CMDI,
|
|
31
|
+
"CWE-94": CODEI,
|
|
32
|
+
"CWE-95": CODEI,
|
|
33
|
+
"CWE-327": WEAK_CRYPTO,
|
|
34
|
+
"CWE-328": WEAK_CRYPTO,
|
|
35
|
+
"CWE-916": WEAK_CRYPTO,
|
|
36
|
+
"CWE-259": SECRET,
|
|
37
|
+
"CWE-321": SECRET,
|
|
38
|
+
"CWE-798": SECRET,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Exact rule ids (tool-qualified) that map without needing a CWE.
|
|
42
|
+
_RULE_TO_CLASS = {
|
|
43
|
+
# Bandit
|
|
44
|
+
"bandit:B608": SQLI,
|
|
45
|
+
"bandit:B602": CMDI,
|
|
46
|
+
"bandit:B604": CMDI,
|
|
47
|
+
"bandit:B605": CMDI,
|
|
48
|
+
"bandit:B609": CMDI,
|
|
49
|
+
"bandit:B102": CODEI,
|
|
50
|
+
"bandit:B307": CODEI,
|
|
51
|
+
"bandit:B303": WEAK_CRYPTO,
|
|
52
|
+
"bandit:B324": WEAK_CRYPTO,
|
|
53
|
+
"bandit:B105": SECRET,
|
|
54
|
+
"bandit:B106": SECRET,
|
|
55
|
+
"bandit:B107": SECRET,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Own rules use the HS-<CLASS>-NNN scheme.
|
|
59
|
+
_OWN_RULE_PREFIX = {
|
|
60
|
+
"HS-SQLI-": SQLI,
|
|
61
|
+
"HS-CMDI-": CMDI,
|
|
62
|
+
"HS-CODEI-": CODEI,
|
|
63
|
+
"HS-CRYPTO-": WEAK_CRYPTO,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_CWE_RE = re.compile(r"CWE[-_ ]?(\d+)", re.IGNORECASE)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize_cwe(raw: str) -> str | None:
|
|
70
|
+
"""`'cwe-89: SQL Injection'` -> `'CWE-89'`; returns None if no CWE number is present."""
|
|
71
|
+
match = _CWE_RE.search(raw)
|
|
72
|
+
return f"CWE-{int(match.group(1))}" if match else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def normalize_cwes(raws: Iterable[str]) -> tuple[str, ...]:
|
|
76
|
+
"""Normalize, dedupe and sort CWE ids (numeric order)."""
|
|
77
|
+
found = {c for c in (normalize_cwe(r) for r in raws) if c}
|
|
78
|
+
return tuple(sorted(found, key=lambda c: int(c.split("-")[1])))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def classify(rule_id: str, cwes: Iterable[str] = ()) -> str:
|
|
82
|
+
"""Map a rule id and its CWEs to a canonical class.
|
|
83
|
+
|
|
84
|
+
Precedence: own rule prefix, then exact tool rule, then gitleaks (always secrets),
|
|
85
|
+
then CWE. Unmappable findings get `other:<lowest CWE>` or `other:<rule_id>`.
|
|
86
|
+
"""
|
|
87
|
+
for prefix, cls in _OWN_RULE_PREFIX.items():
|
|
88
|
+
if rule_id.startswith(prefix):
|
|
89
|
+
return cls
|
|
90
|
+
if rule_id in _RULE_TO_CLASS:
|
|
91
|
+
return _RULE_TO_CLASS[rule_id]
|
|
92
|
+
if rule_id.startswith("gitleaks:"):
|
|
93
|
+
return SECRET
|
|
94
|
+
normalized = normalize_cwes(cwes)
|
|
95
|
+
for cwe in normalized:
|
|
96
|
+
if cwe in _CWE_TO_CLASS:
|
|
97
|
+
return _CWE_TO_CLASS[cwe]
|
|
98
|
+
if normalized:
|
|
99
|
+
return f"{OTHER_PREFIX}{normalized[0]}"
|
|
100
|
+
return f"{OTHER_PREFIX}{rule_id}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_mergeable_class(vuln_class: str, cwes: Iterable[str]) -> bool:
|
|
104
|
+
"""Findings only merge across sources when their identity is grounded.
|
|
105
|
+
|
|
106
|
+
Known classes are always mergeable. `other:` classes merge only when backed by a
|
|
107
|
+
CWE; `other:<rule_id>` (no CWE) never merges with anything else.
|
|
108
|
+
"""
|
|
109
|
+
if vuln_class in KNOWN_CLASSES:
|
|
110
|
+
return True
|
|
111
|
+
return bool(normalize_cwes(cwes))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Importers for third-party scanner reports."""
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Shared importer helpers: path/region normalization and enrichment from source.
|
|
2
|
+
|
|
3
|
+
Per the Finding contract, `sink` and `function` are derived from the parsed file (not
|
|
4
|
+
from a tool's display snippet), so fingerprints do not depend on which tool reported.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import io
|
|
11
|
+
import tokenize
|
|
12
|
+
import warnings
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
from urllib.parse import unquote, urlparse
|
|
16
|
+
|
|
17
|
+
from hackscan.analyzers.ast_pass import _qualnames
|
|
18
|
+
from hackscan.core.models import Region, Severity
|
|
19
|
+
from hackscan.plugins.base import FileContext
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReportError(Exception):
|
|
23
|
+
"""A third-party report could not be read or run."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ImportResult:
|
|
30
|
+
tool: str
|
|
31
|
+
findings: list = field(default_factory=list)
|
|
32
|
+
warnings: list[str] = field(default_factory=list)
|
|
33
|
+
secrets: set[str] = field(default_factory=set) # values to redact from all output
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalize_path(raw: str, root: Path, base_uri: str | None = None) -> str | None:
|
|
37
|
+
"""Tool-reported path/URI -> POSIX path relative to `root`, or None if outside it."""
|
|
38
|
+
if raw.startswith("file:"):
|
|
39
|
+
parsed = urlparse(raw)
|
|
40
|
+
if parsed.netloc not in ("", "localhost"):
|
|
41
|
+
return None # file://other-host/share/... is not a local path
|
|
42
|
+
raw = unquote(parsed.path)
|
|
43
|
+
if len(raw) >= 3 and raw[0] == "/" and raw[2] == ":": # file:///C:/x -> C:/x
|
|
44
|
+
raw = raw[1:]
|
|
45
|
+
else:
|
|
46
|
+
raw = unquote(raw)
|
|
47
|
+
if base_uri:
|
|
48
|
+
base = normalize_path(base_uri, root) if base_uri.startswith("file:") else None
|
|
49
|
+
if base is not None:
|
|
50
|
+
raw = str(PurePosixPath(base) / raw)
|
|
51
|
+
candidate = Path(raw)
|
|
52
|
+
if not candidate.is_absolute():
|
|
53
|
+
candidate = root / candidate
|
|
54
|
+
try:
|
|
55
|
+
rel = candidate.resolve().relative_to(root.resolve())
|
|
56
|
+
except ValueError:
|
|
57
|
+
return None
|
|
58
|
+
return rel.as_posix()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def make_region(
|
|
62
|
+
path: str,
|
|
63
|
+
start_line: int | None,
|
|
64
|
+
start_column: int | None = None,
|
|
65
|
+
end_line: int | None = None,
|
|
66
|
+
end_column: int | None = None,
|
|
67
|
+
) -> Region:
|
|
68
|
+
"""Region with the importer normalization rules (PROJECT.md):
|
|
69
|
+
- missing end line -> start line; missing end column -> same as start (a point),
|
|
70
|
+
so a column-precise report never silently widens to the rest of the line;
|
|
71
|
+
- missing start column -> whole line;
|
|
72
|
+
- invalid values are clamped rather than rejected (non-numbers raise ValueError).
|
|
73
|
+
"""
|
|
74
|
+
line = max(1, int(start_line or 1))
|
|
75
|
+
end = max(line, int(end_line or line))
|
|
76
|
+
col = int(start_column) if start_column and int(start_column) > 0 else 1
|
|
77
|
+
end_col = int(end_column) if end_column and int(end_column) > 0 else None
|
|
78
|
+
if start_column is None or int(start_column) <= 0:
|
|
79
|
+
end_col = None # whole line
|
|
80
|
+
elif end_col is None and end == line:
|
|
81
|
+
end_col = col # point at the reported start
|
|
82
|
+
if end_col is not None and end == line and end_col < col:
|
|
83
|
+
end_col = col
|
|
84
|
+
return Region(path=path, start_line=line, start_column=col, end_line=end, end_column=end_col)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
SEVERITY_FROM_LEVEL = {
|
|
88
|
+
"error": Severity.HIGH,
|
|
89
|
+
"warning": Severity.MEDIUM,
|
|
90
|
+
"note": Severity.LOW,
|
|
91
|
+
"none": Severity.LOW,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def severity_from_score(score: float) -> Severity:
|
|
96
|
+
"""CVSS-like `security-severity` (CodeQL, Semgrep) -> Severity."""
|
|
97
|
+
if score >= 9.0:
|
|
98
|
+
return Severity.CRITICAL
|
|
99
|
+
if score >= 7.0:
|
|
100
|
+
return Severity.HIGH
|
|
101
|
+
if score >= 4.0:
|
|
102
|
+
return Severity.MEDIUM
|
|
103
|
+
return Severity.LOW
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SourceIndex:
|
|
107
|
+
"""Lazily parsed project files for enriching imported findings."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, root: Path) -> None:
|
|
110
|
+
self.root = root
|
|
111
|
+
self._cache: dict[str, tuple[FileContext, dict[int, str]] | None] = {}
|
|
112
|
+
|
|
113
|
+
def context(self, rel_path: str) -> tuple[FileContext, dict[int, str]] | None:
|
|
114
|
+
if rel_path not in self._cache:
|
|
115
|
+
self._cache[rel_path] = self._load(rel_path)
|
|
116
|
+
return self._cache[rel_path]
|
|
117
|
+
|
|
118
|
+
def _load(self, rel_path: str) -> tuple[FileContext, dict[int, str]] | None:
|
|
119
|
+
path = self.root / rel_path
|
|
120
|
+
if path.suffix != ".py":
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
data = path.read_bytes()
|
|
124
|
+
encoding, _ = tokenize.detect_encoding(io.BytesIO(data).readline)
|
|
125
|
+
source = data.decode(encoding)
|
|
126
|
+
with warnings.catch_warnings():
|
|
127
|
+
warnings.simplefilter("ignore", SyntaxWarning)
|
|
128
|
+
tree = ast.parse(source)
|
|
129
|
+
except (OSError, SyntaxError, UnicodeDecodeError, LookupError, ValueError):
|
|
130
|
+
return None
|
|
131
|
+
return FileContext(path=rel_path, source=source, tree=tree), _qualnames(tree)
|
|
132
|
+
|
|
133
|
+
def line_text(self, rel_path: str, line: int) -> str:
|
|
134
|
+
loaded = self.context(rel_path)
|
|
135
|
+
if loaded is not None:
|
|
136
|
+
lines = loaded[0].lines
|
|
137
|
+
return lines[line - 1] if 0 < line <= len(lines) else ""
|
|
138
|
+
try:
|
|
139
|
+
lines = (self.root / rel_path).read_text(encoding="utf-8", errors="replace")
|
|
140
|
+
split = lines.splitlines()
|
|
141
|
+
return split[line - 1] if 0 < line <= len(split) else ""
|
|
142
|
+
except OSError:
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
def enrich(self, region: Region) -> tuple[str, str | None]:
|
|
146
|
+
"""(sink text, enclosing function qualname) for a region in a Python file.
|
|
147
|
+
|
|
148
|
+
The sink is the innermost call that starts on the region's first line and covers
|
|
149
|
+
its start column (or the first call on that line for whole-line regions).
|
|
150
|
+
"""
|
|
151
|
+
loaded = self.context(region.path)
|
|
152
|
+
if loaded is None:
|
|
153
|
+
return "", None
|
|
154
|
+
ctx, qualnames = loaded
|
|
155
|
+
best: ast.AST | None = None
|
|
156
|
+
for node in ast.walk(ctx.tree):
|
|
157
|
+
if not isinstance(node, ast.Call) or node.lineno != region.start_line:
|
|
158
|
+
continue
|
|
159
|
+
col = ctx.char_column(node.lineno, node.col_offset)
|
|
160
|
+
end = (
|
|
161
|
+
ctx.char_column(node.end_lineno, node.end_col_offset)
|
|
162
|
+
if node.end_lineno == node.lineno and node.end_col_offset is not None
|
|
163
|
+
else 10**9
|
|
164
|
+
)
|
|
165
|
+
if region.end_column is not None and not (col <= region.start_column < end):
|
|
166
|
+
continue
|
|
167
|
+
if best is None or (col, -end) > _span_key(ctx, best):
|
|
168
|
+
best = node
|
|
169
|
+
if best is None:
|
|
170
|
+
line_text = (
|
|
171
|
+
ctx.lines[region.start_line - 1] if region.start_line <= len(ctx.lines) else ""
|
|
172
|
+
)
|
|
173
|
+
func = _function_at_line(ctx, qualnames, region.start_line)
|
|
174
|
+
return line_text.strip(), func
|
|
175
|
+
func_node = ctx.enclosing_function(best)
|
|
176
|
+
return ctx.segment(best), qualnames.get(id(func_node)) if func_node else None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _span_key(ctx: FileContext, node: ast.AST) -> tuple[int, int]:
|
|
180
|
+
col = ctx.char_column(node.lineno, node.col_offset)
|
|
181
|
+
end = (
|
|
182
|
+
ctx.char_column(node.end_lineno, node.end_col_offset)
|
|
183
|
+
if node.end_lineno == node.lineno and node.end_col_offset is not None
|
|
184
|
+
else 10**9
|
|
185
|
+
)
|
|
186
|
+
return col, -end
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _function_at_line(ctx: FileContext, qualnames: dict[int, str], line: int) -> str | None:
|
|
190
|
+
best = None
|
|
191
|
+
for node in ast.walk(ctx.tree):
|
|
192
|
+
if (
|
|
193
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
194
|
+
and node.lineno <= line <= (node.end_lineno or node.lineno)
|
|
195
|
+
and (best is None or node.lineno >= best.lineno)
|
|
196
|
+
):
|
|
197
|
+
best = node # innermost: the latest-starting function containing the line
|
|
198
|
+
return qualnames.get(id(best)) if best is not None else None
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Gitleaks JSON report import (`gitleaks detect --report-format json`).
|
|
2
|
+
|
|
3
|
+
Secrets are never copied into findings: the matched text is redacted in the snippet and
|
|
4
|
+
the secret value itself is dropped.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from hackscan.core.models import Evidence, Finding, Severity
|
|
14
|
+
from hackscan.core.taxonomy import SECRET
|
|
15
|
+
from hackscan.importers.common import (
|
|
16
|
+
ImportResult,
|
|
17
|
+
ReportError,
|
|
18
|
+
SourceIndex,
|
|
19
|
+
make_region,
|
|
20
|
+
normalize_path,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
REDACTED = "<REDACTED>"
|
|
24
|
+
SOURCE = "gitleaks"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_gitleaks(path: Path) -> list[dict[str, Any]]:
|
|
28
|
+
try:
|
|
29
|
+
text = path.read_text(encoding="utf-8-sig").strip()
|
|
30
|
+
data = json.loads(text) if text else []
|
|
31
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
32
|
+
raise ReportError(f"{path}: not a readable Gitleaks JSON report: {exc}") from exc
|
|
33
|
+
if not isinstance(data, list):
|
|
34
|
+
raise ReportError(f"{path}: Gitleaks report must be a JSON array")
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def import_gitleaks(items: list[dict[str, Any]], root: Path, index: SourceIndex) -> ImportResult:
|
|
39
|
+
result = ImportResult(tool=SOURCE)
|
|
40
|
+
for item in items:
|
|
41
|
+
if not isinstance(item, dict):
|
|
42
|
+
result.warnings.append("gitleaks: skipped non-object entry")
|
|
43
|
+
continue
|
|
44
|
+
secret = str(item.get("Secret") or "")
|
|
45
|
+
if secret and secret != "REDACTED":
|
|
46
|
+
result.secrets.add(secret)
|
|
47
|
+
try:
|
|
48
|
+
_convert(item, root, index, result)
|
|
49
|
+
except (TypeError, ValueError) as exc:
|
|
50
|
+
result.warnings.append(f"gitleaks: skipped malformed entry: {exc}")
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _convert(item: dict[str, Any], root: Path, index: SourceIndex, result: ImportResult) -> None:
|
|
55
|
+
raw_path = item.get("File") or item.get("file")
|
|
56
|
+
if not raw_path:
|
|
57
|
+
result.warnings.append("gitleaks: skipped entry without File")
|
|
58
|
+
return
|
|
59
|
+
rel = normalize_path(str(raw_path), root)
|
|
60
|
+
if rel is None:
|
|
61
|
+
result.warnings.append(f"gitleaks: {raw_path} is outside the scan root; skipped")
|
|
62
|
+
return
|
|
63
|
+
region = make_region(
|
|
64
|
+
rel,
|
|
65
|
+
item.get("StartLine"),
|
|
66
|
+
item.get("StartColumn"),
|
|
67
|
+
item.get("EndLine"),
|
|
68
|
+
# Gitleaks columns are inclusive; SARIF/Region end columns are exclusive.
|
|
69
|
+
(item.get("EndColumn") or 0) + 1 if item.get("EndColumn") else None,
|
|
70
|
+
)
|
|
71
|
+
secret = str(item.get("Secret") or "")
|
|
72
|
+
rule = str(item.get("RuleID") or "generic")
|
|
73
|
+
description = str(item.get("Description") or f"Secret detected ({rule})")
|
|
74
|
+
if secret:
|
|
75
|
+
description = description.replace(secret, REDACTED)
|
|
76
|
+
line = index.line_text(rel, region.start_line)
|
|
77
|
+
snippet = _redact(line or str(item.get("Match") or ""), secret)
|
|
78
|
+
result.findings.append(
|
|
79
|
+
Finding(
|
|
80
|
+
id="",
|
|
81
|
+
vuln_class=SECRET,
|
|
82
|
+
rule_id=f"{SOURCE}:{rule}",
|
|
83
|
+
severity=Severity.HIGH,
|
|
84
|
+
location=region,
|
|
85
|
+
message=description,
|
|
86
|
+
snippet=snippet,
|
|
87
|
+
# A secret's identity is its location and rule, never its value. No `=`/`:` so
|
|
88
|
+
# secret-class redaction leaves it intact (it feeds the fingerprint).
|
|
89
|
+
sink=f"{rule}@{rel}@L{region.start_line}",
|
|
90
|
+
function=None,
|
|
91
|
+
cwe=("CWE-798",),
|
|
92
|
+
sources=(SOURCE,),
|
|
93
|
+
confidence=70,
|
|
94
|
+
evidence=(Evidence(SOURCE, "tool_message", description),),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _redact(text: str, secret: str) -> str:
|
|
100
|
+
"""Replace the secret; if it is unknown or absent from `text`, keep `text` only when
|
|
101
|
+
no secret was reported at all (otherwise the safest snippet is none)."""
|
|
102
|
+
if secret and secret in text:
|
|
103
|
+
return text.replace(secret, REDACTED)
|
|
104
|
+
return text if not secret else REDACTED
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Run external scanners (`--with`) and load bring-your-own reports (`--import`).
|
|
2
|
+
|
|
3
|
+
Run mode never fails the scan by default: a missing tool, a timeout or unusable output
|
|
4
|
+
becomes a warning and that source is skipped (`--strict-tools` makes it an error).
|
|
5
|
+
A non-zero exit code with valid output is accepted (scanners exit 1 on findings).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from hackscan.importers.common import ImportResult, ReportError, SourceIndex
|
|
17
|
+
from hackscan.importers.gitleaks import import_gitleaks, load_gitleaks
|
|
18
|
+
from hackscan.importers.sarif import import_sarif, load_sarif
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ToolRun:
|
|
23
|
+
tool: str
|
|
24
|
+
status: str # "ok", "partial", "missing", "failed", "timeout", "imported"
|
|
25
|
+
detail: str = ""
|
|
26
|
+
findings: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ExternalResults:
|
|
31
|
+
findings: list = field(default_factory=list)
|
|
32
|
+
warnings: list[str] = field(default_factory=list)
|
|
33
|
+
# Requested reports that could not be imported: the scan is incomplete.
|
|
34
|
+
errors: list[str] = field(default_factory=list)
|
|
35
|
+
runs: list[ToolRun] = field(default_factory=list)
|
|
36
|
+
secrets: set[str] = field(default_factory=set)
|
|
37
|
+
|
|
38
|
+
def add(self, result: ImportResult, run: ToolRun) -> None:
|
|
39
|
+
self.findings.extend(result.findings)
|
|
40
|
+
self.warnings.extend(result.warnings)
|
|
41
|
+
self.secrets |= result.secrets
|
|
42
|
+
run.findings = len(result.findings)
|
|
43
|
+
self.runs.append(run)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Exit codes meaning "ran to completion" (1 = findings reported for semgrep/bandit).
|
|
47
|
+
OK_EXIT_CODES = {"semgrep": {0, 1}, "bandit": {0, 1}, "gitleaks": {0}}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _commands(tool: str, target: Path, out: Path) -> list[str]:
|
|
51
|
+
if tool == "semgrep":
|
|
52
|
+
return [
|
|
53
|
+
"semgrep",
|
|
54
|
+
"scan",
|
|
55
|
+
"--config",
|
|
56
|
+
"p/python",
|
|
57
|
+
"--sarif",
|
|
58
|
+
"--output",
|
|
59
|
+
str(out),
|
|
60
|
+
"--quiet",
|
|
61
|
+
"--disable-version-check",
|
|
62
|
+
"--metrics",
|
|
63
|
+
"off",
|
|
64
|
+
str(target),
|
|
65
|
+
]
|
|
66
|
+
if tool == "bandit":
|
|
67
|
+
return ["bandit", "-r", "-q", "-f", "sarif", "-o", str(out), str(target)]
|
|
68
|
+
if tool == "gitleaks":
|
|
69
|
+
return [
|
|
70
|
+
"gitleaks",
|
|
71
|
+
"detect",
|
|
72
|
+
"--no-git",
|
|
73
|
+
"--no-banner",
|
|
74
|
+
"--report-format",
|
|
75
|
+
"json",
|
|
76
|
+
"--report-path",
|
|
77
|
+
str(out),
|
|
78
|
+
"--source",
|
|
79
|
+
str(target),
|
|
80
|
+
"--exit-code",
|
|
81
|
+
"0",
|
|
82
|
+
]
|
|
83
|
+
raise ValueError(f"unknown tool {tool}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def collect(
|
|
87
|
+
root: Path,
|
|
88
|
+
target: Path,
|
|
89
|
+
with_tools: tuple[str, ...],
|
|
90
|
+
imports: tuple[tuple[str, Path], ...],
|
|
91
|
+
timeout: int,
|
|
92
|
+
index: SourceIndex | None = None,
|
|
93
|
+
) -> ExternalResults:
|
|
94
|
+
index = index or SourceIndex(root)
|
|
95
|
+
results = ExternalResults()
|
|
96
|
+
for tool in with_tools:
|
|
97
|
+
_run_tool(tool, root, target, timeout, index, results)
|
|
98
|
+
for fmt, report in imports:
|
|
99
|
+
try:
|
|
100
|
+
result = _load(fmt, report, root, index)
|
|
101
|
+
except ReportError as exc:
|
|
102
|
+
results.errors.append(f"import {fmt}={report}: {exc}")
|
|
103
|
+
results.runs.append(ToolRun(fmt, "failed", str(exc)))
|
|
104
|
+
continue
|
|
105
|
+
results.add(result, ToolRun(result.tool, "imported", str(report)))
|
|
106
|
+
return results
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _load(fmt: str, report: Path, root: Path, index: SourceIndex) -> ImportResult:
|
|
110
|
+
if fmt == "gitleaks":
|
|
111
|
+
return import_gitleaks(load_gitleaks(report), root, index)
|
|
112
|
+
label = None if fmt == "sarif" else fmt
|
|
113
|
+
return import_sarif(load_sarif(report), root, index, label=label)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _run_tool(
|
|
117
|
+
tool: str, root: Path, target: Path, timeout: int, index: SourceIndex, results: ExternalResults
|
|
118
|
+
) -> None:
|
|
119
|
+
executable = shutil.which(tool)
|
|
120
|
+
if executable is None:
|
|
121
|
+
results.warnings.append(f"{tool}: not found on PATH; skipped")
|
|
122
|
+
results.runs.append(ToolRun(tool, "missing", "not on PATH"))
|
|
123
|
+
return
|
|
124
|
+
with tempfile.TemporaryDirectory(prefix="hackscan-") as tmp:
|
|
125
|
+
out = Path(tmp) / ("report.json" if tool == "gitleaks" else "report.sarif")
|
|
126
|
+
command = _commands(tool, target, out)
|
|
127
|
+
command[0] = executable
|
|
128
|
+
try:
|
|
129
|
+
proc = subprocess.run(
|
|
130
|
+
command, capture_output=True, text=True, timeout=timeout or None, check=False
|
|
131
|
+
)
|
|
132
|
+
except subprocess.TimeoutExpired:
|
|
133
|
+
results.warnings.append(f"{tool}: timed out after {timeout}s; skipped")
|
|
134
|
+
results.runs.append(ToolRun(tool, "timeout", f"{timeout}s"))
|
|
135
|
+
return
|
|
136
|
+
except OSError as exc:
|
|
137
|
+
results.warnings.append(f"{tool}: could not run: {exc}; skipped")
|
|
138
|
+
results.runs.append(ToolRun(tool, "failed", str(exc)))
|
|
139
|
+
return
|
|
140
|
+
if not out.is_file() or out.stat().st_size == 0:
|
|
141
|
+
detail = (proc.stderr or proc.stdout).strip().splitlines()[-1:] or ["no output"]
|
|
142
|
+
results.warnings.append(
|
|
143
|
+
f"{tool}: produced no report (exit {proc.returncode}): {detail[0]}"
|
|
144
|
+
)
|
|
145
|
+
results.runs.append(ToolRun(tool, "failed", f"exit {proc.returncode}"))
|
|
146
|
+
return
|
|
147
|
+
try:
|
|
148
|
+
result = _load(tool, out, root, index)
|
|
149
|
+
except ReportError as exc:
|
|
150
|
+
results.warnings.append(f"{tool}: unusable output: {exc}")
|
|
151
|
+
results.runs.append(ToolRun(tool, "failed", "unusable output"))
|
|
152
|
+
return
|
|
153
|
+
if proc.returncode not in OK_EXIT_CODES[tool]:
|
|
154
|
+
# The tool reported an error but left output: keep what it found, flag the
|
|
155
|
+
# run so it is visible and `--strict-tools` fails.
|
|
156
|
+
results.warnings.append(
|
|
157
|
+
f"{tool}: exited with error code {proc.returncode}; results may be incomplete"
|
|
158
|
+
)
|
|
159
|
+
results.add(result, ToolRun(tool, "partial", f"exit {proc.returncode}"))
|
|
160
|
+
return
|
|
161
|
+
results.add(result, ToolRun(tool, "ok", f"exit {proc.returncode}"))
|