src2sink 1.0.2__tar.gz → 2.0.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 (147) hide show
  1. {src2sink-1.0.2/src2sink.egg-info → src2sink-2.0.0}/PKG-INFO +59 -25
  2. {src2sink-1.0.2 → src2sink-2.0.0}/README.md +58 -20
  3. {src2sink-1.0.2 → src2sink-2.0.0}/pyproject.toml +35 -8
  4. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/api_client_discovery.py +180 -11
  5. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/index_v2.py +1 -0
  6. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/library_source_map.py +3 -1
  7. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/openapi_match.py +21 -6
  8. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/service_call_collect.py +144 -11
  9. src2sink-2.0.0/src2sink/aggregators/service_call_report.py +220 -0
  10. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/taint_buckets.py +4 -0
  11. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/taint_writers.py +41 -1
  12. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/build_metabase_v2.py +203 -57
  13. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/ast_walk.py +27 -0
  14. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/base.py +2 -2
  15. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/config.py +14 -12
  16. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/file_context.py +3 -0
  17. src2sink-2.0.0/src2sink/extractors/http_out.py +324 -0
  18. src2sink-2.0.0/src2sink/extractors/patterns.py +408 -0
  19. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/regex_extractors.py +207 -19
  20. src2sink-2.0.0/src2sink/extractors/symbols.py +78 -0
  21. src2sink-2.0.0/src2sink/extractors/ts_extractors.py +212 -0
  22. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/unified.py +8 -1
  23. src2sink-2.0.0/src2sink/graph_common.py +360 -0
  24. src2sink-2.0.0/src2sink/known_api_clients.py +223 -0
  25. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/repo_utils.py +9 -9
  26. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/sanitize.py +7 -2
  27. src2sink-2.0.0/src2sink/schema.py +89 -0
  28. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/trace.py +109 -27
  29. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/trace_batch.py +26 -6
  30. {src2sink-1.0.2 → src2sink-2.0.0/src2sink.egg-info}/PKG-INFO +59 -25
  31. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink.egg-info/SOURCES.txt +13 -0
  32. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink.egg-info/requires.txt +0 -4
  33. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_aggregators_extra.py +11 -11
  34. src2sink-2.0.0/tests/test_ast_walk.py +221 -0
  35. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_build_internals.py +33 -6
  36. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_characterization.py +6 -6
  37. src2sink-2.0.0/tests/test_cross_repo_caller_coverage.py +731 -0
  38. src2sink-2.0.0/tests/test_demand_side_discovery.py +303 -0
  39. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_dependency_pinning.py +85 -1
  40. src2sink-2.0.0/tests/test_detection_fingerprint_gate.py +103 -0
  41. src2sink-2.0.0/tests/test_detection_version.py +116 -0
  42. src2sink-2.0.0/tests/test_gradle_version_catalogs.py +223 -0
  43. src2sink-2.0.0/tests/test_graph_common.py +197 -0
  44. src2sink-2.0.0/tests/test_http_guard_evidence.py +199 -0
  45. src2sink-2.0.0/tests/test_import_graph.py +157 -0
  46. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_internal_groups.py +2 -2
  47. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_limits.py +107 -0
  48. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_openapi.py +9 -9
  49. src2sink-2.0.0/tests/test_phase1_config.py +110 -0
  50. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_phase2_graphs.py +23 -23
  51. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_phase4_aggregators.py +4 -4
  52. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_phase4_extractor_snapshots.py +45 -6
  53. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_phase4_regression.py +2 -2
  54. src2sink-2.0.0/tests/test_prescreen.py +160 -0
  55. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_redos_bounds.py +115 -6
  56. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_sanitize.py +60 -0
  57. src2sink-2.0.0/tests/test_sql_payload_out.py +255 -0
  58. src2sink-2.0.0/tests/test_sql_sink_evidence.py +582 -0
  59. src2sink-2.0.0/tests/test_sql_source_construction.py +272 -0
  60. src2sink-2.0.0/tests/test_trace_fleet_scaling.py +173 -0
  61. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_trace_render.py +41 -12
  62. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_zz_security_coverage_gate.py +48 -1
  63. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_zz_untrusted_interpolation_guard.py +14 -2
  64. src2sink-1.0.2/src2sink/aggregators/service_call_report.py +0 -118
  65. src2sink-1.0.2/src2sink/extractors/http_out.py +0 -108
  66. src2sink-1.0.2/src2sink/extractors/patterns.py +0 -145
  67. src2sink-1.0.2/src2sink/extractors/ts_extractors.py +0 -123
  68. src2sink-1.0.2/src2sink/graph_common.py +0 -213
  69. src2sink-1.0.2/src2sink/known_api_clients.py +0 -108
  70. src2sink-1.0.2/src2sink/schema.py +0 -57
  71. src2sink-1.0.2/tests/test_graph_common.py +0 -74
  72. src2sink-1.0.2/tests/test_phase1_config.py +0 -53
  73. src2sink-1.0.2/tests/test_prescreen.py +0 -81
  74. {src2sink-1.0.2 → src2sink-2.0.0}/LICENSE +0 -0
  75. {src2sink-1.0.2 → src2sink-2.0.0}/setup.cfg +0 -0
  76. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/__init__.py +0 -0
  77. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/__init__.py +0 -0
  78. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/auth_cards.py +0 -0
  79. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/crypto_cards.py +0 -0
  80. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/data_stores.py +0 -0
  81. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/graphs.py +0 -0
  82. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/openapi_discovery.py +0 -0
  83. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/openapi_edges.py +0 -0
  84. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/openapi_models.py +0 -0
  85. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/openapi_report.py +0 -0
  86. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/payload_producers.py +0 -0
  87. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/phase3.py +0 -0
  88. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/pii_cross_repo.py +0 -0
  89. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/pii_flow_v2.py +0 -0
  90. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/pii_lifecycle.py +0 -0
  91. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/pii_lifecycle_report.py +0 -0
  92. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/pii_touchpoint_collect.py +0 -0
  93. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/pii_touchpoint_utils.py +0 -0
  94. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/queues.py +0 -0
  95. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/ropa.py +0 -0
  96. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/service_call_index.py +0 -0
  97. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/service_call_models.py +0 -0
  98. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/service_calls.py +0 -0
  99. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/taint_catalogs.py +0 -0
  100. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/aggregators/traces_index.py +0 -0
  101. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/constants.py +0 -0
  102. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/curate_internal_libraries.py +0 -0
  103. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/__init__.py +0 -0
  104. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/go.py +0 -0
  105. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/java.py +0 -0
  106. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/javascript.py +0 -0
  107. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/kotlin.py +0 -0
  108. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/node_factory.py +0 -0
  109. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/extractors/python_extractor.py +0 -0
  110. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/internal_groups.py +0 -0
  111. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/library_taint_java.py +0 -0
  112. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/limits.py +0 -0
  113. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/models/__init__.py +0 -0
  114. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/models/auth_model.py +0 -0
  115. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/models/crypto_agility.py +0 -0
  116. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/models/pii_lifecycle.py +0 -0
  117. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/models/ropa.py +0 -0
  118. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/prescreen.py +0 -0
  119. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/py.typed +0 -0
  120. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/record_fleet_baseline.py +0 -0
  121. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/renderers/__init__.py +0 -0
  122. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/renderers/markdown.py +0 -0
  123. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/renderers/mermaid.py +0 -0
  124. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/safe_paths.py +0 -0
  125. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink/vocabulary.py +0 -0
  126. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink.egg-info/dependency_links.txt +0 -0
  127. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink.egg-info/entry_points.txt +0 -0
  128. {src2sink-1.0.2 → src2sink-2.0.0}/src2sink.egg-info/top_level.txt +0 -0
  129. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_api_client_discovery.py +0 -0
  130. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_build_main.py +0 -0
  131. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_config_hygiene.py +0 -0
  132. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_curate.py +0 -0
  133. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_field_precision.py +0 -0
  134. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_module_imports.py +0 -0
  135. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_output_neutralisation.py +0 -0
  136. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_phase0_smoke.py +0 -0
  137. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_phase1_extractors.py +0 -0
  138. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_phase3_pii.py +0 -0
  139. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_phase4_fleet_regression.py +0 -0
  140. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_phase4_taint_caps.py +0 -0
  141. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_post_phase4_backlog.py +0 -0
  142. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_renderers.py +0 -0
  143. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_repo_utils_helpers.py +0 -0
  144. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_run_manifest.py +0 -0
  145. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_security_input_handling.py +0 -0
  146. {src2sink-1.0.2 → src2sink-2.0.0}/tests/test_source_map_fix.py +0 -0
  147. {src2sink-1.0.2 → src2sink-2.0.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.2
3
+ Version: 2.0.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,26 +187,55 @@ 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
 
199
- Releases from 1.0.2 carry signed build provenance (SLSA Build L2) tying each
200
- artefact to the source commit, tag, and workflow run that produced it — so a file
201
- claiming to be src2sink can be checked rather than trusted:
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
+
218
+ Releases carry signed build provenance tying each artefact to the source commit,
219
+ tag, and workflow run that produced it — so a file claiming to be src2sink can be
220
+ checked rather than trusted. From 1.0.3 the provenance is generated by an
221
+ isolated builder (**SLSA Build L3**); 1.0.2 is L2; 1.0.0 and 1.0.1 have none.
202
222
 
203
223
  ```sh
204
- # downloaded from the GitHub release
205
- gh attestation verify src2sink-1.0.2-py3-none-any.whl --repo mimecast/src2sink
224
+ # SLSA provenance, attached to the GitHub release (L3)
225
+ slsa-verifier verify-artifact src2sink-1.0.3-py3-none-any.whl \
226
+ --provenance-path multiple.intoto.jsonl \
227
+ --source-uri github.com/mimecast/src2sink --source-tag v1.0.3
228
+
229
+ # GitHub attestation, for the same artefact
230
+ gh attestation verify src2sink-1.0.3-py3-none-any.whl --repo mimecast/src2sink
206
231
 
207
- # installed from PyPI (PEP 740 attestation)
208
- python -m pypi_attestations verify pypi --repo mimecast/src2sink src2sink-1.0.2-*.whl
232
+ # installed from PyPI instead (PEP 740 attestation)
233
+ python -m pypi_attestations verify pypi --repo mimecast/src2sink src2sink-1.0.3-*.whl
209
234
  ```
210
235
 
211
- 1.0.0 and 1.0.1 predate this and have no provenance. What the claim does and does
212
- not cover — and the plan for Build L3 — is in
213
- [`docs/slsa.md`](https://github.com/mimecast/src2sink/blob/main/docs/slsa.md).
236
+ What the claim does and does not cover is in
237
+ [`docs/slsa.md`](https://github.com/mimecast/src2sink/blob/main/docs/slsa.md) —
238
+ the Build track says nothing about dependencies or source trustworthiness.
214
239
 
215
240
  > **Incremental behaviour:** each re-run compares the repo's current `git HEAD` SHA
216
241
  > against the SHA stored in the existing per-repo JSON. Repos that haven't changed are
@@ -293,8 +318,18 @@ resolved; anything still flagged afterwards has no matching `pom.xml` on disk
293
318
 
294
319
  ### Register another API client library
295
320
 
296
- Edit `src2sink/known_api_clients.py` (`ApiClientBinding`), then re-run
297
- `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.
298
333
 
299
334
  ---
300
335
 
@@ -352,7 +387,7 @@ Treat **low** as “investigate”, not “confirmed”.
352
387
  ## Dependencies
353
388
 
354
389
  v2 requires **tree-sitter** and per-language grammars (via
355
- `uv sync`). See `metabase/docs/phase0-probe.md` if resolution fails.
390
+ `uv sync`).
356
391
  Do not add public PyPI fallbacks.
357
392
 
358
393
  ---
@@ -360,7 +395,6 @@ Do not add public PyPI fallbacks.
360
395
  ## See also
361
396
 
362
397
  - [`SCHEMA.md`](https://github.com/mimecast/src2sink/blob/main/SCHEMA.md) — complete v2 node/edge vocabulary.
363
- - [`docs/implementation-plan.md`](https://github.com/mimecast/src2sink/blob/main/docs/implementation-plan.md) — **phased plan and progress record**.
364
398
  - [`docs/architecture.md`](https://github.com/mimecast/src2sink/blob/main/docs/architecture.md) — components, data flow, trust boundaries.
365
399
  - [`docs/threat-model.md`](https://github.com/mimecast/src2sink/blob/main/docs/threat-model.md) — STRIDE risk register.
366
400
  - [`docs/security-privacy-gap-analysis.md`](https://github.com/mimecast/src2sink/blob/main/docs/security-privacy-gap-analysis.md) — requirements, abuse cases, SRTM.
@@ -369,7 +403,7 @@ Do not add public PyPI fallbacks.
369
403
  - [`docs/api-clients-json.md`](https://github.com/mimecast/src2sink/blob/main/docs/api-clients-json.md) — the internal-service binding file.
370
404
  - [`metabase-usage.md`](https://github.com/mimecast/src2sink/blob/main/metabase-usage.md) — using the output as LLM context.
371
405
  - [`docs/releasing.md`](https://github.com/mimecast/src2sink/blob/main/docs/releasing.md) — publishing a version to PyPI.
372
- - [`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.
373
407
  - [`CHANGELOG.md`](https://github.com/mimecast/src2sink/blob/main/CHANGELOG.md) — what shipped in each version.
374
- - [`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.
375
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,26 +156,55 @@ 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
 
164
- Releases from 1.0.2 carry signed build provenance (SLSA Build L2) tying each
165
- artefact to the source commit, tag, and workflow run that produced it — so a file
166
- claiming to be src2sink can be checked rather than trusted:
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
+
187
+ Releases carry signed build provenance tying each artefact to the source commit,
188
+ tag, and workflow run that produced it — so a file claiming to be src2sink can be
189
+ checked rather than trusted. From 1.0.3 the provenance is generated by an
190
+ isolated builder (**SLSA Build L3**); 1.0.2 is L2; 1.0.0 and 1.0.1 have none.
167
191
 
168
192
  ```sh
169
- # downloaded from the GitHub release
170
- gh attestation verify src2sink-1.0.2-py3-none-any.whl --repo mimecast/src2sink
193
+ # SLSA provenance, attached to the GitHub release (L3)
194
+ slsa-verifier verify-artifact src2sink-1.0.3-py3-none-any.whl \
195
+ --provenance-path multiple.intoto.jsonl \
196
+ --source-uri github.com/mimecast/src2sink --source-tag v1.0.3
197
+
198
+ # GitHub attestation, for the same artefact
199
+ gh attestation verify src2sink-1.0.3-py3-none-any.whl --repo mimecast/src2sink
171
200
 
172
- # installed from PyPI (PEP 740 attestation)
173
- python -m pypi_attestations verify pypi --repo mimecast/src2sink src2sink-1.0.2-*.whl
201
+ # installed from PyPI instead (PEP 740 attestation)
202
+ python -m pypi_attestations verify pypi --repo mimecast/src2sink src2sink-1.0.3-*.whl
174
203
  ```
175
204
 
176
- 1.0.0 and 1.0.1 predate this and have no provenance. What the claim does and does
177
- not cover — and the plan for Build L3 — is in
178
- [`docs/slsa.md`](https://github.com/mimecast/src2sink/blob/main/docs/slsa.md).
205
+ What the claim does and does not cover is in
206
+ [`docs/slsa.md`](https://github.com/mimecast/src2sink/blob/main/docs/slsa.md) —
207
+ the Build track says nothing about dependencies or source trustworthiness.
179
208
 
180
209
  > **Incremental behaviour:** each re-run compares the repo's current `git HEAD` SHA
181
210
  > against the SHA stored in the existing per-repo JSON. Repos that haven't changed are
@@ -258,8 +287,18 @@ resolved; anything still flagged afterwards has no matching `pom.xml` on disk
258
287
 
259
288
  ### Register another API client library
260
289
 
261
- Edit `src2sink/known_api_clients.py` (`ApiClientBinding`), then re-run
262
- `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.
263
302
 
264
303
  ---
265
304
 
@@ -317,7 +356,7 @@ Treat **low** as “investigate”, not “confirmed”.
317
356
  ## Dependencies
318
357
 
319
358
  v2 requires **tree-sitter** and per-language grammars (via
320
- `uv sync`). See `metabase/docs/phase0-probe.md` if resolution fails.
359
+ `uv sync`).
321
360
  Do not add public PyPI fallbacks.
322
361
 
323
362
  ---
@@ -325,7 +364,6 @@ Do not add public PyPI fallbacks.
325
364
  ## See also
326
365
 
327
366
  - [`SCHEMA.md`](https://github.com/mimecast/src2sink/blob/main/SCHEMA.md) — complete v2 node/edge vocabulary.
328
- - [`docs/implementation-plan.md`](https://github.com/mimecast/src2sink/blob/main/docs/implementation-plan.md) — **phased plan and progress record**.
329
367
  - [`docs/architecture.md`](https://github.com/mimecast/src2sink/blob/main/docs/architecture.md) — components, data flow, trust boundaries.
330
368
  - [`docs/threat-model.md`](https://github.com/mimecast/src2sink/blob/main/docs/threat-model.md) — STRIDE risk register.
331
369
  - [`docs/security-privacy-gap-analysis.md`](https://github.com/mimecast/src2sink/blob/main/docs/security-privacy-gap-analysis.md) — requirements, abuse cases, SRTM.
@@ -334,7 +372,7 @@ Do not add public PyPI fallbacks.
334
372
  - [`docs/api-clients-json.md`](https://github.com/mimecast/src2sink/blob/main/docs/api-clients-json.md) — the internal-service binding file.
335
373
  - [`metabase-usage.md`](https://github.com/mimecast/src2sink/blob/main/metabase-usage.md) — using the output as LLM context.
336
374
  - [`docs/releasing.md`](https://github.com/mimecast/src2sink/blob/main/docs/releasing.md) — publishing a version to PyPI.
337
- - [`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.
338
376
  - [`CHANGELOG.md`](https://github.com/mimecast/src2sink/blob/main/CHANGELOG.md) — what shipped in each version.
339
- - [`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.
340
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.2"
3
+ version = "2.0.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),