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/remedies.py
CHANGED
|
@@ -63,11 +63,19 @@ class Remedy:
|
|
|
63
63
|
#: the read-only form of a remedy that writes is the same question asked under
|
|
64
64
|
#: a constraint, and leaving the reader to assemble it is the C4 shape (C4-13).
|
|
65
65
|
also: tuple = ()
|
|
66
|
+
#: The value the option takes, as a placeholder the reader replaces. Separate
|
|
67
|
+
#: from `option` because the battery checks options against the CLI's own
|
|
68
|
+
#: parameter names, and `--profile-from` with no value is a pointer to an
|
|
69
|
+
#: invocation that errors out — the exact defect this registry exists to
|
|
70
|
+
#: prevent.
|
|
71
|
+
argument: str = ""
|
|
66
72
|
|
|
67
73
|
@property
|
|
68
74
|
def invocation(self) -> str:
|
|
69
75
|
return " ".join(
|
|
70
|
-
part for part in
|
|
76
|
+
part for part in
|
|
77
|
+
("ask", self.command, ".", self.option, self.argument, *self.also)
|
|
78
|
+
if part
|
|
71
79
|
)
|
|
72
80
|
|
|
73
81
|
def to_dict(self, root=None) -> dict:
|
|
@@ -107,6 +115,48 @@ REMEDIES: "dict[str, Remedy]" = {
|
|
|
107
115
|
),
|
|
108
116
|
headline="which profile set actually runs",
|
|
109
117
|
),
|
|
118
|
+
Remedy(
|
|
119
|
+
key="which_profile_set_the_deployment_decides",
|
|
120
|
+
command="posture",
|
|
121
|
+
option="--profile-from",
|
|
122
|
+
argument="<file>",
|
|
123
|
+
answers=(
|
|
124
|
+
"the artefact that decides the profile set lives outside the code; "
|
|
125
|
+
"name it — a Kubernetes manifest, compose file, deployment plan, "
|
|
126
|
+
"Dockerfile, dotenv or launch script — and every answer is given under "
|
|
127
|
+
"the profile set it sets, with the file:line that decided it. An "
|
|
128
|
+
"artefact that decides nothing leaves this repository's own verdict "
|
|
129
|
+
"exactly as it was"
|
|
130
|
+
),
|
|
131
|
+
headline="the deployment's own profile set",
|
|
132
|
+
),
|
|
133
|
+
Remedy(
|
|
134
|
+
key="who_calls_the_routes_this_repository_serves",
|
|
135
|
+
command="endpoints",
|
|
136
|
+
option="--consumer",
|
|
137
|
+
argument="<repo>",
|
|
138
|
+
answers=(
|
|
139
|
+
"which routes anybody calls is decided by the consumers, and they are "
|
|
140
|
+
"other repositories; name them and the census becomes a "
|
|
141
|
+
"surface-reduction plan — of the routes a readable rule leaves open, "
|
|
142
|
+
"the ones a consumer calls are work and the ones nobody calls are "
|
|
143
|
+
"surface to delete rather than guard"
|
|
144
|
+
),
|
|
145
|
+
headline="which open routes anybody calls",
|
|
146
|
+
),
|
|
147
|
+
Remedy(
|
|
148
|
+
key="since_when_has_it_been_exposed",
|
|
149
|
+
command="timeline",
|
|
150
|
+
option="--since",
|
|
151
|
+
argument="<ref>",
|
|
152
|
+
answers=(
|
|
153
|
+
"a finding without a date is an argument and a finding with one is a "
|
|
154
|
+
"work item; to walk the commit series and publish where these numbers "
|
|
155
|
+
"moved, with the commit each transition was observed at and the window "
|
|
156
|
+
"it could have happened in"
|
|
157
|
+
),
|
|
158
|
+
headline="since when, and in which commit window",
|
|
159
|
+
),
|
|
110
160
|
Remedy(
|
|
111
161
|
key="no_data_labels_declared",
|
|
112
162
|
command="data-exposure",
|
sourcecode/risk.py
CHANGED
|
@@ -93,6 +93,13 @@ _WRITE_FACTOR = {"writes": 1.8, "reads": 1.0, "unknown": 1.0}
|
|
|
93
93
|
#: never a claim that the query is safely parameterised — a fact this module does
|
|
94
94
|
#: not have.
|
|
95
95
|
_QUERY_FACTOR = {
|
|
96
|
+
# F-BF: request input reaches a MyBatis `${}` interpolation — the value is
|
|
97
|
+
# spliced into the statement, so it changes the query's structure. Weighted
|
|
98
|
+
# the same as the Java-concatenation case on purpose: both mean "request
|
|
99
|
+
# input decides the shape of a query", and the distinction between them is
|
|
100
|
+
# the evidence, not the severity. A second severity would be a claim this
|
|
101
|
+
# module has no measurement for.
|
|
102
|
+
"http_input_reaches_sql_interpolation": 2.2,
|
|
96
103
|
"http_input_concatenated": 2.2,
|
|
97
104
|
"concatenated": 1.5,
|
|
98
105
|
"not_observed": 1.0,
|
|
@@ -368,7 +375,18 @@ def _has_http_input_parameter(root: Path, cir: "CanonicalRepositoryIR") -> bool:
|
|
|
368
375
|
return False
|
|
369
376
|
|
|
370
377
|
|
|
378
|
+
#: Where the method parse is remembered on the CIR it was taken from. Two callers
|
|
379
|
+
#: now need it in one run — the query-sink walk and F-BF's spliced-SQL join — and
|
|
380
|
+
#: this parse is the expensive half of both (measured: 12,7 s of a 16 s run on
|
|
381
|
+
#: jobrunr). Keyed on the CIR object, so a second CIR re-parses and a scoped one
|
|
382
|
+
#: never serves a repository-wide question (C3-94).
|
|
383
|
+
_METHODS_CACHE_ATTR = "_ask_java_methods_cache"
|
|
384
|
+
|
|
385
|
+
|
|
371
386
|
def _java_methods(root: Path, cir: "CanonicalRepositoryIR") -> list[_MethodSource]:
|
|
387
|
+
cached = getattr(cir, _METHODS_CACHE_ATTR, None)
|
|
388
|
+
if isinstance(cached, list):
|
|
389
|
+
return cached
|
|
372
390
|
methods: list[_MethodSource] = []
|
|
373
391
|
for rel in getattr(cir, "files", []) or []:
|
|
374
392
|
if not str(rel).endswith(".java"):
|
|
@@ -401,6 +419,10 @@ def _java_methods(root: Path, cir: "CanonicalRepositoryIR") -> list[_MethodSourc
|
|
|
401
419
|
body=text[body_start + 1:body_end],
|
|
402
420
|
file=str(rel),
|
|
403
421
|
))
|
|
422
|
+
try:
|
|
423
|
+
object.__setattr__(cir, _METHODS_CACHE_ATTR, methods)
|
|
424
|
+
except Exception:
|
|
425
|
+
pass # frozen or slotted CIR — re-parse rather than fail
|
|
404
426
|
return methods
|
|
405
427
|
|
|
406
428
|
|
|
@@ -511,10 +533,24 @@ def _http_input_query_sinks(
|
|
|
511
533
|
return out
|
|
512
534
|
|
|
513
535
|
|
|
536
|
+
def _sql_taint(root: Path, cir: "CanonicalRepositoryIR") -> dict:
|
|
537
|
+
"""F-BF, imported at the seam that uses it (the module imports `risk` back)."""
|
|
538
|
+
from sourcecode.sql_taint import build_sql_taint
|
|
539
|
+
|
|
540
|
+
return build_sql_taint(root, cir)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _sql_reached_files(payload: "Optional[dict]") -> "dict[str, str]":
|
|
544
|
+
from sourcecode.sql_taint import reached_files
|
|
545
|
+
|
|
546
|
+
return reached_files(payload)
|
|
547
|
+
|
|
548
|
+
|
|
514
549
|
def _query_axis(
|
|
515
550
|
symbol: str,
|
|
516
551
|
owners: "Optional[dict[str, list[str]]]",
|
|
517
552
|
http_input_sinks: "Optional[dict[str, str]]" = None,
|
|
553
|
+
sql_interpolation_sinks: "Optional[dict[str, str]]" = None,
|
|
518
554
|
) -> "tuple[str, str]":
|
|
519
555
|
"""Does the defect's own code assemble a query string by concatenation?
|
|
520
556
|
|
|
@@ -522,6 +558,14 @@ def _query_axis(
|
|
|
522
558
|
class-level one for any member of the class — the same scope `reach_scope`
|
|
523
559
|
already publishes for the reach figure.
|
|
524
560
|
"""
|
|
561
|
+
# F-BF: a SEC-008 defect is recorded against the mapper file, and the sink it
|
|
562
|
+
# names lives in that file — so this join needs no second identity. Checked
|
|
563
|
+
# before the Java-owner lookup because a mapper file is never a Java owner:
|
|
564
|
+
# without this the axis returned `not_observed` (×1,0) on every row of a
|
|
565
|
+
# repository whose SQL is in mapper XML, which is what the field measured.
|
|
566
|
+
reached_sql = (sql_interpolation_sinks or {}).get(symbol)
|
|
567
|
+
if reached_sql:
|
|
568
|
+
return "http_input_reaches_sql_interpolation", reached_sql
|
|
525
569
|
if owners is None:
|
|
526
570
|
return "unknown", ""
|
|
527
571
|
if "#" in symbol:
|
|
@@ -744,6 +788,8 @@ def _statement(row: "dict[str, Any]") -> str:
|
|
|
744
788
|
auth = row["auth"]
|
|
745
789
|
write = row["write"]
|
|
746
790
|
adjacent = []
|
|
791
|
+
if row.get("query") == "http_input_reaches_sql_interpolation":
|
|
792
|
+
adjacent.append("passes HTTP input to SQL that splices it into the statement")
|
|
747
793
|
if row.get("query") == "http_input_concatenated":
|
|
748
794
|
adjacent.append("passes HTTP input to a concatenated query sink")
|
|
749
795
|
if row.get("query") == "concatenated":
|
|
@@ -755,7 +801,9 @@ def _statement(row: "dict[str, Any]") -> str:
|
|
|
755
801
|
+ (
|
|
756
802
|
"; the HTTP-input claim is bounded to annotated request parameters, "
|
|
757
803
|
"positional calls in repository Java sources and known query sinks"
|
|
758
|
-
if row.get("query")
|
|
804
|
+
if row.get("query") in (
|
|
805
|
+
"http_input_concatenated", "http_input_reaches_sql_interpolation",
|
|
806
|
+
) else
|
|
759
807
|
", which is an adjacency, not a dataflow claim: nothing here follows "
|
|
760
808
|
"request input to a sink (NC-001)"
|
|
761
809
|
)
|
|
@@ -860,6 +908,16 @@ class RiskComposer:
|
|
|
860
908
|
progress=self._rule_progress("security rules"),
|
|
861
909
|
).findings
|
|
862
910
|
)
|
|
911
|
+
# F-BI: `risk` ranks defects, and a finding the repository adjudicated as
|
|
912
|
+
# not-a-defect-here is not one. Applied over the whole population, after
|
|
913
|
+
# both rule passes, for the same reason the audit applies it there.
|
|
914
|
+
try:
|
|
915
|
+
from sourcecode.waivers import apply_waivers as _apply_waivers
|
|
916
|
+
|
|
917
|
+
findings = _apply_waivers(findings, self.root).kept
|
|
918
|
+
except Exception:
|
|
919
|
+
pass # a malformed waivers file is reported by `spring-audit`
|
|
920
|
+
|
|
863
921
|
self.findings = findings
|
|
864
922
|
|
|
865
923
|
# C3-85: everything from here to the composition is repository-wide work
|
|
@@ -934,6 +992,16 @@ class RiskComposer:
|
|
|
934
992
|
if self.concat_owners and _named("reading HTTP-input query sinks")
|
|
935
993
|
else None
|
|
936
994
|
)
|
|
995
|
+
# F-BF: the other end of the same question, over the sink family this
|
|
996
|
+
# repository class actually uses. Guarded the same way and for the same
|
|
997
|
+
# reason: `build_sql_taint` returns before parsing when the repository has
|
|
998
|
+
# no spliced statement or no annotated HTTP input at all, so the walk is
|
|
999
|
+
# paid only where it can conclude something.
|
|
1000
|
+
self.sql_taint = (
|
|
1001
|
+
_sql_taint(self.root, self.cir)
|
|
1002
|
+
if _named("reading spliced SQL statements") else None
|
|
1003
|
+
)
|
|
1004
|
+
self.sql_interpolation_sinks = _sql_reached_files(self.sql_taint)
|
|
937
1005
|
self.constraint_index = (
|
|
938
1006
|
_constraint_index(self.root, self.cir, extract_java_endpoints(self.root))
|
|
939
1007
|
if _named("indexing the validation surface") else None
|
|
@@ -1162,7 +1230,8 @@ class RiskComposer:
|
|
|
1162
1230
|
if write == "unknown":
|
|
1163
1231
|
blind.append("write_effect: the declaring type's source was not read")
|
|
1164
1232
|
query, query_evidence = _query_axis(
|
|
1165
|
-
symbol, self.concat_owners, self.http_input_query_sinks
|
|
1233
|
+
symbol, self.concat_owners, self.http_input_query_sinks,
|
|
1234
|
+
self.sql_interpolation_sinks,
|
|
1166
1235
|
)
|
|
1167
1236
|
if query == "unknown":
|
|
1168
1237
|
blind.append("query_construction: this build carries no body facts to read the shape from")
|
|
@@ -1270,12 +1339,21 @@ class RiskComposer:
|
|
|
1270
1339
|
"value": query,
|
|
1271
1340
|
"factor": _QUERY_FACTOR.get(query, 1.0),
|
|
1272
1341
|
"authority": (
|
|
1342
|
+
"bounded HTTP-input-to-spliced-SQL propagation (sql-taint-v1)"
|
|
1343
|
+
if query == "http_input_reaches_sql_interpolation" else
|
|
1273
1344
|
"bounded HTTP-input-to-query-sink propagation + "
|
|
1274
1345
|
"argument-shape atoms (dynamic_argument_surface)"
|
|
1275
1346
|
if query == "http_input_concatenated" else
|
|
1276
1347
|
"argument-shape atoms (dynamic_argument_surface)"
|
|
1277
1348
|
),
|
|
1278
1349
|
"basis": (
|
|
1350
|
+
"an annotated HTTP input parameter reaches, through bounded "
|
|
1351
|
+
"positional propagation, a MyBatis statement that splices "
|
|
1352
|
+
"that value into its SQL with `${...}` — so request input "
|
|
1353
|
+
"decides the structure of the query, not only its values. "
|
|
1354
|
+
"The verdict lattice is reaches / does_not_reach / "
|
|
1355
|
+
"undecidable, and only the first is claimed here"
|
|
1356
|
+
if query == "http_input_reaches_sql_interpolation" else
|
|
1279
1357
|
"an annotated HTTP input parameter reaches a known query "
|
|
1280
1358
|
"construction sink through bounded positional propagation "
|
|
1281
1359
|
"over repository Java sources, and the sink receives a "
|
|
@@ -1379,6 +1457,18 @@ def _assemble_payload(
|
|
|
1379
1457
|
# that sentence, and it belongs where the composition is, not one command away.
|
|
1380
1458
|
"non_coverage": non_coverage.block("security_surface"),
|
|
1381
1459
|
}
|
|
1460
|
+
# F-BF: the census behind the `query_construction` axis, published so the
|
|
1461
|
+
# multiplier is auditable rather than asserted — and so the two states the
|
|
1462
|
+
# axis does NOT act on are visible. Present only where the repository has a
|
|
1463
|
+
# spliced statement at all, which keeps every other payload byte-identical.
|
|
1464
|
+
_taint = getattr(composer, "sql_taint", None)
|
|
1465
|
+
if isinstance(_taint, dict) and _taint.get("sinks_total"):
|
|
1466
|
+
payload["sql_taint"] = {
|
|
1467
|
+
key: _taint[key]
|
|
1468
|
+
for key in ("schema_version", "answered", "sinks_total", "summary",
|
|
1469
|
+
"statement", "verdict_lattice", "basis")
|
|
1470
|
+
if key in _taint
|
|
1471
|
+
}
|
|
1382
1472
|
# F-AY: three of this command's six axes are Spring-shaped (access, the
|
|
1383
1473
|
# transaction boundary, the declarative security model), so on a repository
|
|
1384
1474
|
# with no Spring the factors they contribute are `unknown` by construction
|
sourcecode/spring_findings.py
CHANGED
|
@@ -177,6 +177,14 @@ class SpringAuditResult:
|
|
|
177
177
|
summary: dict = field(default_factory=dict)
|
|
178
178
|
# C2-5: one row per defect, each naming the findings that witnessed it.
|
|
179
179
|
defects: list = field(default_factory=list)
|
|
180
|
+
# F-BI: findings this repository adjudicated as not-defects-here, each with
|
|
181
|
+
# the reason and the file that declared it. They are removed from `findings`
|
|
182
|
+
# and published in full — a count that drops with nothing to read is the
|
|
183
|
+
# failure mode adjudication would otherwise introduce.
|
|
184
|
+
waived: list = field(default_factory=list)
|
|
185
|
+
# The waivers block: what was declared, what applied, what expired, what
|
|
186
|
+
# matched nothing. Empty when the repository declares no waivers file.
|
|
187
|
+
waivers: dict = field(default_factory=dict)
|
|
180
188
|
|
|
181
189
|
def finalize(self) -> "SpringAuditResult":
|
|
182
190
|
"""Compute summary stats. Call after all findings are added."""
|
|
@@ -265,6 +273,17 @@ class SpringAuditResult:
|
|
|
265
273
|
"confidence_level": conf_level,
|
|
266
274
|
"confidence_basis": _conf_basis,
|
|
267
275
|
}
|
|
276
|
+
# F-BI: the count that says the population above is not the whole one.
|
|
277
|
+
# Only ever present beside the block that says what left and why, so a
|
|
278
|
+
# smaller number can never be read without its adjudication.
|
|
279
|
+
if self.waivers:
|
|
280
|
+
self.summary["waived_findings"] = len(self.waived)
|
|
281
|
+
self.summary["waived_basis"] = (
|
|
282
|
+
"This repository adjudicated the findings above as not defects "
|
|
283
|
+
"here. They are not counted in total_findings and are published "
|
|
284
|
+
"in full under `waived`, each with its reason and the file that "
|
|
285
|
+
"declared it (see `waivers`)."
|
|
286
|
+
)
|
|
268
287
|
# C2-31 (second half): the truncation was declared only in `_partial`, at
|
|
269
288
|
# the root of the payload, while the numbers a consumer actually reads live
|
|
270
289
|
# here. `summary.total_defects: 15` with `confidence_level: high` reads as a
|
|
@@ -315,8 +334,18 @@ class SpringAuditResult:
|
|
|
315
334
|
# A non-empty posture wins: it is produced by exactly one scope (security),
|
|
316
335
|
# so there is no cross-scope conflict to resolve.
|
|
317
336
|
posture: dict = {}
|
|
337
|
+
# F-BI: adjudication is applied over the *merged* population, never
|
|
338
|
+
# inside one scope — a waiver naming a security finding is not
|
|
339
|
+
# "unmatched" because the transaction scope did not find it. So these are
|
|
340
|
+
# normally empty here; they are carried rather than dropped for the case
|
|
341
|
+
# a caller merges results that were already adjudicated.
|
|
342
|
+
waived: list = []
|
|
343
|
+
waivers: dict = {}
|
|
318
344
|
for r in results:
|
|
319
345
|
findings.extend(r.findings)
|
|
346
|
+
waived.extend(r.waived)
|
|
347
|
+
if r.waivers and not waivers:
|
|
348
|
+
waivers = r.waivers
|
|
320
349
|
limitations.extend(r.limitations)
|
|
321
350
|
metadata.update(r.metadata)
|
|
322
351
|
# C1-42: `update` is right for every key that belongs to one scope and
|
|
@@ -361,6 +390,8 @@ class SpringAuditResult:
|
|
|
361
390
|
limitations=limitations,
|
|
362
391
|
metadata=metadata,
|
|
363
392
|
security_posture=posture,
|
|
393
|
+
waived=waived,
|
|
394
|
+
waivers=waivers,
|
|
364
395
|
# C2-31: one cut scope makes the joined answer a floor. Losing the flag
|
|
365
396
|
# in the merge would restore the exact defect — a truncated report that
|
|
366
397
|
# presents itself as a complete one.
|
|
@@ -378,6 +409,11 @@ class SpringAuditResult:
|
|
|
378
409
|
"summary": self.summary,
|
|
379
410
|
"defects": self.defects,
|
|
380
411
|
"findings": [f.to_dict() for f in self.findings],
|
|
412
|
+
# F-BI: present only when the repository declares waivers, so a
|
|
413
|
+
# payload from a repository that adjudicates nothing is unchanged
|
|
414
|
+
# byte for byte.
|
|
415
|
+
**({"waived": self.waived} if self.waivers else {}),
|
|
416
|
+
**({"waivers": self.waivers} if self.waivers else {}),
|
|
381
417
|
"limitations": self.limitations,
|
|
382
418
|
"metadata": self.metadata,
|
|
383
419
|
"security_posture": self.security_posture,
|
|
@@ -439,6 +475,9 @@ class SpringAuditResult:
|
|
|
439
475
|
}
|
|
440
476
|
|
|
441
477
|
_cap(compact, "findings", key=lambda f: SEVERITY_ORDER.get(f.get("severity"), 3))
|
|
478
|
+
# F-BI: capped by the same rule as the rest. The `waivers` block beside it
|
|
479
|
+
# keeps the counts, so a sampled list never hides how much was waived.
|
|
480
|
+
_cap(compact, "waived", key=lambda f: SEVERITY_ORDER.get(f.get("severity"), 3))
|
|
442
481
|
# C2-5: the defect rows are the shorter list and the decision-grade one,
|
|
443
482
|
# so they are capped with the same rule rather than exempted from it.
|
|
444
483
|
_cap(compact, "defects", key=lambda d: SEVERITY_ORDER.get(d.get("severity"), 3))
|
sourcecode/spring_impact.py
CHANGED
|
@@ -1597,6 +1597,16 @@ def _run_audit_for_chain(
|
|
|
1597
1597
|
posture = getattr(sec_result, "security_posture", None) or {}
|
|
1598
1598
|
except Exception:
|
|
1599
1599
|
pass
|
|
1600
|
+
# F-BI: the same adjudication the audit publishes, applied to the findings
|
|
1601
|
+
# this command attaches to an impact. A row the repository decided is not a
|
|
1602
|
+
# defect here must not come back attached to a pull request.
|
|
1603
|
+
try:
|
|
1604
|
+
from sourcecode.waivers import apply_waivers as _apply_waivers
|
|
1605
|
+
|
|
1606
|
+
findings = _apply_waivers(findings, root).kept
|
|
1607
|
+
except Exception:
|
|
1608
|
+
pass # a malformed waivers file is reported by `spring-audit`, not here
|
|
1609
|
+
|
|
1600
1610
|
answer = (findings, posture)
|
|
1601
1611
|
try:
|
|
1602
1612
|
if not isinstance(cache, dict):
|