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 ADDED
@@ -0,0 +1,3 @@
1
+ """HackScan: Python SAST orchestrator and verifier."""
2
+
3
+ __version__ = "0.1.0"
@@ -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