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,76 @@
|
|
|
1
|
+
"""Stable, vendor-independent finding ids.
|
|
2
|
+
|
|
3
|
+
id = H(vuln_class, path, enclosing function, normalized sink expression)
|
|
4
|
+
|
|
5
|
+
The sink expression comes from `Finding.sink` (text taken from the source file), falling
|
|
6
|
+
back to `snippet` only when no sink was recorded. Line numbers are deliberately excluded
|
|
7
|
+
so ids survive unrelated edits that shift code.
|
|
8
|
+
Identical sinks in the same function are disambiguated by occurrence order.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import hashlib
|
|
15
|
+
import re
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from dataclasses import replace
|
|
19
|
+
from functools import lru_cache
|
|
20
|
+
|
|
21
|
+
from hackscan.core.models import Finding
|
|
22
|
+
|
|
23
|
+
ID_PREFIX = "hs1-" # bump if the fingerprint recipe changes
|
|
24
|
+
_HEX_LEN = 32
|
|
25
|
+
_WS_RE = re.compile(r"\s+")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@lru_cache(maxsize=65536)
|
|
29
|
+
def normalize_expression(code: str) -> str:
|
|
30
|
+
"""Canonical form of a code snippet: formatting- and comment-insensitive when parseable."""
|
|
31
|
+
text = code.strip()
|
|
32
|
+
if not text:
|
|
33
|
+
return ""
|
|
34
|
+
for mode in ("eval", "exec"):
|
|
35
|
+
try:
|
|
36
|
+
return ast.unparse(ast.parse(text, mode=mode))
|
|
37
|
+
except SyntaxError:
|
|
38
|
+
continue
|
|
39
|
+
return _WS_RE.sub(" ", text)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compute_fingerprint(
|
|
43
|
+
vuln_class: str, path: str, function: str | None, sink_expression: str, occurrence: int = 0
|
|
44
|
+
) -> str:
|
|
45
|
+
parts = [vuln_class, path, function or "<module>", normalize_expression(sink_expression)]
|
|
46
|
+
if occurrence:
|
|
47
|
+
parts.append(f"#{occurrence}")
|
|
48
|
+
digest = hashlib.sha256("\x1f".join(parts).encode("utf-8")).hexdigest()
|
|
49
|
+
return ID_PREFIX + digest[:_HEX_LEN]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def assign_ids(findings: Iterable[Finding]) -> list[Finding]:
|
|
53
|
+
"""Return findings with `id` set. Input order does not affect the result."""
|
|
54
|
+
groups: dict[str, list[Finding]] = defaultdict(list)
|
|
55
|
+
for f in findings:
|
|
56
|
+
base = compute_fingerprint(f.vuln_class, f.location.path, f.function, sink_text(f))
|
|
57
|
+
groups[base].append(f)
|
|
58
|
+
|
|
59
|
+
result: list[Finding] = []
|
|
60
|
+
for base, members in groups.items():
|
|
61
|
+
members.sort(key=lambda f: (*f.location.sort_key(), f.canonical_json()))
|
|
62
|
+
for occurrence, f in enumerate(members):
|
|
63
|
+
new_id = (
|
|
64
|
+
base
|
|
65
|
+
if occurrence == 0
|
|
66
|
+
else compute_fingerprint(
|
|
67
|
+
f.vuln_class, f.location.path, f.function, sink_text(f), occurrence
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
result.append(replace(f, id=new_id))
|
|
71
|
+
result.sort(key=Finding.sort_key)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def sink_text(f: Finding) -> str:
|
|
76
|
+
return f.sink or f.snippet
|
hackscan/core/models.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Finding schema v1. Frozen at M0: changes require bumping SCHEMA_VERSION.
|
|
2
|
+
|
|
3
|
+
Every pass and importer produces or transforms `Finding` objects. Passes never delete
|
|
4
|
+
findings; they return updated copies (see `dataclasses.replace`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from functools import cached_property
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
OWN_SOURCE = "hackscan"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Severity(str, Enum):
|
|
22
|
+
LOW = "low"
|
|
23
|
+
MEDIUM = "medium"
|
|
24
|
+
HIGH = "high"
|
|
25
|
+
CRITICAL = "critical"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def rank(self) -> int:
|
|
29
|
+
return _SEVERITY_RANK[self]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_SEVERITY_RANK = {Severity.LOW: 0, Severity.MEDIUM: 1, Severity.HIGH: 2, Severity.CRITICAL: 3}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Status(str, Enum):
|
|
36
|
+
CANDIDATE = "candidate"
|
|
37
|
+
CONFIRMED = "confirmed"
|
|
38
|
+
SUPPRESSED = "suppressed"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def rank(self) -> int:
|
|
42
|
+
"""Merge precedence: confirmed > candidate > suppressed."""
|
|
43
|
+
return _STATUS_RANK[self]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_STATUS_RANK = {Status.SUPPRESSED: 0, Status.CANDIDATE: 1, Status.CONFIRMED: 2}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Region:
|
|
51
|
+
"""Source location. Lines and columns are 1-based (SARIF convention).
|
|
52
|
+
|
|
53
|
+
`path` is a POSIX path relative to the scan root. `end_column` is exclusive (SARIF
|
|
54
|
+
`endColumn`); None means "to end of line".
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
path: str
|
|
58
|
+
start_line: int
|
|
59
|
+
start_column: int = 1
|
|
60
|
+
end_line: int | None = None
|
|
61
|
+
end_column: int | None = None
|
|
62
|
+
|
|
63
|
+
def __post_init__(self) -> None:
|
|
64
|
+
if self.start_line < 1 or self.start_column < 1:
|
|
65
|
+
raise ValueError(f"Region lines/columns are 1-based: {self}")
|
|
66
|
+
if "\\" in self.path:
|
|
67
|
+
raise ValueError(f"Region path must be POSIX-style: {self.path!r}")
|
|
68
|
+
if self.end_line is None:
|
|
69
|
+
object.__setattr__(self, "end_line", self.start_line)
|
|
70
|
+
if self.end_line < self.start_line:
|
|
71
|
+
raise ValueError(f"Region ends before it starts: {self}")
|
|
72
|
+
if self.end_column is not None and (
|
|
73
|
+
self.end_column < 1
|
|
74
|
+
or (self.end_line == self.start_line and self.end_column < self.start_column)
|
|
75
|
+
):
|
|
76
|
+
raise ValueError(f"Region end column is invalid: {self}")
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def start_pos(self) -> tuple[int, float]:
|
|
80
|
+
return (self.start_line, self.start_column)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def end_pos(self) -> tuple[int, float]:
|
|
84
|
+
"""Exclusive end position; whole-line when `end_column` is None."""
|
|
85
|
+
if self.end_column is None:
|
|
86
|
+
return (self.end_line, math.inf)
|
|
87
|
+
# Zero-width regions still cover the character they point at.
|
|
88
|
+
if self.end_line == self.start_line and self.end_column == self.start_column:
|
|
89
|
+
return (self.end_line, self.end_column + 1)
|
|
90
|
+
return (self.end_line, self.end_column)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def span(self) -> tuple[int, float]:
|
|
94
|
+
"""Ordering heuristic preferring precise regions: (extra lines, column delta).
|
|
95
|
+
|
|
96
|
+
Only used to pick visit order; correctness of dedupe does not depend on it.
|
|
97
|
+
"""
|
|
98
|
+
return (self.end_line - self.start_line, self.end_pos[1] - self.start_column)
|
|
99
|
+
|
|
100
|
+
def overlaps(self, other: Region) -> bool:
|
|
101
|
+
"""Position overlap (line and column aware) within the same file."""
|
|
102
|
+
return (
|
|
103
|
+
self.path == other.path
|
|
104
|
+
and self.start_pos < other.end_pos
|
|
105
|
+
and other.start_pos < self.end_pos
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def sort_key(self) -> tuple[str, int, int]:
|
|
109
|
+
return (self.path, self.start_line, self.start_column)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class Evidence:
|
|
114
|
+
"""One piece of supporting evidence, tagged with the producer (pass or tool)."""
|
|
115
|
+
|
|
116
|
+
producer: str
|
|
117
|
+
kind: str # e.g. "taint_step", "tool_message", "llm_rationale"
|
|
118
|
+
message: str
|
|
119
|
+
region: Region | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class FixEdit:
|
|
124
|
+
region: Region
|
|
125
|
+
replacement: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class Fix:
|
|
130
|
+
description: str
|
|
131
|
+
edits: tuple[FixEdit, ...]
|
|
132
|
+
producer: str
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class Finding:
|
|
137
|
+
id: str
|
|
138
|
+
vuln_class: str
|
|
139
|
+
rule_id: str
|
|
140
|
+
severity: Severity
|
|
141
|
+
location: Region
|
|
142
|
+
message: str
|
|
143
|
+
snippet: str = ""
|
|
144
|
+
cwe: tuple[str, ...] = ()
|
|
145
|
+
related_rules: tuple[str, ...] = ()
|
|
146
|
+
sources: tuple[str, ...] = (OWN_SOURCE,)
|
|
147
|
+
status: Status = Status.CANDIDATE
|
|
148
|
+
suppression: str | None = None
|
|
149
|
+
confidence: int = 50
|
|
150
|
+
evidence: tuple[Evidence, ...] = ()
|
|
151
|
+
fix: Fix | None = None
|
|
152
|
+
# Enclosing function qualname. Like `sink`, producers derive it from the parsed file
|
|
153
|
+
# (importers look it up by location), so it does not depend on which tool reported.
|
|
154
|
+
function: str | None = None
|
|
155
|
+
# Source text of the sink expression at `location`, used for fingerprinting.
|
|
156
|
+
# Producers set it from the parsed file (own engine: the sink AST node; importers:
|
|
157
|
+
# the file text covered by the region), never from a tool-provided display snippet.
|
|
158
|
+
sink: str = ""
|
|
159
|
+
|
|
160
|
+
def __post_init__(self) -> None:
|
|
161
|
+
if not 0 <= self.confidence <= 100:
|
|
162
|
+
raise ValueError(f"confidence must be 0-100, got {self.confidence}")
|
|
163
|
+
if (self.status is Status.SUPPRESSED) != (self.suppression is not None):
|
|
164
|
+
raise ValueError("suppression reason is required iff status is suppressed")
|
|
165
|
+
|
|
166
|
+
def sort_key(self) -> tuple[str, int, int, str, str, str]:
|
|
167
|
+
"""Deterministic output ordering: path, line, column, rule, id, then full content."""
|
|
168
|
+
return (*self.location.sort_key(), self.rule_id, self.id, self.canonical_json())
|
|
169
|
+
|
|
170
|
+
def canonical_json(self) -> str:
|
|
171
|
+
"""Stable serialization; a total-order tie-breaker for equal-looking findings."""
|
|
172
|
+
return self._canonical_json
|
|
173
|
+
|
|
174
|
+
@cached_property
|
|
175
|
+
def _canonical_json(self) -> str:
|
|
176
|
+
# Safe to cache: Finding is frozen, and cached_property writes to __dict__
|
|
177
|
+
# directly (not a dataclass field, so eq/hash/replace are unaffected).
|
|
178
|
+
return json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":"))
|
|
179
|
+
|
|
180
|
+
def to_dict(self) -> dict[str, Any]:
|
|
181
|
+
return {
|
|
182
|
+
"schema_version": SCHEMA_VERSION,
|
|
183
|
+
"id": self.id,
|
|
184
|
+
"vuln_class": self.vuln_class,
|
|
185
|
+
"rule_id": self.rule_id,
|
|
186
|
+
"related_rules": list(self.related_rules),
|
|
187
|
+
"cwe": list(self.cwe),
|
|
188
|
+
"severity": self.severity.value,
|
|
189
|
+
"confidence": self.confidence,
|
|
190
|
+
"status": self.status.value,
|
|
191
|
+
"suppression": self.suppression,
|
|
192
|
+
"sources": list(self.sources),
|
|
193
|
+
"message": self.message,
|
|
194
|
+
"location": _region_to_dict(self.location),
|
|
195
|
+
"function": self.function,
|
|
196
|
+
"sink": self.sink,
|
|
197
|
+
"snippet": self.snippet,
|
|
198
|
+
"evidence": [
|
|
199
|
+
{
|
|
200
|
+
"producer": e.producer,
|
|
201
|
+
"kind": e.kind,
|
|
202
|
+
"message": e.message,
|
|
203
|
+
"region": _region_to_dict(e.region) if e.region else None,
|
|
204
|
+
}
|
|
205
|
+
for e in self.evidence
|
|
206
|
+
],
|
|
207
|
+
"fix": None
|
|
208
|
+
if self.fix is None
|
|
209
|
+
else {
|
|
210
|
+
"description": self.fix.description,
|
|
211
|
+
"producer": self.fix.producer,
|
|
212
|
+
"edits": [
|
|
213
|
+
{"region": _region_to_dict(ed.region), "replacement": ed.replacement}
|
|
214
|
+
for ed in self.fix.edits
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def from_dict(cls, data: dict[str, Any]) -> Finding:
|
|
221
|
+
version = data.get("schema_version")
|
|
222
|
+
if version != SCHEMA_VERSION:
|
|
223
|
+
raise ValueError(f"unsupported Finding schema_version {version!r}")
|
|
224
|
+
fix = data.get("fix")
|
|
225
|
+
return cls(
|
|
226
|
+
id=data["id"],
|
|
227
|
+
vuln_class=data["vuln_class"],
|
|
228
|
+
rule_id=data["rule_id"],
|
|
229
|
+
related_rules=tuple(data.get("related_rules", ())),
|
|
230
|
+
cwe=tuple(data.get("cwe", ())),
|
|
231
|
+
severity=Severity(data["severity"]),
|
|
232
|
+
confidence=data["confidence"],
|
|
233
|
+
status=Status(data["status"]),
|
|
234
|
+
suppression=data.get("suppression"),
|
|
235
|
+
sources=tuple(data["sources"]),
|
|
236
|
+
message=data["message"],
|
|
237
|
+
location=Region(**data["location"]),
|
|
238
|
+
function=data.get("function"),
|
|
239
|
+
sink=data.get("sink", ""),
|
|
240
|
+
snippet=data.get("snippet", ""),
|
|
241
|
+
evidence=tuple(
|
|
242
|
+
Evidence(
|
|
243
|
+
producer=e["producer"],
|
|
244
|
+
kind=e["kind"],
|
|
245
|
+
message=e["message"],
|
|
246
|
+
region=Region(**e["region"]) if e.get("region") else None,
|
|
247
|
+
)
|
|
248
|
+
for e in data.get("evidence", ())
|
|
249
|
+
),
|
|
250
|
+
fix=None
|
|
251
|
+
if fix is None
|
|
252
|
+
else Fix(
|
|
253
|
+
description=fix["description"],
|
|
254
|
+
producer=fix["producer"],
|
|
255
|
+
edits=tuple(
|
|
256
|
+
FixEdit(region=Region(**ed["region"]), replacement=ed["replacement"])
|
|
257
|
+
for ed in fix["edits"]
|
|
258
|
+
),
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _region_to_dict(region: Region) -> dict[str, Any]:
|
|
264
|
+
return {
|
|
265
|
+
"path": region.path,
|
|
266
|
+
"start_line": region.start_line,
|
|
267
|
+
"start_column": region.start_column,
|
|
268
|
+
"end_line": region.end_line,
|
|
269
|
+
"end_column": region.end_column,
|
|
270
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Scan pipeline: discover -> analyze (passes 1-2, parallel) -> import -> merge -> ids.
|
|
2
|
+
|
|
3
|
+
Pipeline order per PROJECT.md: collect (own engine + importers) -> `merge_findings` ->
|
|
4
|
+
`assign_ids`. Output is deterministic regardless of worker scheduling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from hackscan.analyzers.ast_pass import FileResult, analyze_file
|
|
18
|
+
from hackscan.config import DEFAULT_IGNORES, HackScanConfig
|
|
19
|
+
from hackscan.core.dedupe import merge_findings
|
|
20
|
+
from hackscan.core.fingerprint import assign_ids
|
|
21
|
+
from hackscan.core.models import Finding
|
|
22
|
+
from hackscan.core.redact import redact_findings, redact_messages
|
|
23
|
+
from hackscan.importers.common import SourceIndex
|
|
24
|
+
from hackscan.importers.runner import ToolRun, collect
|
|
25
|
+
from hackscan.plugins.base import RulePlugin
|
|
26
|
+
from hackscan.plugins.loader import resolve_plugins
|
|
27
|
+
|
|
28
|
+
PARALLEL_THRESHOLD = 16 # below this many files, process start-up costs more than it saves
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ScanResult:
|
|
33
|
+
root: Path
|
|
34
|
+
findings: list[Finding]
|
|
35
|
+
files_scanned: int
|
|
36
|
+
duration_seconds: float
|
|
37
|
+
errors: list[str] = field(default_factory=list) # files we could not analyze
|
|
38
|
+
warnings: list[str] = field(default_factory=list) # external tool problems
|
|
39
|
+
tool_runs: list[ToolRun] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def scan(target: Path, config: HackScanConfig) -> ScanResult:
|
|
43
|
+
started = time.perf_counter()
|
|
44
|
+
target = target.resolve()
|
|
45
|
+
root = target if target.is_dir() else target.parent
|
|
46
|
+
plugins = resolve_plugins(config.plugins)
|
|
47
|
+
files = list(discover_files(target, root, config.ignore))
|
|
48
|
+
|
|
49
|
+
results = _analyze(files, root, plugins, config)
|
|
50
|
+
findings: list[Finding] = []
|
|
51
|
+
errors: list[str] = []
|
|
52
|
+
for result in results:
|
|
53
|
+
findings.extend(result.findings)
|
|
54
|
+
errors.extend(result.errors)
|
|
55
|
+
|
|
56
|
+
external = collect(
|
|
57
|
+
root,
|
|
58
|
+
target,
|
|
59
|
+
config.with_tools,
|
|
60
|
+
config.imports,
|
|
61
|
+
config.tool_timeout,
|
|
62
|
+
SourceIndex(root),
|
|
63
|
+
)
|
|
64
|
+
# Imported findings obey the same ignores as discovery (defaults included).
|
|
65
|
+
findings.extend(
|
|
66
|
+
f for f in external.findings if not is_ignored_path(f.location.path, config.ignore)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
final = assign_ids(redact_findings(merge_findings(findings), external.secrets))
|
|
70
|
+
return ScanResult(
|
|
71
|
+
root=root,
|
|
72
|
+
findings=final,
|
|
73
|
+
files_scanned=len(files),
|
|
74
|
+
duration_seconds=time.perf_counter() - started,
|
|
75
|
+
errors=redact_messages([*sorted(errors), *external.errors], external.secrets),
|
|
76
|
+
warnings=redact_messages(external.warnings, external.secrets),
|
|
77
|
+
tool_runs=external.runs,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# -- discovery ----------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def discover_files(target: Path, root: Path, ignore: tuple[str, ...]) -> Iterator[Path]:
|
|
85
|
+
"""Python files under `target`, skipping default and configured ignores (sorted)."""
|
|
86
|
+
if target.is_file():
|
|
87
|
+
if target.suffix in (".py", ".pyw") and not _is_ignored(target.name, ignore):
|
|
88
|
+
yield target
|
|
89
|
+
return
|
|
90
|
+
for dirpath, dirnames, filenames in os.walk(target):
|
|
91
|
+
current = Path(dirpath)
|
|
92
|
+
rel_dir = current.relative_to(root).as_posix()
|
|
93
|
+
dirnames[:] = sorted(
|
|
94
|
+
d
|
|
95
|
+
for d in dirnames
|
|
96
|
+
if not _is_ignored(f"{rel_dir}/{d}" if rel_dir != "." else d, ignore, is_dir=True)
|
|
97
|
+
)
|
|
98
|
+
for name in sorted(filenames):
|
|
99
|
+
if not name.endswith((".py", ".pyw")):
|
|
100
|
+
continue
|
|
101
|
+
rel = f"{rel_dir}/{name}" if rel_dir != "." else name
|
|
102
|
+
if not _is_ignored(rel, ignore):
|
|
103
|
+
yield current / name
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_ignored_path(rel_path: str, patterns: tuple[str, ...]) -> bool:
|
|
107
|
+
"""Whether a file path is excluded: any directory component matching a default ignore
|
|
108
|
+
(`.venv`, `node_modules`, ...) or a configured pattern."""
|
|
109
|
+
parts = rel_path.split("/")
|
|
110
|
+
if any(fnmatch.fnmatch(part, p) for part in parts[:-1] for p in DEFAULT_IGNORES):
|
|
111
|
+
return True
|
|
112
|
+
return _is_ignored(rel_path, patterns)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_ignored(rel_path: str, patterns: tuple[str, ...], is_dir: bool = False) -> bool:
|
|
116
|
+
"""Default ignores match any path component; configured globs match the relative path
|
|
117
|
+
(`tests/*`, `**/migrations/**`) or any single component (`legacy`)."""
|
|
118
|
+
parts = rel_path.split("/")
|
|
119
|
+
if is_dir and any(fnmatch.fnmatch(parts[-1], p) for p in DEFAULT_IGNORES):
|
|
120
|
+
return True
|
|
121
|
+
for pattern in patterns:
|
|
122
|
+
pattern = pattern.strip().rstrip("/")
|
|
123
|
+
if not pattern:
|
|
124
|
+
continue
|
|
125
|
+
if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(rel_path, pattern + "/*"):
|
|
126
|
+
return True
|
|
127
|
+
if pattern.startswith("**/") and fnmatch.fnmatch(rel_path, pattern[3:]):
|
|
128
|
+
return True
|
|
129
|
+
if "/" not in pattern and any(fnmatch.fnmatch(part, pattern) for part in parts):
|
|
130
|
+
return True
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# -- analysis -----------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
_WORKER_STATE: dict = {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _worker_init(plugins_dir: str | None, taint: bool) -> None:
|
|
140
|
+
_WORKER_STATE["plugins"] = resolve_plugins(Path(plugins_dir) if plugins_dir else None)
|
|
141
|
+
_WORKER_STATE["taint"] = taint
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _worker_analyze(args: tuple[str, str]) -> FileResult:
|
|
145
|
+
path, root = args
|
|
146
|
+
return analyze_file(
|
|
147
|
+
Path(path), Path(root), _WORKER_STATE["plugins"], taint=_WORKER_STATE["taint"]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _analyze(
|
|
152
|
+
files: list[Path], root: Path, plugins: list[RulePlugin], config: HackScanConfig
|
|
153
|
+
) -> list[FileResult]:
|
|
154
|
+
jobs = config.jobs or min(8, os.cpu_count() or 1)
|
|
155
|
+
if jobs <= 1 or len(files) < PARALLEL_THRESHOLD:
|
|
156
|
+
return [analyze_file(f, root, plugins, taint=config.taint) for f in files]
|
|
157
|
+
plugins_dir = str(config.plugins) if config.plugins else None
|
|
158
|
+
with ProcessPoolExecutor(
|
|
159
|
+
max_workers=jobs, initializer=_worker_init, initargs=(plugins_dir, config.taint)
|
|
160
|
+
) as pool:
|
|
161
|
+
chunk = max(1, len(files) // (jobs * 4))
|
|
162
|
+
return list(
|
|
163
|
+
pool.map(_worker_analyze, [(str(f), str(root)) for f in files], chunksize=chunk)
|
|
164
|
+
)
|
hackscan/core/redact.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Secret redaction for every user-facing field of a finding.
|
|
2
|
+
|
|
3
|
+
Applied after merging, before ids are assigned, so no output format (text, JSON, SARIF)
|
|
4
|
+
can carry a secret regardless of which tool reported it or which contributor became the
|
|
5
|
+
primary of a merged finding.
|
|
6
|
+
|
|
7
|
+
- Known secret values (e.g. Gitleaks `Secret`) are replaced wherever they appear.
|
|
8
|
+
- Findings of class `secret` never carry tool-provided text: their message and evidence
|
|
9
|
+
become generic, and in snippet/sink every string literal and every assigned value
|
|
10
|
+
(`token = abc`, `password: abc`) is replaced. A tool may describe a secret in any
|
|
11
|
+
form (quoted, unquoted, inside a message), so content-based redaction is not enough.
|
|
12
|
+
- `redact_messages` applies known-secret replacement to warnings and errors too.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from dataclasses import replace
|
|
20
|
+
|
|
21
|
+
from hackscan.core.models import Finding
|
|
22
|
+
from hackscan.core.taxonomy import SECRET
|
|
23
|
+
|
|
24
|
+
REDACTED = "<REDACTED>"
|
|
25
|
+
MIN_SECRET_LENGTH = 4 # shorter "secrets" would redact ordinary text
|
|
26
|
+
_LITERAL_RE = re.compile(r"""(?P<q>['"])(?:\\.|(?!(?P=q)).)+(?P=q)""")
|
|
27
|
+
# `key = value` / `key: value` / `key=value` (value up to end of line or comment).
|
|
28
|
+
_ASSIGNED_RE = re.compile(r"(?P<key>[=:]\s*)(?P<value>[^\s#'\"][^#]*?)(?P<tail>\s*(?:#.*)?)$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def redact_text(text: str, secrets: Iterable[str], literals: bool) -> str:
|
|
32
|
+
for secret in sorted(set(secrets), key=len, reverse=True):
|
|
33
|
+
if len(secret) >= MIN_SECRET_LENGTH:
|
|
34
|
+
text = text.replace(secret, REDACTED)
|
|
35
|
+
if literals:
|
|
36
|
+
text = _LITERAL_RE.sub(lambda m: f"{m.group('q')}{REDACTED}{m.group('q')}", text)
|
|
37
|
+
return text
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def redact_code(text: str, secrets: Iterable[str]) -> str:
|
|
41
|
+
"""Secret-class snippet/sink: literals and assigned values removed, line by line."""
|
|
42
|
+
lines = []
|
|
43
|
+
for line in redact_text(text, secrets, literals=True).split("\n"):
|
|
44
|
+
lines.append(
|
|
45
|
+
_ASSIGNED_RE.sub(lambda m: f"{m.group('key')}{REDACTED}{m.group('tail')}", line)
|
|
46
|
+
)
|
|
47
|
+
return "\n".join(lines)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def redact_messages(messages: Iterable[str], secrets: Iterable[str]) -> list[str]:
|
|
51
|
+
secrets = list(secrets)
|
|
52
|
+
return [redact_text(m, secrets, literals=False) for m in messages]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def redact_findings(findings: list[Finding], secrets: Iterable[str]) -> list[Finding]:
|
|
56
|
+
secrets = [s for s in set(secrets) if len(s) >= MIN_SECRET_LENGTH]
|
|
57
|
+
out = []
|
|
58
|
+
for f in findings:
|
|
59
|
+
if f.vuln_class == SECRET:
|
|
60
|
+
generic = f"Possible hard-coded secret reported by {', '.join(f.sources)}."
|
|
61
|
+
new = replace(
|
|
62
|
+
f,
|
|
63
|
+
message=generic,
|
|
64
|
+
snippet=redact_code(f.snippet, secrets),
|
|
65
|
+
sink=redact_code(f.sink, secrets),
|
|
66
|
+
evidence=tuple(
|
|
67
|
+
replace(e, message=f"{e.producer} reported a secret ({f.rule_id}).")
|
|
68
|
+
for e in f.evidence
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
|
|
73
|
+
def clean(text: str) -> str:
|
|
74
|
+
return redact_text(text, secrets, literals=False)
|
|
75
|
+
|
|
76
|
+
new = replace(
|
|
77
|
+
f,
|
|
78
|
+
message=clean(f.message),
|
|
79
|
+
snippet=clean(f.snippet),
|
|
80
|
+
sink=clean(f.sink),
|
|
81
|
+
evidence=tuple(replace(e, message=clean(e.message)) for e in f.evidence),
|
|
82
|
+
)
|
|
83
|
+
out.append(new if new != f else f)
|
|
84
|
+
return out
|