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.
- sourcecode/__init__.py +1 -1
- sourcecode/cache_model.py +1 -0
- sourcecode/cli.py +257 -5
- sourcecode/consumer_join.py +357 -0
- sourcecode/environment_resolution.py +200 -12
- sourcecode/format_contract.py +1 -0
- sourcecode/non_coverage.py +12 -8
- sourcecode/perf.py +15 -1
- sourcecode/remedies.py +51 -1
- sourcecode/risk.py +92 -2
- sourcecode/spring_findings.py +39 -0
- sourcecode/spring_impact.py +10 -0
- sourcecode/sql_taint.py +362 -0
- sourcecode/timeline.py +459 -0
- sourcecode/verify_rules.py +9 -0
- sourcecode/waivers.py +384 -0
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/METADATA +52 -14
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/RECORD +21 -17
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/WHEEL +0 -0
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/sql_taint.py
ADDED
|
@@ -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
|