src2sink 2.0.0__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 (148) hide show
  1. {src2sink-2.0.0/src2sink.egg-info → src2sink-2.1.0}/PKG-INFO +1 -1
  2. {src2sink-2.0.0 → src2sink-2.1.0}/pyproject.toml +1 -1
  3. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/build_metabase_v2.py +105 -7
  4. src2sink-2.1.0/src2sink/dependencies.py +214 -0
  5. src2sink-2.1.0/src2sink/derive.py +250 -0
  6. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/patterns.py +37 -1
  7. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/regex_extractors.py +15 -1
  8. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/ts_extractors.py +74 -82
  9. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/unified.py +7 -4
  10. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/graph_common.py +48 -4
  11. src2sink-2.1.0/src2sink/maven.py +334 -0
  12. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/repo_utils.py +33 -23
  13. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/schema.py +7 -1
  14. {src2sink-2.0.0 → src2sink-2.1.0/src2sink.egg-info}/PKG-INFO +1 -1
  15. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink.egg-info/SOURCES.txt +11 -0
  16. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_build_internals.py +6 -1
  17. src2sink-2.1.0/tests/test_call_observations.py +125 -0
  18. src2sink-2.1.0/tests/test_derive_pass.py +164 -0
  19. src2sink-2.1.0/tests/test_detection_input_coverage.py +134 -0
  20. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_detection_version.py +6 -1
  21. src2sink-2.1.0/tests/test_maven_resolution.py +239 -0
  22. src2sink-2.1.0/tests/test_oi26_receiver_scope.py +141 -0
  23. src2sink-2.1.0/tests/test_path_match_significance.py +110 -0
  24. src2sink-2.1.0/tests/test_polyglot_dependencies.py +164 -0
  25. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_redos_bounds.py +5 -0
  26. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_repo_utils_helpers.py +0 -9
  27. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_security_input_handling.py +5 -4
  28. src2sink-2.1.0/tests/test_sql_classifier.py +114 -0
  29. {src2sink-2.0.0 → src2sink-2.1.0}/LICENSE +0 -0
  30. {src2sink-2.0.0 → src2sink-2.1.0}/README.md +0 -0
  31. {src2sink-2.0.0 → src2sink-2.1.0}/setup.cfg +0 -0
  32. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/__init__.py +0 -0
  33. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/__init__.py +0 -0
  34. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/api_client_discovery.py +0 -0
  35. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/auth_cards.py +0 -0
  36. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/crypto_cards.py +0 -0
  37. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/data_stores.py +0 -0
  38. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/graphs.py +0 -0
  39. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/index_v2.py +0 -0
  40. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/library_source_map.py +0 -0
  41. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/openapi_discovery.py +0 -0
  42. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/openapi_edges.py +0 -0
  43. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/openapi_match.py +0 -0
  44. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/openapi_models.py +0 -0
  45. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/openapi_report.py +0 -0
  46. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/payload_producers.py +0 -0
  47. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/phase3.py +0 -0
  48. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/pii_cross_repo.py +0 -0
  49. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/pii_flow_v2.py +0 -0
  50. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/pii_lifecycle.py +0 -0
  51. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/pii_lifecycle_report.py +0 -0
  52. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/pii_touchpoint_collect.py +0 -0
  53. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/pii_touchpoint_utils.py +0 -0
  54. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/queues.py +0 -0
  55. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/ropa.py +0 -0
  56. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/service_call_collect.py +0 -0
  57. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/service_call_index.py +0 -0
  58. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/service_call_models.py +0 -0
  59. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/service_call_report.py +0 -0
  60. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/service_calls.py +0 -0
  61. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/taint_buckets.py +0 -0
  62. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/taint_catalogs.py +0 -0
  63. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/taint_writers.py +0 -0
  64. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/traces_index.py +0 -0
  65. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/constants.py +0 -0
  66. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/curate_internal_libraries.py +0 -0
  67. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/__init__.py +0 -0
  68. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/ast_walk.py +0 -0
  69. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/base.py +0 -0
  70. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/config.py +0 -0
  71. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/file_context.py +0 -0
  72. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/go.py +0 -0
  73. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/http_out.py +0 -0
  74. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/java.py +0 -0
  75. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/javascript.py +0 -0
  76. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/kotlin.py +0 -0
  77. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/node_factory.py +0 -0
  78. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/python_extractor.py +0 -0
  79. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/symbols.py +0 -0
  80. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/internal_groups.py +0 -0
  81. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/known_api_clients.py +0 -0
  82. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/library_taint_java.py +0 -0
  83. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/limits.py +0 -0
  84. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/models/__init__.py +0 -0
  85. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/models/auth_model.py +0 -0
  86. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/models/crypto_agility.py +0 -0
  87. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/models/pii_lifecycle.py +0 -0
  88. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/models/ropa.py +0 -0
  89. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/prescreen.py +0 -0
  90. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/py.typed +0 -0
  91. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/record_fleet_baseline.py +0 -0
  92. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/renderers/__init__.py +0 -0
  93. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/renderers/markdown.py +0 -0
  94. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/renderers/mermaid.py +0 -0
  95. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/safe_paths.py +0 -0
  96. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/sanitize.py +0 -0
  97. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/trace.py +0 -0
  98. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/trace_batch.py +0 -0
  99. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/vocabulary.py +0 -0
  100. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink.egg-info/dependency_links.txt +0 -0
  101. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink.egg-info/entry_points.txt +0 -0
  102. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink.egg-info/requires.txt +0 -0
  103. {src2sink-2.0.0 → src2sink-2.1.0}/src2sink.egg-info/top_level.txt +0 -0
  104. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_aggregators_extra.py +0 -0
  105. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_api_client_discovery.py +0 -0
  106. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_ast_walk.py +0 -0
  107. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_build_main.py +0 -0
  108. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_characterization.py +0 -0
  109. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_config_hygiene.py +0 -0
  110. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_cross_repo_caller_coverage.py +0 -0
  111. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_curate.py +0 -0
  112. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_demand_side_discovery.py +0 -0
  113. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_dependency_pinning.py +0 -0
  114. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_detection_fingerprint_gate.py +0 -0
  115. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_field_precision.py +0 -0
  116. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_gradle_version_catalogs.py +0 -0
  117. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_graph_common.py +0 -0
  118. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_http_guard_evidence.py +0 -0
  119. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_import_graph.py +0 -0
  120. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_internal_groups.py +0 -0
  121. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_limits.py +0 -0
  122. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_module_imports.py +0 -0
  123. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_openapi.py +0 -0
  124. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_output_neutralisation.py +0 -0
  125. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase0_smoke.py +0 -0
  126. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase1_config.py +0 -0
  127. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase1_extractors.py +0 -0
  128. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase2_graphs.py +0 -0
  129. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase3_pii.py +0 -0
  130. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase4_aggregators.py +0 -0
  131. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase4_extractor_snapshots.py +0 -0
  132. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase4_fleet_regression.py +0 -0
  133. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase4_regression.py +0 -0
  134. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase4_taint_caps.py +0 -0
  135. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_post_phase4_backlog.py +0 -0
  136. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_prescreen.py +0 -0
  137. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_renderers.py +0 -0
  138. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_run_manifest.py +0 -0
  139. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_sanitize.py +0 -0
  140. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_source_map_fix.py +0 -0
  141. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_sql_payload_out.py +0 -0
  142. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_sql_sink_evidence.py +0 -0
  143. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_sql_source_construction.py +0 -0
  144. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_trace_batch.py +0 -0
  145. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_trace_fleet_scaling.py +0 -0
  146. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_trace_render.py +0 -0
  147. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_zz_security_coverage_gate.py +0 -0
  148. {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_zz_untrusted_interpolation_guard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: src2sink
3
- Version: 2.0.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "src2sink"
3
- version = "2.0.0"
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"
@@ -32,6 +32,13 @@ from .aggregators.phase3 import aggregate_phase3_v2
32
32
  from .aggregators.pii_flow_v2 import write_pii_flow_v2
33
33
  from .aggregators.taint_catalogs import aggregate_taint_catalogs_v2
34
34
  from .constants import MAX_FILE_BYTES, SKIP_DIRS, SOURCE_EXTENSIONS
35
+ from .maven import resolve_pom_dependencies
36
+ from .dependencies import (
37
+ parse_go_mod,
38
+ parse_npm_dependencies,
39
+ parse_python_dependencies,
40
+ unparsed_ecosystem_notes,
41
+ )
35
42
  from .extractors.config import extract_from_config, is_config_path
36
43
  from .extractors.unified import extract_from_file
37
44
  from .internal_groups import (
@@ -52,12 +59,22 @@ from .repo_utils import (
52
59
  detect_git_sha,
53
60
  is_internal_coordinate,
54
61
  is_skipped_path,
55
- parse_package_json_dependencies,
56
- parse_pom_dependencies,
57
62
  )
58
63
  from .safe_paths import is_escaping_symlink
59
64
  from .sanitize import redact_literals
60
- from .schema import DETECTION_VERSION, SCHEMA_VERSION, FlowEdge, FlowNode, RepoSummaryV2
65
+ from .derive import (
66
+ DERIVATION_VERSION,
67
+ OBSERVATION_FAMILIES,
68
+ derive_from_observations,
69
+ is_derived,
70
+ )
71
+ from .schema import (
72
+ DETECTION_VERSION,
73
+ SCHEMA_VERSION,
74
+ FlowEdge,
75
+ FlowNode,
76
+ RepoSummaryV2,
77
+ )
61
78
 
62
79
  ROOT = Path(__file__).resolve().parent.parent.parent
63
80
 
@@ -251,9 +268,12 @@ def _collect_dependencies(repo_root: Path) -> tuple[list[dict[str, str]], list[s
251
268
  """
252
269
  deps: list[dict[str, str]] = []
253
270
  notes: list[str] = []
271
+ # The fleet root is two levels up (repos/<group>/<name>), and is what lets a
272
+ # parent POM in another repository resolve without a registry (OI-18).
273
+ fleet_root = repo_root.parent.parent if repo_root.parent.parent.is_dir() else None
254
274
  for pom in repo_root.rglob("pom.xml"):
255
275
  if not is_skipped_path(pom, repo_root):
256
- deps.extend(parse_pom_dependencies(pom))
276
+ deps.extend(resolve_pom_dependencies(pom, repo_root, fleet_root))
257
277
  gradle_paths = [
258
278
  g
259
279
  for g in list(repo_root.rglob("build.gradle")) + list(repo_root.rglob("build.gradle.kts"))
@@ -272,10 +292,22 @@ def _collect_dependencies(repo_root: Path) -> tuple[list[dict[str, str]], list[s
272
292
  "in build.gradle* matched no catalog entry; dependencies may be "
273
293
  "incomplete (looked for *.versions.toml and settings.gradle*)"
274
294
  )
295
+ deps.extend(_collect_non_jvm_dependencies(repo_root))
296
+ notes.extend(unparsed_ecosystem_notes(repo_root))
297
+ return deps, notes
298
+
299
+
300
+ def _collect_non_jvm_dependencies(repo_root: Path) -> list[dict[str, str]]:
301
+ """Gather npm, Go and Python dependencies, preferring lockfiles (OI-19)."""
302
+ deps: list[dict[str, str]] = []
275
303
  for pkg in repo_root.rglob("package.json"):
276
304
  if not is_skipped_path(pkg, repo_root):
277
- deps.extend(parse_package_json_dependencies(pkg))
278
- return deps, notes
305
+ deps.extend(parse_npm_dependencies(repo_root, pkg))
306
+ for gomod in repo_root.rglob("go.mod"):
307
+ if not is_skipped_path(gomod, repo_root):
308
+ deps.extend(parse_go_mod(gomod))
309
+ deps.extend(parse_python_dependencies(repo_root))
310
+ return deps
279
311
 
280
312
 
281
313
  def _record_dependencies(summary: RepoSummaryV2, deps: list[dict[str, str]]) -> None:
@@ -358,6 +390,7 @@ def analyse_repo_v2(repo_root: Path, group: str, name: str, path_rel: str) -> Re
358
390
  path=path_rel,
359
391
  schema_version=SCHEMA_VERSION,
360
392
  detection_version=DETECTION_VERSION,
393
+ derivation_version=DERIVATION_VERSION,
361
394
  )
362
395
  summary.git_sha = detect_git_sha(repo_root)
363
396
  summary.analysed_at = dt.datetime.now(dt.timezone.utc).isoformat()
@@ -402,6 +435,20 @@ def summary_to_dict(summary: RepoSummaryV2) -> dict[str, Any]:
402
435
  return d
403
436
 
404
437
 
438
+ # The evidence string raw-code-payload edges carry, so a re-derive can drop the
439
+ # previous ones without touching edges from other passes.
440
+ _RAW_PAYLOAD_EVIDENCE = "endpoint accepts a raw SQL-shaped field executed in this file"
441
+
442
+
443
+ def _read_json(path: Path) -> dict[str, Any]:
444
+ """Read a JSON record, returning an empty mapping if it cannot be read."""
445
+ try:
446
+ result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
447
+ except (OSError, json.JSONDecodeError):
448
+ return {}
449
+ return result
450
+
451
+
405
452
  def _existing_record_is_current(json_path: Path, current_sha: str) -> bool:
406
453
  """True if a prior run's record was built from this source *by this detector*.
407
454
 
@@ -427,6 +474,51 @@ def _existing_record_is_current(json_path: Path, current_sha: str) -> bool:
427
474
  )
428
475
 
429
476
 
477
+ def _observed_nodes(data: dict[str, Any]) -> list[FlowNode] | None:
478
+ """Return a record's observation nodes, or None if it predates the layer.
479
+
480
+ A record with no observations cannot be re-derived — there is nothing to
481
+ derive *from* — so the caller must fall back to a full re-analysis.
482
+ """
483
+ nodes = [FlowNode(**n) for n in (data.get("nodes") or [])]
484
+ if not any(n.family in OBSERVATION_FAMILIES for n in nodes):
485
+ return None
486
+ return [n for n in nodes if not is_derived(n)]
487
+
488
+
489
+ def _rederive_record(json_path: Path) -> bool:
490
+ """Recompute a record's derived nodes from its own observations, in place.
491
+
492
+ The observations are still good — only the rules that interpret them changed —
493
+ so this rewrites the findings without re-reading a single source file. That is
494
+ the point of separating observation from derivation: correcting a
495
+ classification costs a pass over records rather than a fleet rescan.
496
+
497
+ Returns False if the record cannot be read or predates the observation layer,
498
+ in which case the caller must fall back to a full re-analysis.
499
+ """
500
+ try:
501
+ data = json.loads(json_path.read_text(encoding="utf-8"))
502
+ except (OSError, json.JSONDecodeError):
503
+ return False
504
+ observed = _observed_nodes(data)
505
+ if observed is None:
506
+ return False
507
+
508
+ derived_nodes, derived_edges = derive_from_observations(observed)
509
+ kept_edges = [
510
+ e for e in (data.get("edges") or [])
511
+ if e.get("evidence") != _RAW_PAYLOAD_EVIDENCE
512
+ ]
513
+ data["nodes"] = [dataclasses.asdict(n) for n in [*observed, *derived_nodes]]
514
+ data["edges"] = kept_edges + [dataclasses.asdict(e) for e in derived_edges]
515
+ data["derivation_version"] = DERIVATION_VERSION
516
+ json_path.write_text(
517
+ json.dumps(data, indent=2, sort_keys=True), encoding="utf-8"
518
+ )
519
+ return True
520
+
521
+
430
522
  def process_one_v2(args: tuple[Path, str, str, Path, bool]) -> dict[str, Any] | None:
431
523
  """Worker unit of work: analyse one repo (unless unchanged) and write its JSON/Markdown."""
432
524
  repo_root, group, name, metabase_root, force = args
@@ -435,7 +527,13 @@ def process_one_v2(args: tuple[Path, str, str, Path, bool]) -> dict[str, Any] |
435
527
  if not force:
436
528
  current_sha = detect_git_sha(repo_root)
437
529
  if current_sha and _existing_record_is_current(json_path, current_sha):
438
- return {"_skipped": True, "group": group, "name": name}
530
+ existing = _read_json(json_path)
531
+ if existing.get("derivation_version") == DERIVATION_VERSION:
532
+ return {"_skipped": True, "group": group, "name": name}
533
+ # Source and detector both match; only the interpretation moved. Redo
534
+ # the findings from the observations already recorded — no parsing.
535
+ if _rederive_record(json_path):
536
+ return {"_rederived": True, "group": group, "name": name}
439
537
 
440
538
  try:
441
539
  path_rel = str(repo_root.relative_to(repo_root.parent.parent))
@@ -0,0 +1,214 @@
1
+ """Declared dependencies, per ecosystem, resolved as far as offline data allows.
2
+
3
+ Three rules run through everything here, and they are the corrections `OI-18` and
4
+ `OI-19` were raised for.
5
+
6
+ **Lockfile before manifest.** A committed lockfile *is* the effective
7
+ resolution — exact, offline, no registry. Maven needs inheritance chasing only
8
+ because it has no lockfile convention; treating it as the representative case is
9
+ what left Go and Python unparsed while the hard ecosystem got the attention.
10
+
11
+ **A range is not a version.** `^1.4.2` names a set. Where no lockfile exists the
12
+ honest record is the constraint, marked as one, so nothing downstream compares it
13
+ as though it were a point.
14
+
15
+ **An absent parse is not an empty result.** A repo in an unparsed ecosystem must
16
+ say so, or `dependencies_internal: []` reads as "no internal dependencies".
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ import tomllib
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from .repo_utils import is_internal_coordinate, safe_read_text
28
+
29
+ # Ecosystems recognised for *identity* but not for dependencies. Listing them
30
+ # lets a scan say so, rather than emitting an empty list that reads as a result.
31
+ UNPARSED_MANIFESTS = {
32
+ "Cargo.toml": "rust",
33
+ "composer.json": "php",
34
+ "Gemfile": "ruby",
35
+ }
36
+
37
+ _GOMOD_REQUIRE_BLOCK = re.compile(r"require\s*\(([^)]{0,20000})\)", re.S)
38
+ _GOMOD_REQUIRE_LINE = re.compile(
39
+ r"^\s*(?:require\s+)?([A-Za-z0-9._~\-]+(?:\.[A-Za-z]{2,}|)"
40
+ r"(?:/[A-Za-z0-9._~\-]+){1,10})\s+(v[0-9][A-Za-z0-9.+\-]{0,60})",
41
+ re.M,
42
+ )
43
+ _PY_REQUIREMENT = re.compile(
44
+ r"^\s*([A-Za-z0-9][A-Za-z0-9._\-]{0,120})\s*([<>=!~^][^;#]{0,120})?"
45
+ )
46
+
47
+
48
+ def _dep(name: str, version: str, kind_of_version: str, *, group: str = "") -> dict[str, str]:
49
+ """Build one dependency record, classified internal or external."""
50
+ return {
51
+ "groupId": group,
52
+ "artifactId": name,
53
+ "version": version,
54
+ "version_kind": kind_of_version,
55
+ "kind": "internal" if is_internal_coordinate(group or name, name) else "external",
56
+ }
57
+
58
+
59
+ def parse_go_mod(path: Path) -> list[dict[str, str]]:
60
+ """Parse ``require`` entries from a ``go.mod``.
61
+
62
+ The easiest ecosystem in the set and the one that was missing: Go states
63
+ exact versions with no ranges, no properties and no inheritance, because
64
+ minimal version selection resolves at build time from these values alone.
65
+ Indirect requirements are included — a transitive internal dependency is
66
+ still a dependency on an internal component.
67
+ """
68
+ text = safe_read_text(path)
69
+ if not text:
70
+ return []
71
+ # `require (...)` blocks and single-line `require x v1` both appear.
72
+ bodies = _GOMOD_REQUIRE_BLOCK.findall(text)
73
+ bodies.extend(
74
+ line for line in text.splitlines() if line.strip().startswith("require ")
75
+ )
76
+ deps: list[dict[str, str]] = []
77
+ for body in bodies:
78
+ for module, version in _GOMOD_REQUIRE_LINE.findall(body):
79
+ deps.append(_dep(module, version, "resolved"))
80
+ return deps
81
+
82
+
83
+ def _python_lock_versions(repo_root: Path) -> dict[str, str]:
84
+ """Map distribution name to resolved version from any committed Python lockfile."""
85
+ for name in ("uv.lock", "poetry.lock"):
86
+ text = safe_read_text(repo_root / name)
87
+ if not text:
88
+ continue
89
+ try:
90
+ data = tomllib.loads(text)
91
+ except tomllib.TOMLDecodeError:
92
+ continue
93
+ return {
94
+ str(pkg.get("name", "")): str(pkg.get("version", ""))
95
+ for pkg in data.get("package", [])
96
+ if pkg.get("name")
97
+ }
98
+ return {}
99
+
100
+
101
+ def parse_python_dependencies(repo_root: Path) -> list[dict[str, str]]:
102
+ """Parse Python dependencies, preferring a lockfile's resolved versions.
103
+
104
+ ``pyproject.toml`` states ranges; the lockfile states what those ranges
105
+ became. Where both exist the lockfile wins, and where only the manifest
106
+ exists the range is recorded as a range.
107
+ """
108
+ text = safe_read_text(repo_root / "pyproject.toml")
109
+ if not text:
110
+ return []
111
+ try:
112
+ project = tomllib.loads(text).get("project") or {}
113
+ except tomllib.TOMLDecodeError:
114
+ return []
115
+
116
+ locked = _python_lock_versions(repo_root)
117
+ deps: list[dict[str, str]] = []
118
+ for requirement in project.get("dependencies") or []:
119
+ m = _PY_REQUIREMENT.match(str(requirement))
120
+ if not m:
121
+ continue
122
+ name, spec = m.group(1), (m.group(2) or "").strip()
123
+ if name in locked:
124
+ deps.append(_dep(name, locked[name], "resolved"))
125
+ else:
126
+ deps.append(_dep(name, spec, "range" if spec else "unresolved"))
127
+ return deps
128
+
129
+
130
+ def _npm_lock_versions(repo_root: Path) -> dict[str, str]:
131
+ """Map package name to resolved version from ``package-lock.json``.
132
+
133
+ Only the npm lockfile is parsed. ``yarn.lock`` and ``pnpm-lock.yaml`` use
134
+ bespoke formats; a repo carrying one falls back to the manifest range, which
135
+ is recorded honestly as a range rather than guessed at.
136
+ """
137
+ text = safe_read_text(repo_root / "package-lock.json")
138
+ if not text:
139
+ return {}
140
+ try:
141
+ data = json.loads(text)
142
+ except json.JSONDecodeError:
143
+ return {}
144
+ out = _npm_lock_packages(data.get("packages") or {})
145
+ for name, entry in (data.get("dependencies") or {}).items():
146
+ version = (entry or {}).get("version")
147
+ if version:
148
+ out.setdefault(name, str(version))
149
+ return out
150
+
151
+
152
+ def _npm_lock_packages(packages: dict[str, Any]) -> dict[str, str]:
153
+ """Read resolved versions from a lockfileVersion 2/3 ``packages`` map."""
154
+ out: dict[str, str] = {}
155
+ for path_key, entry in packages.items():
156
+ if not path_key.startswith("node_modules/"):
157
+ continue
158
+ name = path_key.split("node_modules/", 1)[1]
159
+ version = (entry or {}).get("version")
160
+ if name and version:
161
+ out[name] = str(version)
162
+ return out
163
+
164
+
165
+ def parse_npm_dependencies(repo_root: Path, package_json: Path) -> list[dict[str, str]]:
166
+ """Parse npm dependencies, preferring the lockfile's resolved versions."""
167
+ text = safe_read_text(package_json)
168
+ if not text:
169
+ return []
170
+ try:
171
+ data = json.loads(text)
172
+ except json.JSONDecodeError:
173
+ return []
174
+
175
+ locked = _npm_lock_versions(repo_root)
176
+ deps: list[dict[str, str]] = []
177
+ for section in ("dependencies", "devDependencies"):
178
+ for name, spec in (data.get(section) or {}).items():
179
+ if name in locked:
180
+ deps.append(_dep(name, locked[name], "resolved"))
181
+ else:
182
+ deps.append(_dep(name, str(spec), "range"))
183
+ return deps
184
+
185
+
186
+ def unparsed_ecosystem_notes(repo_root: Path) -> list[str]:
187
+ """Note any manifest whose ecosystem is recognised but not parsed.
188
+
189
+ Without this, `dependencies_internal: []` means both "no internal
190
+ dependencies" and "we cannot read this repo's manifest", and a reviewer has
191
+ no way to tell which — the failure §6 of the open-issues document describes.
192
+ """
193
+ notes: list[str] = []
194
+ for manifest, ecosystem in sorted(UNPARSED_MANIFESTS.items()):
195
+ if (repo_root / manifest).is_file():
196
+ notes.append(
197
+ f"{manifest} present but {ecosystem} dependencies are not parsed; "
198
+ "dependencies_internal is incomplete for this repo, not empty"
199
+ )
200
+ return notes
201
+
202
+
203
+ def dependency_summary(deps: list[dict[str, Any]]) -> dict[str, int]:
204
+ """Count dependencies by how well their version is known.
205
+
206
+ Surfaces the `OI-18` question at fleet scale: how much of the dependency data
207
+ is a version, how much a constraint, and how much could not be worked out.
208
+ """
209
+ counts = {"resolved": 0, "range": 0, "unresolved": 0}
210
+ for d in deps:
211
+ counts[str(d.get("version_kind", "unresolved"))] = counts.get(
212
+ str(d.get("version_kind", "unresolved")), 0
213
+ ) + 1
214
+ return counts
@@ -0,0 +1,250 @@
1
+ """Turn recorded observations into findings, without reading any source.
2
+
3
+ Extraction records *what was seen*: a call, its receiver, whether that receiver
4
+ reads as a database, whether the file showed SQL evidence. This module decides
5
+ *what that means*.
6
+
7
+ The split exists so that a correction is cheap. While classification ran inside
8
+ extraction, changing what a library means changed which nodes existed, so every
9
+ change to a rule needed the fleet re-parsed — and a defect like `OI-26` could
10
+ only be fixed by re-scanning every repository. Derivation reads records, so the
11
+ same correction is a pass over data already on disk: no repositories checked
12
+ out, no parsing, no `DETECTION_VERSION` bump.
13
+
14
+ Everything here is a pure function of the observation nodes handed to it. If
15
+ something in this module ever needs the source text, the observation record was
16
+ missing a field and *that* is the bug — see
17
+ ``tests/test_derive_pass.py::test_derivation_reproduces_the_findings_from_observations_alone``,
18
+ which enforces it by throwing the source away.
19
+
20
+ See docs/plans/observe-then-classify.md §3.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Iterator
26
+ from typing import Any
27
+
28
+ from .extractors.node_factory import make_edge, make_node
29
+ from .extractors.patterns import (
30
+ SQL_EXECUTION_SINK_NAMES,
31
+ SQL_SINK_NAMES,
32
+ receiver_is_another_boundary,
33
+ )
34
+ from .schema import FlowEdge, FlowNode
35
+
36
+ # Produced *here* rather than by extraction. Keyed on (family, kind) rather than
37
+ # family alone, because `sql` is both: a `sql`/`sink` is derived from a call
38
+ # observation, while a `sql`/`source` is a string-concatenation finding the regex
39
+ # pass observes directly. Stripping by family would delete the latter and
40
+ # derivation would not rebuild it.
41
+ # What *interpreted* the observations, as distinct from what recorded them.
42
+ #
43
+ # Bumping this invalidates derived nodes and nothing else: the observations are
44
+ # still good, so a repo is re-derived from its existing record rather than
45
+ # re-parsed. Bump DETECTION_VERSION instead when what is *observed* changes,
46
+ # which is the more expensive event.
47
+ #
48
+ # It lives here rather than in schema.py deliberately. schema.py is a *detection*
49
+ # input, so a bump there would change the detection fingerprint and force the
50
+ # fleet rescan this separation exists to avoid.
51
+ DERIVATION_VERSION = 1
52
+
53
+ DERIVED_FAMILIES = frozenset({("sql", "sink"), ("raw-code-payload", "source")})
54
+
55
+ # Families that are pure observation: recorded by extraction, never findings in
56
+ # themselves, and read by the derivations below.
57
+ OBSERVATION_FAMILIES = frozenset({"call-site", "sql-field-marker"})
58
+
59
+
60
+ def is_derived(node: FlowNode) -> bool:
61
+ """True if a node is produced by derivation rather than observed by extraction.
62
+
63
+ The inverse selects the input to :func:`derive_from_observations`, so this is
64
+ what makes a record re-derivable: strip the derived nodes and what remains is
65
+ exactly what the scan saw.
66
+ """
67
+ return (node.family, node.kind) in DERIVED_FAMILIES
68
+
69
+
70
+ def _has_sql_evidence(detail: dict[str, Any], *, hint: bool) -> bool:
71
+ """Whether anything vouches for a sink-named call actually being SQL.
72
+
73
+ The three signals are not interchangeable, which is the whole of `OI-26`.
74
+ They were OR'd together, so the weakest decided once satisfied — and the
75
+ weakest is file-scoped, meaning one real query anywhere in a file admitted
76
+ every sink-named call in it. ``httpClient.execute(r)`` became a SQL execution
77
+ sink because a JDBC query sat in the same class, and execution sinks feed
78
+ :func:`link_raw_code_payloads`, so it could fabricate the injection endpoint
79
+ `OI-7` exists to prevent.
80
+
81
+ Ordered by how *local* the evidence is:
82
+
83
+ * a **library hint** names the SQL API in the call text itself, so it is
84
+ self-evidencing and settles the question outright;
85
+ * a **database receiver** is evidence about this call;
86
+ * **file evidence** is a fact about other code in the same file, so it is the
87
+ weakest. It rescues a call whose receiver is *unknown* —
88
+ ``runner.execute(STATEMENT)`` — where there is nothing local to judge. It
89
+ does **not** rescue a receiver recognised as some other boundary, because
90
+ that is negative local evidence and a fact about the neighbours cannot
91
+ overturn it.
92
+
93
+ The distinction that matters is between *unknown* and *known to be something
94
+ else*. Collapsing the two either reinstates `OI-26` or withdraws the
95
+ unknown-receiver recall that file evidence exists for.
96
+ """
97
+ if hint or detail["receiver_is_database"]:
98
+ return True
99
+ if receiver_is_another_boundary(detail["receiver"]):
100
+ return False
101
+ return bool(detail["file_sql_evidence"])
102
+
103
+
104
+ def sql_verdict(detail: dict[str, Any]) -> bool | None:
105
+ """Decide whether one observation is a SQL sink, and whether it executes.
106
+
107
+ Returns ``None`` when the call is not SQL at all, otherwise whether it is an
108
+ *execution* sink rather than an ORM-style one. The single place a SQL
109
+ classification is made.
110
+ """
111
+ name = detail["symbol"]
112
+ hint = detail["library_hint"]
113
+ if not (name in SQL_SINK_NAMES or hint):
114
+ return None
115
+ if not _has_sql_evidence(detail, hint=hint):
116
+ return None
117
+ return name in SQL_EXECUTION_SINK_NAMES or hint
118
+
119
+
120
+ def classify_sql(observations: list[FlowNode]) -> list[FlowNode]:
121
+ """Emit `sql` sink nodes for the call observations that classify as SQL."""
122
+ out: list[FlowNode] = []
123
+ for obs in observations:
124
+ if obs.family != "call-site":
125
+ continue
126
+ d = obs.detail
127
+ is_execution = sql_verdict(d)
128
+ if is_execution is None:
129
+ continue
130
+ out.append(make_node(
131
+ repo=obs.repo,
132
+ file=obs.file,
133
+ line=obs.line,
134
+ language=obs.language,
135
+ kind="sink",
136
+ family="sql",
137
+ detail={
138
+ "symbol": d["symbol"],
139
+ "receiver": d["receiver"] or "",
140
+ "execution": is_execution,
141
+ "parameterised": d["parameterised"],
142
+ "raw": d["raw"],
143
+ },
144
+ confidence="high" if is_execution else "medium",
145
+ ))
146
+ return out
147
+
148
+
149
+ def _payload_node(ep: FlowNode, sink: FlowNode, marker: FlowNode) -> FlowNode:
150
+ """Build the raw-code-payload node for one (endpoint, sink, marker) triple."""
151
+ return make_node(
152
+ repo=ep.repo,
153
+ file=ep.file,
154
+ line=marker.line,
155
+ language=ep.language,
156
+ kind="source",
157
+ family="raw-code-payload",
158
+ detail={
159
+ "field_line": marker.line,
160
+ "endpoint_path": ep.detail.get("path"),
161
+ "sink_symbol": sink.detail.get("symbol"),
162
+ "sink_line": sink.line,
163
+ },
164
+ data_class="raw-sql-payload",
165
+ confidence="high",
166
+ )
167
+
168
+
169
+ def _distinct_triples(
170
+ endpoints: list[FlowNode], sinks: list[FlowNode], markers: list[FlowNode]
171
+ ) -> Iterator[tuple[FlowNode, FlowNode, FlowNode]]:
172
+ """Yield each (endpoint, sink, marker) combination once.
173
+
174
+ Deduplicated on the values that reach the finding — path, marker line, sink
175
+ symbol — rather than on node identity, so two endpoints declaring the same
176
+ path do not produce the same finding twice.
177
+ """
178
+ seen: set[tuple[str | None, int, str | None]] = set()
179
+ for ep in endpoints:
180
+ for sink in sinks:
181
+ for marker in markers:
182
+ key = (ep.detail.get("path"), marker.line, sink.detail.get("symbol"))
183
+ if key in seen:
184
+ continue
185
+ seen.add(key)
186
+ yield ep, sink, marker
187
+
188
+
189
+ def _link_one_file(
190
+ file_nodes: list[FlowNode],
191
+ ) -> tuple[list[FlowNode], list[FlowEdge]]:
192
+ """Correlate endpoints, SQL-field markers and execution sinks within one file."""
193
+ endpoints = [n for n in file_nodes if n.family == "http-in"]
194
+ markers = [n for n in file_nodes if n.family == "sql-field-marker"]
195
+ sinks = [
196
+ n for n in file_nodes
197
+ if n.family == "sql" and n.kind == "sink" and n.detail.get("execution")
198
+ ]
199
+ if not (endpoints and markers and sinks):
200
+ return [], []
201
+
202
+ nodes: list[FlowNode] = []
203
+ edges: list[FlowEdge] = []
204
+ for ep, sink, marker in _distinct_triples(endpoints, sinks, markers):
205
+ nodes.append(_payload_node(ep, sink, marker))
206
+ edges.append(make_edge(
207
+ ep, sink,
208
+ kind="intra-file",
209
+ evidence="endpoint accepts a raw SQL-shaped field executed in this file",
210
+ confidence="medium",
211
+ ))
212
+ return nodes, edges
213
+
214
+
215
+ def link_raw_code_payloads(
216
+ observations: list[FlowNode], sql_sinks: list[FlowNode]
217
+ ) -> tuple[list[FlowNode], list[FlowEdge]]:
218
+ """Tag an endpoint in a file that also holds a SQL-shaped field and an execution sink.
219
+
220
+ Correlation is per *file*, which is all the evidence available: these three
221
+ things appearing together is suggestive, not proof that the endpoint reaches
222
+ the sink. `OI-17` is what would establish that.
223
+ """
224
+ by_file: dict[str, list[FlowNode]] = {}
225
+ for n in [*observations, *sql_sinks]:
226
+ by_file.setdefault(n.file, []).append(n)
227
+
228
+ nodes: list[FlowNode] = []
229
+ edges: list[FlowEdge] = []
230
+ for file_nodes in by_file.values():
231
+ file_new_nodes, file_new_edges = _link_one_file(file_nodes)
232
+ nodes.extend(file_new_nodes)
233
+ edges.extend(file_new_edges)
234
+ return nodes, edges
235
+
236
+
237
+ def derive_from_observations(
238
+ observations: list[FlowNode],
239
+ ) -> tuple[list[FlowNode], list[FlowEdge]]:
240
+ """Derive every finding implied by a set of observations.
241
+
242
+ Pure: the same observations always give the same findings, so re-running over
243
+ a record that already carries derived nodes is safe — pass only the
244
+ observations and replace, rather than appending to what is there.
245
+ """
246
+ sql_sinks = classify_sql(observations)
247
+ payload_nodes, payload_edges = link_raw_code_payloads(observations, sql_sinks)
248
+ return [*sql_sinks, *payload_nodes], payload_edges
249
+
250
+ # probe