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,127 @@
|
|
|
1
|
+
"""SQL injection: non-constant SQL reaching a DB execution sink.
|
|
2
|
+
|
|
3
|
+
Candidates:
|
|
4
|
+
- string formatting/concatenation at the sink, when the receiver looks like a DB object
|
|
5
|
+
or the literal parts look like SQL (confidence 70);
|
|
6
|
+
- any other non-constant query (variable, call result, attribute) passed to a DB-looking
|
|
7
|
+
receiver (confidence 40). The taint pass confirms or suppresses these.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
|
|
16
|
+
from hackscan.core.models import Severity
|
|
17
|
+
from hackscan.plugins.base import (
|
|
18
|
+
FileContext,
|
|
19
|
+
Match,
|
|
20
|
+
RulePlugin,
|
|
21
|
+
first_arg,
|
|
22
|
+
has_sql_text,
|
|
23
|
+
is_constant,
|
|
24
|
+
is_string_formatting,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Method names that execute raw SQL on DB-API cursors/connections, Django and pandas.
|
|
28
|
+
SQL_METHODS = frozenset(
|
|
29
|
+
{"execute", "executemany", "executescript", "raw", "read_sql", "read_sql_query"}
|
|
30
|
+
)
|
|
31
|
+
# Fully-qualified functions that take raw SQL text.
|
|
32
|
+
SQL_FUNCTIONS = frozenset(
|
|
33
|
+
{"sqlalchemy.text", "sqlalchemy.sql.text", "pandas.read_sql", "pandas.read_sql_query"}
|
|
34
|
+
)
|
|
35
|
+
DB_MODULES = (
|
|
36
|
+
"sqlite3",
|
|
37
|
+
"psycopg2",
|
|
38
|
+
"psycopg",
|
|
39
|
+
"pymysql",
|
|
40
|
+
"MySQLdb",
|
|
41
|
+
"mysql.connector",
|
|
42
|
+
"cx_Oracle",
|
|
43
|
+
"oracledb",
|
|
44
|
+
"sqlalchemy",
|
|
45
|
+
"django.db",
|
|
46
|
+
"asyncpg",
|
|
47
|
+
"aiosqlite",
|
|
48
|
+
)
|
|
49
|
+
_DB_NAME_RE = re.compile(
|
|
50
|
+
r"(^|_)(cur|curs|cursor|conn|connection|db|database|session|engine|objects|tx|transaction)s?$",
|
|
51
|
+
re.IGNORECASE,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SqlInjection(RulePlugin):
|
|
56
|
+
rule_id = "HS-SQLI-001"
|
|
57
|
+
name = "sql-injection"
|
|
58
|
+
description = "Non-constant SQL is passed to a database execution call."
|
|
59
|
+
severity = Severity.HIGH
|
|
60
|
+
cwe = ("CWE-89",)
|
|
61
|
+
default_confidence = 70
|
|
62
|
+
|
|
63
|
+
def check(self, node: ast.AST, ctx: FileContext) -> Iterable[Match]:
|
|
64
|
+
assert isinstance(node, ast.Call)
|
|
65
|
+
strong = bool(ctx.call_names(node) & SQL_FUNCTIONS)
|
|
66
|
+
method = isinstance(node.func, ast.Attribute) and node.func.attr in SQL_METHODS
|
|
67
|
+
if not (strong or method):
|
|
68
|
+
return
|
|
69
|
+
query = first_arg(node, "sql") or first_arg(node, "query")
|
|
70
|
+
if query is None or is_constant(query):
|
|
71
|
+
return
|
|
72
|
+
if isinstance(query, ast.Call) and ctx.call_names(query) & SQL_FUNCTIONS:
|
|
73
|
+
return # e.g. session.execute(text(...)): reported at the inner text() call
|
|
74
|
+
db_receiver = strong or _is_db_receiver(node.func, ctx)
|
|
75
|
+
|
|
76
|
+
if is_string_formatting(query):
|
|
77
|
+
if db_receiver or has_sql_text(query):
|
|
78
|
+
yield Match(
|
|
79
|
+
node, "SQL query is built with string formatting/concatenation.", arg=query
|
|
80
|
+
)
|
|
81
|
+
return
|
|
82
|
+
if db_receiver:
|
|
83
|
+
yield Match(
|
|
84
|
+
node,
|
|
85
|
+
f"Non-constant query `{_short(ctx.segment(query))}` reaches a SQL sink.",
|
|
86
|
+
confidence=40,
|
|
87
|
+
arg=query,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_db_receiver(func: ast.expr, ctx: FileContext) -> bool:
|
|
92
|
+
if not isinstance(func, ast.Attribute):
|
|
93
|
+
return False
|
|
94
|
+
receiver = func.value
|
|
95
|
+
if _chain_has_db_call(receiver, ctx):
|
|
96
|
+
return True
|
|
97
|
+
# handle = sqlite3.connect(...); handle.execute(...)
|
|
98
|
+
if isinstance(receiver, ast.Name) and any(
|
|
99
|
+
_chain_has_db_call(value, ctx) for value in ctx.binding_values(receiver.id, func)
|
|
100
|
+
):
|
|
101
|
+
return True
|
|
102
|
+
last = receiver
|
|
103
|
+
if isinstance(last, ast.Call):
|
|
104
|
+
last = last.func
|
|
105
|
+
ident = last.attr if isinstance(last, ast.Attribute) else getattr(last, "id", "")
|
|
106
|
+
return bool(_DB_NAME_RE.search(ident))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _chain_has_db_call(expr: ast.AST, ctx: FileContext) -> bool:
|
|
110
|
+
"""sqlite3.connect(...).execute(...), psycopg.connect(...).cursor().execute(...)"""
|
|
111
|
+
while isinstance(expr, (ast.Call, ast.Attribute)):
|
|
112
|
+
if isinstance(expr, ast.Call):
|
|
113
|
+
if any(_in_db_module(n) for n in ctx.call_names(expr)):
|
|
114
|
+
return True
|
|
115
|
+
expr = expr.func
|
|
116
|
+
else:
|
|
117
|
+
expr = expr.value
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _in_db_module(name: str) -> bool:
|
|
122
|
+
return any(name == m or name.startswith(m + ".") for m in DB_MODULES)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _short(text: str, limit: int = 40) -> str:
|
|
126
|
+
text = " ".join(text.split())
|
|
127
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Weak cryptography: MD5/SHA-1 hashing, unless explicitly marked non-security."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from hackscan.core.models import Severity
|
|
9
|
+
from hackscan.plugins.base import FileContext, Match, RulePlugin, first_arg, keyword
|
|
10
|
+
|
|
11
|
+
WEAK_ALGORITHMS = frozenset({"md5", "sha1", "md4", "md2"})
|
|
12
|
+
WEAK_CONSTRUCTORS = {f"hashlib.{alg}": alg for alg in WEAK_ALGORITHMS}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WeakHash(RulePlugin):
|
|
16
|
+
rule_id = "HS-CRYPTO-001"
|
|
17
|
+
name = "weak-hash"
|
|
18
|
+
description = "A cryptographically broken hash (MD5/SHA-1) is used."
|
|
19
|
+
severity = Severity.LOW
|
|
20
|
+
cwe = ("CWE-328",)
|
|
21
|
+
default_confidence = 80
|
|
22
|
+
|
|
23
|
+
def check(self, node: ast.AST, ctx: FileContext) -> Iterable[Match]:
|
|
24
|
+
assert isinstance(node, ast.Call)
|
|
25
|
+
names = ctx.call_names(node)
|
|
26
|
+
confidence = None
|
|
27
|
+
if constructors := sorted(names & WEAK_CONSTRUCTORS.keys()):
|
|
28
|
+
algorithm = WEAK_CONSTRUCTORS[constructors[0]]
|
|
29
|
+
elif "hashlib.new" in names:
|
|
30
|
+
arg = first_arg(node, "name")
|
|
31
|
+
literals = [arg]
|
|
32
|
+
if isinstance(arg, ast.Name):
|
|
33
|
+
# Find a literal algorithm name bound locally (never used to dismiss).
|
|
34
|
+
literals = ctx.assignments_before(arg.id, node)
|
|
35
|
+
confidence = 60
|
|
36
|
+
weak = sorted({_normalize(v.value) for v in literals if _is_str(v)} & WEAK_ALGORITHMS)
|
|
37
|
+
if not weak:
|
|
38
|
+
return
|
|
39
|
+
algorithm = weak[0]
|
|
40
|
+
else:
|
|
41
|
+
return
|
|
42
|
+
flag = keyword(node, "usedforsecurity")
|
|
43
|
+
if isinstance(flag, ast.Constant) and flag.value is False:
|
|
44
|
+
return
|
|
45
|
+
yield Match(node, f"Weak hash algorithm {algorithm.upper()} is used.", confidence)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_str(node: ast.AST | None) -> bool:
|
|
49
|
+
return isinstance(node, ast.Constant) and isinstance(node.value, str)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize(name: str) -> str:
|
|
53
|
+
return name.lower().replace("-", "").replace("_", "")
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Lexical scopes and name bindings, following Python's scoping rules closely enough
|
|
2
|
+
for sink resolution (and, from M2, taint tracking).
|
|
3
|
+
|
|
4
|
+
- Function bodies (def/async def/lambda) and comprehensions are scopes. Decorators,
|
|
5
|
+
default values, annotations and a comprehension's first iterable are evaluated in the
|
|
6
|
+
*enclosing* scope.
|
|
7
|
+
- Class bodies are scopes visible only to code directly in the class body (not to
|
|
8
|
+
methods or comprehensions inside the class).
|
|
9
|
+
- `global` redirects a name to the module scope; `nonlocal` to the enclosing function.
|
|
10
|
+
An assignment expression (`:=`) inside a comprehension binds in the enclosing scope.
|
|
11
|
+
- Bindings are flow-insensitive *may* sets: a scope records every way it binds a name.
|
|
12
|
+
Resolution is recall-oriented: if the binding scope imports the name anywhere
|
|
13
|
+
(`import os` ... `os = fallback`, try/except or conditional imports), the name may be
|
|
14
|
+
that module and resolves to it. Only names bound exclusively by non-import bindings
|
|
15
|
+
(parameters, assignments, loop targets) resolve to "local value" (empty set).
|
|
16
|
+
- Unbound names resolve to themselves (builtins) and, if a `from m import *` is in scope,
|
|
17
|
+
also to `m.<name>`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
|
|
26
|
+
FUNCTION_TYPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)
|
|
27
|
+
COMPREHENSION_TYPES = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
|
|
28
|
+
_NESTED_SCOPES = (*FUNCTION_TYPES, ast.ClassDef, *COMPREHENSION_TYPES)
|
|
29
|
+
|
|
30
|
+
IMPORT = "import"
|
|
31
|
+
LOCAL = "local"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(eq=False)
|
|
35
|
+
class Scope:
|
|
36
|
+
node: ast.AST
|
|
37
|
+
parent: Scope | None
|
|
38
|
+
# name -> set of (kind, import target or None)
|
|
39
|
+
bindings: dict[str, set[tuple[str, str | None]]] = field(default_factory=dict)
|
|
40
|
+
globals_: set[str] = field(default_factory=set)
|
|
41
|
+
nonlocals: set[str] = field(default_factory=set)
|
|
42
|
+
star_imports: list[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_function(self) -> bool:
|
|
46
|
+
return isinstance(self.node, FUNCTION_TYPES)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_comprehension(self) -> bool:
|
|
50
|
+
return isinstance(self.node, COMPREHENSION_TYPES)
|
|
51
|
+
|
|
52
|
+
def bind(self, name: str, kind: str, target: str | None = None) -> None:
|
|
53
|
+
self.bindings.setdefault(name, set()).add((kind, target))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ScopeIndex:
|
|
57
|
+
def __init__(self, tree: ast.Module) -> None:
|
|
58
|
+
self.module = Scope(tree, None)
|
|
59
|
+
self._scope_of: dict[int, Scope] = {}
|
|
60
|
+
self._walrus_targets: set[int] = set()
|
|
61
|
+
self._pending_nonlocal: list[tuple[Scope, str, str, str | None]] = []
|
|
62
|
+
self._visit_body(self.module, tree.body)
|
|
63
|
+
# Enclosing bindings may appear after the nested def, so resolve owners last.
|
|
64
|
+
for scope, name, kind, target in self._pending_nonlocal:
|
|
65
|
+
self._nonlocal_owner(scope, name).bind(name, kind, target)
|
|
66
|
+
|
|
67
|
+
# -- queries -------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def scope_of(self, node: ast.AST) -> Scope:
|
|
70
|
+
"""Scope in which `node` executes."""
|
|
71
|
+
return self._scope_of.get(id(node), self.module)
|
|
72
|
+
|
|
73
|
+
def enclosing_function(self, node: ast.AST) -> ast.AST | None:
|
|
74
|
+
scope: Scope | None = self.scope_of(node)
|
|
75
|
+
while scope is not None and not scope.is_function:
|
|
76
|
+
scope = scope.parent
|
|
77
|
+
return scope.node if scope is not None else None
|
|
78
|
+
|
|
79
|
+
def resolve_name(self, name: str, node: ast.AST) -> frozenset[str]:
|
|
80
|
+
"""All dotted paths `name` may refer to at `node`; empty for local values."""
|
|
81
|
+
star: list[str] = []
|
|
82
|
+
scope: Scope | None = self.scope_of(node)
|
|
83
|
+
first = True
|
|
84
|
+
while scope is not None:
|
|
85
|
+
star.extend(scope.star_imports)
|
|
86
|
+
if isinstance(scope.node, ast.ClassDef) and not first:
|
|
87
|
+
scope = scope.parent # class scope invisible to nested scopes
|
|
88
|
+
continue
|
|
89
|
+
first = False
|
|
90
|
+
if name in scope.globals_ and scope is not self.module:
|
|
91
|
+
star.extend(self.module.star_imports)
|
|
92
|
+
return self._resolve_in(self.module, name, star)
|
|
93
|
+
if name in scope.nonlocals:
|
|
94
|
+
scope = scope.parent
|
|
95
|
+
while scope is not None and not scope.is_function:
|
|
96
|
+
scope = scope.parent
|
|
97
|
+
continue
|
|
98
|
+
if name in scope.bindings:
|
|
99
|
+
return self._resolve_in(scope, name, star)
|
|
100
|
+
scope = scope.parent
|
|
101
|
+
return frozenset({name, *(f"{m}.{name}" for m in star)})
|
|
102
|
+
|
|
103
|
+
def _resolve_in(self, scope: Scope, name: str, star: list[str]) -> frozenset[str]:
|
|
104
|
+
kinds = scope.bindings.get(name)
|
|
105
|
+
if not kinds:
|
|
106
|
+
return frozenset({name, *(f"{m}.{name}" for m in star)})
|
|
107
|
+
imports = {t for k, t in kinds if k == IMPORT and t}
|
|
108
|
+
# A local binding may be conditional, so a star import can still supply the name.
|
|
109
|
+
return frozenset(imports | {f"{m}.{name}" for m in star})
|
|
110
|
+
|
|
111
|
+
# -- construction ----------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def _visit_body(self, scope: Scope, body: list[ast.AST]) -> None:
|
|
114
|
+
for stmt in body:
|
|
115
|
+
for node in _same_scope_nodes(stmt):
|
|
116
|
+
if isinstance(node, ast.Global):
|
|
117
|
+
scope.globals_.update(node.names)
|
|
118
|
+
elif isinstance(node, ast.Nonlocal):
|
|
119
|
+
scope.nonlocals.update(node.names)
|
|
120
|
+
for stmt in body:
|
|
121
|
+
self._visit(stmt, scope)
|
|
122
|
+
|
|
123
|
+
def _visit(self, node: ast.AST, scope: Scope) -> None:
|
|
124
|
+
self._scope_of[id(node)] = scope
|
|
125
|
+
if isinstance(node, COMPREHENSION_TYPES):
|
|
126
|
+
self._enter_comprehension(node, scope)
|
|
127
|
+
return
|
|
128
|
+
if isinstance(node, _NESTED_SCOPES):
|
|
129
|
+
self._enter_nested(node, scope)
|
|
130
|
+
return
|
|
131
|
+
self._bind(node, scope)
|
|
132
|
+
for child in ast.iter_child_nodes(node):
|
|
133
|
+
self._visit(child, scope)
|
|
134
|
+
|
|
135
|
+
def _enter_nested(self, node: ast.AST, scope: Scope) -> None:
|
|
136
|
+
outer: list[ast.AST] = list(getattr(node, "decorator_list", []))
|
|
137
|
+
if isinstance(node, FUNCTION_TYPES):
|
|
138
|
+
outer += list(node.args.defaults)
|
|
139
|
+
outer += [d for d in node.args.kw_defaults if d is not None]
|
|
140
|
+
if not isinstance(node, ast.Lambda):
|
|
141
|
+
outer += _annotations(node)
|
|
142
|
+
if isinstance(node, ast.ClassDef):
|
|
143
|
+
outer += [*node.bases, *(k.value for k in node.keywords)]
|
|
144
|
+
for part in outer:
|
|
145
|
+
self._visit(part, scope)
|
|
146
|
+
if not isinstance(node, ast.Lambda):
|
|
147
|
+
self._bind_store(scope, node.name) # type: ignore[attr-defined]
|
|
148
|
+
|
|
149
|
+
inner = Scope(node, scope)
|
|
150
|
+
if isinstance(node, FUNCTION_TYPES):
|
|
151
|
+
for arg in _all_args(node.args):
|
|
152
|
+
inner.bind(arg, LOCAL)
|
|
153
|
+
body = [node.body] if isinstance(node, ast.Lambda) else node.body
|
|
154
|
+
else:
|
|
155
|
+
body = node.body # type: ignore[attr-defined]
|
|
156
|
+
self._visit_body(inner, body)
|
|
157
|
+
|
|
158
|
+
def _enter_comprehension(self, node: ast.AST, scope: Scope) -> None:
|
|
159
|
+
generators: list[ast.comprehension] = node.generators # type: ignore[attr-defined]
|
|
160
|
+
self._visit(generators[0].iter, scope) # first iterable: enclosing scope
|
|
161
|
+
inner = Scope(node, scope)
|
|
162
|
+
for gen in generators:
|
|
163
|
+
for target in ast.walk(gen.target):
|
|
164
|
+
if isinstance(target, ast.Name):
|
|
165
|
+
inner.bind(target.id, LOCAL)
|
|
166
|
+
rest: list[ast.AST] = []
|
|
167
|
+
for i, gen in enumerate(generators):
|
|
168
|
+
rest.append(gen.target)
|
|
169
|
+
if i:
|
|
170
|
+
rest.append(gen.iter)
|
|
171
|
+
rest.extend(gen.ifs)
|
|
172
|
+
if isinstance(node, ast.DictComp):
|
|
173
|
+
rest += [node.key, node.value]
|
|
174
|
+
else:
|
|
175
|
+
rest.append(node.elt) # type: ignore[attr-defined]
|
|
176
|
+
for part in rest:
|
|
177
|
+
self._visit(part, inner)
|
|
178
|
+
|
|
179
|
+
def _bind(self, node: ast.AST, scope: Scope) -> None:
|
|
180
|
+
if isinstance(node, ast.Import):
|
|
181
|
+
for a in node.names:
|
|
182
|
+
if a.asname:
|
|
183
|
+
self._bind_import(scope, a.asname, a.name)
|
|
184
|
+
else:
|
|
185
|
+
top = a.name.split(".")[0]
|
|
186
|
+
self._bind_import(scope, top, top)
|
|
187
|
+
elif isinstance(node, ast.ImportFrom):
|
|
188
|
+
if node.module and node.level == 0:
|
|
189
|
+
for a in node.names:
|
|
190
|
+
if a.name == "*":
|
|
191
|
+
scope.star_imports.append(node.module)
|
|
192
|
+
else:
|
|
193
|
+
self._bind_import(scope, a.asname or a.name, f"{node.module}.{a.name}")
|
|
194
|
+
else: # relative import: project-local module, unknown to us
|
|
195
|
+
for a in node.names:
|
|
196
|
+
if a.name != "*":
|
|
197
|
+
self._bind_store(scope, a.asname or a.name)
|
|
198
|
+
elif isinstance(node, ast.NamedExpr):
|
|
199
|
+
target_scope = scope
|
|
200
|
+
while target_scope.is_comprehension and target_scope.parent is not None:
|
|
201
|
+
target_scope = target_scope.parent
|
|
202
|
+
self._bind_store(target_scope, node.target.id)
|
|
203
|
+
self._walrus_targets.add(id(node.target))
|
|
204
|
+
elif isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
205
|
+
if id(node) not in self._walrus_targets:
|
|
206
|
+
self._bind_store(scope, node.id)
|
|
207
|
+
elif isinstance(node, ast.ExceptHandler) and node.name:
|
|
208
|
+
self._bind_store(scope, node.name)
|
|
209
|
+
|
|
210
|
+
def _owner(self, scope: Scope, name: str) -> Scope:
|
|
211
|
+
"""Scope that actually receives a binding of `name` made in `scope`."""
|
|
212
|
+
if name in scope.globals_:
|
|
213
|
+
return self.module
|
|
214
|
+
return scope
|
|
215
|
+
|
|
216
|
+
def _nonlocal_owner(self, scope: Scope, name: str) -> Scope:
|
|
217
|
+
parent = scope.parent
|
|
218
|
+
fallback = None
|
|
219
|
+
while parent is not None:
|
|
220
|
+
if parent.is_function:
|
|
221
|
+
if name in parent.bindings and name not in parent.nonlocals:
|
|
222
|
+
return parent
|
|
223
|
+
fallback = fallback or parent
|
|
224
|
+
parent = parent.parent
|
|
225
|
+
return fallback or scope
|
|
226
|
+
|
|
227
|
+
def _bind_store(self, scope: Scope, name: str) -> None:
|
|
228
|
+
self._add(scope, name, LOCAL, None)
|
|
229
|
+
|
|
230
|
+
def _bind_import(self, scope: Scope, name: str, target: str) -> None:
|
|
231
|
+
self._add(scope, name, IMPORT, target)
|
|
232
|
+
|
|
233
|
+
def _add(self, scope: Scope, name: str, kind: str, target: str | None) -> None:
|
|
234
|
+
if name in scope.nonlocals and name not in scope.globals_:
|
|
235
|
+
self._pending_nonlocal.append((scope, name, kind, target))
|
|
236
|
+
else:
|
|
237
|
+
self._owner(scope, name).bind(name, kind, target)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _same_scope_nodes(node: ast.AST) -> Iterator[ast.AST]:
|
|
241
|
+
"""`node` and its descendants, not descending into nested scopes (even if `node` is one)."""
|
|
242
|
+
stack = [node]
|
|
243
|
+
while stack:
|
|
244
|
+
current = stack.pop()
|
|
245
|
+
yield current
|
|
246
|
+
if isinstance(current, _NESTED_SCOPES):
|
|
247
|
+
continue
|
|
248
|
+
stack.extend(ast.iter_child_nodes(current))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _all_args(args: ast.arguments) -> list[str]:
|
|
252
|
+
names = [a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)]
|
|
253
|
+
names += [a.arg for a in (args.vararg, args.kwarg) if a is not None]
|
|
254
|
+
return names
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _annotations(func: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.AST]:
|
|
258
|
+
args = func.args
|
|
259
|
+
every = (*args.posonlyargs, *args.args, *args.kwonlyargs, args.vararg, args.kwarg)
|
|
260
|
+
anns: list[ast.AST] = [a.annotation for a in every if a is not None and a.annotation]
|
|
261
|
+
if func.returns is not None:
|
|
262
|
+
anns.append(func.returns)
|
|
263
|
+
return anns
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""SARIF 2.1.0 export."""
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""SARIF 2.1.0 export (validated against the OASIS schema in tests).
|
|
2
|
+
|
|
3
|
+
- One run, tool driver `HackScan` with a `reportingDescriptor` per rule that occurs.
|
|
4
|
+
- Every finding is emitted, including suppressed ones (with `suppressions[]`), so code
|
|
5
|
+
scanning platforms can show them as dismissed rather than losing them.
|
|
6
|
+
- `partialFingerprints["hackscan/v1"]` carries the stable finding id. (GitHub code
|
|
7
|
+
scanning only reads `primaryLocationLineHash`, which `upload-sarif` computes itself.)
|
|
8
|
+
- Artifact URIs are relative to the repository root when the scan root is inside a git
|
|
9
|
+
work tree (GitHub resolves them that way), otherwise to the scan root; URI-escaped.
|
|
10
|
+
- GitHub does not document support for `suppressions`, so a suppressed result would
|
|
11
|
+
still become an alert there: pass `omit_suppressed=True` (`--sarif-omit-suppressed`)
|
|
12
|
+
for uploads, keep them (default) for audit trails.
|
|
13
|
+
- Columns are Unicode code points (`columnKind`), matching the engine's char columns.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
from urllib.parse import quote
|
|
22
|
+
|
|
23
|
+
from hackscan import __version__
|
|
24
|
+
from hackscan.core.models import Finding, Severity, Status
|
|
25
|
+
from hackscan.core.pipeline import ScanResult
|
|
26
|
+
from hackscan.plugins.base import RulePlugin
|
|
27
|
+
|
|
28
|
+
SCHEMA_URI = "https://json.schemastore.org/sarif-2.1.0.json"
|
|
29
|
+
INFORMATION_URI = "https://pypi.org/project/hackscan/"
|
|
30
|
+
FINGERPRINT_KEY = "hackscan/v1"
|
|
31
|
+
SRCROOT = "%SRCROOT%"
|
|
32
|
+
|
|
33
|
+
LEVEL = {
|
|
34
|
+
Severity.CRITICAL: "error",
|
|
35
|
+
Severity.HIGH: "error",
|
|
36
|
+
Severity.MEDIUM: "warning",
|
|
37
|
+
Severity.LOW: "note",
|
|
38
|
+
}
|
|
39
|
+
# GitHub code scanning reads `security-severity` (CVSS-like) to bucket alerts.
|
|
40
|
+
SECURITY_SEVERITY = {
|
|
41
|
+
Severity.CRITICAL: "9.5",
|
|
42
|
+
Severity.HIGH: "8.0",
|
|
43
|
+
Severity.MEDIUM: "5.5",
|
|
44
|
+
Severity.LOW: "3.0",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def export_sarif(
|
|
49
|
+
result: ScanResult, plugins: Iterable[RulePlugin] = (), *, omit_suppressed: bool = False
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
by_id = {p.rule_id: p for p in plugins}
|
|
52
|
+
findings = sorted(result.findings, key=Finding.sort_key)
|
|
53
|
+
if omit_suppressed:
|
|
54
|
+
findings = [f for f in findings if f.status is not Status.SUPPRESSED]
|
|
55
|
+
base = repository_root(result.root) or result.root.resolve()
|
|
56
|
+
prefix = result.root.resolve().relative_to(base).as_posix()
|
|
57
|
+
prefix = "" if prefix == "." else prefix + "/"
|
|
58
|
+
rule_ids = sorted({f.rule_id for f in findings})
|
|
59
|
+
rule_index = {rule_id: i for i, rule_id in enumerate(rule_ids)}
|
|
60
|
+
rules = [_rule(rule_id, by_id.get(rule_id), findings) for rule_id in rule_ids]
|
|
61
|
+
notifications = [{"level": "error", "message": {"text": e}} for e in result.errors] + [
|
|
62
|
+
{"level": "warning", "message": {"text": w}} for w in result.warnings
|
|
63
|
+
]
|
|
64
|
+
return {
|
|
65
|
+
"$schema": SCHEMA_URI,
|
|
66
|
+
"version": "2.1.0",
|
|
67
|
+
"runs": [
|
|
68
|
+
{
|
|
69
|
+
"tool": {
|
|
70
|
+
"driver": {
|
|
71
|
+
"name": "HackScan",
|
|
72
|
+
"version": __version__,
|
|
73
|
+
"semanticVersion": __version__,
|
|
74
|
+
"informationUri": INFORMATION_URI,
|
|
75
|
+
"rules": rules,
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"originalUriBaseIds": {SRCROOT: {"uri": base.as_uri() + "/"}},
|
|
79
|
+
"columnKind": "unicodeCodePoints",
|
|
80
|
+
"invocations": [
|
|
81
|
+
{
|
|
82
|
+
# False when files or requested reports could not be analyzed:
|
|
83
|
+
# results are incomplete (errors are listed as notifications).
|
|
84
|
+
"executionSuccessful": not result.errors,
|
|
85
|
+
"toolExecutionNotifications": notifications,
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
"results": [_result(f, rule_index[f.rule_id], prefix) for f in findings],
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _rule(rule_id: str, plugin: RulePlugin | None, findings: list[Finding]) -> dict[str, Any]:
|
|
95
|
+
sample = next(f for f in findings if f.rule_id == rule_id)
|
|
96
|
+
severity = plugin.severity if plugin is not None else sample.severity
|
|
97
|
+
cwes = plugin.cwe if plugin is not None else sample.cwe
|
|
98
|
+
name = plugin.name if plugin is not None else rule_id.split(":", 1)[-1]
|
|
99
|
+
description = plugin.description if plugin is not None else sample.message
|
|
100
|
+
tags = ["security", *(f"external/cwe/{c.lower()}" for c in cwes)]
|
|
101
|
+
return {
|
|
102
|
+
"id": rule_id,
|
|
103
|
+
"name": _pascal(name),
|
|
104
|
+
"shortDescription": {"text": description[:200]},
|
|
105
|
+
"fullDescription": {"text": description},
|
|
106
|
+
"defaultConfiguration": {"level": LEVEL[severity]},
|
|
107
|
+
"help": {"text": f"{description} Vulnerability class: {sample.vuln_class}."},
|
|
108
|
+
"properties": {
|
|
109
|
+
"tags": tags,
|
|
110
|
+
"security-severity": SECURITY_SEVERITY[severity],
|
|
111
|
+
"precision": "high" if plugin is not None else "medium",
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _result(f: Finding, index: int, prefix: str) -> dict[str, Any]:
|
|
117
|
+
uri = _uri(prefix + f.location.path)
|
|
118
|
+
out: dict[str, Any] = {
|
|
119
|
+
"ruleId": f.rule_id,
|
|
120
|
+
"ruleIndex": index,
|
|
121
|
+
"level": LEVEL[f.severity],
|
|
122
|
+
"message": {"text": f.message},
|
|
123
|
+
"locations": [{"physicalLocation": _physical(f, uri)}],
|
|
124
|
+
"partialFingerprints": {FINGERPRINT_KEY: f.id},
|
|
125
|
+
"properties": {
|
|
126
|
+
"severity": f.severity.value,
|
|
127
|
+
"security-severity": SECURITY_SEVERITY[f.severity],
|
|
128
|
+
"confidence": f.confidence,
|
|
129
|
+
"status": f.status.value,
|
|
130
|
+
"vulnClass": f.vuln_class,
|
|
131
|
+
"cwe": list(f.cwe),
|
|
132
|
+
"sources": list(f.sources),
|
|
133
|
+
"relatedRules": list(f.related_rules),
|
|
134
|
+
"function": f.function,
|
|
135
|
+
"evidence": [
|
|
136
|
+
{"producer": e.producer, "kind": e.kind, "message": e.message} for e in f.evidence
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
if f.status is Status.SUPPRESSED:
|
|
141
|
+
out["suppressions"] = [
|
|
142
|
+
{
|
|
143
|
+
"kind": "inSource" if (f.suppression or "").startswith("inline:") else "external",
|
|
144
|
+
"status": "accepted",
|
|
145
|
+
"justification": f.suppression or "",
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
if f.fix is not None:
|
|
149
|
+
out["fixes"] = [
|
|
150
|
+
{
|
|
151
|
+
"description": {"text": f.fix.description},
|
|
152
|
+
"artifactChanges": [
|
|
153
|
+
{
|
|
154
|
+
"artifactLocation": {"uri": uri, "uriBaseId": SRCROOT},
|
|
155
|
+
"replacements": [
|
|
156
|
+
{
|
|
157
|
+
"deletedRegion": _region(edit.region),
|
|
158
|
+
"insertedContent": {"text": edit.replacement},
|
|
159
|
+
}
|
|
160
|
+
for edit in f.fix.edits
|
|
161
|
+
],
|
|
162
|
+
}
|
|
163
|
+
],
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _physical(f: Finding, uri: str) -> dict[str, Any]:
|
|
170
|
+
region = _region(f.location)
|
|
171
|
+
if f.snippet:
|
|
172
|
+
region["snippet"] = {"text": f.snippet}
|
|
173
|
+
return {
|
|
174
|
+
"artifactLocation": {"uri": uri, "uriBaseId": SRCROOT},
|
|
175
|
+
"region": region,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _region(r) -> dict[str, Any]:
|
|
180
|
+
region: dict[str, Any] = {
|
|
181
|
+
"startLine": r.start_line,
|
|
182
|
+
"startColumn": r.start_column,
|
|
183
|
+
"endLine": r.end_line,
|
|
184
|
+
}
|
|
185
|
+
if r.end_column is not None:
|
|
186
|
+
region["endColumn"] = r.end_column
|
|
187
|
+
return region
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _uri(path: str) -> str:
|
|
191
|
+
return quote(path, safe="/-_.~")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def repository_root(start: Path) -> Path | None:
|
|
195
|
+
"""Nearest enclosing directory containing `.git` (a work tree), if any."""
|
|
196
|
+
current = start.resolve()
|
|
197
|
+
for candidate in (current, *current.parents):
|
|
198
|
+
if (candidate / ".git").exists():
|
|
199
|
+
return candidate
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _pascal(name: str) -> str:
|
|
204
|
+
parts = [p for p in name.replace("_", "-").replace(".", "-").replace("/", "-").split("-") if p]
|
|
205
|
+
return "".join(p[:1].upper() + p[1:] for p in parts) or "Rule"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Taint models: sources and sanitizers."""
|