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
hackscan/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analysis passes."""
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Pass 1: run AST rule plugins over a file and emit candidate findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import io
|
|
7
|
+
import tokenize
|
|
8
|
+
import warnings
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from hackscan.analyzers.suppressions import apply_inline_suppressions
|
|
14
|
+
from hackscan.analyzers.taint_pass import apply_taint
|
|
15
|
+
from hackscan.core.models import OWN_SOURCE, Evidence, Finding, Region
|
|
16
|
+
from hackscan.core.taxonomy import classify
|
|
17
|
+
from hackscan.plugins.base import FileContext, Match, RulePlugin
|
|
18
|
+
|
|
19
|
+
PRODUCER = "ast"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class FileResult:
|
|
24
|
+
path: str
|
|
25
|
+
findings: list[Finding] = field(default_factory=list)
|
|
26
|
+
errors: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def analyze_source(
|
|
30
|
+
source: str, rel_path: str, plugins: Sequence[RulePlugin], *, taint: bool = True
|
|
31
|
+
) -> FileResult:
|
|
32
|
+
"""Parse `source`, run every plugin (pass 1), then taint (pass 2) and inline
|
|
33
|
+
suppressions. Parse and plugin errors are reported, not raised."""
|
|
34
|
+
result = FileResult(path=rel_path)
|
|
35
|
+
try:
|
|
36
|
+
with warnings.catch_warnings():
|
|
37
|
+
warnings.simplefilter("ignore", SyntaxWarning) # e.g. invalid escapes in scanned code
|
|
38
|
+
tree = ast.parse(source, filename=rel_path, type_comments=False)
|
|
39
|
+
except (SyntaxError, ValueError) as exc:
|
|
40
|
+
result.errors.append(f"{rel_path}: cannot parse: {exc}")
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
ctx = FileContext(path=rel_path, source=source, tree=tree)
|
|
44
|
+
by_type: dict[type[ast.AST], list[RulePlugin]] = {}
|
|
45
|
+
for plugin in plugins:
|
|
46
|
+
for node_type in plugin.node_types:
|
|
47
|
+
by_type.setdefault(node_type, []).append(plugin)
|
|
48
|
+
|
|
49
|
+
qualnames = _qualnames(tree)
|
|
50
|
+
candidates: list[tuple[Finding, Match]] = []
|
|
51
|
+
for node in ast.walk(tree):
|
|
52
|
+
for plugin in by_type.get(type(node), ()):
|
|
53
|
+
try:
|
|
54
|
+
matches = list(plugin.check(node, ctx))
|
|
55
|
+
except Exception as exc: # a broken rule must not abort the scan
|
|
56
|
+
line = getattr(node, "lineno", "?")
|
|
57
|
+
result.errors.append(f"{rel_path}:{line}: rule {plugin.rule_id} failed: {exc}")
|
|
58
|
+
continue
|
|
59
|
+
for match in matches:
|
|
60
|
+
try:
|
|
61
|
+
candidates.append((_to_finding(match, plugin, ctx, qualnames), match))
|
|
62
|
+
except Exception as exc: # malformed match from a (user) plugin
|
|
63
|
+
line = getattr(node, "lineno", "?")
|
|
64
|
+
result.errors.append(
|
|
65
|
+
f"{rel_path}:{line}: rule {plugin.rule_id} returned a bad match: {exc}"
|
|
66
|
+
)
|
|
67
|
+
findings = [f for f, _ in candidates]
|
|
68
|
+
if taint:
|
|
69
|
+
try:
|
|
70
|
+
findings = apply_taint(ctx, candidates)
|
|
71
|
+
except RecursionError: # pathological nesting: keep pass-1 candidates
|
|
72
|
+
result.errors.append(f"{rel_path}: taint analysis skipped (nesting too deep)")
|
|
73
|
+
findings = apply_inline_suppressions(findings, ctx)
|
|
74
|
+
result.findings = sorted(findings, key=Finding.sort_key)
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def analyze_file(
|
|
79
|
+
path: Path, root: Path, plugins: Sequence[RulePlugin], *, taint: bool = True
|
|
80
|
+
) -> FileResult:
|
|
81
|
+
rel_path = path.relative_to(root).as_posix()
|
|
82
|
+
try:
|
|
83
|
+
data = path.read_bytes()
|
|
84
|
+
# Honors PEP 263 coding declarations and BOMs, defaulting to UTF-8.
|
|
85
|
+
encoding, _ = tokenize.detect_encoding(io.BytesIO(data).readline)
|
|
86
|
+
source = data.decode(encoding)
|
|
87
|
+
except (OSError, SyntaxError, UnicodeDecodeError, LookupError) as exc:
|
|
88
|
+
return FileResult(path=rel_path, errors=[f"{rel_path}: cannot read: {exc}"])
|
|
89
|
+
return analyze_source(source, rel_path, plugins, taint=taint)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _to_finding(
|
|
93
|
+
match: Match, plugin: RulePlugin, ctx: FileContext, qualnames: dict[int, str]
|
|
94
|
+
) -> Finding:
|
|
95
|
+
node = match.node
|
|
96
|
+
if not isinstance(node, ast.AST) or not hasattr(node, "lineno"):
|
|
97
|
+
raise TypeError(f"match node must be a located AST node, got {type(node).__name__}")
|
|
98
|
+
start_line = node.lineno
|
|
99
|
+
end_line = node.end_lineno or start_line
|
|
100
|
+
region = Region(
|
|
101
|
+
path=ctx.path,
|
|
102
|
+
start_line=start_line,
|
|
103
|
+
start_column=ctx.char_column(start_line, node.col_offset),
|
|
104
|
+
end_line=end_line,
|
|
105
|
+
end_column=(
|
|
106
|
+
ctx.char_column(end_line, node.end_col_offset)
|
|
107
|
+
if node.end_col_offset is not None
|
|
108
|
+
else None
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
func = ctx.enclosing_function(node)
|
|
112
|
+
sink = ctx.segment(node)
|
|
113
|
+
return Finding(
|
|
114
|
+
id="",
|
|
115
|
+
vuln_class=classify(plugin.rule_id, plugin.cwe),
|
|
116
|
+
rule_id=plugin.rule_id,
|
|
117
|
+
severity=plugin.severity,
|
|
118
|
+
location=region,
|
|
119
|
+
message=match.message,
|
|
120
|
+
snippet=_line_snippet(ctx, start_line, end_line),
|
|
121
|
+
sink=sink,
|
|
122
|
+
function=qualnames.get(id(func)) if func is not None else None,
|
|
123
|
+
cwe=plugin.cwe,
|
|
124
|
+
sources=(OWN_SOURCE,),
|
|
125
|
+
confidence=match.confidence if match.confidence is not None else plugin.default_confidence,
|
|
126
|
+
evidence=(Evidence(PRODUCER, "rule_match", f"{plugin.name}: {match.message}"),),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _line_snippet(ctx: FileContext, start: int, end: int, max_lines: int = 5) -> str:
|
|
131
|
+
lines = ctx.lines[start - 1 : min(end, start + max_lines - 1)]
|
|
132
|
+
return "\n".join(lines)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _qualnames(tree: ast.Module) -> dict[int, str]:
|
|
136
|
+
"""Map id(function node) -> dotted qualname (`Class.method`, `outer.<locals>.inner`)."""
|
|
137
|
+
names: dict[int, str] = {}
|
|
138
|
+
|
|
139
|
+
def visit(node: ast.AST, prefix: str) -> None:
|
|
140
|
+
for child in ast.iter_child_nodes(node):
|
|
141
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
142
|
+
qual = f"{prefix}{child.name}"
|
|
143
|
+
names[id(child)] = qual
|
|
144
|
+
visit(child, f"{qual}.<locals>.")
|
|
145
|
+
elif isinstance(child, ast.ClassDef):
|
|
146
|
+
visit(child, f"{prefix}{child.name}.")
|
|
147
|
+
elif isinstance(child, ast.Lambda):
|
|
148
|
+
names[id(child)] = f"{prefix}<lambda>"
|
|
149
|
+
visit(child, prefix)
|
|
150
|
+
else:
|
|
151
|
+
visit(child, prefix)
|
|
152
|
+
|
|
153
|
+
visit(tree, "")
|
|
154
|
+
return names
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Inline suppressions: `# hackscan: ignore` or `# hackscan: ignore[RULE, ...]`.
|
|
2
|
+
|
|
3
|
+
The comment may sit on any line of the finding's region (start line for single-line
|
|
4
|
+
calls, closing line for multi-line ones). Suppressed findings are kept, with
|
|
5
|
+
`suppression = "inline:hackscan-ignore"`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import re
|
|
12
|
+
import tokenize
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
|
|
15
|
+
from hackscan.core.models import Evidence, Finding, Status
|
|
16
|
+
from hackscan.plugins.base import FileContext
|
|
17
|
+
|
|
18
|
+
INLINE = "inline:hackscan-ignore"
|
|
19
|
+
_DIRECTIVE_RE = re.compile(r"#\s*hackscan:\s*ignore(?:\[(?P<rules>[^\]]*)\])?", re.IGNORECASE)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def directives(source: str) -> dict[int, frozenset[str] | None]:
|
|
23
|
+
"""Line -> rule ids to ignore (None = all rules). Only real comments count."""
|
|
24
|
+
out: dict[int, frozenset[str] | None] = {}
|
|
25
|
+
try:
|
|
26
|
+
tokens = tokenize.generate_tokens(io.StringIO(source).readline)
|
|
27
|
+
for tok in tokens:
|
|
28
|
+
if tok.type != tokenize.COMMENT:
|
|
29
|
+
continue
|
|
30
|
+
match = _DIRECTIVE_RE.search(tok.string)
|
|
31
|
+
if not match:
|
|
32
|
+
continue
|
|
33
|
+
rules = match.group("rules")
|
|
34
|
+
line = tok.start[0]
|
|
35
|
+
if rules is None:
|
|
36
|
+
out[line] = None
|
|
37
|
+
elif out.get(line, frozenset()) is not None:
|
|
38
|
+
parsed = frozenset(r.strip() for r in rules.split(",") if r.strip())
|
|
39
|
+
out[line] = (out.get(line) or frozenset()) | parsed
|
|
40
|
+
except (tokenize.TokenError, SyntaxError):
|
|
41
|
+
pass # unparseable files have no findings anyway
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def apply_inline_suppressions(findings: list[Finding], ctx: FileContext) -> list[Finding]:
|
|
46
|
+
marks = directives(ctx.source)
|
|
47
|
+
if not marks:
|
|
48
|
+
return findings
|
|
49
|
+
out = []
|
|
50
|
+
for f in findings:
|
|
51
|
+
lines = range(f.location.start_line, f.location.end_line + 1)
|
|
52
|
+
hit = any(
|
|
53
|
+
line in marks and (marks[line] is None or f.rule_id in marks[line]) for line in lines
|
|
54
|
+
)
|
|
55
|
+
if hit and f.status is not Status.SUPPRESSED:
|
|
56
|
+
f = replace(
|
|
57
|
+
f,
|
|
58
|
+
status=Status.SUPPRESSED,
|
|
59
|
+
suppression=INLINE,
|
|
60
|
+
evidence=(*f.evidence, Evidence("inline", "suppression", "Ignored by comment.")),
|
|
61
|
+
)
|
|
62
|
+
out.append(f)
|
|
63
|
+
return out
|