sourcecode 5.2.0__py3-none-any.whl → 5.3.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,362 @@
1
+ """sql_taint.py — does request input reach a spliced SQL statement? (F-BF)
2
+
3
+ SEC-008 reports every MyBatis `${}` in a mapper: `#{x}` binds a parameter, `${x}`
4
+ splices text into the statement, so a request-controlled value can change the
5
+ *structure* of the query. That report is a list of places to review. What a team
6
+ asks next is which of them an attacker can actually reach — *"la diferencia entre
7
+ 16 sitios que revisar y 2 sitios que arreglar hoy"* — and `risk`'s
8
+ `query_construction` axis answered `not_observed` (×1,0) on **all 98 rows** of
9
+ the field's repository, because its propagation ends at Java query-builder calls
10
+ and that repository's SQL lives in mapper XML.
11
+
12
+ This is the bounded join between the two ends the product already resolves:
13
+
14
+ * **sources** — parameters annotated `@RequestParam` / `@PathVariable` /
15
+ `@RequestBody`, the same seeds `risk` already walks from;
16
+ * **sinks** — the `${}` interpolations SEC-008 already indexes, keyed by the
17
+ mapper namespace and statement id they belong to;
18
+ * **the path** — the same bounded, positional call propagation over repository
19
+ Java sources. Not interprocedural taint in the CodeQL sense, and this product
20
+ must not claim to be that.
21
+
22
+ The verdict is three-state and the third state is the load-bearing one:
23
+
24
+ * ``reaches`` — a tainted value is passed to a call whose name is a statement of a
25
+ mapper that splices, with the trace that showed it;
26
+ * ``does_not_reach`` — that statement **is** called in this repository, and no
27
+ call site passes anything derived from an HTTP input. A bounded negative, and
28
+ it is stated only where the call sites were found;
29
+ * ``undecidable`` — no resolvable call site, a namespace this repository does not
30
+ declare, or a path that leaves the sources this walk reads. Declared, never
31
+ guessed: answering `does_not_reach` by approximation would put a confident *no*
32
+ on a route that does leak, which is the one failure mode that would make this
33
+ feature worse than not shipping it (NC-001, NC-008).
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import Any, Optional
41
+
42
+ SCHEMA_VERSION = "sql-taint-v1"
43
+
44
+ REACHES = "reaches"
45
+ DOES_NOT_REACH = "does_not_reach"
46
+ UNDECIDABLE = "undecidable"
47
+
48
+ _MAPPER_TAG = re.compile(r"<mapper\b[^>]*>", re.IGNORECASE)
49
+ _NAMESPACE = re.compile(r"namespace\s*=\s*['\"]([^'\"]+)['\"]", re.IGNORECASE)
50
+ _STATEMENT = re.compile(
51
+ r"<(?P<tag>select|insert|update|delete)\b(?P<attrs>[^>]*)>(?P<body>.*?)</(?P=tag)>",
52
+ re.IGNORECASE | re.DOTALL,
53
+ )
54
+ _ID_ATTR = re.compile(r"\bid\s*=\s*['\"]([^'\"]+)['\"]", re.IGNORECASE)
55
+ _INTERPOLATION = re.compile(r"\$\{\s*([^}]+?)\s*\}")
56
+
57
+ #: Bound on the walk, mirroring the one `risk` already applies: a repository that
58
+ #: would need more than this to answer is answered `undecidable` rather than
59
+ #: answered late.
60
+ _MAX_VISITS = 2000
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class SqlSink:
65
+ """One spliced statement: where it is written, and what it splices."""
66
+
67
+ namespace: str
68
+ statement_id: str
69
+ tag: str
70
+ file: str
71
+ line: int
72
+ interpolations: tuple[str, ...]
73
+
74
+ @property
75
+ def symbol(self) -> str:
76
+ return f"{self.namespace}#{self.statement_id}" if self.namespace else self.statement_id
77
+
78
+ def to_dict(self) -> dict:
79
+ return {
80
+ "namespace": self.namespace,
81
+ "statement_id": self.statement_id,
82
+ "statement": self.tag.lower(),
83
+ "symbol": self.symbol,
84
+ "file": self.file,
85
+ "line": self.line,
86
+ "interpolates": list(self.interpolations),
87
+ }
88
+
89
+
90
+ def _line_of(text: str, index: int) -> int:
91
+ return text.count("\n", 0, index) + 1
92
+
93
+
94
+ def _mapper_files(root: Path, cir: Any) -> list[str]:
95
+ """Every mapper XML under *root*, found the way SEC-008 finds them.
96
+
97
+ The CIR's file list is Java, so a sink family that lives in XML cannot be
98
+ read off it — reading `cir.files` here answered "no mappers" on every real
99
+ repository while the fixtures that injected XML into the list passed. The
100
+ walk is `repository_ir.walk_source_tree` with the same `*Mapper.xml`
101
+ admission `security_config_scan` uses, so this surface and SEC-008 cannot
102
+ disagree about what a mapper is. Anything a caller has already put in
103
+ `cir.files` is honoured too, which keeps a scoped or synthetic IR readable.
104
+ """
105
+ found: list[str] = [
106
+ str(rel) for rel in (getattr(cir, "files", []) or []) if str(rel).endswith(".xml")
107
+ ]
108
+ try:
109
+ from sourcecode.repository_ir import walk_source_tree
110
+
111
+ for path in walk_source_tree(Path(root)):
112
+ if not path.name.endswith("Mapper.xml"):
113
+ continue
114
+ try:
115
+ rel = path.relative_to(Path(root)).as_posix()
116
+ except ValueError:
117
+ continue
118
+ if rel not in found:
119
+ found.append(rel)
120
+ except Exception:
121
+ pass # an unreadable tree yields what the IR already carried
122
+ return found
123
+
124
+
125
+ def mapper_sinks(root: Path, cir: Any) -> list[SqlSink]:
126
+ """Every `${}`-splicing statement in the repository's MyBatis mappers.
127
+
128
+ The same population SEC-008 reports, read here with the two keys the join
129
+ needs and SEC-008 does not publish: the namespace and the statement id.
130
+ """
131
+ out: list[SqlSink] = []
132
+ for name in _mapper_files(root, cir):
133
+ try:
134
+ text = (Path(root) / name).read_text(encoding="utf-8", errors="replace")
135
+ except OSError:
136
+ continue
137
+ tag = _MAPPER_TAG.search(text)
138
+ if not tag:
139
+ continue
140
+ namespace_match = _NAMESPACE.search(tag.group(0))
141
+ namespace = namespace_match.group(1) if namespace_match else ""
142
+ for statement in _STATEMENT.finditer(text):
143
+ body = statement.group("body")
144
+ interpolations = tuple(
145
+ " ".join(m.group(1).split()) for m in _INTERPOLATION.finditer(body)
146
+ )
147
+ if not interpolations:
148
+ continue
149
+ id_match = _ID_ATTR.search(statement.group("attrs"))
150
+ out.append(SqlSink(
151
+ namespace=namespace,
152
+ statement_id=id_match.group(1) if id_match else "",
153
+ tag=statement.group("tag"),
154
+ file=name,
155
+ line=_line_of(text, statement.start()),
156
+ interpolations=interpolations,
157
+ ))
158
+ return out
159
+
160
+
161
+ def _tainted_call_names(methods: list) -> dict[str, list[str]]:
162
+ """Call name → traces, for every call that receives an HTTP-derived value.
163
+
164
+ The walk is `risk`'s, unchanged and imported rather than copied: the seeds,
165
+ the positional argument mapping and the visit bound are one authority, and a
166
+ second copy of them would drift into a second answer to the same question.
167
+ """
168
+ from sourcecode.risk import _call_edges_with_tainted_args, _CALL_RE
169
+
170
+ by_name: dict[str, list] = {}
171
+ for method in methods:
172
+ by_name.setdefault(method.name, []).append(method)
173
+
174
+ reached: dict[str, list[str]] = {}
175
+ queue: list[tuple[Any, frozenset, str]] = [
176
+ (
177
+ method,
178
+ method.http_inputs,
179
+ f"{method.owner}({', '.join(sorted(method.http_inputs))})",
180
+ )
181
+ for method in methods
182
+ if method.http_inputs
183
+ ]
184
+ seen: set[tuple[str, tuple[str, ...]]] = set()
185
+ while queue and len(seen) < _MAX_VISITS:
186
+ method, tainted, path = queue.pop(0)
187
+ key = (method.owner, tuple(sorted(tainted)))
188
+ if key in seen:
189
+ continue
190
+ seen.add(key)
191
+ # Any call in this body that receives a tainted argument is a candidate
192
+ # sink invocation — including calls this repository cannot resolve to a
193
+ # body, which is exactly the mapper-interface case: the method is
194
+ # declared in an interface and implemented by MyBatis at runtime.
195
+ for call in _CALL_RE.finditer(method.body):
196
+ name = call.group(1)
197
+ args = [a.strip() for a in call.group(2).split(",")]
198
+ if not any(
199
+ re.search(rf"\b{re.escape(src)}\b", arg)
200
+ for arg in args for src in tainted
201
+ ):
202
+ continue
203
+ # `path` already ends at this method, so the trace names the call
204
+ # rather than repeating the owner it is inside.
205
+ reached.setdefault(name, []).append(f"{path} calls {name}(…)")
206
+ for target, mapped in _call_edges_with_tainted_args(method, tainted, by_name):
207
+ queue.append((target, mapped, f"{path} -> {target.owner}"))
208
+ return reached
209
+
210
+
211
+ def _called_names(methods: list) -> set[str]:
212
+ """Every call name invoked anywhere in the repository's sources.
213
+
214
+ What separates *"called, never with request input"* from *"never called
215
+ here"* — the difference between a bounded negative and an unknown.
216
+ """
217
+ from sourcecode.risk import _CALL_RE
218
+
219
+ names: set[str] = set()
220
+ for method in methods:
221
+ for call in _CALL_RE.finditer(method.body):
222
+ names.add(call.group(1))
223
+ return names
224
+
225
+
226
+ def build_sql_taint(root: Path, cir: Any) -> dict:
227
+ """Which spliced statements request input reaches, and which are undecided."""
228
+ from sourcecode.risk import _has_http_input_parameter, _java_methods
229
+
230
+ root = Path(root)
231
+ sinks = mapper_sinks(root, cir)
232
+ payload: dict = {
233
+ "schema_version": SCHEMA_VERSION,
234
+ "sinks_total": len(sinks),
235
+ "verdict_lattice": [REACHES, DOES_NOT_REACH, UNDECIDABLE],
236
+ "basis": (
237
+ "Sources are parameters annotated @RequestParam/@PathVariable/"
238
+ "@RequestBody; sinks are MyBatis `${}` interpolations (the SEC-008 "
239
+ "population, keyed by namespace and statement id); the path is "
240
+ "bounded positional propagation over repository Java sources. This is "
241
+ "NOT interprocedural taint analysis, and `does_not_reach` is stated "
242
+ "only where the call sites were found — everything else is "
243
+ "`undecidable`, never a confident no."
244
+ ),
245
+ }
246
+ if not sinks:
247
+ payload.update({"answered": False, "sinks": [], "summary": {}})
248
+ payload["statement"] = (
249
+ "No MyBatis mapper in this repository splices a value into its SQL, so "
250
+ "there is no sink of this shape to reach. That is a fact about this "
251
+ "sink family only."
252
+ )
253
+ return payload
254
+
255
+ if not _has_http_input_parameter(root, cir):
256
+ # The same equivalence C3-88 established: with no annotated HTTP input
257
+ # anywhere, the walk's result is fixed whatever the method universe holds,
258
+ # so the parse is not paid. Every sink is undecided rather than clean —
259
+ # input can still arrive through a shape this walk does not read.
260
+ payload.update({
261
+ "answered": True,
262
+ "sinks": [
263
+ {**sink.to_dict(), "verdict": UNDECIDABLE,
264
+ "why": "no annotated HTTP input parameter exists in this repository, "
265
+ "so no source of this shape can be traced to this statement"}
266
+ for sink in sinks
267
+ ],
268
+ "summary": {REACHES: 0, DOES_NOT_REACH: 0, UNDECIDABLE: len(sinks)},
269
+ })
270
+ return payload
271
+
272
+ methods = _java_methods(root, cir)
273
+ tainted_calls = _tainted_call_names(methods)
274
+ called = _called_names(methods)
275
+ declared_namespaces = {
276
+ s for s in _class_symbols(cir)
277
+ }
278
+
279
+ rows: list[dict] = []
280
+ counts = {REACHES: 0, DOES_NOT_REACH: 0, UNDECIDABLE: 0}
281
+ for sink in sinks:
282
+ row = sink.to_dict()
283
+ traces = tainted_calls.get(sink.statement_id) if sink.statement_id else None
284
+ if traces:
285
+ row["verdict"] = REACHES
286
+ row["trace"] = traces[0]
287
+ row["trace_count"] = len(traces)
288
+ row["why"] = (
289
+ "an annotated HTTP input parameter is passed, through bounded "
290
+ "positional propagation, to a call named for this statement"
291
+ )
292
+ elif not sink.statement_id:
293
+ row["verdict"] = UNDECIDABLE
294
+ row["why"] = (
295
+ "the statement declares no id, so no call site can be matched to it"
296
+ )
297
+ elif sink.statement_id in called:
298
+ row["verdict"] = DOES_NOT_REACH
299
+ row["why"] = (
300
+ "this statement is called in repository sources and no call site "
301
+ "passes a value derived from an annotated HTTP input. Bounded: "
302
+ "input arriving by a shape this walk does not read (reflection, "
303
+ "dynamic mapper invocation, another repository) is not covered"
304
+ )
305
+ else:
306
+ row["verdict"] = UNDECIDABLE
307
+ row["why"] = (
308
+ "no call site for this statement was found in repository sources — "
309
+ "it may be invoked dynamically, from another module, or by a name "
310
+ "this walk cannot resolve"
311
+ + (
312
+ ""
313
+ if sink.namespace in declared_namespaces
314
+ else "; the mapper namespace is not declared in this repository"
315
+ )
316
+ )
317
+ counts[row["verdict"]] += 1
318
+ rows.append(row)
319
+
320
+ payload.update({"answered": True, "sinks": rows, "summary": counts})
321
+ payload["statement"] = (
322
+ f"{counts[REACHES]} spliced statement(s) are reached by request input, "
323
+ f"{counts[DOES_NOT_REACH]} are called without it, and "
324
+ f"{counts[UNDECIDABLE]} could not be decided here."
325
+ )
326
+ return payload
327
+
328
+
329
+ def _class_symbols(cir: Any) -> set[str]:
330
+ symbols = getattr(cir, "symbols", None)
331
+ raw = symbols.keys() if isinstance(symbols, dict) else (symbols or [])
332
+ out: set[str] = set()
333
+ for item in raw:
334
+ if isinstance(item, str):
335
+ fqn = item
336
+ elif isinstance(item, dict):
337
+ fqn = str(item.get("fqn") or item.get("id") or "")
338
+ else:
339
+ fqn = str(getattr(item, "fqn", "") or getattr(item, "id", ""))
340
+ if fqn and "#" not in fqn:
341
+ out.add(fqn)
342
+ return out
343
+
344
+
345
+ def reached_files(payload: Optional[dict]) -> dict[str, str]:
346
+ """mapper file → the trace that reached it, for the `risk` axis to read.
347
+
348
+ Keyed on the file because that is the symbol SEC-008 records a finding
349
+ against, so the join needs no second identity.
350
+ """
351
+ if not payload or not payload.get("answered"):
352
+ return {}
353
+ out: dict[str, str] = {}
354
+ for row in payload.get("sinks") or []:
355
+ if row.get("verdict") != REACHES:
356
+ continue
357
+ out.setdefault(
358
+ row["file"],
359
+ f"{row['trace']} -> {row['symbol']} splices ${{{row['interpolates'][0]}}}"
360
+ if row.get("interpolates") else row.get("trace", ""),
361
+ )
362
+ return out