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,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."""