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/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 ("ask", self.command, ".", self.option, *self.also) if part
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") == "http_input_concatenated" else
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
@@ -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))
@@ -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):