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,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}"))