src2sink 1.0.3__tar.gz → 2.1.0__tar.gz

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.
Files changed (158) hide show
  1. {src2sink-1.0.3/src2sink.egg-info → src2sink-2.1.0}/PKG-INFO +43 -15
  2. {src2sink-1.0.3 → src2sink-2.1.0}/README.md +42 -10
  3. {src2sink-1.0.3 → src2sink-2.1.0}/pyproject.toml +35 -8
  4. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/api_client_discovery.py +180 -11
  5. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/index_v2.py +1 -0
  6. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/library_source_map.py +3 -1
  7. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/openapi_match.py +21 -6
  8. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/service_call_collect.py +144 -11
  9. src2sink-2.1.0/src2sink/aggregators/service_call_report.py +220 -0
  10. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/taint_buckets.py +4 -0
  11. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/taint_writers.py +41 -1
  12. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/build_metabase_v2.py +304 -60
  13. src2sink-2.1.0/src2sink/dependencies.py +214 -0
  14. src2sink-2.1.0/src2sink/derive.py +250 -0
  15. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/ast_walk.py +27 -0
  16. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/base.py +2 -2
  17. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/config.py +14 -12
  18. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/file_context.py +3 -0
  19. src2sink-2.1.0/src2sink/extractors/http_out.py +324 -0
  20. src2sink-2.1.0/src2sink/extractors/patterns.py +444 -0
  21. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/regex_extractors.py +222 -20
  22. src2sink-2.1.0/src2sink/extractors/symbols.py +78 -0
  23. src2sink-2.1.0/src2sink/extractors/ts_extractors.py +204 -0
  24. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/unified.py +14 -4
  25. src2sink-2.1.0/src2sink/graph_common.py +404 -0
  26. src2sink-2.1.0/src2sink/known_api_clients.py +223 -0
  27. src2sink-2.1.0/src2sink/maven.py +334 -0
  28. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/repo_utils.py +39 -29
  29. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/sanitize.py +7 -2
  30. src2sink-2.1.0/src2sink/schema.py +95 -0
  31. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/trace.py +109 -27
  32. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/trace_batch.py +26 -6
  33. {src2sink-1.0.3 → src2sink-2.1.0/src2sink.egg-info}/PKG-INFO +43 -15
  34. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink.egg-info/SOURCES.txt +24 -0
  35. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink.egg-info/requires.txt +0 -4
  36. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_aggregators_extra.py +11 -11
  37. src2sink-2.1.0/tests/test_ast_walk.py +221 -0
  38. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_build_internals.py +38 -6
  39. src2sink-2.1.0/tests/test_call_observations.py +125 -0
  40. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_characterization.py +6 -6
  41. src2sink-2.1.0/tests/test_cross_repo_caller_coverage.py +731 -0
  42. src2sink-2.1.0/tests/test_demand_side_discovery.py +303 -0
  43. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_dependency_pinning.py +85 -1
  44. src2sink-2.1.0/tests/test_derive_pass.py +164 -0
  45. src2sink-2.1.0/tests/test_detection_fingerprint_gate.py +103 -0
  46. src2sink-2.1.0/tests/test_detection_input_coverage.py +134 -0
  47. src2sink-2.1.0/tests/test_detection_version.py +121 -0
  48. src2sink-2.1.0/tests/test_gradle_version_catalogs.py +223 -0
  49. src2sink-2.1.0/tests/test_graph_common.py +197 -0
  50. src2sink-2.1.0/tests/test_http_guard_evidence.py +199 -0
  51. src2sink-2.1.0/tests/test_import_graph.py +157 -0
  52. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_internal_groups.py +2 -2
  53. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_limits.py +107 -0
  54. src2sink-2.1.0/tests/test_maven_resolution.py +239 -0
  55. src2sink-2.1.0/tests/test_oi26_receiver_scope.py +141 -0
  56. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_openapi.py +9 -9
  57. src2sink-2.1.0/tests/test_path_match_significance.py +110 -0
  58. src2sink-2.1.0/tests/test_phase1_config.py +110 -0
  59. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_phase2_graphs.py +23 -23
  60. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_phase4_aggregators.py +4 -4
  61. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_phase4_extractor_snapshots.py +45 -6
  62. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_phase4_regression.py +2 -2
  63. src2sink-2.1.0/tests/test_polyglot_dependencies.py +164 -0
  64. src2sink-2.1.0/tests/test_prescreen.py +160 -0
  65. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_redos_bounds.py +120 -6
  66. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_repo_utils_helpers.py +0 -9
  67. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_sanitize.py +60 -0
  68. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_security_input_handling.py +5 -4
  69. src2sink-2.1.0/tests/test_sql_classifier.py +114 -0
  70. src2sink-2.1.0/tests/test_sql_payload_out.py +255 -0
  71. src2sink-2.1.0/tests/test_sql_sink_evidence.py +582 -0
  72. src2sink-2.1.0/tests/test_sql_source_construction.py +272 -0
  73. src2sink-2.1.0/tests/test_trace_fleet_scaling.py +173 -0
  74. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_trace_render.py +41 -12
  75. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_zz_security_coverage_gate.py +48 -1
  76. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_zz_untrusted_interpolation_guard.py +14 -2
  77. src2sink-1.0.3/src2sink/aggregators/service_call_report.py +0 -118
  78. src2sink-1.0.3/src2sink/extractors/http_out.py +0 -108
  79. src2sink-1.0.3/src2sink/extractors/patterns.py +0 -145
  80. src2sink-1.0.3/src2sink/extractors/ts_extractors.py +0 -123
  81. src2sink-1.0.3/src2sink/graph_common.py +0 -213
  82. src2sink-1.0.3/src2sink/known_api_clients.py +0 -108
  83. src2sink-1.0.3/src2sink/schema.py +0 -57
  84. src2sink-1.0.3/tests/test_graph_common.py +0 -74
  85. src2sink-1.0.3/tests/test_phase1_config.py +0 -53
  86. src2sink-1.0.3/tests/test_prescreen.py +0 -81
  87. {src2sink-1.0.3 → src2sink-2.1.0}/LICENSE +0 -0
  88. {src2sink-1.0.3 → src2sink-2.1.0}/setup.cfg +0 -0
  89. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/__init__.py +0 -0
  90. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/__init__.py +0 -0
  91. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/auth_cards.py +0 -0
  92. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/crypto_cards.py +0 -0
  93. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/data_stores.py +0 -0
  94. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/graphs.py +0 -0
  95. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/openapi_discovery.py +0 -0
  96. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/openapi_edges.py +0 -0
  97. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/openapi_models.py +0 -0
  98. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/openapi_report.py +0 -0
  99. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/payload_producers.py +0 -0
  100. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/phase3.py +0 -0
  101. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/pii_cross_repo.py +0 -0
  102. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/pii_flow_v2.py +0 -0
  103. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/pii_lifecycle.py +0 -0
  104. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/pii_lifecycle_report.py +0 -0
  105. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/pii_touchpoint_collect.py +0 -0
  106. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/pii_touchpoint_utils.py +0 -0
  107. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/queues.py +0 -0
  108. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/ropa.py +0 -0
  109. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/service_call_index.py +0 -0
  110. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/service_call_models.py +0 -0
  111. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/service_calls.py +0 -0
  112. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/taint_catalogs.py +0 -0
  113. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/aggregators/traces_index.py +0 -0
  114. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/constants.py +0 -0
  115. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/curate_internal_libraries.py +0 -0
  116. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/__init__.py +0 -0
  117. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/go.py +0 -0
  118. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/java.py +0 -0
  119. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/javascript.py +0 -0
  120. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/kotlin.py +0 -0
  121. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/node_factory.py +0 -0
  122. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/extractors/python_extractor.py +0 -0
  123. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/internal_groups.py +0 -0
  124. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/library_taint_java.py +0 -0
  125. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/limits.py +0 -0
  126. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/models/__init__.py +0 -0
  127. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/models/auth_model.py +0 -0
  128. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/models/crypto_agility.py +0 -0
  129. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/models/pii_lifecycle.py +0 -0
  130. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/models/ropa.py +0 -0
  131. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/prescreen.py +0 -0
  132. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/py.typed +0 -0
  133. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/record_fleet_baseline.py +0 -0
  134. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/renderers/__init__.py +0 -0
  135. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/renderers/markdown.py +0 -0
  136. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/renderers/mermaid.py +0 -0
  137. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/safe_paths.py +0 -0
  138. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink/vocabulary.py +0 -0
  139. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink.egg-info/dependency_links.txt +0 -0
  140. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink.egg-info/entry_points.txt +0 -0
  141. {src2sink-1.0.3 → src2sink-2.1.0}/src2sink.egg-info/top_level.txt +0 -0
  142. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_api_client_discovery.py +0 -0
  143. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_build_main.py +0 -0
  144. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_config_hygiene.py +0 -0
  145. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_curate.py +0 -0
  146. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_field_precision.py +0 -0
  147. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_module_imports.py +0 -0
  148. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_output_neutralisation.py +0 -0
  149. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_phase0_smoke.py +0 -0
  150. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_phase1_extractors.py +0 -0
  151. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_phase3_pii.py +0 -0
  152. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_phase4_fleet_regression.py +0 -0
  153. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_phase4_taint_caps.py +0 -0
  154. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_post_phase4_backlog.py +0 -0
  155. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_renderers.py +0 -0
  156. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_run_manifest.py +0 -0
  157. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_source_map_fix.py +0 -0
  158. {src2sink-1.0.3 → src2sink-2.1.0}/tests/test_trace_batch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: src2sink
3
- Version: 1.0.3
3
+ Version: 2.1.0
4
4
  Summary: Cross-repository source-to-sink metabase for LLM-assisted SAST
5
5
  Author: Brett Crawley
6
6
  License-Expression: MIT
@@ -20,10 +20,6 @@ Requires-Python: >=3.14
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: defusedxml>=0.7.1
23
- Requires-Dist: ipykernel>=7.2.0
24
- Requires-Dist: jupyterlab>=4.5.7
25
- Requires-Dist: openpyxl>=3.1.5
26
- Requires-Dist: pandas>=3.0.2
27
23
  Requires-Dist: tree-sitter>=0.25.2
28
24
  Requires-Dist: tree-sitter-go>=0.25.0
29
25
  Requires-Dist: tree-sitter-java>=0.23.5
@@ -33,11 +29,12 @@ Requires-Dist: tree-sitter-python>=0.25.0
33
29
  Requires-Dist: tree-sitter-typescript>=0.23.2
34
30
  Dynamic: license-file
35
31
 
36
- ![src2sink logo](https://raw.githubusercontent.com/mimecast/src2sink/main/images/src2sink-logo-light.png)
32
+ ![src2sink — cross-repository taint tracking, from source to sink](https://raw.githubusercontent.com/mimecast/src2sink/main/images/src2sink-header.png)
37
33
  # Source-Code Metabase
38
34
 
39
35
  [![CI](https://github.com/mimecast/src2sink/actions/workflows/ci.yml/badge.svg)](https://github.com/mimecast/src2sink/actions/workflows/ci.yml)
40
36
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/mimecast/src2sink/blob/main/LICENSE)
37
+ [![SLSA Build Level 3](https://slsa.dev/images/gh-badge-level3.svg)](https://github.com/mimecast/src2sink/attestations)
41
38
 
42
39
  A structured, human-readable knowledge base of the analysed source-code
43
40
  ecosystem. Designed to be loaded as **context for LLM SAST** so that
@@ -46,7 +43,6 @@ sinks in another, internal libraries that act as transparent pass-
46
43
  throughs to dangerous APIs.
47
44
 
48
45
  **Canonical field definitions:** [`SCHEMA.md`](https://github.com/mimecast/src2sink/blob/main/SCHEMA.md)
49
- **Roadmap / gaps:** [`NEXT_STEPS.md`](https://github.com/mimecast/src2sink/blob/main/NEXT_STEPS.md)
50
46
 
51
47
  ---
52
48
 
@@ -92,7 +88,8 @@ about the full path, not a single repo in isolation.
92
88
  | `taint/config-data-stores.md` + `.jsonl` | JDBC/Mongo/Redis/S3 from config |
93
89
  | `taint/config-security.md` + `.jsonl` | Security-sensitive config keys |
94
90
  | `taint/config-crypto.md` + `.jsonl` | Cipher suites / signing algorithms from config |
95
- | `graphs/service-call-graph.md` + `service-call-edges.jsonl` | Cross-repo HTTP edges (`http-in` ↔ enriched `http-out`) |
91
+ | `graphs/service-call-graph.md` + `service-call-edges.jsonl` | Cross-repo HTTP edges (`http-in` ↔ enriched `http-out`, plus api-client-binding hops) |
92
+ | `graphs/service-call-unmatched.jsonl` | Outbound calls that resolved to nothing — the negative-coverage signal |
96
93
  | `graphs/queue-graph.md` + `.jsonl` | Topic producers / consumers |
97
94
  | `graphs/data-store-graph.md` + `.jsonl` | Config-discovered stores ↔ repos |
98
95
  | `graphs/payload-endpoint-producers.md` + `.jsonl` | Registered API clients → target services |
@@ -109,7 +106,6 @@ about the full path, not a single repo in isolation.
109
106
  |------|---------|
110
107
  | `SCHEMA.md` | Field and node-family definitions |
111
108
  | `README.md` | This file |
112
- | `NEXT_STEPS.md` | Gaps and follow-ups |
113
109
  | `internal-libraries/<coord>.md` | Per-library taint tables (hand-curated) |
114
110
  | `conventions/*.md` | Auth, crypto agility, PII naming (mixed) |
115
111
 
@@ -191,11 +187,34 @@ pull request, and weekly (advisories move even when the code does not):
191
187
  checkout. Known false positives carry inline `# nosec` / `# nosemgrep`
192
188
  annotations with a stated reason rather than blanket rule exclusions.
193
189
 
190
+ ### Release gates
191
+
192
+ A separate workflow, [`.github/workflows/release.yml`](https://github.com/mimecast/src2sink/blob/main/.github/workflows/release.yml),
193
+ runs when a `v*` tag is pushed — and on the 1st of each month as an unattended
194
+ rehearsal that builds and attests without publishing, so upstream breakage
195
+ surfaces before a release depends on it.
196
+
197
+ | Stage | What it does |
198
+ |---|---|
199
+ | `build` | builds sdist + wheel from the tagged tree, **fails if the tag and the packaged version disagree**, `twine check`s the metadata |
200
+ | `provenance` | generates SLSA provenance in an isolated builder the build steps cannot reach (Build L3) |
201
+ | `publish` | uploads to PyPI over OIDC Trusted Publishing — no API token exists in this repository — after a human approves the deployment |
202
+ | `release` | creates the GitHub release with the version's changelog section as the body, and attaches the artefacts plus the provenance |
203
+
204
+ Publishing runs only after provenance succeeds, so a failure there stops a
205
+ release before the irreversible step. Permissions are split per job: only
206
+ `publish` can mint an OIDC token, only `release` can write to the repository.
207
+
194
208
  Publishing a version to PyPI: [`docs/releasing.md`](https://github.com/mimecast/src2sink/blob/main/docs/releasing.md).
195
209
  Known small gaps: [`docs/todo.md`](https://github.com/mimecast/src2sink/blob/main/docs/todo.md).
196
210
 
197
211
  ### Verifying a release
198
212
 
213
+ The SLSA badge above is a claim, like every badge — it links to
214
+ [this repository's attestations](https://github.com/mimecast/src2sink/attestations),
215
+ which is the evidence. Do not take either on trust; the commands below check the
216
+ artefact you actually downloaded.
217
+
199
218
  Releases carry signed build provenance tying each artefact to the source commit,
200
219
  tag, and workflow run that produced it — so a file claiming to be src2sink can be
201
220
  checked rather than trusted. From 1.0.3 the provenance is generated by an
@@ -299,8 +318,18 @@ resolved; anything still flagged afterwards has no matching `pom.xml` on disk
299
318
 
300
319
  ### Register another API client library
301
320
 
302
- Edit `src2sink/known_api_clients.py` (`ApiClientBinding`), then re-run
303
- `src2sink-build` (or `--graphs-only`).
321
+ Add a binding to `api-clients.json` (see
322
+ [`docs/api-clients-json.md`](https://github.com/mimecast/src2sink/blob/main/docs/api-clients-json.md)
323
+ for the field reference and the `--discover-api-clients` drafting flow), then
324
+ re-run `src2sink-build --api-clients api-clients.json` (or `--graphs-only`).
325
+
326
+ This is how the tool sees hops that regular SAST cannot: a repo that calls a
327
+ service through its published client library has no URL, host, or service name
328
+ anywhere in its source, so the binding is the only thing that knows the call
329
+ crosses a service boundary. If a supplied `api-clients.json` loads zero bindings
330
+ the build fails rather than quietly producing a client-blind metabase; check
331
+ `api_clients_binding_count` in `run-manifest.json` and the "API-client binding
332
+ coverage" table in `graphs/service-call-graph.md` to confirm detection is live.
304
333
 
305
334
  ---
306
335
 
@@ -358,7 +387,7 @@ Treat **low** as “investigate”, not “confirmed”.
358
387
  ## Dependencies
359
388
 
360
389
  v2 requires **tree-sitter** and per-language grammars (via
361
- `uv sync`). See `metabase/docs/phase0-probe.md` if resolution fails.
390
+ `uv sync`).
362
391
  Do not add public PyPI fallbacks.
363
392
 
364
393
  ---
@@ -366,7 +395,6 @@ Do not add public PyPI fallbacks.
366
395
  ## See also
367
396
 
368
397
  - [`SCHEMA.md`](https://github.com/mimecast/src2sink/blob/main/SCHEMA.md) — complete v2 node/edge vocabulary.
369
- - [`docs/implementation-plan.md`](https://github.com/mimecast/src2sink/blob/main/docs/implementation-plan.md) — **phased plan and progress record**.
370
398
  - [`docs/architecture.md`](https://github.com/mimecast/src2sink/blob/main/docs/architecture.md) — components, data flow, trust boundaries.
371
399
  - [`docs/threat-model.md`](https://github.com/mimecast/src2sink/blob/main/docs/threat-model.md) — STRIDE risk register.
372
400
  - [`docs/security-privacy-gap-analysis.md`](https://github.com/mimecast/src2sink/blob/main/docs/security-privacy-gap-analysis.md) — requirements, abuse cases, SRTM.
@@ -375,7 +403,7 @@ Do not add public PyPI fallbacks.
375
403
  - [`docs/api-clients-json.md`](https://github.com/mimecast/src2sink/blob/main/docs/api-clients-json.md) — the internal-service binding file.
376
404
  - [`metabase-usage.md`](https://github.com/mimecast/src2sink/blob/main/metabase-usage.md) — using the output as LLM context.
377
405
  - [`docs/releasing.md`](https://github.com/mimecast/src2sink/blob/main/docs/releasing.md) — publishing a version to PyPI.
378
- - [`docs/slsa.md`](https://github.com/mimecast/src2sink/blob/main/docs/slsa.md) — build-provenance plan (SLSA Build L2 → L3).
406
+ - [`docs/slsa.md`](https://github.com/mimecast/src2sink/blob/main/docs/slsa.md) — build provenance: what is claimed, how to verify it, and what it does not cover.
379
407
  - [`CHANGELOG.md`](https://github.com/mimecast/src2sink/blob/main/CHANGELOG.md) — what shipped in each version.
380
- - [`NEXT_STEPS.md`](https://github.com/mimecast/src2sink/blob/main/NEXT_STEPS.md) — roadmap · [`docs/todo.md`](https://github.com/mimecast/src2sink/blob/main/docs/todo.md) — known small gaps.
408
+ - [`docs/todo.md`](https://github.com/mimecast/src2sink/blob/main/docs/todo.md) — known small gaps.
381
409
  - `/ai-sast-scanner` skill — SAST prompt this metabase feeds.
@@ -1,8 +1,9 @@
1
- ![src2sink logo](https://raw.githubusercontent.com/mimecast/src2sink/main/images/src2sink-logo-light.png)
1
+ ![src2sink — cross-repository taint tracking, from source to sink](https://raw.githubusercontent.com/mimecast/src2sink/main/images/src2sink-header.png)
2
2
  # Source-Code Metabase
3
3
 
4
4
  [![CI](https://github.com/mimecast/src2sink/actions/workflows/ci.yml/badge.svg)](https://github.com/mimecast/src2sink/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/mimecast/src2sink/blob/main/LICENSE)
6
+ [![SLSA Build Level 3](https://slsa.dev/images/gh-badge-level3.svg)](https://github.com/mimecast/src2sink/attestations)
6
7
 
7
8
  A structured, human-readable knowledge base of the analysed source-code
8
9
  ecosystem. Designed to be loaded as **context for LLM SAST** so that
@@ -11,7 +12,6 @@ sinks in another, internal libraries that act as transparent pass-
11
12
  throughs to dangerous APIs.
12
13
 
13
14
  **Canonical field definitions:** [`SCHEMA.md`](https://github.com/mimecast/src2sink/blob/main/SCHEMA.md)
14
- **Roadmap / gaps:** [`NEXT_STEPS.md`](https://github.com/mimecast/src2sink/blob/main/NEXT_STEPS.md)
15
15
 
16
16
  ---
17
17
 
@@ -57,7 +57,8 @@ about the full path, not a single repo in isolation.
57
57
  | `taint/config-data-stores.md` + `.jsonl` | JDBC/Mongo/Redis/S3 from config |
58
58
  | `taint/config-security.md` + `.jsonl` | Security-sensitive config keys |
59
59
  | `taint/config-crypto.md` + `.jsonl` | Cipher suites / signing algorithms from config |
60
- | `graphs/service-call-graph.md` + `service-call-edges.jsonl` | Cross-repo HTTP edges (`http-in` ↔ enriched `http-out`) |
60
+ | `graphs/service-call-graph.md` + `service-call-edges.jsonl` | Cross-repo HTTP edges (`http-in` ↔ enriched `http-out`, plus api-client-binding hops) |
61
+ | `graphs/service-call-unmatched.jsonl` | Outbound calls that resolved to nothing — the negative-coverage signal |
61
62
  | `graphs/queue-graph.md` + `.jsonl` | Topic producers / consumers |
62
63
  | `graphs/data-store-graph.md` + `.jsonl` | Config-discovered stores ↔ repos |
63
64
  | `graphs/payload-endpoint-producers.md` + `.jsonl` | Registered API clients → target services |
@@ -74,7 +75,6 @@ about the full path, not a single repo in isolation.
74
75
  |------|---------|
75
76
  | `SCHEMA.md` | Field and node-family definitions |
76
77
  | `README.md` | This file |
77
- | `NEXT_STEPS.md` | Gaps and follow-ups |
78
78
  | `internal-libraries/<coord>.md` | Per-library taint tables (hand-curated) |
79
79
  | `conventions/*.md` | Auth, crypto agility, PII naming (mixed) |
80
80
 
@@ -156,11 +156,34 @@ pull request, and weekly (advisories move even when the code does not):
156
156
  checkout. Known false positives carry inline `# nosec` / `# nosemgrep`
157
157
  annotations with a stated reason rather than blanket rule exclusions.
158
158
 
159
+ ### Release gates
160
+
161
+ A separate workflow, [`.github/workflows/release.yml`](https://github.com/mimecast/src2sink/blob/main/.github/workflows/release.yml),
162
+ runs when a `v*` tag is pushed — and on the 1st of each month as an unattended
163
+ rehearsal that builds and attests without publishing, so upstream breakage
164
+ surfaces before a release depends on it.
165
+
166
+ | Stage | What it does |
167
+ |---|---|
168
+ | `build` | builds sdist + wheel from the tagged tree, **fails if the tag and the packaged version disagree**, `twine check`s the metadata |
169
+ | `provenance` | generates SLSA provenance in an isolated builder the build steps cannot reach (Build L3) |
170
+ | `publish` | uploads to PyPI over OIDC Trusted Publishing — no API token exists in this repository — after a human approves the deployment |
171
+ | `release` | creates the GitHub release with the version's changelog section as the body, and attaches the artefacts plus the provenance |
172
+
173
+ Publishing runs only after provenance succeeds, so a failure there stops a
174
+ release before the irreversible step. Permissions are split per job: only
175
+ `publish` can mint an OIDC token, only `release` can write to the repository.
176
+
159
177
  Publishing a version to PyPI: [`docs/releasing.md`](https://github.com/mimecast/src2sink/blob/main/docs/releasing.md).
160
178
  Known small gaps: [`docs/todo.md`](https://github.com/mimecast/src2sink/blob/main/docs/todo.md).
161
179
 
162
180
  ### Verifying a release
163
181
 
182
+ The SLSA badge above is a claim, like every badge — it links to
183
+ [this repository's attestations](https://github.com/mimecast/src2sink/attestations),
184
+ which is the evidence. Do not take either on trust; the commands below check the
185
+ artefact you actually downloaded.
186
+
164
187
  Releases carry signed build provenance tying each artefact to the source commit,
165
188
  tag, and workflow run that produced it — so a file claiming to be src2sink can be
166
189
  checked rather than trusted. From 1.0.3 the provenance is generated by an
@@ -264,8 +287,18 @@ resolved; anything still flagged afterwards has no matching `pom.xml` on disk
264
287
 
265
288
  ### Register another API client library
266
289
 
267
- Edit `src2sink/known_api_clients.py` (`ApiClientBinding`), then re-run
268
- `src2sink-build` (or `--graphs-only`).
290
+ Add a binding to `api-clients.json` (see
291
+ [`docs/api-clients-json.md`](https://github.com/mimecast/src2sink/blob/main/docs/api-clients-json.md)
292
+ for the field reference and the `--discover-api-clients` drafting flow), then
293
+ re-run `src2sink-build --api-clients api-clients.json` (or `--graphs-only`).
294
+
295
+ This is how the tool sees hops that regular SAST cannot: a repo that calls a
296
+ service through its published client library has no URL, host, or service name
297
+ anywhere in its source, so the binding is the only thing that knows the call
298
+ crosses a service boundary. If a supplied `api-clients.json` loads zero bindings
299
+ the build fails rather than quietly producing a client-blind metabase; check
300
+ `api_clients_binding_count` in `run-manifest.json` and the "API-client binding
301
+ coverage" table in `graphs/service-call-graph.md` to confirm detection is live.
269
302
 
270
303
  ---
271
304
 
@@ -323,7 +356,7 @@ Treat **low** as “investigate”, not “confirmed”.
323
356
  ## Dependencies
324
357
 
325
358
  v2 requires **tree-sitter** and per-language grammars (via
326
- `uv sync`). See `metabase/docs/phase0-probe.md` if resolution fails.
359
+ `uv sync`).
327
360
  Do not add public PyPI fallbacks.
328
361
 
329
362
  ---
@@ -331,7 +364,6 @@ Do not add public PyPI fallbacks.
331
364
  ## See also
332
365
 
333
366
  - [`SCHEMA.md`](https://github.com/mimecast/src2sink/blob/main/SCHEMA.md) — complete v2 node/edge vocabulary.
334
- - [`docs/implementation-plan.md`](https://github.com/mimecast/src2sink/blob/main/docs/implementation-plan.md) — **phased plan and progress record**.
335
367
  - [`docs/architecture.md`](https://github.com/mimecast/src2sink/blob/main/docs/architecture.md) — components, data flow, trust boundaries.
336
368
  - [`docs/threat-model.md`](https://github.com/mimecast/src2sink/blob/main/docs/threat-model.md) — STRIDE risk register.
337
369
  - [`docs/security-privacy-gap-analysis.md`](https://github.com/mimecast/src2sink/blob/main/docs/security-privacy-gap-analysis.md) — requirements, abuse cases, SRTM.
@@ -340,7 +372,7 @@ Do not add public PyPI fallbacks.
340
372
  - [`docs/api-clients-json.md`](https://github.com/mimecast/src2sink/blob/main/docs/api-clients-json.md) — the internal-service binding file.
341
373
  - [`metabase-usage.md`](https://github.com/mimecast/src2sink/blob/main/metabase-usage.md) — using the output as LLM context.
342
374
  - [`docs/releasing.md`](https://github.com/mimecast/src2sink/blob/main/docs/releasing.md) — publishing a version to PyPI.
343
- - [`docs/slsa.md`](https://github.com/mimecast/src2sink/blob/main/docs/slsa.md) — build-provenance plan (SLSA Build L2 → L3).
375
+ - [`docs/slsa.md`](https://github.com/mimecast/src2sink/blob/main/docs/slsa.md) — build provenance: what is claimed, how to verify it, and what it does not cover.
344
376
  - [`CHANGELOG.md`](https://github.com/mimecast/src2sink/blob/main/CHANGELOG.md) — what shipped in each version.
345
- - [`NEXT_STEPS.md`](https://github.com/mimecast/src2sink/blob/main/NEXT_STEPS.md) — roadmap · [`docs/todo.md`](https://github.com/mimecast/src2sink/blob/main/docs/todo.md) — known small gaps.
377
+ - [`docs/todo.md`](https://github.com/mimecast/src2sink/blob/main/docs/todo.md) — known small gaps.
346
378
  - `/ai-sast-scanner` skill — SAST prompt this metabase feeds.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "src2sink"
3
- version = "1.0.3"
3
+ version = "2.1.0"
4
4
  description = "Cross-repository source-to-sink metabase for LLM-assisted SAST"
5
5
  authors = [{ name = "Brett Crawley" }]
6
6
  readme = "README.md"
@@ -18,12 +18,11 @@ classifiers = [
18
18
  "Topic :: Software Development :: Quality Assurance",
19
19
  "Typing :: Typed",
20
20
  ]
21
+ # Kept deliberately small: this package is installed into other people's
22
+ # environments, so every runtime dependency is audited surface (SC-1 / TA-011).
23
+ # Anything listed here must be imported somewhere under src2sink/.
21
24
  dependencies = [
22
25
  "defusedxml>=0.7.1",
23
- "ipykernel>=7.2.0",
24
- "jupyterlab>=4.5.7",
25
- "openpyxl>=3.1.5",
26
- "pandas>=3.0.2",
27
26
  "tree-sitter>=0.25.2",
28
27
  "tree-sitter-go>=0.25.0",
29
28
  "tree-sitter-java>=0.23.5",
@@ -70,8 +69,12 @@ src2sink-baseline = "src2sink.record_fleet_baseline:main"
70
69
 
71
70
  [tool.pytest.ini_options]
72
71
  testpaths = ["tests"]
73
- # Coverage is reported and gated on every run (Phase E). Current coverage ~82%.
74
- addopts = "--cov=src2sink --cov-report=term-missing --cov-fail-under=80"
72
+ # Coverage is reported and gated on every run. The global floor ratchets: it is
73
+ # set just under current coverage so it catches a real regression, and is raised
74
+ # when coverage rises. Per-module floors live in tests/test_zz_security_coverage_gate.py
75
+ # (security modules at 90%, detection modules at 88%), since coverage.py has a
76
+ # single global fail_under and no per-module concept.
77
+ addopts = "--cov=src2sink --cov-report=term-missing --cov-fail-under=85"
75
78
  markers = [
76
79
  "fleet: fleet-wide regression (needs metabase/repos v2 JSONs)",
77
80
  ]
@@ -96,4 +99,28 @@ pretty = true
96
99
  # E7 statement, E9 syntax). Deliberately NOT enabling the line-length / whitespace
97
100
  # style families (E1/E2/E5, W) — turning those on is a large, separate reformat.
98
101
  # target-version is inferred from project.requires-python.
99
- select = ["E4", "E7", "E9", "F"]
102
+ #
103
+ # D1xx are the docstring *presence* rules: every module, class and public function
104
+ # must be documented. The rest of the D family is deliberately left off — D401
105
+ # ("imperative mood") would require rewriting 23 predicate docstrings from
106
+ # "True when the receiver names a database handle" to "Return True when…", which
107
+ # is noise rather than clarity for a boolean-returning function.
108
+ #
109
+ # PLC0415 keeps imports at module level. 26 had drifted into function bodies and
110
+ # only one was load-bearing; the rest were habit, including a constant that the
111
+ # same package already imported at the top elsewhere. The rule has no autofix, so
112
+ # it reports rather than moves — which is what makes it safe here: a real import
113
+ # cycle must be answered with a `# noqa: PLC0415` naming the cycle, not silently
114
+ # rewritten. Note that isort (`I`) does NOT cover this; it only orders the
115
+ # top-level block and leaves a function-level import exactly where it sits.
116
+ select = ["E4", "E7", "E9", "F", "D1", "PLC0415"]
117
+
118
+ [tool.ruff.lint.per-file-ignores]
119
+ # Tests document themselves through their names and module docstrings; requiring
120
+ # one on every fixture helper adds ceremony without adding information.
121
+ #
122
+ # Tests and gate scripts also import inside functions on purpose — to read a
123
+ # module fresh after mutating config, or to assert on import behaviour itself.
124
+ # PLC0415 protects the package, whose import graph is what a cycle can break.
125
+ "tests/*" = ["D1", "PLC0415"]
126
+ "scripts/*" = ["PLC0415"]
@@ -30,7 +30,10 @@ from collections import defaultdict
30
30
  from pathlib import Path
31
31
  from typing import Any
32
32
 
33
- from ..graph_common import load_v2_repo_records, repo_id
33
+ from ..graph_common import load_v2_repo_records, match_path_in_inbound_index, repo_id
34
+ from ..repo_utils import _build_component_identity_index
35
+ from .library_source_map import _resolve_clone_path
36
+ from .service_call_index import build_inbound_index
34
37
 
35
38
  DISCOVERED_FILE = "api-clients.discovered.json"
36
39
 
@@ -144,6 +147,88 @@ def _collect_candidates(
144
147
  return cands
145
148
 
146
149
 
150
+ # A proposed class_pattern is matched as a plain substring in an unguarded,
151
+ # language-agnostic tier, so one appearing across the fleet manufactures phantom
152
+ # edges everywhere rather than merely adding noise.
153
+ MAX_PATTERN_REPOS = 3
154
+
155
+
156
+ def _is_binding_stamped(detail: dict[str, Any]) -> bool:
157
+ """True when a binding, not an observation, put the target on this node.
158
+
159
+ Demand-side discovery resolves targets against routes and aliases that
160
+ promoted bindings already influence. Re-ingesting an edge a binding created
161
+ as fresh evidence *for that binding* inflates confidence on every run, so the
162
+ provenance already recorded in ``target_repo_evidence`` is used to exclude it.
163
+ """
164
+ evidence = detail.get("target_repo_evidence")
165
+ return isinstance(evidence, str) and evidence.startswith("api-client class")
166
+
167
+
168
+ def _enclosing_class(file_path: str) -> str:
169
+ """Best-effort class name for a call site, from its file name.
170
+
171
+ The aggregation phase has the metabase, not the sources, so the enclosing
172
+ class is taken from the file stem. That is exact for Java and Kotlin, where
173
+ the public type must match the file name, and a reasonable proposal
174
+ elsewhere — the reviewer confirms it either way.
175
+ """
176
+ return Path(file_path).stem if file_path else ""
177
+
178
+
179
+ def _repos_containing_class(records: list[dict[str, Any]], class_name: str) -> set[str]:
180
+ """Repos with a file of this name, as a proxy for corpus-wide occurrence."""
181
+ hits: set[str] = set()
182
+ for data in records:
183
+ rid = repo_id(data)
184
+ for node in data.get("nodes", []):
185
+ if _enclosing_class(str(node.get("file", ""))) == class_name:
186
+ hits.add(rid)
187
+ break
188
+ return hits
189
+
190
+
191
+ def _demand_side_observations(
192
+ records: list[dict[str, Any]],
193
+ ) -> dict[str, dict[str, Any]]:
194
+ """Map target repo -> observed call sites that resolve to it.
195
+
196
+ Mines the direction supply-side discovery cannot reach: a call site landing
197
+ on a known service in a repo that declares no client library for it. A repo
198
+ that hand-rolls HTTP has no ``*-client`` dependency, so no amount of
199
+ dependency parsing finds it (OI-4).
200
+ """
201
+
202
+ inbound = build_inbound_index(records)
203
+ memo: dict[str, tuple[list[Any], str]] = {}
204
+ observed: dict[str, dict[str, Any]] = {}
205
+
206
+ for data in records:
207
+ consumer = repo_id(data)
208
+ for node in data.get("nodes", []):
209
+ if node.get("family") != "http-out":
210
+ continue
211
+ detail = node.get("detail") or {}
212
+ if _is_binding_stamped(detail):
213
+ continue
214
+ path = detail.get("path")
215
+ if not isinstance(path, str) or not path:
216
+ continue
217
+ rows, _conf = match_path_in_inbound_index(path, inbound, memo=memo)
218
+ for target, target_path, _method, _ref in rows:
219
+ if target == consumer:
220
+ continue
221
+ entry = observed.setdefault(
222
+ target, {"consumers": set(), "classes": set(), "paths": set()}
223
+ )
224
+ entry["consumers"].add(consumer)
225
+ entry["paths"].add(target_path)
226
+ cls = _enclosing_class(str(node.get("file", "")))
227
+ if cls:
228
+ entry["classes"].add(cls)
229
+ return observed
230
+
231
+
147
232
  def _build_entry(
148
233
  cand: dict[str, Any],
149
234
  scanned: set[str],
@@ -152,6 +237,7 @@ def _build_entry(
152
237
  """Assemble a candidate entry, preserving a reviewer's non-pending decision."""
153
238
  consumers = sorted(cand.pop("consumers"))
154
239
  coordinate = cand.pop("coordinate")
240
+ warnings = cand.pop("warnings", [])
155
241
  has_paths = bool(cand["paths"])
156
242
  evidence = {
157
243
  "coordinate": coordinate,
@@ -167,6 +253,13 @@ def _build_entry(
167
253
  else:
168
254
  entry = {k: cand[k] for k in _TUNABLE_FIELDS}
169
255
  entry["status"] = STATUS_PENDING
256
+ # How the candidate was found. `both` is materially stronger than either
257
+ # alone: a declared dependency and an observed call site are independent
258
+ # lines of evidence. `call-site` sorts lowest, since it rests on the path
259
+ # matching that OI-1 showed can be wrong.
260
+ entry["discovery_method"] = cand.get("discovery_method", "dependency")
261
+ if warnings:
262
+ entry["warnings"] = warnings
170
263
  entry["confidence"] = _confidence(
171
264
  str(entry["target_repo"] or ""), scanned, bool(entry["paths"])
172
265
  )
@@ -174,6 +267,87 @@ def _build_entry(
174
267
  return entry
175
268
 
176
269
 
270
+ def _apply_demand_side(
271
+ cands: dict[str, dict[str, Any]],
272
+ records: list[dict[str, Any]],
273
+ ) -> None:
274
+ """Enrich supply-side candidates with observed call sites, or create new ones.
275
+
276
+ Runs *after* the supply-side pass so it can do a keyed lookup rather than a
277
+ merge: the two directions produce different fields for the same candidate
278
+ (``maven_artifact`` and ``import_prefix`` only exist supply-side;
279
+ ``class_patterns`` only demand-side), and a demand-side-only candidate has no
280
+ artifact id to key on. Sequencing is for correctness, not speed — both passes
281
+ run in the aggregation phase with the fleet already in memory.
282
+ """
283
+ observed = _demand_side_observations(records)
284
+ by_target = {c["target_repo"]: (k, c) for k, c in cands.items() if c["target_repo"]}
285
+
286
+ for target, seen in sorted(observed.items()):
287
+ classes = sorted(seen["classes"])
288
+ warnings = []
289
+ for cls in classes:
290
+ repos = _repos_containing_class(records, cls)
291
+ if len(repos) > MAX_PATTERN_REPOS:
292
+ warnings.append(
293
+ f"class_pattern {cls!r} appears in {len(repos)} repos; too "
294
+ "generic to be safe — narrow it before accepting"
295
+ )
296
+
297
+ existing = by_target.get(target)
298
+ if existing is not None:
299
+ _key, cand = existing
300
+ cand["class_patterns"] = sorted({*cand["class_patterns"], *classes})
301
+ cand["paths"] = sorted({*cand["paths"], *seen["paths"]})
302
+ cand["consumers"] |= seen["consumers"]
303
+ cand["discovery_method"] = "both"
304
+ if warnings:
305
+ cand["warnings"] = warnings
306
+ continue
307
+
308
+ # No dependency declares this hop — the hand-rolled case supply-side
309
+ # discovery cannot reach at all.
310
+ cands[_key_demand(target)] = {
311
+ "target_repo": target,
312
+ "maven_artifact": "",
313
+ "import_prefix": "",
314
+ "paths": sorted(seen["paths"]),
315
+ "payload_fields": ["sql"],
316
+ "service_aliases": [target.split("/")[-1]],
317
+ "class_patterns": classes,
318
+ "coordinate": "",
319
+ "consumers": set(seen["consumers"]),
320
+ "discovery_method": "call-site",
321
+ **({"warnings": warnings} if warnings else {}),
322
+ }
323
+
324
+
325
+ def _key_demand(target_repo: str) -> str:
326
+ """Candidate key for a call-site-only hop, which has no artifact to key on."""
327
+ return _key(target_repo, "")
328
+
329
+
330
+ def discover_api_clients_from_records(
331
+ metabase_root: Path,
332
+ records: list[dict[str, Any]],
333
+ resolve: Any,
334
+ ) -> int:
335
+ """Run both discovery passes over in-memory records; returns the candidate count."""
336
+ scanned = {repo_id(d) for d in records}
337
+ cands = _collect_candidates(records, resolve)
338
+ for cand in cands.values():
339
+ cand.setdefault("discovery_method", "dependency")
340
+ _apply_demand_side(cands, records)
341
+ prev = _load_discovered(metabase_root / DISCOVERED_FILE)
342
+
343
+ entries = [
344
+ _build_entry(cand, scanned, prev.get(key))
345
+ for key, cand in sorted(cands.items())
346
+ ]
347
+ _write_discovered(metabase_root, entries)
348
+ return len(entries)
349
+
350
+
177
351
  def discover_api_clients(
178
352
  metabase_root: Path,
179
353
  repo_jsons: list[Path],
@@ -185,23 +359,19 @@ def discover_api_clients(
185
359
  ``status`` and tuned fields of any candidate a reviewer has already accepted
186
360
  or rejected. Never touches the authoritative ``api-clients.json``.
187
361
  """
188
- from ..repo_utils import _build_component_identity_index
189
- from .library_source_map import _resolve_clone_path
190
362
 
191
363
  records = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
192
364
  by_coord, by_name, by_full = _build_component_identity_index(repos_root)
193
365
 
194
366
  def resolve(coord: str) -> str | None:
367
+ """Resolve a maven coordinate to the repo id that publishes it, or None."""
195
368
  return _resolve_clone_path(coord, by_coord, by_name, by_full)
196
369
 
197
- scanned = {repo_id(d) for d in records}
198
- cands = _collect_candidates(records, resolve)
199
- prev = _load_discovered(metabase_root / DISCOVERED_FILE)
370
+ return discover_api_clients_from_records(metabase_root, records, resolve)
200
371
 
201
- entries = [
202
- _build_entry(cand, scanned, prev.get(key))
203
- for key, cand in sorted(cands.items())
204
- ]
372
+
373
+ def _write_discovered(metabase_root: Path, entries: list[dict[str, Any]]) -> None:
374
+ """Persist the candidate file."""
205
375
  out = {
206
376
  "_comment": (
207
377
  "CANDIDATE api-client bindings discovered by src2sink-build "
@@ -215,7 +385,6 @@ def discover_api_clients(
215
385
  (metabase_root / DISCOVERED_FILE).write_text(
216
386
  json.dumps(out, indent=2, sort_keys=False) + "\n", encoding="utf-8"
217
387
  )
218
- return len(entries)
219
388
 
220
389
 
221
390
  def _load_bindings(path: Path) -> list[dict[str, Any]]:
@@ -34,6 +34,7 @@ def _row_from_record(data: dict[str, Any]) -> dict[str, Any]:
34
34
  "http_out": families.get("http-out", 0),
35
35
  "sql": families.get("sql", 0),
36
36
  "raw_payload": families.get("raw-code-payload", 0),
37
+ "sql_payload_out": families.get("sql-payload-out", 0),
37
38
  "pii_field": families.get("pii-field", 0),
38
39
  "pii_log": families.get("pii-log", 0),
39
40
  "queue": families.get("queue-pub", 0) + families.get("queue-sub", 0),
@@ -6,11 +6,14 @@ src2sink-build from per-repo dependency data; manual edits to
6
6
  """
7
7
 
8
8
  from __future__ import annotations
9
+
9
10
  import json
10
11
  from collections import defaultdict
11
12
  from pathlib import Path
12
13
  from typing import Any
13
14
 
15
+ from ..repo_utils import _build_component_identity_index
16
+
14
17
  STATUS_PENDING = "pending"
15
18
  STATUS_CLONED = "cloned"
16
19
  STATUS_EXCLUDED = "excluded"
@@ -153,7 +156,6 @@ def fix_flagged_mappings(metabase_root: Path, repos_root: Path) -> int:
153
156
  if not isinstance(mappings, list) or not mappings:
154
157
  return 0
155
158
 
156
- from ..repo_utils import _build_component_identity_index
157
159
 
158
160
  by_coord, by_name, by_full = _build_component_identity_index(repos_root)
159
161