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.
- {src2sink-2.0.0/src2sink.egg-info → src2sink-2.1.0}/PKG-INFO +1 -1
- {src2sink-2.0.0 → src2sink-2.1.0}/pyproject.toml +1 -1
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/build_metabase_v2.py +105 -7
- src2sink-2.1.0/src2sink/dependencies.py +214 -0
- src2sink-2.1.0/src2sink/derive.py +250 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/patterns.py +37 -1
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/regex_extractors.py +15 -1
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/ts_extractors.py +74 -82
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/unified.py +7 -4
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/graph_common.py +48 -4
- src2sink-2.1.0/src2sink/maven.py +334 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/repo_utils.py +33 -23
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/schema.py +7 -1
- {src2sink-2.0.0 → src2sink-2.1.0/src2sink.egg-info}/PKG-INFO +1 -1
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink.egg-info/SOURCES.txt +11 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_build_internals.py +6 -1
- src2sink-2.1.0/tests/test_call_observations.py +125 -0
- src2sink-2.1.0/tests/test_derive_pass.py +164 -0
- src2sink-2.1.0/tests/test_detection_input_coverage.py +134 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_detection_version.py +6 -1
- src2sink-2.1.0/tests/test_maven_resolution.py +239 -0
- src2sink-2.1.0/tests/test_oi26_receiver_scope.py +141 -0
- src2sink-2.1.0/tests/test_path_match_significance.py +110 -0
- src2sink-2.1.0/tests/test_polyglot_dependencies.py +164 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_redos_bounds.py +5 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_repo_utils_helpers.py +0 -9
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_security_input_handling.py +5 -4
- src2sink-2.1.0/tests/test_sql_classifier.py +114 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/LICENSE +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/README.md +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/setup.cfg +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/__init__.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/__init__.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/api_client_discovery.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/auth_cards.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/crypto_cards.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/data_stores.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/graphs.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/index_v2.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/library_source_map.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/openapi_discovery.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/openapi_edges.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/openapi_match.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/openapi_models.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/openapi_report.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/payload_producers.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/phase3.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/pii_cross_repo.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/pii_flow_v2.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/pii_lifecycle.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/pii_lifecycle_report.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/pii_touchpoint_collect.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/pii_touchpoint_utils.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/queues.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/ropa.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/service_call_collect.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/service_call_index.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/service_call_models.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/service_call_report.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/service_calls.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/taint_buckets.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/taint_catalogs.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/taint_writers.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/aggregators/traces_index.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/constants.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/curate_internal_libraries.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/__init__.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/ast_walk.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/base.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/config.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/file_context.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/go.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/http_out.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/java.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/javascript.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/kotlin.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/node_factory.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/python_extractor.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/extractors/symbols.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/internal_groups.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/known_api_clients.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/library_taint_java.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/limits.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/models/__init__.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/models/auth_model.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/models/crypto_agility.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/models/pii_lifecycle.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/models/ropa.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/prescreen.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/py.typed +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/record_fleet_baseline.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/renderers/__init__.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/renderers/markdown.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/renderers/mermaid.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/safe_paths.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/sanitize.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/trace.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/trace_batch.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink/vocabulary.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink.egg-info/dependency_links.txt +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink.egg-info/entry_points.txt +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink.egg-info/requires.txt +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/src2sink.egg-info/top_level.txt +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_aggregators_extra.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_api_client_discovery.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_ast_walk.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_build_main.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_characterization.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_config_hygiene.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_cross_repo_caller_coverage.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_curate.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_demand_side_discovery.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_dependency_pinning.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_detection_fingerprint_gate.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_field_precision.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_gradle_version_catalogs.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_graph_common.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_http_guard_evidence.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_import_graph.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_internal_groups.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_limits.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_module_imports.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_openapi.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_output_neutralisation.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase0_smoke.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase1_config.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase1_extractors.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase2_graphs.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase3_pii.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase4_aggregators.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase4_extractor_snapshots.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase4_fleet_regression.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase4_regression.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_phase4_taint_caps.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_post_phase4_backlog.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_prescreen.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_renderers.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_run_manifest.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_sanitize.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_source_map_fix.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_sql_payload_out.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_sql_sink_evidence.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_sql_source_construction.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_trace_batch.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_trace_fleet_scaling.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_trace_render.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_zz_security_coverage_gate.py +0 -0
- {src2sink-2.0.0 → src2sink-2.1.0}/tests/test_zz_untrusted_interpolation_guard.py +0 -0
|
@@ -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 .
|
|
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(
|
|
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(
|
|
278
|
-
|
|
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
|
-
|
|
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
|