lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 735da60528a187a1f116e63faf216e3a5bd6f81b
parent ac37ed713a7703673aabbd69a2aad4767d3dde0a
Author: triesap <tyson@radroots.org>
Date:   Sat,  5 Sep 2026 13:33:08 +0000

xtask: enforce immutable advisory snapshots

Diffstat:
Acontracts/architecture/decisions/services_hardening_advisory_snapshots.v1.json | 729+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/assets/advisory_inventory.init.gradle | 691+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/advisory_snapshot.rs | 7547+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/main.rs | 15++++++++++++++-
Mtools/xtask/src/safe_artifact_io.rs | 775+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Atools/xtask/tests/services_hardening_advisory_snapshot_decision.rs | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 10023 insertions(+), 16 deletions(-)

diff --git a/contracts/architecture/decisions/services_hardening_advisory_snapshots.v1.json b/contracts/architecture/decisions/services_hardening_advisory_snapshots.v1.json @@ -0,0 +1,729 @@ +{ + "schema": "radroots.services-hardening.advisory-snapshot-decisions.v1", + "contract_version": 1, + "decision_state": "active", + "owner": "tools/xtask", + "self_test_command": "cargo xtask advisory-snapshot-self-test", + "platform_scope": { + "implementation": [ + "macos_aarch64" + ], + "source_gate": [ + "macos_aarch64" + ], + "cross_platform_promotion_owner": "step-297" + }, + "scope": { + "claims": [ + "closed_advisory_workload_inventory", + "exact_step_287_tool_acquisition_binding", + "one_external_immutable_rustsec_snapshot_per_candidate", + "one_bounded_nvd_only_update_per_candidate", + "offline_no_update_analysis", + "digest_time_freshness", + "owned_exact_expiring_suppressions", + "descriptor_admitted_actual_scanner_outputs", + "deterministic_byte_canonical_snapshot_archives" + ], + "nonclaims": [ + "live_rustsec_snapshot_acquisition", + "live_nvd_update_or_provider_access_at_step_294", + "scanner_or_provider_current_availability_before_step_297", + "candidate_or_generation_instance_created", + "step_314_advisory_qualification_or_pass_evidence", + "oss_index_or_credential_authority", + "cross_platform_runtime_promotion_before_step_297", + "artifact_nix_oci_signing_notarization_publication_deployment_or_production_qualification" + ] + }, + "tool_binding": { + "authority": "exact_full_step_287_tool_acquisition_objects", + "required_ids": [ + "cargo_audit", + "owasp_dependency_check", + "java", + "gradle_wrapper" + ], + "comparison": "parse_the_complete_structured_row_require_exact_equality_then_digest_canonical_compact_utf8_json", + "free_digest_without_parsed_row": "reject", + "availability": "must_be_observed_and_bound_by_the_step_297_generation_scoped_tool_manifest_before_live_use", + "live_observation_authority": "exact_radroots_services_hardening_rshr_200_tool_observation_v1_rows_from_the_step_297_generation_scoped_tool_manifest_and_fresh_advisory_lane_observation", + "live_observation_binding": "candidate_generation_tool_manifest_snapshot_sha256_platform_observation_sha256_producer_request_sha256_and_canonical_ordered_required_tool_rows", + "copied_pin_or_observation_fields_without_the_resolved_manifest_and_observation_digests": "reject", + "tool_review_maximum_age_seconds": 86400, + "path_only_or_version_only_admission": "reject" + }, + "tool_acquisition": { + "cargo_audit": { + "distribution": "governed_binary", + "id": "cargo_audit", + "platform": [ + { + "executable_name": "cargo-audit", + "executable_resolution": "path_lookup", + "id": "macos_aarch64", + "normalized_version": "0.22.1", + "package_name": "cargo-audit", + "package_receipt_kind": "cargo_install_identity_projection_canonical_json_v1", + "package_receipt_projection": { + "acquisition_source_sha256": "2f4e27b0ab2d116c87c29db159ad42565cdcdccf77eb62ef0486ddd017a02da6", + "executable_sha256": "c5cd7c0da8a9d0dff338aa1a2a30b0c723fde8201c23481f49e75be0bb77fe74", + "identity": { + "cargo_crates_entry": { + "binaries": [ + "cargo-audit" + ], + "package": "cargo-audit", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "version": "0.22.1" + }, + "crate_archive_exact_bytes_sha256": "2f4e27b0ab2d116c87c29db159ad42565cdcdccf77eb62ef0486ddd017a02da6" + }, + "kind": "cargo_install_identity_projection_canonical_json_v1", + "normalized_version": "0.22.1", + "package_name": "cargo-audit", + "platform": "macos_aarch64", + "provider": "pinned_archive", + "resolver_executable_name": "none", + "resolver_executable_sha256": "none", + "schema": "radroots.services-hardening.rshr-200-package-receipt-projection.v1", + "selected_executable": { + "exact_bytes_sha256": "c5cd7c0da8a9d0dff338aa1a2a30b0c723fde8201c23481f49e75be0bb77fe74", + "mode": "100755", + "path": "bin/cargo-audit" + } + }, + "package_receipt_sha256": "74196dcade966311e535813d2e3bda2f9515186c700f3074acdc2849abce1303", + "platform_image_sha256": "none", + "provider": "pinned_archive", + "repository": "none", + "repository_relative_path": "none", + "source_digest_kind": "source_artifact_exact_bytes_v1", + "source_identity": "cargo-audit", + "source_identity_projection": "source_locator_exact_artifact_bytes", + "source_locator": "https://static.crates.io/crates/cargo-audit/cargo-audit-0.22.1.crate", + "source_revision": "none", + "source_sha256": "2f4e27b0ab2d116c87c29db159ad42565cdcdccf77eb62ef0486ddd017a02da6", + "source_tree": "none", + "source_version": "0.22.1", + "version_argv": [ + "--version" + ], + "version_parser": "cargo_audit_version_v1", + "version_relation": "source_version_equals_normalized_version", + "version_stream": "stdout" + } + ], + "required_lane": [ + "advisory", + "aggregate" + ], + "resolution": "exact_name_path_source_digest_and_version_output", + "resolution_authority": "candidate-tool-contract", + "role": "advisory" + }, + "owasp_dependency_check": { + "distribution": "governed_binary", + "id": "owasp_dependency_check", + "platform": [ + { + "executable_name": "dependency-check", + "executable_resolution": "path_lookup", + "id": "macos_aarch64", + "normalized_version": "12.2.2", + "package_name": "dependency-check", + "package_receipt_kind": "archive_tree_identity_projection_canonical_json_v1", + "package_receipt_projection": { + "acquisition_source_sha256": "bf07fefd81af3094c5f6850423b014df44db62ce2dbad0f79079a90df675e44a", + "executable_sha256": "d683a49ec335eeca93d8707f3e8ce21d7ba63a1e619a325c6518f89c25efcdc4", + "identity": { + "archive_exact_bytes_sha256": "bf07fefd81af3094c5f6850423b014df44db62ce2dbad0f79079a90df675e44a", + "tree_projection": { + "canonical_json_sha256": "d1d4022193b0677fd200eb4d7b34842313a97e4d70ce0c2ce26928b784687790", + "canonicalization": "canonical_compact_utf8_json_sorted_keys_no_lf_array_entries_bytewise_ascii_path_order_resolved_root_relative_link_target_v1", + "entry_count": 86, + "entry_schema": "radroots.services-hardening.archive-tree-entry.v1", + "max_entry_count": 100000, + "max_member_uncompressed_bytes": 1073741824, + "max_total_uncompressed_bytes": 8589934592, + "schema": "radroots.services-hardening.archive-tree-entry-projection.v1", + "total_uncompressed_bytes": 32444859 + } + }, + "kind": "archive_tree_identity_projection_canonical_json_v1", + "normalized_version": "12.2.2", + "package_name": "dependency-check", + "platform": "macos_aarch64", + "provider": "pinned_archive", + "resolver_executable_name": "none", + "resolver_executable_sha256": "none", + "schema": "radroots.services-hardening.rshr-200-package-receipt-projection.v1", + "selected_executable": { + "exact_bytes_sha256": "d683a49ec335eeca93d8707f3e8ce21d7ba63a1e619a325c6518f89c25efcdc4", + "mode": "100755", + "path": "bin/dependency-check" + } + }, + "package_receipt_sha256": "83d9a668179fb5e9ad6fd5c625a9ab58b8371a6d32b59808fc8d2973957f54a2", + "platform_image_sha256": "none", + "provider": "pinned_archive", + "repository": "none", + "repository_relative_path": "none", + "source_digest_kind": "source_artifact_exact_bytes_v1", + "source_identity": "dependency-check", + "source_identity_projection": "source_locator_exact_artifact_bytes", + "source_locator": "https://github.com/dependency-check/DependencyCheck/releases/download/v12.2.2/dependency-check-12.2.2-release.zip", + "source_revision": "none", + "source_sha256": "bf07fefd81af3094c5f6850423b014df44db62ce2dbad0f79079a90df675e44a", + "source_tree": "none", + "source_version": "12.2.2", + "version_argv": [ + "--version" + ], + "version_parser": "owasp_dependency_check_version_v1", + "version_relation": "source_version_equals_normalized_version", + "version_stream": "stdout" + } + ], + "required_lane": [ + "advisory", + "aggregate" + ], + "resolution": "exact_name_path_source_digest_and_version_output", + "resolution_authority": "candidate-tool-contract", + "role": "advisory" + }, + "java": { + "distribution": "governed_binary", + "id": "java", + "platform": [ + { + "executable_name": "java", + "executable_resolution": "path_lookup", + "id": "macos_aarch64", + "normalized_version": "21.0.12.1", + "package_name": "net.temurin.21.jdk", + "package_receipt_kind": "apple_pkgutil_identity_projection_canonical_json_v1", + "package_receipt_projection": { + "acquisition_source_sha256": "575bb8d9d604821d8f350325b28a35e49bcffd7ec33727b41edc8d709537dada", + "executable_sha256": "9be1d0a740ff6502df1a762145e62860f5de4b7e17658d9cb9498da3acf9d16c", + "identity": { + "pkg_info": { + "install_location": "", + "pkg_version": "21.0.12.1+1-LTS", + "pkgid": "net.temurin.21.jdk", + "receipt_plist_version": 1, + "volume": "/" + }, + "pkgutil_files_lc_all_c_stdout_exact_bytes_sha256": "4d176bdae561efa1653174389a961a798e67b208105268ccfefb6494f1f3bb15" + }, + "kind": "apple_pkgutil_identity_projection_canonical_json_v1", + "normalized_version": "21.0.12.1", + "package_name": "net.temurin.21.jdk", + "platform": "macos_aarch64", + "provider": "pinned_archive", + "resolver_executable_name": "none", + "resolver_executable_sha256": "none", + "schema": "radroots.services-hardening.rshr-200-package-receipt-projection.v1", + "selected_executable": { + "exact_bytes_sha256": "9be1d0a740ff6502df1a762145e62860f5de4b7e17658d9cb9498da3acf9d16c", + "mode": "100755", + "path": "bin/java" + } + }, + "package_receipt_sha256": "e7018157655e26443392ace45967e427aafa1dd98e2e67f94bdd04267d1d9296", + "platform_image_sha256": "none", + "provider": "pinned_archive", + "repository": "none", + "repository_relative_path": "none", + "source_digest_kind": "source_artifact_exact_bytes_v1", + "source_identity": "net.temurin.21.jdk", + "source_identity_projection": "source_locator_exact_artifact_bytes", + "source_locator": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_mac_hotspot_21.0.12.1_1.pkg", + "source_revision": "none", + "source_sha256": "575bb8d9d604821d8f350325b28a35e49bcffd7ec33727b41edc8d709537dada", + "source_tree": "none", + "source_version": "21.0.12.1", + "version_argv": [ + "-version" + ], + "version_parser": "java_version_v1", + "version_relation": "source_version_equals_normalized_version", + "version_stream": "stderr" + } + ], + "required_lane": [ + "advisory", + "macos_artifacts", + "package_install", + "release_evidence", + "reproducibility", + "aggregate" + ], + "resolution": "exact_name_path_source_digest_and_version_output", + "resolution_authority": "candidate-tool-contract", + "role": "runtime" + }, + "gradle_wrapper": { + "distribution": "gradle_wrapper", + "id": "gradle_wrapper", + "platform": [ + { + "executable_name": "gradlew", + "executable_resolution": "repository_relative", + "id": "macos_aarch64", + "normalized_version": "9.5.0", + "package_name": "gradle", + "package_receipt_kind": "gradle_distribution_identity_projection_canonical_json_v1", + "package_receipt_projection": { + "acquisition_source_sha256": "553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746", + "executable_sha256": "ab5c0cad16305af2e619c159c1f58dd68d07fab9c11e36701e109c0277407f7a", + "identity": { + "distribution_archive_exact_bytes_sha256": "553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746", + "wrapper_jar_exact_bytes_sha256": "497c8c2a7e5031f6aa847f88104aa80a93532ec32ee17bdb8d1d2f67a194a9c7" + }, + "kind": "gradle_distribution_identity_projection_canonical_json_v1", + "normalized_version": "9.5.0", + "package_name": "gradle", + "platform": "macos_aarch64", + "provider": "gradle_distribution", + "resolver_executable_name": "none", + "resolver_executable_sha256": "none", + "schema": "radroots.services-hardening.rshr-200-package-receipt-projection.v1", + "selected_executable": { + "exact_bytes_sha256": "ab5c0cad16305af2e619c159c1f58dd68d07fab9c11e36701e109c0277407f7a", + "mode": "100755", + "path": "bin/gradlew" + } + }, + "package_receipt_sha256": "a6538aaef9f5e2510cf4bbfe6d560e64ae4065e64a5c8ba0807d7448f6a89610", + "platform_image_sha256": "none", + "provider": "gradle_distribution", + "repository": "oss/harvestcircle", + "repository_relative_path": "gradlew", + "source_digest_kind": "source_artifact_exact_bytes_v1", + "source_identity": "gradle", + "source_identity_projection": "source_locator_exact_artifact_bytes", + "source_locator": "https://services.gradle.org/distributions/gradle-9.5.0-bin.zip", + "source_revision": "none", + "source_sha256": "553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746", + "source_tree": "none", + "source_version": "9.5.0", + "version_argv": [ + "--version" + ], + "version_parser": "gradle_wrapper_version_v1", + "version_relation": "source_version_equals_normalized_version", + "version_stream": "stdout" + } + ], + "required_lane": [ + "advisory", + "macos_artifacts", + "package_install", + "release_evidence", + "reproducibility", + "aggregate" + ], + "resolution": "exact_name_path_source_digest_and_version_output", + "resolution_authority": "gradle-wrapper-lock", + "role": "build" + } + }, + "workload_inventory": { + "ordering": "cargo_then_gradle_kotlin_within_each_class_exact_list_order", + "completeness": "exact_presence_unique_no_unexpected_no_reordered_and_every_workload_emits_one_result_even_when_zero_findings", + "per_workload_input_binding": "candidate_source_identity_exact_dependency_input_bytes_and_sha256_dependency_count_scanner_argv_and_raw_report_bytes_and_sha256", + "cargo": [ + { + "id": "cli", + "repository": "oss/cli", + "workdir": ".", + "manifest_path": "Cargo.toml", + "lockfile_path": "Cargo.lock" + }, + { + "id": "harvest_core", + "repository": "oss/harvestcircle", + "workdir": "core", + "manifest_path": "core/Cargo.toml", + "lockfile_path": "core/Cargo.lock" + }, + { + "id": "harvest_xtask", + "repository": "oss/harvestcircle", + "workdir": "tools/xtask", + "manifest_path": "tools/xtask/Cargo.toml", + "lockfile_path": "tools/xtask/Cargo.lock" + }, + { + "id": "ios_ffi", + "repository": "oss/ios_app", + "workdir": "RadrootsFFI", + "manifest_path": "Cargo.toml", + "lockfile_path": "Cargo.lock" + }, + { + "id": "lib", + "repository": "oss/lib", + "workdir": ".", + "manifest_path": "Cargo.toml", + "lockfile_path": "Cargo.lock" + }, + { + "id": "myc", + "repository": "oss/myc", + "workdir": ".", + "manifest_path": "Cargo.toml", + "lockfile_path": "Cargo.lock" + }, + { + "id": "radrootsd", + "repository": "oss/radrootsd", + "workdir": ".", + "manifest_path": "Cargo.toml", + "lockfile_path": "Cargo.lock" + }, + { + "id": "rhi", + "repository": "oss/rhi", + "workdir": ".", + "manifest_path": "Cargo.toml", + "lockfile_path": "Cargo.lock" + }, + { + "id": "root", + "repository": "_radroots", + "workdir": ".", + "manifest_path": "Cargo.toml", + "lockfile_path": "Cargo.lock" + }, + { + "id": "sdk", + "repository": "oss/sdk", + "workdir": ".", + "manifest_path": "Cargo.toml", + "lockfile_path": "Cargo.lock" + } + ], + "gradle_kotlin": [ + { + "id": "app_design_system", + "repository": "oss/harvestcircle", + "build_root": ".", + "project_path": ":app:design_system", + "configuration": "desktopRuntimeClasspath" + }, + { + "id": "app_desktop", + "repository": "oss/harvestcircle", + "build_root": ".", + "project_path": ":app:desktop", + "configuration": "runtimeClasspath" + }, + { + "id": "app_shared", + "repository": "oss/harvestcircle", + "build_root": ".", + "project_path": ":app:shared", + "configuration": "desktopRuntimeClasspath" + }, + { + "id": "tools_design_catalog", + "repository": "oss/harvestcircle", + "build_root": ".", + "project_path": ":tools:design_catalog", + "configuration": "desktopRuntimeClasspath" + }, + { + "id": "build_logic_contracts", + "repository": "oss/harvestcircle", + "build_root": "build-logic", + "project_path": ":contracts", + "configuration": "runtimeClasspath" + }, + { + "id": "build_logic_plugins", + "repository": "oss/harvestcircle", + "build_root": "build-logic", + "project_path": ":plugins", + "configuration": "runtimeClasspath" + } + ] + }, + "gradle_dependency_graph": { + "projection_count": 6, + "projection_order": "exact_workload_inventory_gradle_kotlin_order", + "projection_owner": "tools_xtask_decision_bound_ephemeral_gradle_init_script", + "init_script_identity": "exact_embedded_utf8_bytes_sha256_4c51de29877813dd679c970507602940fdf8c38a586f8d2f6d101b6a93a8b598_compiled_into_the_step_294_implementation_and_bound_into_every_projection_input_digest", + "init_script_lifecycle": "materialize_create_new_0600_in_a_fresh_private_directory_read_with_evidence_before_invocation_and_revalidate_after_use", + "init_script_authority": "read_only_resolution_and_projection_only_no_repository_source_or_build_script_mutation", + "wrapper_tool_id": "gradle_wrapper", + "runner": "step_292_bounded_process_runner", + "execution": "sequential_one_module_configuration_per_invocation_no_parallel_full_graphs", + "working_directory": "exact_retained_candidate_oss_harvestcircle_repository_root_with_pre_and_post_entry_count_and_tree_digest_equality", + "argv_template": [ + "--offline", + "--no-daemon", + "--no-parallel", + "--no-configuration-cache", + "--project-dir", + "{exact_build_root}", + "--init-script", + "{private_evidenced_init_script}", + "{exact_project_path}:rshrAdvisoryGraph", + "-PrshrAdvisoryConfiguration={exact_configuration}", + "-PrshrAdvisoryOutput={private_create_new_projection}" + ], + "gradle_state": "fresh_private_gradle_user_home_seeded_only_from_digest_verified_governed_wrapper_and_dependency_cache_inputs_with_network_disabled_and_no_ambient_user_or_project_cache_admitted", + "environment": "replace_allowlist_with_private_gradle_user_home_java_home_locale_timezone_tmpdir_and_step_292_required_process_fields", + "configuration_selection": "exact_named_configuration_from_the_workload_row_exists_is_resolvable_and_no_other_configuration_is_resolved", + "unresolved_component_or_artifact": "nonpass_without_partial_projection", + "graph_completeness": "resolution_root_all_selected_components_all_dependency_edges_all_resolved_artifacts_and_one_explicit_project_root_row_even_when_the_external_artifact_set_is_empty", + "component_identity": "canonical_module_group_name_version_or_build_root_and_project_path_with_selected_variant_attributes_no_display_string_as_sole_identity", + "artifact_identity": "component_identity_artifact_name_artifact_type_classifier_byte_length_and_sha256_from_the_exact_safely_read_bytes", + "canonical_projection": "canonical_compact_utf8_json_sorted_keys_with_components_edges_and_artifacts_each_sorted_by_their_complete_identity_and_no_host_paths", + "raw_projection_maximum_bytes_each": 8388608, + "artifact_source_roots": "each_source_path_is_descriptor_resolved_relative_to_exactly_one_governed_candidate_build_output_or_seed_cache_root_and_each_root_role_and_opaque_identity_digest_is_bound_into_the_projection_input_and_normalization_receipt", + "input_digest": "domain_separated_sha256_of_candidate_generation_harvest_source_revision_and_tree_workload_row_init_script_sha256_wrapper_tool_manifest_row_fresh_wrapper_observation_gradle_dependency_control_snapshot_and_seed_cache_inventory", + "result_digest": "sha256_of_the_exact_canonical_projection_bytes_observed_through_step_293_evidenced_io", + "scan_materialization": "fresh_private_create_new_projection_with_one_regular_file_per_resolved_artifact_named_by_lowercase_sha256_plus_validated_original_extension_and_revalidated_against_the_canonical_graph", + "scan_materialization_duplicates": "identical_digest_and_extension_coalesce_only_when_all_bound_artifact_identities_are_retained_in_the_graph_every_other_collision_is_nonpass", + "odc_scan_count": "exactly_one_per_projection_in_exact_projection_order", + "odc_nvd_state": "one_unique_fresh_private_materialized_copy_per_scan_with_source_pre_scan_and_post_scan_tree_digests_equal_to_the_single_accepted_candidate_nvd_snapshot", + "odc_argv_template": [ + "--noupdate", + "--data", + "{private_materialized_nvd_snapshot}", + "--scan", + "{private_materialized_graph_projection}", + "--project", + "{exact_workload_id}", + "--format", + "JSON", + "--out", + "{private_create_new_report}", + "--disableKnownExploited", + "--disableRetireJs", + "--disableHostedSuppressions", + "--disableVersionCheck", + "--disableOssIndex", + "--disableCentral", + "--disableNodeAudit", + "--disableYarnAudit", + "--disablePnpmAudit" + ], + "result_binding": "one_complete_dependency_check_12_2_2_json_report_per_workload_even_with_zero_findings_bound_to_candidate_generation_workload_input_digest_graph_digest_nvd_digest_exact_argv_exit_status_process_evidence_report_byte_length_and_report_sha256", + "private_provider_evidence": "actual_raw_scanner_report_bytes_and_exact_argv_may_retain_attempt_local_absolute_paths_inside_access_controlled_candidate_evidence_so_the_output_file_and_argument_bindings_remain_independently_verifiable", + "normalized_outputs": "finding_inventory_result_digests_and_diagnostics_are_path_free_and_credential_free" + }, + "snapshot_model": { + "providers": [ + "rustsec", + "owasp_nvd" + ], + "accepted_snapshots_per_provider_per_candidate": 1, + "source_admission": "one_exact_bounded_traversal_snapshot_retained_while_every_member_is_read_with_read_evidenced_then_final_revalidation", + "evidence_origin": "byte_length_and_sha256_are_computed_from_the_exact_bytes_actually_read_not_accepted_from_caller_fields", + "archive_encoding": "deterministic_single_member_gzip_of_canonical_tar", + "archive_member_order": "raw_relative_path_bytes_ascending", + "archive_metadata": "mtime_zero_uid_zero_gid_zero_uname_empty_gname_empty_directories_0755_files_0644", + "archive_admission": "step_293_descriptor_bound_parser_plus_step_294_deterministic_header_member_order_and_full_byte_reencoding_equality_before_snapshot_acceptance", + "archive_evidence": "compressed_length_sha256_expanded_length_member_count_and_payload_length_from_the_admitted_bytes_plus_exact_reencoded_compressed_length_and_sha256_equality", + "archive_reencoding_backend": { + "compression_level": 6, + "flate2": "1.1.9", + "flate2_checksum": "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c", + "miniz_oxide": "0.8.9", + "miniz_oxide_checksum": "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316", + "golden_fixture_byte_length": 147, + "golden_fixture_sha256": "6073c70a98ff9ab610085ed74b7886811da3fe64e9ec2771464300a94befd0fb" + }, + "archive_materialization": "fresh_private_0700_directory_create_new_0644_members_descriptor_bound_to_the_archive_and_parent_then_exact_membership_and_tree_revalidated_before_and_after_scanner_use", + "creation": "create_new_only_no_overwrite_no_rollback_and_failed_output_is_untrusted", + "immutability": "snapshot_identity_and_digest_are_fixed_once_adopted_and_every_later_use_revalidates_the_exact_snapshot", + "temporal_order": "acquisition_completion_epoch_less_than_or_equal_to_digest_finalization_epoch_less_than_or_equal_to_analysis_completion_epoch_less_than_or_equal_to_evaluation_epoch", + "logical_storage": "digest_addressed_normalized_identity_without_host_paths_while_access_controlled_raw_provider_evidence_retains_the_actual_report_and_argument_bindings", + "logical_uri_template": "extbuild-cas://sha256/{sha256}", + "logical_reference_fields": [ + "logical_uri", + "sha256", + "size_bytes", + "media_type", + "logical_role" + ], + "logical_reference_validation": "uri_digest_equals_sha256_and_resolved_exact_bytes_size_and_digest_are_revalidated_under_the_step_286_cas_policy" + }, + "rustsec": { + "provider": "rustsec", + "snapshot_acquisition": "external_to_cargo_audit_then_boundedly_admitted_as_one_immutable_candidate_snapshot", + "scanner_network_actions": 0, + "analysis_tool_id": "cargo_audit", + "analysis_count": "exactly_once_for_each_of_the_ten_cargo_workloads", + "analysis_network": "disabled", + "analysis_working_directory": "fresh_empty_config_free_directory", + "analysis_home": "fresh_empty_directory", + "analysis_cargo_home": "fresh_empty_directory", + "analysis_argv_policy": [ + "audit", + "--db", + "exact_snapshot_directory", + "--no-fetch", + "--json", + "--file", + "exact_workload_lockfile" + ], + "fetch_or_update_arguments": "forbidden", + "yanked_registry_policy": "no_registry_network_or_update_no_ambient_cached_registry_index_admitted_and_no_yanked_result_accepted_the_pinned_tool_has_no_no_yank_argument", + "informational_warning_policy": "complete_notice_unmaintained_and_unsound_rows_are_governed_findings_and_require_the_same_exact_suppression_decision_as_vulnerabilities", + "exit_status_policy": "zero_or_one_with_complete_valid_json_is_report_bearing_and_findings_are_evaluated_after_parse_all_other_statuses_are_nonpass", + "report_parsing": "bounded_strict_complete_cargo_audit_0_22_1_json_from_the_actual_captured_stdout_per_workload_no_caller_normalized_clean_wrapper", + "report_input_binding": "each_raw_report_binds_the_exact_workload_id_lockfile_sha256_dependency_count_database_identity_scanner_argv_exit_status_byte_length_and_sha256", + "raw_output_admission": "actual_stdout_is_written_create_new_0600_in_a_fresh_private_directory_then_admitted_as_the_only_regular_file_read_with_descriptor_evidence_and_revalidated_before_parse_after_parse_and_before_final_acceptance", + "known_vulnerability": "nonpass_unless_one_active_exact_owned_suppression_matches", + "non_waivable_advisory_ids": [ + "RUSTSEC-2026-0253" + ] + }, + "owasp_dependency_check_nvd": { + "provider": "owasp_nvd", + "update_tool_id": "owasp_dependency_check", + "runtime_tool_id": "java", + "gradle_tool_id": "gradle_wrapper", + "update_count_per_candidate": 1, + "update_operation": "bounded_dependency_check_updateonly_into_a_new_private_directory", + "update_argv_template": [ + "--updateonly", + "--data", + "{private_create_new_nvd_staging_directory}", + "--disableKnownExploited", + "--disableRetireJs", + "--disableHostedSuppressions", + "--disableVersionCheck", + "--disableOssIndex", + "--disableCentral", + "--disableNodeAudit", + "--disableYarnAudit", + "--disablePnpmAudit" + ], + "update_provider_allowlist": [ + "nvd" + ], + "update_endpoint_allowlist": [ + "https://services.nvd.nist.gov/rest/json/cves/2.0" + ], + "update_http_methods": [ + "GET" + ], + "non_nvd_updates": "disabled_and_rejected", + "update_network": "contracted_fetch_only", + "update_network_trace": "complete_canonical_bounded_request_response_trace_with_exact_enforcement_program_and_configuration_digests_proves_only_sequential_get_requests_to_the_exact_nvd_https_endpoint_with_clear_nonsecret_query_values_contiguous_pagination_and_response_metadata_then_is_digest_bound_without_credentials_or_response_bodies", + "update_network_trace_limits": { + "maximum_requests": 1024, + "maximum_response_bytes_each": 67108864, + "maximum_response_bytes_aggregate": 536870912, + "maximum_trace_bytes": 16777216 + }, + "credential_environment": "absent", + "partial_or_failed_update": "untrusted_nonpass_and_never_adopted", + "analysis_count": "exactly_once_for_each_of_the_six_gradle_kotlin_modules", + "analysis_network": "disabled", + "analysis_update_mode": "no_update", + "gradle_network_mode": "offline", + "gradle_auto_update": false, + "analysis_failure_threshold_arguments": "forbidden_including_fail_on_cvss", + "exit_status_policy": "zero_with_complete_valid_json_is_report_bearing_and_findings_are_evaluated_after_parse_every_nonzero_status_is_nonpass", + "report_parsing": "bounded_strict_complete_dependency_check_12_2_2_json_from_the_actual_evidenced_output_file_per_workload_no_caller_normalized_clean_wrapper_and_vulnerability_ids_are_matched_software_identifiers_consistent_with_parsed_vulnerable_software_not_advisory_ids", + "report_input_binding": "each_raw_report_binds_the_exact_workload_id_dependency_graph_input_sha256_dependency_count_nvd_database_identity_scanner_argv_exit_status_byte_length_and_sha256", + "report_template_binding": { + "distribution_package_receipt_sha256": "83d9a668179fb5e9ad6fd5c625a9ab58b8371a6d32b59808fc8d2973957f54a2", + "dependency_check_core_jar_relative_identity": "dependency-check-core-12.2.2.jar", + "dependency_check_core_jar_sha256": "1ebe55e542b2f4d2727380395843922381447dc8b9a4f1633e77096f47ebfb48", + "template_member": "templates/jsonReport.vsl", + "template_member_byte_length": 34639, + "template_member_sha256": "35881409102304d5a91d08566c1d0fda6fa5f9ec8810994fcdd5106aed3729b5", + "parser": "strict_exact_schema_for_report_scan_project_dependency_evidence_identifier_related_dependency_vulnerability_cvss_reference_and_vulnerable_software_objects_with_unknown_fields_rejected" + }, + "raw_output_admission": "actual_output_file_is_create_new_0600_in_a_fresh_private_directory_then_admitted_as_the_only_regular_file_read_with_descriptor_evidence_and_revalidated_before_parse_after_parse_and_before_final_acceptance", + "known_vulnerability": "nonpass_unless_one_active_exact_owned_suppression_matches" + }, + "bounded_execution": { + "runner": "step_292_bounded_process_runner", + "environment": "replace_with_explicit_allowlist", + "stdin": "closed", + "deadline_clock": "monotonic", + "maximum_concurrent_top_level_operations": 1, + "required_process_receipts": 23, + "maximum_command_seconds": 3600, + "maximum_stdout_bytes": 67108864, + "maximum_stderr_bytes": 67108864, + "timeout_unavailable_interrupted_or_unparseable": "nonpass_without_fallback", + "exit_status_interpretation": "provider_specific_and_never_substitutes_for_complete_report_parsing", + "working_directories": "unique_opaque_identity_per_operation_with_kind_logical_uri_pre_and_post_entry_counts_and_tree_digests_cargo_audit_empty_config_free_gradle_retained_candidate_source_nvd_update_fresh_then_nonempty_and_odc_analysis_fresh_empty", + "environment_evidence": "exact_ordered_name_and_logical_role_allowlist_with_sha256_of_each_actual_replacement_value_fixed_C_and_UTC_values_shared_pinned_java_and_path_values_and_unique_private_roots_no_ambient_inheritance", + "authority_byte_limits": { + "snapshot_manifest": 67108864, + "producer_request": 1048576, + "tool_manifest": 16777216, + "tool_observation": 4194304, + "provider_execution_evidence": 67108864, + "raw_scanner_output_each": 33554432 + } + }, + "freshness": { + "clock": "caller_supplied_positive_utc_epoch_seconds", + "snapshot_time": "provider_digest_finalization_epoch_bound_to_the_accepted_snapshot_digest", + "future_snapshot_time": "reject", + "age_arithmetic": "checked_nonnegative_seconds", + "qualification_maximum_age_seconds": 86400, + "development_reuse_maximum_age_seconds": 604800, + "boundary": "fresh_when_age_is_less_than_or_equal_to_the_selected_maximum", + "providers_evaluated_independently": true, + "stale_or_missing_provider": "nonpass" + }, + "suppressions": { + "inventory": "canonical_ordered_exact_rows_bound_into_the_candidate_advisory_input_digest", + "required_fields": [ + "id", + "provider", + "advisory_id", + "workload_id", + "package_ecosystem", + "package_namespace", + "package_name", + "package_version", + "owner", + "rationale", + "created_at_epoch", + "expires_at_epoch" + ], + "matching": "one_row_matches_one_finding_only_when_provider_advisory_id_workload_id_package_ecosystem_package_namespace_package_name_and_package_version_are_all_exact", + "wildcards_ranges_regex_and_lexical_patterns": "forbidden", + "owner_and_rationale": "nonempty_bounded_safe_text", + "expiry": "active_only_when_evaluation_epoch_is_strictly_less_than_expires_at_epoch", + "expiry_equality": "expired", + "future_creation_time": "reject", + "unused_duplicate_ambiguous_or_unknown_rows": "nonpass", + "non_waivable_advisory_ids": [ + "RUSTSEC-2026-0253" + ], + "non_waivable_match": "reject_even_when_a_suppression_row_is_otherwise_exact" + }, + "result_model": { + "pass": "both_provider_snapshots_fresh_all_sixteen_workloads_present_in_exact_order_all_scans_completed_and_every_finding_is_absent_or_exactly_suppressed", + "unavailable": "nonpass", + "timeout": "nonpass", + "stale": "nonpass", + "missing_duplicate_unexpected_or_reordered_inventory": "nonpass", + "unknown_scanner_fields_or_malformed_reports": "nonpass", + "diagnostics": "stable_bounded_path_free_member_free_source_free_and_credential_free_while_raw_access_controlled_provider_evidence_remains_separate" + }, + "required_vectors": [ + "known_vulnerable_fixture", + "missing_inventory", + "unavailable", + "stale", + "timeout", + "expired_suppression" + ] +} diff --git a/tools/xtask/assets/advisory_inventory.init.gradle b/tools/xtask/assets/advisory_inventory.init.gradle @@ -0,0 +1,691 @@ +import groovy.json.JsonOutput +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.attribute.PosixFileAttributes +import java.nio.file.attribute.PosixFilePermissions +import java.security.MessageDigest +import java.util.HexFormat +import org.gradle.api.Named +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.artifacts.component.ModuleComponentSelector +import org.gradle.api.artifacts.component.ProjectComponentIdentifier +import org.gradle.api.artifacts.component.ProjectComponentSelector +import org.gradle.api.artifacts.result.ResolvedDependencyResult +import org.gradle.api.artifacts.result.UnresolvedDependencyResult + +final long MAX_ARTIFACT_BYTES = 17_179_869_184L +final long MAX_TOTAL_ARTIFACT_BYTES = 17_179_869_184L +final int MAX_JSON_BYTES = 67_108_864 +final int STREAM_BUFFER_BYTES = 65_536 +final int MAX_EXTERNAL_VARIANT_DEPTH = 4 +final int MAX_COMPONENTS = 65_536 +final int MAX_DEPENDENCY_RESULTS = 262_144 +final int MAX_ARTIFACTS = 65_536 +final int MAX_VARIANTS_PER_COMPONENT = 4_096 +final int MAX_ATTRIBUTES = 1_024 +final int MAX_CAPABILITIES = 1_024 +final int MAX_REJECTED_VERSIONS = 1_024 +final Set<String> ADMITTED_EXTENSIONS = [ + "aar", "jar", "js", "klib", "module", "pom", "wasm", "zip" +] as Set + +def authorities = [ + "harvestcircle": [ + build_root: ".", + workloads: [ + ":app:design_system|desktopRuntimeClasspath": "app_design_system", + ":app:desktop|runtimeClasspath": "app_desktop", + ":app:shared|desktopRuntimeClasspath": "app_shared", + ":tools:design_catalog|desktopRuntimeClasspath": "tools_design_catalog", + ], + ], + "harvestcircle-build-logic": [ + build_root: "build-logic", + workloads: [ + ":contracts|runtimeClasspath": "build_logic_contracts", + ":plugins|runtimeClasspath": "build_logic_plugins", + ], + ], +] + +def fail = { -> + throw new GradleException("governed advisory graph projection failed") +} + +def safeString = { Object value, int maximum, boolean allowEmpty = false -> + if (!(value instanceof CharSequence)) { + fail() + } + String text = value.toString() + if ((!allowEmpty && text.isEmpty()) + || text.getBytes(StandardCharsets.UTF_8).length > maximum + || text.codePoints().anyMatch { codePoint -> + codePoint > 0x7f || Character.isISOControl(codePoint) + }) { + fail() + } + text +} + +def nullableString = { Object value, int maximum, boolean allowEmpty = false -> + value == null ? null : safeString(value, maximum, allowEmpty) +} + +Closure<String> canonicalJson +canonicalJson = { Object value -> + if (value == null || value instanceof Boolean || value instanceof Number + || value instanceof CharSequence) { + return JsonOutput.toJson(value) + } + if (value instanceof Map) { + def keys = value.keySet().collect { key -> + if (!(key instanceof String)) { + fail() + } + safeString(key, 256) + }.sort() + return "{" + keys.collect { key -> + JsonOutput.toJson(key) + ":" + canonicalJson(value[key]) + }.join(",") + "}" + } + if (value instanceof Collection) { + return "[" + value.collect { entry -> canonicalJson(entry) }.join(",") + "]" + } + fail() +} + +def boundedSize = { Collection values, int maximum, boolean allowEmpty = true -> + int count = values.size() + if ((!allowEmpty && count == 0) || count > maximum) { + fail() + } + count +} + +def canonicalSortedSet = { Collection rows, int maximum, boolean allowEmpty = true -> + boundedSize(rows, maximum, allowEmpty) + def byIdentity = new TreeMap<String, Object>() + rows.each { row -> + String identity = canonicalJson(row) + byIdentity[identity] = row + } + if (byIdentity.size() > maximum || (!allowEmpty && byIdentity.isEmpty())) { + fail() + } + byIdentity.values().toList() +} + +def canonicalSortedRows = { Collection rows, int maximum, boolean allowEmpty = true -> + boundedSize(rows, maximum, allowEmpty) + def byIdentity = new TreeMap<String, Object>() + rows.each { row -> + String identity = canonicalJson(row) + if (byIdentity.put(identity, row) != null) { + fail() + } + } + if (byIdentity.size() != rows.size()) { + fail() + } + byIdentity.values().toList() +} + +def attributeValue = { Object value -> + if (value instanceof Named) { + return safeString(value.name, 512) + } + if (value instanceof Enum) { + return safeString(value.name(), 512) + } + if (value instanceof CharSequence || value instanceof Number || value instanceof Boolean) { + return safeString(String.valueOf(value), 512, true) + } + fail() +} + +def attributeRows = { attributes -> + def keys = attributes.keySet() + int expectedCount = boundedSize(keys, MAX_ATTRIBUTES) + def rows = keys.collect { attribute -> + [ + name: safeString(attribute.name, 512), + value: attributeValue(attributes.getAttribute(attribute)), + ] + }.sort { left, right -> + left.name <=> right.name ?: left.value <=> right.value + } + if (rows.size() != expectedCount + || rows.collect { row -> row.name }.toSet().size() != rows.size()) { + fail() + } + rows +} + +def capabilityRows = { capabilities -> + int expectedCount = boundedSize(capabilities, MAX_CAPABILITIES) + def rows = capabilities.collect { capability -> + [ + group: safeString(capability.group, 256, true), + name: safeString(capability.name, 256), + version: nullableString(capability.version, 128, true), + ] + } + if (rows.size() != expectedCount) { + fail() + } + canonicalSortedSet(rows, MAX_CAPABILITIES) +} + +Closure<Map> variantRow +variantRow = { variant, int depth = 0 -> + if (depth > MAX_EXTERNAL_VARIANT_DEPTH) { + fail() + } + def external = variant.externalVariant + [ + attributes: attributeRows(variant.attributes), + capabilities: capabilityRows(variant.capabilities), + external_variant: external.present ? variantRow(external.get(), depth + 1) : null, + ] +} + +def variantEnvelope = { Collection variants -> + def selected = canonicalSortedSet(variants, MAX_VARIANTS_PER_COMPONENT, false) + [selected: selected] +} + +def requiredProperty = { String name -> + def value = gradle.startParameter.projectProperties[name] + if (value == null) { + fail() + } + safeString(value, 4_096) +} + +def logicalBuildRoot = { String invocationBuildRoot, String buildPath -> + String admitted = safeString(buildPath, 256) + if (admitted == ":") { + return invocationBuildRoot + } + if (admitted == ":build-logic") { + return "build-logic" + } + fail() +} + +def componentCore = { identifier, String invocationBuildRoot -> + if (identifier instanceof ModuleComponentIdentifier) { + return [ + build_root: null, + group: safeString(identifier.group, 256), + kind: "module", + name: safeString(identifier.module, 256), + project_path: null, + version: safeString(identifier.version, 128), + ] + } + if (identifier instanceof ProjectComponentIdentifier) { + return [ + build_root: logicalBuildRoot(invocationBuildRoot, identifier.build.buildPath), + group: null, + kind: "project", + name: null, + project_path: safeString(identifier.projectPath, 256), + version: null, + ] + } + fail() +} + +def selectorRow = { selector, String invocationBuildRoot -> + if (!selector.capabilitySelectors.isEmpty()) { + fail() + } + if (selector instanceof ModuleComponentSelector) { + def constraint = selector.versionConstraint + def rejectedVersions = constraint.rejectedVersions + int rejectedCount = boundedSize(rejectedVersions, MAX_REJECTED_VERSIONS) + def rejected = rejectedVersions.collect { version -> + safeString(version, 512) + } + if (rejected.size() != rejectedCount) { + fail() + } + return [ + attributes: attributeRows(selector.attributes), + build_root: null, + capabilities: capabilityRows(selector.requestedCapabilities), + group: safeString(selector.group, 256), + kind: "module", + name: safeString(selector.module, 256), + project_path: null, + version: safeString(selector.version, 512, true), + version_constraint: [ + branch: nullableString(constraint.branch, 256, true), + preferred: safeString(constraint.preferredVersion, 512, true), + rejected: canonicalSortedSet(rejected, MAX_REJECTED_VERSIONS), + required: safeString(constraint.requiredVersion, 512, true), + strict: safeString(constraint.strictVersion, 512, true), + ], + ] + } + if (selector instanceof ProjectComponentSelector) { + return [ + attributes: attributeRows(selector.attributes), + build_root: logicalBuildRoot(invocationBuildRoot, selector.buildPath), + capabilities: capabilityRows(selector.requestedCapabilities), + group: null, + kind: "project", + name: null, + project_path: safeString(selector.projectPath, 256), + version: null, + version_constraint: null, + ] + } + fail() +} + +def sameBasicIdentity = { BasicFileAttributes left, BasicFileAttributes right -> + left.fileKey() != null + && right.fileKey() != null + && left.fileKey() == right.fileKey() + && left.isRegularFile() + && right.isRegularFile() + && left.size() == right.size() + && left.lastModifiedTime() == right.lastModifiedTime() + && left.creationTime() == right.creationTime() +} + +def digestBytes = { byte[] bytes -> + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)) +} + +def observeRegularFile = { Path path, long maximum -> + if (!path.isAbsolute() || path != path.normalize()) { + fail() + } + BasicFileAttributes before = Files.readAttributes( + path, BasicFileAttributes, LinkOption.NOFOLLOW_LINKS + ) + if (!before.isRegularFile() || before.isSymbolicLink() || before.fileKey() == null + || before.size() <= 0L || before.size() > maximum) { + fail() + } + def digest = MessageDigest.getInstance("SHA-256") + long observed = 0L + FileChannel channel = FileChannel.open( + path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS + ) + try { + if (channel.size() != before.size()) { + fail() + } + ByteBuffer buffer = ByteBuffer.allocate(STREAM_BUFFER_BYTES) + while (true) { + int count = channel.read(buffer) + if (count < 0) { + break + } + if (count == 0) { + fail() + } + observed = Math.addExact(observed, (long) count) + if (observed > maximum) { + fail() + } + buffer.flip() + digest.update(buffer) + buffer.clear() + } + if (observed != before.size() || channel.position() != observed + || channel.size() != observed) { + fail() + } + } finally { + channel.close() + } + BasicFileAttributes after = Files.readAttributes( + path, BasicFileAttributes, LinkOption.NOFOLLOW_LINKS + ) + if (!sameBasicIdentity(before, after) || after.size() != observed) { + fail() + } + [ + byte_length: observed, + sha256: HexFormat.of().formatHex(digest.digest()), + ] +} + +def artifactCorrelationKey = { artifact, String invocationBuildRoot -> + Path source = artifact.file.toPath().toAbsolutePath().normalize() + canonicalJson([ + component: componentCore(artifact.id.componentIdentifier, invocationBuildRoot), + source_path: safeString(source.toString(), 4_096), + ]) +} + +def moduleVersionRow = { moduleVersion -> + if (moduleVersion == null) { + fail() + } + def identifier = moduleVersion.id + if (identifier == null) { + fail() + } + [ + group: safeString(identifier.group, 256), + name: safeString(identifier.name, 256), + version: safeString(identifier.version, 128), + ] +} + +def writeCreateNew = { Path output, byte[] bytes -> + if (!output.isAbsolute() || output != output.normalize() + || output.fileName == null || output.parent == null + || bytes.length <= 0 || bytes.length > MAX_JSON_BYTES) { + fail() + } + safeString(output.fileName.toString(), 256) + Path parent = output.parent + BasicFileAttributes parentBefore = Files.readAttributes( + parent, BasicFileAttributes, LinkOption.NOFOLLOW_LINKS + ) + if (!parentBefore.isDirectory() || parentBefore.isSymbolicLink() + || parentBefore.fileKey() == null + || Files.exists(output, LinkOption.NOFOLLOW_LINKS)) { + fail() + } + def outputOptions = [ + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS, + ] as Set + def outputPermissions = PosixFilePermissions.fromString("rw-------") + FileChannel channel = FileChannel.open( + output, outputOptions, PosixFilePermissions.asFileAttribute(outputPermissions) + ) + try { + ByteBuffer buffer = ByteBuffer.wrap(bytes) + while (buffer.hasRemaining()) { + if (channel.write(buffer) <= 0) { + fail() + } + } + channel.force(true) + if (channel.position() != bytes.length || channel.size() != bytes.length) { + fail() + } + } finally { + channel.close() + } + PosixFileAttributes outputAttributes = Files.readAttributes( + output, PosixFileAttributes, LinkOption.NOFOLLOW_LINKS + ) + if (!outputAttributes.isRegularFile() || outputAttributes.isSymbolicLink() + || outputAttributes.fileKey() == null + || outputAttributes.permissions() != outputPermissions) { + fail() + } + BasicFileAttributes parentAfter = Files.readAttributes( + parent, BasicFileAttributes, LinkOption.NOFOLLOW_LINKS + ) + if (parentBefore.fileKey() != parentAfter.fileKey() + || !parentAfter.isDirectory() || parentAfter.isSymbolicLink()) { + fail() + } + def observed = observeRegularFile(output, MAX_JSON_BYTES) + if (observed.byte_length != bytes.length || observed.sha256 != digestBytes(bytes)) { + fail() + } +} + +gradle.afterProject { project, state -> + if (state.failure != null) { + return + } + def authority = authorities[project.rootProject.name] + if (authority == null) { + return + } + def matching = authority.workloads.findAll { key, ignored -> + key.startsWith(project.path + "|") + } + if (matching.isEmpty()) { + return + } + if (project.tasks.findByName("rshrAdvisoryGraph") != null) { + fail() + } + project.tasks.register("rshrAdvisoryGraph") { + doLast { + String targetConfiguration = requiredProperty("rshrAdvisoryConfiguration") + String outputArgument = requiredProperty("rshrAdvisoryOutput") + String authorityKey = project.path + "|" + targetConfiguration + String workloadId = authority.workloads[authorityKey] + String expectedTask = project.path + ":rshrAdvisoryGraph" + if (workloadId == null + || gradle.startParameter.taskNames != [expectedTask] + || !gradle.startParameter.offline) { + fail() + } + def configuration = project.configurations.findByName(targetConfiguration) + if (configuration == null || !configuration.canBeResolved) { + fail() + } + + String invocationBuildRoot = authority.build_root + def resolution = configuration.incoming.resolutionResult + def allDependencies = resolution.allDependencies + int dependencyCount = boundedSize( + allDependencies, MAX_DEPENDENCY_RESULTS + ) + def dependencyResults = allDependencies.toList() + if (dependencyResults.size() != dependencyCount) { + fail() + } + dependencyResults.each { dependency -> + if (dependency instanceof UnresolvedDependencyResult + || !(dependency instanceof ResolvedDependencyResult)) { + fail() + } + } + + def componentStates = new TreeMap<String, Object>() + def allComponents = resolution.allComponents + int componentCount = boundedSize(allComponents, MAX_COMPONENTS, false) + allComponents.each { component -> + Map core = componentCore(component.id, invocationBuildRoot) + String key = canonicalJson(core) + if (componentStates.containsKey(key)) { + fail() + } + def rawVariants = component.variants + int variantCount = boundedSize( + rawVariants, MAX_VARIANTS_PER_COMPONENT + ) + def variants = rawVariants.collect { variant -> variantRow(variant) } + if (variants.size() != variantCount) { + fail() + } + componentStates[key] = [ + component: component, + core: core, + variants: variants, + ] + } + if (componentStates.size() != componentCount) { + fail() + } + def appendVariant = { Map componentState, Map selectedVariant -> + if (componentState.variants.size() >= MAX_VARIANTS_PER_COMPONENT) { + fail() + } + componentState.variants.add(selectedVariant) + } + def rootComponent = resolution.rootComponent.get() + Map rootCore = componentCore(rootComponent.id, invocationBuildRoot) + String rootKey = canonicalJson(rootCore) + if (!componentStates.containsKey(rootKey)) { + fail() + } + appendVariant( + componentStates[rootKey], variantRow(resolution.rootVariant.get()) + ) + + def edgeRows = [] + dependencyResults.each { dependency -> + Map from = componentCore(dependency.from.id, invocationBuildRoot) + Map to = componentCore(dependency.selected.id, invocationBuildRoot) + String fromKey = canonicalJson(from) + String toKey = canonicalJson(to) + if (!componentStates.containsKey(fromKey) + || !componentStates.containsKey(toKey)) { + fail() + } + Map selectedVariant = variantRow(dependency.resolvedVariant) + appendVariant(componentStates[toKey], selectedVariant) + edgeRows.add([ + constraint: dependency.constraint, + from: from, + requested: selectorRow(dependency.requested, invocationBuildRoot), + selected_variant: selectedVariant, + to: to, + ]) + } + if (edgeRows.size() != dependencyCount) { + fail() + } + edgeRows = canonicalSortedRows(edgeRows, MAX_DEPENDENCY_RESULTS) + + def artifactCollection = configuration.incoming.artifacts + if (!artifactCollection.failures.isEmpty()) { + fail() + } + def resolvedArtifacts = artifactCollection.artifacts + int artifactCount = boundedSize(resolvedArtifacts, MAX_ARTIFACTS) + def legacyResolution = configuration.resolvedConfiguration + if (legacyResolution.hasError()) { + fail() + } + legacyResolution.rethrowFailure() + def legacyArtifacts = legacyResolution.resolvedArtifacts + int legacyArtifactCount = boundedSize(legacyArtifacts, MAX_ARTIFACTS) + if (legacyArtifactCount != artifactCount) { + fail() + } + def legacyByKey = new TreeMap<String, Object>() + legacyArtifacts.each { artifact -> + String key = artifactCorrelationKey(artifact, invocationBuildRoot) + def metadata = [ + artifact_id: artifact.id, + artifact_type: safeString(artifact.type, 64), + classifier: nullableString(artifact.classifier, 128), + extension: safeString(artifact.extension, 64), + logical_name: safeString(artifact.name, 256), + module_version: moduleVersionRow(artifact.moduleVersion), + ] + if (!ADMITTED_EXTENSIONS.contains(metadata.extension) + || legacyByKey.put(key, metadata) != null) { + fail() + } + } + if (legacyByKey.size() != legacyArtifactCount) { + fail() + } + long totalArtifactBytes = 0L + def artifactRows = resolvedArtifacts.collect { artifact -> + Map component = componentCore( + artifact.id.componentIdentifier, invocationBuildRoot + ) + Map variantOwner = componentCore(artifact.variant.owner, invocationBuildRoot) + String componentKey = canonicalJson(component) + if (componentKey != canonicalJson(variantOwner) + || !componentStates.containsKey(componentKey)) { + fail() + } + Map selectedVariant = variantRow(artifact.variant) + appendVariant(componentStates[componentKey], selectedVariant) + Path source = artifact.file.toPath().toAbsolutePath().normalize() + String fileName = safeString(source.fileName.toString(), 256) + int separator = fileName.lastIndexOf('.') + if (separator <= 0 || separator == fileName.length() - 1) { + fail() + } + String extension = safeString(fileName.substring(separator + 1), 64) + if (!ADMITTED_EXTENSIONS.contains(extension)) { + fail() + } + String correlationKey = artifactCorrelationKey( + artifact, invocationBuildRoot + ) + def metadata = legacyByKey.remove(correlationKey) + if (metadata == null || metadata.artifact_id != artifact.id + || metadata.extension != extension) { + fail() + } + def observation = observeRegularFile(source, MAX_ARTIFACT_BYTES) + totalArtifactBytes = Math.addExact( + totalArtifactBytes, (long) observation.byte_length + ) + if (totalArtifactBytes > MAX_TOTAL_ARTIFACT_BYTES) { + fail() + } + [ + artifact_name: fileName, + artifact_type: metadata.artifact_type, + classifier: metadata.classifier, + component: component, + extension: extension, + group: component.group, + logical_name: metadata.logical_name, + name: component.name, + module_version: metadata.module_version, + observed_byte_length: observation.byte_length, + observed_sha256: observation.sha256, + source_path: safeString(source.toString(), 4_096), + variant: selectedVariant, + version: component.version, + ] + } + if (artifactRows.size() != artifactCount + || !legacyByKey.isEmpty() + || !artifactCollection.failures.isEmpty()) { + fail() + } + artifactRows = canonicalSortedRows(artifactRows, MAX_ARTIFACTS) + + def componentRows = componentStates.collect { key, value -> + value.core + [ + root: key == rootKey, + variant: variantEnvelope(value.variants), + ] + }.sort { left, right -> + canonicalJson(left) <=> canonicalJson(right) + } + if (componentRows.size() != componentCount + || componentRows.count { row -> row.root } != 1) { + fail() + } + + def manifest = [ + schema: "radroots.gradle-advisory-graph.v1", + workload_id: workloadId, + build_root: invocationBuildRoot, + project_path: project.path, + configuration: targetConfiguration, + components: componentRows, + edges: edgeRows, + artifacts: artifactRows, + ] + byte[] bytes = canonicalJson(manifest).getBytes(StandardCharsets.UTF_8) + writeCreateNew(Paths.get(outputArgument), bytes) + } + } +} diff --git a/tools/xtask/src/advisory_snapshot.rs b/tools/xtask/src/advisory_snapshot.rs @@ -0,0 +1,7547 @@ +//! Candidate-bound immutable advisory snapshot admission. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fmt; +use std::path::{Component, Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +use crate::safe_artifact_io::{ + self, ArchiveEvidence, FileEvidence, TarGzipLimits, TraversalLimits, TraversalSnapshot, + TraversedFile, +}; + +const DECISION_PATH: &str = + "contracts/architecture/decisions/services_hardening_advisory_snapshots.v1.json"; +const DECISION_BYTES: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../contracts/architecture/decisions/services_hardening_advisory_snapshots.v1.json" +)); +const GRADLE_INIT_SCRIPT_BYTES: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/advisory_inventory.init.gradle" +)); +const GRADLE_INIT_SCRIPT_SHA256: &str = + "4c51de29877813dd679c970507602940fdf8c38a586f8d2f6d101b6a93a8b598"; +const MANIFEST_NAME: &str = "manifest.json"; +const RUSTSEC_ARCHIVE_NAME: &str = "rustsec-db.tar.gz"; +const RUSTSEC_REPORT_NAME: &str = "rustsec-report.json"; +const NVD_ARCHIVE_NAME: &str = "nvd-data.tar.gz"; +const OWASP_REPORT_NAME: &str = "owasp-report.json"; +const GRADLE_RAW_GRAPH_NAME: &str = "inventory.json"; +const RAW_SCANNER_OUTPUT_NAME: &str = "scanner-output.json"; +const SNAPSHOT_FILE_NAMES: [&str; 5] = [ + MANIFEST_NAME, + NVD_ARCHIVE_NAME, + OWASP_REPORT_NAME, + RUSTSEC_ARCHIVE_NAME, + RUSTSEC_REPORT_NAME, +]; +const MAX_SNAPSHOT_MANIFEST_BYTES: u64 = 67_108_864; +const MAX_PRODUCER_REQUEST_BYTES: usize = 1_048_576; +const MAX_TOOL_MANIFEST_BYTES: usize = 16_777_216; +const MAX_TOOL_OBSERVATION_BYTES: usize = 4_194_304; +const MAX_NVD_TRACE_BYTES: usize = 16_777_216; +const MAX_PROVIDER_EVIDENCE_BYTES: usize = 67_108_864; +const MAX_REPORT_BYTES: u64 = 67_108_864; +const MAX_RAW_WORKLOAD_REPORT_BYTES: u64 = 33_554_432; +const MAX_GRADLE_GRAPH_BYTES: u64 = 8_388_608; +const MAX_GRADLE_ARTIFACT_BYTES: u64 = 17_179_869_184; +const MAX_GRADLE_COMPONENTS: usize = 65_536; +const MAX_GRADLE_EDGES: usize = 262_144; +const MAX_GRADLE_ARTIFACTS: usize = 65_536; +const MAX_GRADLE_VARIANTS: usize = 4_096; +const MAX_GRADLE_ATTRIBUTES: usize = 1_024; +const MAX_GRADLE_CAPABILITIES: usize = 1_024; +const MAX_GRADLE_REJECTED_VERSIONS: usize = 1_024; +const MAX_GRADLE_EXTERNAL_VARIANT_DEPTH: usize = 4; +const MAX_NVD_REQUESTS: usize = 1_024; +const MAX_NVD_RESPONSE_BYTES: u64 = 67_108_864; +const MAX_NVD_AGGREGATE_BYTES: u64 = 536_870_912; +const QUALIFICATION_FRESHNESS_SECONDS: u64 = 86_400; +const DEVELOPMENT_REUSE_SECONDS: u64 = 604_800; +const TOOL_REVIEW_SECONDS: u64 = 86_400; +const ANALYSIS_DEADLINE_SECONDS: u64 = 3_600; +const NON_WAIVABLE_RUSTSEC: &str = "RUSTSEC-2026-0253"; +const BOUNDED_PROCESS_DECISION_SHA256: &str = + "41e11fe9bfecfb946063bafcb4df7eb811abdf03b0eaf8df4bff0ccb382d9784"; +const BOUNDED_PROCESS_SOURCE_SHA256: &str = + "56b8de0c3c34b7481f0f8c792e37dfb0d9fc12a5b990287c979e89fd6989202e"; + +const TOOL_PINS: [ToolPin; 4] = [ + ToolPin { + id: "cargo_audit", + version: "0.22.1", + executable_sha256: "c5cd7c0da8a9d0dff338aa1a2a30b0c723fde8201c23481f49e75be0bb77fe74", + source_sha256: "2f4e27b0ab2d116c87c29db159ad42565cdcdccf77eb62ef0486ddd017a02da6", + receipt_sha256: "74196dcade966311e535813d2e3bda2f9515186c700f3074acdc2849abce1303", + projection_sha256: "c945e827744d62b46e46826c8c066e3f8f354d972fa7f069d963e287b8e7b595", + }, + ToolPin { + id: "owasp_dependency_check", + version: "12.2.2", + executable_sha256: "d683a49ec335eeca93d8707f3e8ce21d7ba63a1e619a325c6518f89c25efcdc4", + source_sha256: "bf07fefd81af3094c5f6850423b014df44db62ce2dbad0f79079a90df675e44a", + receipt_sha256: "83d9a668179fb5e9ad6fd5c625a9ab58b8371a6d32b59808fc8d2973957f54a2", + projection_sha256: "1011327eeed9ed5c4835bdf8e08f5b1852cf118506563daa5ddb0a251e65fb9a", + }, + ToolPin { + id: "java", + version: "21.0.12.1", + executable_sha256: "9be1d0a740ff6502df1a762145e62860f5de4b7e17658d9cb9498da3acf9d16c", + source_sha256: "575bb8d9d604821d8f350325b28a35e49bcffd7ec33727b41edc8d709537dada", + receipt_sha256: "e7018157655e26443392ace45967e427aafa1dd98e2e67f94bdd04267d1d9296", + projection_sha256: "2e45aabf8388778d521d51e1ade0d4403fae4d656773bf547e2105dbd79ae655", + }, + ToolPin { + id: "gradle_wrapper", + version: "9.5.0", + executable_sha256: "ab5c0cad16305af2e619c159c1f58dd68d07fab9c11e36701e109c0277407f7a", + source_sha256: "553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746", + receipt_sha256: "a6538aaef9f5e2510cf4bbfe6d560e64ae4065e64a5c8ba0807d7448f6a89610", + projection_sha256: "f84dc2cb5139d080b28eaee9bc67fcce9bb9acc5e6f609fc00f247b1dd282ef8", + }, +]; + +const CANDIDATE_REPOSITORIES: [&str; 10] = [ + "_radroots", + "oss/lib", + "oss/sdk", + "oss/myc", + "oss/rhi", + "oss/apple_kit", + "oss/cli", + "oss/radrootsd", + "oss/ios_app", + "oss/harvestcircle", +]; + +const CARGO_AUDIT_ARGUMENTS: [&str; 7] = [ + "audit", + "--db", + "{absolute_rustsec_snapshot}", + "--no-fetch", + "--json", + "--file", + "{absolute_cargo_lock}", +]; +const OWASP_UPDATE_ARGUMENTS: [&str; 12] = [ + "--updateonly", + "--data", + "{nvd_staging}", + "--disableKnownExploited", + "--disableRetireJs", + "--disableHostedSuppressions", + "--disableVersionCheck", + "--disableOssIndex", + "--disableCentral", + "--disableNodeAudit", + "--disableYarnAudit", + "--disablePnpmAudit", +]; +const OWASP_OFFLINE_ARGUMENTS: [&str; 20] = [ + "--noupdate", + "--data", + "{nvd_snapshot}", + "--scan", + "{gradle_inventory}", + "--project", + "{exact_workload_id}", + "--format", + "JSON", + "--out", + "{report}", + "--disableKnownExploited", + "--disableRetireJs", + "--disableHostedSuppressions", + "--disableVersionCheck", + "--disableOssIndex", + "--disableCentral", + "--disableNodeAudit", + "--disableYarnAudit", + "--disablePnpmAudit", +]; + +#[derive(Clone, Copy)] +struct ToolPin { + id: &'static str, + version: &'static str, + executable_sha256: &'static str, + source_sha256: &'static str, + receipt_sha256: &'static str, + projection_sha256: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AdvisoryFailureKind { + ArchiveRejected, + BindingChanged, + ExpiredSuppression, + InventoryMismatch, + InvalidContract, + InvalidReport, + InvalidSnapshot, + KnownVulnerability, + StaleSnapshot, + TimedOut, + Unavailable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct AdvisoryError { + kind: AdvisoryFailureKind, +} + +impl AdvisoryError { + const fn new(kind: AdvisoryFailureKind) -> Self { + Self { kind } + } + + pub(crate) const fn kind(self) -> AdvisoryFailureKind { + self.kind + } +} + +impl fmt::Display for AdvisoryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + AdvisoryFailureKind::ArchiveRejected => "advisory archive admission failed", + AdvisoryFailureKind::BindingChanged => "advisory snapshot binding changed", + AdvisoryFailureKind::ExpiredSuppression => "advisory suppression is expired", + AdvisoryFailureKind::InventoryMismatch => "advisory inventory differs", + AdvisoryFailureKind::InvalidContract => "advisory contract differs", + AdvisoryFailureKind::InvalidReport => "advisory scanner report is invalid", + AdvisoryFailureKind::InvalidSnapshot => "advisory snapshot is invalid", + AdvisoryFailureKind::KnownVulnerability => "advisory findings remain unsuppressed", + AdvisoryFailureKind::StaleSnapshot => "advisory snapshot is stale", + AdvisoryFailureKind::TimedOut => "advisory operation timed out", + AdvisoryFailureKind::Unavailable => "mandatory advisory input is unavailable", + }) + } +} + +impl std::error::Error for AdvisoryError {} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct CandidateIdentity { + pub(crate) generation: u64, + pub(crate) digest: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SourceIdentity { + pub(crate) repository: String, + pub(crate) revision: String, + pub(crate) tree: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct WorkloadInventory { + pub(crate) id: String, + pub(crate) repository: String, + pub(crate) build_root: String, + pub(crate) package_manager: String, + pub(crate) language: String, + pub(crate) manifest_path: Option<String>, + pub(crate) lockfile_path: Option<String>, + pub(crate) project_path: Option<String>, + pub(crate) configuration: Option<String>, + pub(crate) dependency_count: u64, + pub(crate) input_sha256: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum FreshnessMode { + Development, + Qualification, +} + +pub(crate) struct AdmissionRequest { + pub(crate) candidate: CandidateIdentity, + pub(crate) sources: Vec<SourceIdentity>, + pub(crate) inventory: Vec<WorkloadInventory>, + pub(crate) evaluation_epoch: u64, + pub(crate) freshness: FreshnessMode, + pub(crate) producer_request: Vec<u8>, + pub(crate) step_297_tool_manifest: Vec<u8>, + pub(crate) fresh_tool_observation: Vec<u8>, + pub(crate) nvd_network_trace: Vec<u8>, + pub(crate) provider_execution_evidence: Vec<u8>, + pub(crate) admitted_gradle_graphs: Vec<AdmittedGradleGraph>, + pub(crate) admitted_scanner_outputs: Vec<AdmittedScannerOutput>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct TrustedProducerRequest { + schema: String, + candidate_generation: u64, + candidate_digest: String, + platform: String, + process_contract_sha256: String, + process_runner_source_sha256: String, + nvd_endpoint: String, + nvd_enforcement_program_sha256: String, + nvd_enforcement_configuration_sha256: String, + nvd_query_keys: Vec<String>, + workload_ids: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct TrustedToolManifest { + schema: String, + candidate_generation: u64, + candidate_digest: String, + platform: String, + producer_request_sha256: String, + tool_acquisition: Vec<ToolAcquisition>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct TrustedToolObservation { + schema: String, + candidate_generation: u64, + candidate_digest: String, + platform: String, + producer_request_sha256: String, + tool_manifest_sha256: String, + row_projection_sha256: String, + observed_at_epoch: u64, + tool_state: Vec<ToolState>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct NvdNetworkTrace { + schema: String, + candidate_generation: u64, + candidate_digest: String, + producer_request_sha256: String, + enforcement: String, + enforcement_program_sha256: String, + enforcement_configuration_sha256: String, + request: Vec<NvdRequestTrace>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct NvdRequestTrace { + sequence: u64, + method: String, + scheme: String, + authority: String, + path: String, + started_at_epoch: u64, + completed_at_epoch: u64, + query: Vec<NvdQueryTrace>, + response_status: u16, + response_byte_length: u64, + response_sha256: String, + response_start_index: u64, + response_results_per_page: u64, + response_total_results: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct NvdQueryTrace { + name: String, + value: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct TrustedProviderEvidence { + schema: String, + candidate_generation: u64, + candidate_digest: String, + platform: String, + producer_request_sha256: String, + candidate_advisory_input_sha256: String, + gradle_projection: Vec<GradleProjection>, + provider_snapshot: Vec<ProviderSnapshot>, + process_receipt: Vec<TrustedProcessReceipt>, + suppressions: Vec<Suppression>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct TrustedProcessReceipt { + id: String, + state: OperationState, + program_sha256: String, + runtime_sha256: Vec<String>, + arguments_sha256: String, + environment_sha256: String, + environment: Vec<ProcessEnvironmentRow>, + working_directory_sha256: String, + working_directory: ProcessWorkingDirectory, + path_binding: Vec<ProcessPathBinding>, + stdin_closed: bool, + deadline_seconds: u64, + started_at_epoch: u64, + completed_at_epoch: u64, + exit_code: i32, + stdout_byte_length: u64, + stdout_sha256: String, + stderr_byte_length: u64, + stderr_sha256: String, + input_sha256: String, + output_sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ProcessEnvironmentRow { + name: String, + logical_value: String, + value_sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ProcessWorkingDirectory { + logical_uri: String, + kind: String, + identity_sha256: String, + pre_execution_entry_count: u64, + pre_execution_tree_sha256: String, + post_execution_entry_count: u64, + post_execution_tree_sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ProcessPathBinding { + argument_index: u16, + logical_role: String, + identity_sha256: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +enum ProviderId { + OwaspNvd, + Rustsec, +} + +impl ProviderId { + const fn as_str(self) -> &'static str { + match self { + Self::OwaspNvd => "owasp_nvd", + Self::Rustsec => "rustsec", + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum OperationState { + Complete, + Failed, + TimedOut, + Unavailable, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ToolAcquisition { + id: String, + projection: Value, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ToolState { + id: String, + state: OperationState, + reviewed_at_epoch: u64, + normalized_version: String, + executable_sha256: String, + source_sha256: String, + package_receipt_sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct BlobBinding { + path: String, + byte_length: u64, + sha256: String, + logical_uri: String, + media_type: String, + logical_role: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ProviderSnapshot { + provider: ProviderId, + acquisition_kind: String, + acquisition_count: u8, + acquisition_state: OperationState, + acquired_at_epoch: u64, + digest_time_epoch: u64, + database_identity_sha256: String, + materialized_tree_sha256: String, + bounded_deadline_seconds: u64, + network_mode: String, + producer_request_sha256: String, + network_trace_sha256: String, + acquisition_arguments: Vec<String>, + archive_format: String, + archive: BlobBinding, + archive_expanded_bytes: u64, + archive_member_count: u64, + archive_payload_bytes: u64, + analysis_state: OperationState, + analyzed_at_epoch: u64, + analysis_environment: String, + analysis_arguments: Vec<String>, + report: BlobBinding, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct GradleProjection { + workload_id: String, + state: OperationState, + raw_graph_byte_length: u64, + raw_graph_sha256: String, + init_script_sha256: String, + wrapper_arguments: Vec<String>, + environment_keys: Vec<String>, + environment_sha256: String, + exit_code: i32, + source_revision: String, + source_tree: String, + input_sha256: String, + dependency_count: u64, + component_count: u64, + edge_count: u64, + artifact_count: u64, + components: Vec<GradleComponent>, + edges: Vec<GradleEdge>, + artifacts: Vec<GradleArtifact>, + canonical_graph_sha256: String, + materialized_tree_sha256: String, + normalization_receipt_sha256: String, + artifact_source_roots_sha256: String, + seed_cache_inventory_sha256: String, + wrapper_distribution_sha256: String, + process_receipt_sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct GradleComponent { + root: bool, + kind: String, + group: Option<String>, + name: Option<String>, + version: Option<String>, + build_root: Option<String>, + project_path: Option<String>, + variant: Value, + variant_sha256: String, + identity_sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct GradleEdge { + from_identity_sha256: String, + to_identity_sha256: String, + requested: Value, + requested_sha256: String, + constraint: bool, + selected_variant: Value, + selected_variant_sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct GradleArtifact { + component_identity_sha256: String, + component: String, + package_ecosystem: String, + package_namespace: String, + package_name: String, + package_version: String, + artifact_sha256: String, + artifact_name: String, + logical_name: String, + artifact_type: String, + classifier: Option<String>, + byte_length: u64, + extension: String, + materialized_name: String, + variant: Value, + variant_sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct Suppression { + id: String, + provider: ProviderId, + advisory_id: String, + workload_id: String, + package_ecosystem: String, + package_namespace: String, + package_name: String, + package_version: String, + owner: String, + rationale: String, + created_at_epoch: u64, + expires_at_epoch: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct SnapshotManifest { + schema: String, + candidate: CandidateIdentity, + sources: Vec<SourceIdentity>, + inventory_sha256: String, + inventory: Vec<WorkloadInventory>, + candidate_advisory_input_sha256: String, + step_297_tool_manifest_sha256: String, + fresh_tool_observation_sha256: String, + tool_acquisition: Vec<ToolAcquisition>, + tool_state: Vec<ToolState>, + gradle_projection: Vec<GradleProjection>, + provider_snapshot: Vec<ProviderSnapshot>, + suppressions: Vec<Suppression>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct Finding { + provider: ProviderId, + advisory_id: String, + package_ecosystem: String, + package_namespace: String, + package_name: String, + package_version: String, + workload_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AdvisorySnapshotEvidence { + pub(crate) candidate: CandidateIdentity, + pub(crate) manifest: FileEvidence, + pub(crate) source_inventory_sha256: String, + pub(crate) inventory_sha256: String, + pub(crate) fresh_tool_observation_sha256: String, + pub(crate) tool_row_projection_sha256: String, + pub(crate) rustsec_archive: ArchiveEvidence, + pub(crate) nvd_archive: ArchiveEvidence, + pub(crate) rustsec_report: FileEvidence, + pub(crate) owasp_report: FileEvidence, + pub(crate) provider_time_trace_sha256: String, + pub(crate) finding_inventory_sha256: String, + pub(crate) suppression_inventory_sha256: String, + pub(crate) candidate_advisory_input_sha256: String, + pub(crate) gradle_projection_sha256: String, + pub(crate) provider_freshness: Vec<ProviderFreshnessEvidence>, + pub(crate) finding_count: usize, + pub(crate) suppression_count: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProviderFreshnessEvidence { + pub(crate) provider: String, + pub(crate) acquired_at_epoch: u64, + pub(crate) digest_time_epoch: u64, + pub(crate) analyzed_at_epoch: u64, + pub(crate) database_identity_sha256: String, + pub(crate) archive_sha256: String, + pub(crate) report_sha256: String, + pub(crate) network_trace_sha256: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReportEnvelope { + schema: String, + provider: ProviderId, + workload_result: Vec<WorkloadResult>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkloadResult { + workload_id: String, + input_sha256: String, + dependency_count: u64, + provider_archive_sha256: String, + materialized_tree_sha256: String, + tool_observation_sha256: String, + arguments: Vec<String>, + environment_sha256: String, + process_receipt_sha256: String, + database_copy: Option<ScannerDatabaseCopy>, + exit_code: i32, + raw_output_byte_length: u64, + raw_output_sha256: String, + raw_scanner_output: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ScannerDatabaseCopy { + root_identity_sha256: String, + source_tree_sha256: String, + pre_scan_tree_sha256: String, + post_scan_tree_sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RawGradleGraph { + schema: String, + workload_id: String, + build_root: String, + project_path: String, + configuration: String, + components: Vec<RawGradleComponent>, + edges: Vec<RawGradleEdge>, + artifacts: Vec<RawGradleArtifact>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RawGradleComponentCore { + build_root: Option<String>, + group: Option<String>, + kind: String, + name: Option<String>, + project_path: Option<String>, + version: Option<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RawGradleComponent { + build_root: Option<String>, + group: Option<String>, + kind: String, + name: Option<String>, + project_path: Option<String>, + root: bool, + variant: RawGradleVariantEnvelope, + version: Option<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RawGradleVariantEnvelope { + selected: Vec<RawGradleVariant>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RawGradleVariant { + attributes: Vec<RawGradleAttribute>, + capabilities: Vec<RawGradleCapability>, + external_variant: Option<Box<RawGradleVariant>>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RawGradleAttribute { + name: String, + value: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RawGradleCapability { + group: String, + name: String, + version: Option<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RawGradleEdge { + constraint: bool, + from: RawGradleComponentCore, + requested: RawGradleSelector, + selected_variant: RawGradleVariant, + to: RawGradleComponentCore, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RawGradleSelector { + attributes: Vec<RawGradleAttribute>, + build_root: Option<String>, + capabilities: Vec<RawGradleCapability>, + group: Option<String>, + kind: String, + name: Option<String>, + project_path: Option<String>, + version: Option<String>, + version_constraint: Option<RawGradleVersionConstraint>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RawGradleVersionConstraint { + branch: Option<String>, + preferred: String, + rejected: Vec<String>, + required: String, + strict: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RawGradleArtifact { + artifact_name: String, + artifact_type: String, + classifier: Option<String>, + component: RawGradleComponentCore, + extension: String, + group: Option<String>, + logical_name: String, + module_version: RawGradleModuleVersion, + name: Option<String>, + observed_byte_length: u64, + observed_sha256: String, + source_path: String, + variant: RawGradleVariant, + version: Option<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RawGradleModuleVersion { + group: String, + name: String, + version: String, +} + +pub(crate) struct AdmittedGradleGraph { + raw: FileEvidence, + workload_id: String, + components: Vec<GradleComponent>, + edges: Vec<GradleEdge>, + artifacts: Vec<GradleArtifact>, + canonical_graph_sha256: String, + materialized_tree_sha256: String, + normalization_receipt_sha256: String, + artifact_source_roots_sha256: String, + source_snapshot: TraversalSnapshot, + materialized_snapshot: TraversalSnapshot, + _materialization: tempfile::TempDir, +} + +impl AdmittedGradleGraph { + pub(crate) fn revalidate(&self) -> Result<(), AdvisoryError> { + self.source_snapshot + .revalidate() + .map_err(map_binding_error)?; + self.materialized_snapshot + .revalidate() + .map_err(map_binding_error) + } + + fn raw_evidence(&self) -> &FileEvidence { + &self.raw + } + + fn matches_projection(&self, projection: &GradleProjection) -> bool { + projection.workload_id == self.workload_id + && projection.raw_graph_byte_length == self.raw.byte_length + && projection.raw_graph_sha256 == self.raw.sha256 + && projection.components == self.components + && projection.edges == self.edges + && projection.artifacts == self.artifacts + && projection.canonical_graph_sha256 == self.canonical_graph_sha256 + && projection.materialized_tree_sha256 == self.materialized_tree_sha256 + && projection.normalization_receipt_sha256 == self.normalization_receipt_sha256 + && projection.artifact_source_roots_sha256 == self.artifact_source_roots_sha256 + } +} + +pub(crate) struct AdmittedScannerOutput { + provider: ProviderId, + workload_id: String, + bytes: Vec<u8>, + evidence: FileEvidence, + snapshot: TraversalSnapshot, +} + +impl AdmittedScannerOutput { + fn revalidate(&self) -> Result<(), AdvisoryError> { + self.snapshot.revalidate().map_err(map_binding_error) + } +} + +fn admit_raw_scanner_output( + root: &Path, + provider: ProviderId, + workload_id: &str, +) -> Result<AdmittedScannerOutput, AdvisoryError> { + let snapshot = safe_artifact_io::traverse_regular_files( + root, + TraversalLimits { + max_entries: 1, + max_files: 1, + max_total_bytes: MAX_RAW_WORKLOAD_REPORT_BYTES, + max_file_bytes: MAX_RAW_WORKLOAD_REPORT_BYTES, + max_depth: 1, + max_path_bytes: 64, + }, + &[], + ) + .map_err(map_binding_error)?; + let file = snapshot + .files() + .first() + .filter(|file| { + snapshot.entry_count() == 1 + && snapshot.files().len() == 1 + && file.relative_path() == Path::new(RAW_SCANNER_OUTPUT_NAME) + }) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let (bytes, evidence) = snapshot + .read_evidenced(file, MAX_RAW_WORKLOAD_REPORT_BYTES) + .map_err(map_binding_error)?; + if bytes.is_empty() || serde_json::from_slice::<Value>(&bytes).is_err() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + snapshot.revalidate().map_err(map_binding_error)?; + Ok(AdmittedScannerOutput { + provider, + workload_id: workload_id.to_owned(), + bytes, + evidence, + snapshot, + }) +} + +struct GradleArtifactSourceRoot<'a> { + path: &'a Path, + logical_role: &'static str, + identity_sha256: &'a str, +} + +struct NormalizedRawGradleGraph { + components: Vec<GradleComponent>, + edges: Vec<GradleEdge>, + artifacts: Vec<GradleArtifact>, + source_paths: Vec<PathBuf>, +} + +fn admit_raw_gradle_graph( + raw_root: &Path, + materialization_parent: &Path, + authority: &WorkloadAuthority, + source_revision: &str, + expected_dependency_count: u64, + artifact_source_roots: &[GradleArtifactSourceRoot<'_>], +) -> Result<AdmittedGradleGraph, AdvisoryError> { + let source_snapshot = safe_artifact_io::traverse_regular_files( + raw_root, + TraversalLimits { + max_entries: 1, + max_files: 1, + max_total_bytes: MAX_GRADLE_GRAPH_BYTES, + max_file_bytes: MAX_GRADLE_GRAPH_BYTES, + max_depth: 1, + max_path_bytes: 64, + }, + &[], + ) + .map_err(map_binding_error)?; + let raw_file = source_snapshot + .files() + .first() + .filter(|file| { + source_snapshot.entry_count() == 1 + && source_snapshot.files().len() == 1 + && file.relative_path() == Path::new(GRADLE_RAW_GRAPH_NAME) + }) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let (raw_bytes, raw_evidence) = source_snapshot + .read_evidenced(raw_file, MAX_GRADLE_GRAPH_BYTES) + .map_err(map_binding_error)?; + let raw_value: Value = serde_json::from_slice(&raw_bytes) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + if canonical_json_without_lf(&raw_value) != raw_bytes { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let raw: RawGradleGraph = serde_json::from_value(raw_value) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let artifact_source_roots_sha256 = artifact_source_roots_digest(artifact_source_roots)?; + let normalized = normalize_raw_gradle_graph( + &raw, + authority, + source_revision, + expected_dependency_count, + artifact_source_roots, + )?; + + if !materialization_parent.is_absolute() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + safe_artifact_io::validate_trusted_output_directory(materialization_parent) + .map_err(map_binding_error)?; + let materialization = tempfile::Builder::new() + .prefix(".radroots-advisory-scan-") + .tempdir_in(materialization_parent) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions( + materialization.path(), + std::fs::Permissions::from_mode(0o700), + ) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + } + #[cfg(not(unix))] + { + return Err(AdvisoryError::new(AdvisoryFailureKind::Unavailable)); + } + + let mut materialized = BTreeMap::<(String, String), FileEvidence>::new(); + for (artifact, source) in normalized.artifacts.iter().zip(&normalized.source_paths) { + let key = (artifact.artifact_sha256.clone(), artifact.extension.clone()); + let evidence = if let Some(existing) = materialized.get(&key) { + let observed = safe_artifact_io::hash_regular_path(source, MAX_GRADLE_ARTIFACT_BYTES) + .map_err(map_binding_error)?; + if &observed != existing { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + observed + } else { + let observed = safe_artifact_io::copy_regular_to_new_path( + source, + &materialization.path().join(&artifact.materialized_name), + MAX_GRADLE_ARTIFACT_BYTES, + ) + .map_err(map_binding_error)?; + materialized.insert(key, observed.clone()); + observed + }; + if evidence.byte_length != artifact.byte_length + || evidence.sha256 != artifact.artifact_sha256 + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + } + let maximum_files = u64::try_from(materialized.len().max(1)) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let materialized_snapshot = safe_artifact_io::traverse_regular_files( + materialization.path(), + TraversalLimits { + max_entries: maximum_files, + max_files: maximum_files, + max_total_bytes: MAX_GRADLE_ARTIFACT_BYTES, + max_file_bytes: MAX_GRADLE_ARTIFACT_BYTES, + max_depth: 1, + max_path_bytes: 128, + }, + &[], + ) + .map_err(map_binding_error)?; + if materialized_snapshot.files().len() != materialized.len() + || materialized_snapshot.root_permission_mode() != 0o700 + || materialized_snapshot + .files() + .iter() + .any(|file| file.permission_mode() != 0o600) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + let materialized_tree_sha256 = materialized_tree_digest(&materialized_snapshot)?; + let canonical_graph_sha256 = gradle_graph_digest_parts( + authority.id, + &normalized.components, + &normalized.edges, + &normalized.artifacts, + )?; + let normalization_receipt_sha256 = gradle_normalization_receipt_digest( + authority.id, + raw_evidence.byte_length, + &raw_evidence.sha256, + &canonical_graph_sha256, + &materialized_tree_sha256, + &artifact_source_roots_sha256, + )?; + source_snapshot.revalidate().map_err(map_binding_error)?; + materialized_snapshot + .revalidate() + .map_err(map_binding_error)?; + Ok(AdmittedGradleGraph { + raw: raw_evidence, + workload_id: authority.id.to_owned(), + components: normalized.components, + edges: normalized.edges, + artifacts: normalized.artifacts, + canonical_graph_sha256, + materialized_tree_sha256, + normalization_receipt_sha256, + artifact_source_roots_sha256, + source_snapshot, + materialized_snapshot, + _materialization: materialization, + }) +} + +fn normalize_raw_gradle_graph( + raw: &RawGradleGraph, + authority: &WorkloadAuthority, + source_revision: &str, + expected_dependency_count: u64, + artifact_source_roots: &[GradleArtifactSourceRoot<'_>], +) -> Result<NormalizedRawGradleGraph, AdvisoryError> { + if raw.schema != "radroots.gradle-advisory-graph.v1" + || authority.package_manager != "gradle" + || raw.workload_id != authority.id + || raw.build_root != authority.build_root + || raw.project_path.as_str() + != authority + .project_path + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))? + || raw.configuration.as_str() + != authority + .configuration + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))? + || raw.components.is_empty() + || raw.components.len() > MAX_GRADLE_COMPONENTS + || raw.edges.len() > MAX_GRADLE_EDGES + || raw.artifacts.len() > MAX_GRADLE_ARTIFACTS + || raw.edges.len() as u64 != expected_dependency_count + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + require_strict_canonical_order(&raw.components)?; + require_strict_canonical_order(&raw.edges)?; + require_strict_canonical_order(&raw.artifacts)?; + + let mut components = Vec::with_capacity(raw.components.len()); + let mut component_by_core = BTreeMap::<Vec<u8>, (String, RawGradleVariantEnvelope)>::new(); + for component in &raw.components { + let core = raw_component_core(component); + validate_raw_component_core(&core)?; + validate_raw_variant_envelope(&component.variant)?; + let variant = serde_json::to_value(&component.variant) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let mut normalized = GradleComponent { + root: component.root, + kind: component.kind.clone(), + group: component.group.clone(), + name: component.name.clone(), + version: component.version.clone(), + build_root: component.build_root.clone(), + project_path: component.project_path.clone(), + variant, + variant_sha256: String::new(), + identity_sha256: String::new(), + }; + normalized.variant_sha256 = gradle_variant_digest(&normalized.variant)?; + normalized.identity_sha256 = gradle_component_digest(&normalized)?; + let core_key = canonical_row_key(&core)?; + if component_by_core + .insert( + core_key, + ( + normalized.identity_sha256.clone(), + component.variant.clone(), + ), + ) + .is_some() + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + components.push(normalized); + } + components.sort_by(|left, right| left.identity_sha256.cmp(&right.identity_sha256)); + if components + .windows(2) + .any(|window| window[0].identity_sha256 >= window[1].identity_sha256) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let roots = components + .iter() + .filter(|component| component.root) + .collect::<Vec<_>>(); + if roots.len() != 1 + || roots[0].kind != "project" + || roots[0].build_root.as_deref() != Some(authority.build_root) + || roots[0].project_path.as_deref() != authority.project_path + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + + let mut edges = Vec::with_capacity(raw.edges.len()); + for edge in &raw.edges { + validate_raw_component_core(&edge.from)?; + validate_raw_component_core(&edge.to)?; + validate_raw_selector(&edge.requested)?; + validate_raw_variant(&edge.selected_variant, 0)?; + let (from_identity, _) = component_by_core + .get(&canonical_row_key(&edge.from)?) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let (to_identity, to_variants) = component_by_core + .get(&canonical_row_key(&edge.to)?) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + if from_identity == to_identity || !to_variants.selected.contains(&edge.selected_variant) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let requested = serde_json::to_value(&edge.requested) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let selected_variant = serde_json::to_value(&edge.selected_variant) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + edges.push(GradleEdge { + from_identity_sha256: from_identity.clone(), + to_identity_sha256: to_identity.clone(), + requested_sha256: gradle_request_digest(&requested)?, + requested, + constraint: edge.constraint, + selected_variant_sha256: gradle_variant_digest(&selected_variant)?, + selected_variant, + }); + } + edges.sort_by(|left, right| { + canonical_row_key(left) + .unwrap_or_default() + .cmp(&canonical_row_key(right).unwrap_or_default()) + }); + require_strict_canonical_order(&edges)?; + let mut reachable = BTreeSet::from([roots[0].identity_sha256.as_str()]); + loop { + let before = reachable.len(); + for edge in &edges { + if reachable.contains(edge.from_identity_sha256.as_str()) { + reachable.insert(edge.to_identity_sha256.as_str()); + } + } + if reachable.len() == before { + break; + } + } + if reachable.len() != components.len() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + + let mut artifacts = Vec::with_capacity(raw.artifacts.len()); + let mut source_paths = Vec::with_capacity(raw.artifacts.len()); + let mut total_artifact_bytes = 0_u64; + for artifact in &raw.artifacts { + validate_raw_component_core(&artifact.component)?; + validate_raw_variant(&artifact.variant, 0)?; + let (component_identity, variants) = component_by_core + .get(&canonical_row_key(&artifact.component)?) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + if !variants.selected.contains(&artifact.variant) + || artifact.group != artifact.component.group + || artifact.name != artifact.component.name + || artifact.version != artifact.component.version + || !valid_ascii_text(&artifact.module_version.group, 256, false) + || !valid_ascii_text(&artifact.module_version.name, 256, false) + || !valid_ascii_text(&artifact.module_version.version, 128, false) + || artifact.observed_byte_length == 0 + || artifact.observed_byte_length > MAX_GRADLE_ARTIFACT_BYTES + || !valid_hex(&artifact.observed_sha256, 64) + || !valid_ascii_text(&artifact.artifact_name, 256, false) + || artifact.artifact_name.contains(['/', '\\']) + || !valid_ascii_text(&artifact.logical_name, 256, false) + || !valid_ascii_text(&artifact.artifact_type, 64, false) + || !valid_artifact_extension(&artifact.extension) + || !artifact + .artifact_name + .ends_with(&format!(".{}", artifact.extension)) + || artifact + .classifier + .as_deref() + .is_some_and(|value| !valid_ascii_text(value, 128, false)) + || !valid_ascii_text(&artifact.source_path, 4_096, false) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let source_path = PathBuf::from(&artifact.source_path); + let source_roots = artifact_source_roots + .iter() + .filter_map(|root| { + source_path + .strip_prefix(root.path) + .ok() + .filter(|relative| { + !relative.as_os_str().is_empty() + && relative + .components() + .all(|component| matches!(component, Component::Normal(_))) + }) + .map(|relative| (root, relative)) + }) + .collect::<Vec<_>>(); + if !valid_absolute_normal_path(&source_path) || source_roots.len() != 1 { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let (source_root, relative) = source_roots[0]; + let source_evidence = + safe_artifact_io::hash_regular(source_root.path, relative, MAX_GRADLE_ARTIFACT_BYTES) + .map_err(map_binding_error)?; + if source_evidence.byte_length != artifact.observed_byte_length + || source_evidence.sha256 != artifact.observed_sha256 + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + total_artifact_bytes = total_artifact_bytes + .checked_add(artifact.observed_byte_length) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + if total_artifact_bytes > MAX_GRADLE_ARTIFACT_BYTES { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let component = components + .iter() + .find(|component| &component.identity_sha256 == component_identity) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let component_label = + match component.kind.as_str() { + "module" => { + let group = component.group.as_deref().ok_or_else(|| { + AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch) + })?; + let name = component.name.as_deref().ok_or_else(|| { + AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch) + })?; + let version = component.version.as_deref().ok_or_else(|| { + AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch) + })?; + if artifact.module_version.group != group + || artifact.module_version.name != name + || artifact.module_version.version != version + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + format!("{group}:{name}:{version}") + } + "project" => { + let root = component.build_root.as_deref().ok_or_else(|| { + AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch) + })?; + let project = component.project_path.as_deref().ok_or_else(|| { + AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch) + })?; + if !valid_project_module_version( + root, + project, + &artifact.module_version, + source_revision, + ) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + format!("{root}:{project}") + } + _ => { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + }; + let variant = serde_json::to_value(&artifact.variant) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + artifacts.push(GradleArtifact { + component_identity_sha256: component_identity.clone(), + component: component_label, + package_ecosystem: "maven".to_owned(), + package_namespace: artifact.module_version.group.clone(), + package_name: artifact.module_version.name.clone(), + package_version: artifact.module_version.version.clone(), + artifact_sha256: artifact.observed_sha256.clone(), + artifact_name: artifact.artifact_name.clone(), + logical_name: artifact.logical_name.clone(), + artifact_type: artifact.artifact_type.clone(), + classifier: artifact.classifier.clone(), + byte_length: artifact.observed_byte_length, + extension: artifact.extension.clone(), + materialized_name: format!("{}.{}", artifact.observed_sha256, artifact.extension), + variant_sha256: gradle_variant_digest(&variant)?, + variant, + }); + source_paths.push(source_path); + } + let mut paired = artifacts.into_iter().zip(source_paths).collect::<Vec<_>>(); + paired.sort_by(|left, right| { + canonical_row_key(&left.0) + .unwrap_or_default() + .cmp(&canonical_row_key(&right.0).unwrap_or_default()) + }); + if paired.windows(2).any(|window| window[0].0 == window[1].0) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let (artifacts, source_paths): (Vec<_>, Vec<_>) = paired.into_iter().unzip(); + Ok(NormalizedRawGradleGraph { + components, + edges, + artifacts, + source_paths, + }) +} + +fn raw_component_core(component: &RawGradleComponent) -> RawGradleComponentCore { + RawGradleComponentCore { + build_root: component.build_root.clone(), + group: component.group.clone(), + kind: component.kind.clone(), + name: component.name.clone(), + project_path: component.project_path.clone(), + version: component.version.clone(), + } +} + +fn valid_absolute_normal_path(path: &Path) -> bool { + path.is_absolute() + && path + .components() + .all(|component| matches!(component, Component::RootDir | Component::Normal(_))) +} + +fn artifact_source_roots_digest( + roots: &[GradleArtifactSourceRoot<'_>], +) -> Result<String, AdvisoryError> { + if roots.is_empty() || roots.len() > 2 { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let mut rows = Vec::with_capacity(roots.len()); + let mut roles = BTreeSet::new(); + for root in roots { + if !valid_absolute_normal_path(root.path) + || !matches!( + root.logical_role, + "candidate_build_output" | "governed_seed_cache" + ) + || !roles.insert(root.logical_role) + || !valid_hex(root.identity_sha256, 64) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + rows.push(serde_json::json!({ + "identity_sha256": root.identity_sha256, + "logical_role": root.logical_role, + })); + } + rows.sort_by(|left, right| { + canonical_json_without_lf(left).cmp(&canonical_json_without_lf(right)) + }); + domain_json_digest( + b"radroots-advisory-gradle-artifact-source-roots-v1\0", + &rows, + ) +} + +#[derive(Clone, Copy)] +struct WorkloadAuthority { + id: &'static str, + repository: &'static str, + build_root: &'static str, + package_manager: &'static str, + language: &'static str, + manifest_path: Option<&'static str>, + lockfile_path: Option<&'static str>, + project_path: Option<&'static str>, + configuration: Option<&'static str>, +} + +const WORKLOADS: [WorkloadAuthority; 16] = [ + cargo_workload("cli", "oss/cli", ".", "Cargo.toml", "Cargo.lock"), + cargo_workload( + "harvest_core", + "oss/harvestcircle", + "core", + "core/Cargo.toml", + "core/Cargo.lock", + ), + cargo_workload( + "harvest_xtask", + "oss/harvestcircle", + "tools/xtask", + "tools/xtask/Cargo.toml", + "tools/xtask/Cargo.lock", + ), + cargo_workload( + "ios_ffi", + "oss/ios_app", + "RadrootsFFI", + "Cargo.toml", + "Cargo.lock", + ), + cargo_workload("lib", "oss/lib", ".", "Cargo.toml", "Cargo.lock"), + cargo_workload("myc", "oss/myc", ".", "Cargo.toml", "Cargo.lock"), + cargo_workload( + "radrootsd", + "oss/radrootsd", + ".", + "Cargo.toml", + "Cargo.lock", + ), + cargo_workload("rhi", "oss/rhi", ".", "Cargo.toml", "Cargo.lock"), + cargo_workload("root", "_radroots", ".", "Cargo.toml", "Cargo.lock"), + cargo_workload("sdk", "oss/sdk", ".", "Cargo.toml", "Cargo.lock"), + gradle_workload( + "app_design_system", + ".", + ":app:design_system", + "desktopRuntimeClasspath", + ), + gradle_workload("app_desktop", ".", ":app:desktop", "runtimeClasspath"), + gradle_workload("app_shared", ".", ":app:shared", "desktopRuntimeClasspath"), + gradle_workload( + "tools_design_catalog", + ".", + ":tools:design_catalog", + "desktopRuntimeClasspath", + ), + gradle_workload( + "build_logic_contracts", + "build-logic", + ":contracts", + "runtimeClasspath", + ), + gradle_workload( + "build_logic_plugins", + "build-logic", + ":plugins", + "runtimeClasspath", + ), +]; + +const fn cargo_workload( + id: &'static str, + repository: &'static str, + build_root: &'static str, + manifest_path: &'static str, + lockfile_path: &'static str, +) -> WorkloadAuthority { + WorkloadAuthority { + id, + repository, + build_root, + package_manager: "cargo", + language: "rust", + manifest_path: Some(manifest_path), + lockfile_path: Some(lockfile_path), + project_path: None, + configuration: None, + } +} + +const fn gradle_workload( + id: &'static str, + build_root: &'static str, + project_path: &'static str, + configuration: &'static str, +) -> WorkloadAuthority { + WorkloadAuthority { + id, + repository: "oss/harvestcircle", + build_root, + package_manager: "gradle", + language: "kotlin", + manifest_path: None, + lockfile_path: None, + project_path: Some(project_path), + configuration: Some(configuration), + } +} + +pub(crate) fn validate_decision(root: &Path) -> Result<(), String> { + let bytes = safe_artifact_io::read_regular_path(&root.join(DECISION_PATH), 1_048_576) + .map_err(|_| "advisory snapshot decision admission failed".to_owned())?; + if bytes != DECISION_BYTES { + return Err("advisory snapshot decision differs from compiled authority".to_owned()); + } + let value: Value = serde_json::from_slice(&bytes) + .map_err(|_| "advisory snapshot decision is invalid".to_owned())?; + if canonical_pretty_json(&value).as_slice() != bytes { + return Err("advisory snapshot decision is not canonical pretty JSON".to_owned()); + } + Ok(()) +} + +pub(crate) fn admit_snapshot( + root: &Path, + materialization_parent: &Path, + request: &AdmissionRequest, +) -> Result<AdvisorySnapshotEvidence, AdvisoryError> { + validate_request(request)?; + let snapshot = safe_artifact_io::traverse_regular_files( + root, + TraversalLimits { + max_entries: SNAPSHOT_FILE_NAMES.len() as u64, + max_files: SNAPSHOT_FILE_NAMES.len() as u64, + max_total_bytes: 4_430_233_600, + max_file_bytes: 2_147_483_648, + max_depth: 1, + max_path_bytes: 128, + }, + &[], + ) + .map_err(map_binding_error)?; + let files = exact_snapshot_files(&snapshot)?; + let manifest_file = files + .get(MANIFEST_NAME) + .copied() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let (manifest_bytes, manifest_evidence) = snapshot + .read_evidenced(manifest_file, MAX_SNAPSHOT_MANIFEST_BYTES) + .map_err(map_binding_error)?; + let manifest: SnapshotManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let manifest_value: Value = serde_json::from_slice(&manifest_bytes) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + if canonical_json(&manifest_value) != manifest_bytes { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + validate_manifest(&manifest, request)?; + + let mut archive_evidence = BTreeMap::new(); + let mut materialized_archives = Vec::new(); + let mut report_evidences = BTreeMap::new(); + let mut findings = Vec::new(); + for graph in &request.admitted_gradle_graphs { + graph.revalidate()?; + } + for provider in &manifest.provider_snapshot { + let archive_file = files + .get(provider.archive.path.as_str()) + .copied() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let archive = snapshot + .admit_deterministic_tar_gzip(archive_file, archive_limits()) + .map_err(map_archive_error)?; + validate_blob(&provider.archive, &archive.compressed)?; + if provider.archive_expanded_bytes != archive.expanded_bytes + || provider.archive_member_count != archive.member_count + || provider.archive_payload_bytes != archive.payload_bytes + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + let materialized = snapshot + .materialize_deterministic_tar_gzip( + archive_file, + materialization_parent, + archive_limits(), + ) + .map_err(map_archive_error)?; + let materialized_tree = materialized_tree_digest(materialized.snapshot())?; + if materialized.evidence() != &archive + || materialized_tree != provider.materialized_tree_sha256 + || !valid_hex(&provider.database_identity_sha256, 64) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + materialized.revalidate().map_err(map_binding_error)?; + archive_evidence.insert(provider.provider, archive); + materialized_archives.push(materialized); + + let report_file = files + .get(provider.report.path.as_str()) + .copied() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let (report_bytes, report_evidence) = snapshot + .read_evidenced(report_file, MAX_REPORT_BYTES) + .map_err(map_binding_error)?; + validate_blob(&provider.report, &report_evidence)?; + findings.extend(parse_unsuppressed_report( + provider, + &report_bytes, + request, + &manifest.gradle_projection, + )?); + report_evidences.insert(provider.provider, report_evidence); + } + for graph in &request.admitted_gradle_graphs { + graph.revalidate()?; + } + for materialized in &materialized_archives { + materialized.revalidate().map_err(map_binding_error)?; + } + for output in &request.admitted_scanner_outputs { + output.revalidate()?; + } + + let unsuppressed_count = findings.len(); + let finding_inventory_sha256 = finding_digest(&findings)?; + apply_suppressions( + &mut findings, + &manifest.suppressions, + request.evaluation_epoch, + )?; + if !findings.is_empty() { + return Err(AdvisoryError::new(AdvisoryFailureKind::KnownVulnerability)); + } + snapshot.revalidate().map_err(map_binding_error)?; + for materialized in &materialized_archives { + materialized.revalidate().map_err(map_binding_error)?; + } + for output in &request.admitted_scanner_outputs { + output.revalidate()?; + } + let rustsec = archive_evidence + .remove(&ProviderId::Rustsec) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let nvd = archive_evidence + .remove(&ProviderId::OwaspNvd) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let rustsec_report = report_evidences + .remove(&ProviderId::Rustsec) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let owasp_report = report_evidences + .remove(&ProviderId::OwaspNvd) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let provider_freshness = manifest + .provider_snapshot + .iter() + .map(|provider| ProviderFreshnessEvidence { + provider: provider.provider.as_str().to_owned(), + acquired_at_epoch: provider.acquired_at_epoch, + digest_time_epoch: provider.digest_time_epoch, + analyzed_at_epoch: provider.analyzed_at_epoch, + database_identity_sha256: provider.database_identity_sha256.clone(), + archive_sha256: provider.archive.sha256.clone(), + report_sha256: provider.report.sha256.clone(), + network_trace_sha256: provider.network_trace_sha256.clone(), + }) + .collect(); + Ok(AdvisorySnapshotEvidence { + candidate: manifest.candidate, + manifest: manifest_evidence, + source_inventory_sha256: source_digest(&manifest.sources)?, + inventory_sha256: manifest.inventory_sha256, + fresh_tool_observation_sha256: manifest.fresh_tool_observation_sha256, + tool_row_projection_sha256: tool_observation_digest( + &manifest.tool_acquisition, + &manifest.tool_state, + )?, + rustsec_archive: rustsec, + nvd_archive: nvd, + rustsec_report, + owasp_report, + provider_time_trace_sha256: provider_trace_digest(&manifest.provider_snapshot)?, + finding_inventory_sha256, + suppression_inventory_sha256: suppression_digest(&manifest.suppressions)?, + candidate_advisory_input_sha256: manifest.candidate_advisory_input_sha256, + gradle_projection_sha256: gradle_projection_digest(&manifest.gradle_projection)?, + provider_freshness, + finding_count: unsuppressed_count, + suppression_count: manifest.suppressions.len(), + }) +} + +fn exact_snapshot_files( + snapshot: &TraversalSnapshot, +) -> Result<BTreeMap<&str, &TraversedFile>, AdvisoryError> { + if snapshot.entry_count() != SNAPSHOT_FILE_NAMES.len() as u64 + || snapshot.files().len() != SNAPSHOT_FILE_NAMES.len() + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let mut files = BTreeMap::new(); + for file in snapshot.files() { + let relative = file + .relative_path() + .to_str() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + if Path::new(relative).components().count() != 1 + || !SNAPSHOT_FILE_NAMES.contains(&relative) + || files.insert(relative, file).is_some() + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + } + Ok(files) +} + +fn validate_request(request: &AdmissionRequest) -> Result<(), AdvisoryError> { + if request.candidate.generation == 0 + || !valid_hex(&request.candidate.digest, 64) + || request.evaluation_epoch == 0 + || request.sources.is_empty() + || request.inventory.len() != WORKLOADS.len() + || request.admitted_gradle_graphs.len() + != WORKLOADS + .iter() + .filter(|workload| workload.package_manager == "gradle") + .count() + || request.admitted_scanner_outputs.len() != WORKLOADS.len() + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + validate_trusted_authority(request)?; + if request.sources.len() != CANDIDATE_REPOSITORIES.len() + || !request + .sources + .iter() + .map(|source| source.repository.as_str()) + .eq(CANDIDATE_REPOSITORIES) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let mut repositories = BTreeSet::new(); + for source in &request.sources { + if !valid_identifier(&source.repository) + || !valid_hex(&source.revision, 40) + || !valid_hex(&source.tree, 40) + || !repositories.insert(source.repository.as_str()) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + } + for (actual, expected) in request.inventory.iter().zip(WORKLOADS) { + if actual.id != expected.id + || actual.repository != expected.repository + || actual.build_root != expected.build_root + || actual.package_manager != expected.package_manager + || actual.language != expected.language + || actual.manifest_path.as_deref() != expected.manifest_path + || actual.lockfile_path.as_deref() != expected.lockfile_path + || actual.project_path.as_deref() != expected.project_path + || actual.configuration.as_deref() != expected.configuration + || actual.dependency_count == 0 + || !valid_hex(&actual.input_sha256, 64) + || !repositories.contains(actual.repository.as_str()) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + } + if !request + .admitted_gradle_graphs + .iter() + .map(|graph| graph.workload_id.as_str()) + .eq(WORKLOADS + .iter() + .filter(|workload| workload.package_manager == "gradle") + .map(|workload| workload.id)) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + for graph in &request.admitted_gradle_graphs { + graph.revalidate()?; + } + let expected_scanner_outputs = [ProviderId::Rustsec, ProviderId::OwaspNvd] + .into_iter() + .flat_map(|provider| { + WORKLOADS + .iter() + .filter(move |workload| match provider { + ProviderId::Rustsec => workload.package_manager == "cargo", + ProviderId::OwaspNvd => workload.package_manager == "gradle", + }) + .map(move |workload| (provider, workload.id)) + }); + if !request + .admitted_scanner_outputs + .iter() + .map(|output| (output.provider, output.workload_id.as_str())) + .eq(expected_scanner_outputs) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + for output in &request.admitted_scanner_outputs { + output.revalidate()?; + } + Ok(()) +} + +fn validate_manifest( + manifest: &SnapshotManifest, + request: &AdmissionRequest, +) -> Result<(), AdvisoryError> { + if manifest.schema != "radroots.advisory-snapshot.v1" + || manifest.candidate != request.candidate + || manifest.sources != request.sources + || manifest.inventory != request.inventory + || manifest.inventory_sha256 != inventory_digest(&request.inventory)? + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let (tool_manifest, tool_observation, trace, provider_evidence) = + validate_trusted_authority(request)?; + if manifest.step_297_tool_manifest_sha256 != sha256(&request.step_297_tool_manifest) + || manifest.fresh_tool_observation_sha256 != sha256(&request.fresh_tool_observation) + || manifest.tool_acquisition != tool_manifest.tool_acquisition + || manifest.tool_state != tool_observation.tool_state + || manifest.gradle_projection != provider_evidence.gradle_projection + || manifest.provider_snapshot != provider_evidence.provider_snapshot + || manifest.suppressions != provider_evidence.suppressions + || manifest.candidate_advisory_input_sha256 + != provider_evidence.candidate_advisory_input_sha256 + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + validate_tool_acquisitions(&manifest.tool_acquisition)?; + validate_tool_states(&manifest.tool_state, request.evaluation_epoch)?; + validate_gradle_projections( + &manifest.gradle_projection, + request, + &provider_evidence.process_receipt, + )?; + if manifest.provider_snapshot.len() != 2 + || manifest.provider_snapshot[0].provider != ProviderId::Rustsec + || manifest.provider_snapshot[1].provider != ProviderId::OwaspNvd + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + for provider in &manifest.provider_snapshot { + validate_provider( + provider, + request, + &trace, + &provider_evidence.process_receipt, + )?; + } + validate_temporal_order( + &tool_observation, + &manifest.provider_snapshot, + &provider_evidence.process_receipt, + )?; + if manifest.candidate_advisory_input_sha256 != candidate_advisory_input_digest(manifest)? { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + Ok(()) +} + +fn validate_trusted_authority( + request: &AdmissionRequest, +) -> Result< + ( + TrustedToolManifest, + TrustedToolObservation, + NvdNetworkTrace, + TrustedProviderEvidence, + ), + AdvisoryError, +> { + if request.producer_request.len() > MAX_PRODUCER_REQUEST_BYTES + || request.step_297_tool_manifest.len() > MAX_TOOL_MANIFEST_BYTES + || request.fresh_tool_observation.len() > MAX_TOOL_OBSERVATION_BYTES + || request.nvd_network_trace.len() > MAX_NVD_TRACE_BYTES + || request.provider_execution_evidence.len() > MAX_PROVIDER_EVIDENCE_BYTES + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + let producer: TrustedProducerRequest = parse_canonical_authority(&request.producer_request)?; + let producer_digest = sha256(&request.producer_request); + let tool_manifest: TrustedToolManifest = + parse_canonical_authority(&request.step_297_tool_manifest)?; + let observation: TrustedToolObservation = + parse_canonical_authority(&request.fresh_tool_observation)?; + let trace: NvdNetworkTrace = parse_canonical_authority(&request.nvd_network_trace)?; + let provider_evidence: TrustedProviderEvidence = + parse_canonical_authority(&request.provider_execution_evidence)?; + let row_projection = + tool_observation_digest(&tool_manifest.tool_acquisition, &observation.tool_state)?; + let expected_workloads = WORKLOADS + .iter() + .map(|workload| workload.id.to_owned()) + .collect::<Vec<_>>(); + let expected_query_keys = [ + "lastModStartDate", + "lastModEndDate", + "startIndex", + "resultsPerPage", + ] + .into_iter() + .map(str::to_owned) + .collect::<Vec<_>>(); + if producer.schema != "radroots.advisory-producer-request.v1" + || producer.candidate_generation != request.candidate.generation + || producer.candidate_digest != request.candidate.digest + || producer.platform != "macos_aarch64" + || producer.process_contract_sha256 != BOUNDED_PROCESS_DECISION_SHA256 + || producer.process_runner_source_sha256 != BOUNDED_PROCESS_SOURCE_SHA256 + || producer.nvd_endpoint != "/rest/json/cves/2.0" + || !valid_hex(&producer.nvd_enforcement_program_sha256, 64) + || !valid_hex(&producer.nvd_enforcement_configuration_sha256, 64) + || producer.nvd_query_keys != expected_query_keys + || producer.workload_ids != expected_workloads + || tool_manifest.schema != "radroots.advisory-tool-manifest-projection.v1" + || tool_manifest.candidate_generation != request.candidate.generation + || tool_manifest.candidate_digest != request.candidate.digest + || tool_manifest.platform != "macos_aarch64" + || tool_manifest.producer_request_sha256 != producer_digest + || observation.schema != "radroots.advisory-tool-observation.v1" + || observation.candidate_generation != request.candidate.generation + || observation.candidate_digest != request.candidate.digest + || observation.platform != "macos_aarch64" + || observation.producer_request_sha256 != producer_digest + || observation.tool_manifest_sha256 != sha256(&request.step_297_tool_manifest) + || observation.row_projection_sha256 != row_projection + || observation.observed_at_epoch > request.evaluation_epoch + || request + .evaluation_epoch + .checked_sub(observation.observed_at_epoch) + .is_none_or(|age| age > TOOL_REVIEW_SECONDS) + || trace.schema != "radroots.advisory-nvd-network-trace.v1" + || trace.candidate_generation != request.candidate.generation + || trace.candidate_digest != request.candidate.digest + || trace.producer_request_sha256 != producer_digest + || trace.enforcement != "deny_by_default_nvd_api_get_only_proxy" + || trace.enforcement_program_sha256 != producer.nvd_enforcement_program_sha256 + || trace.enforcement_configuration_sha256 != producer.nvd_enforcement_configuration_sha256 + || trace.request.is_empty() + || trace.request.len() > MAX_NVD_REQUESTS + || provider_evidence.schema != "radroots.advisory-provider-execution-evidence.v1" + || provider_evidence.candidate_generation != request.candidate.generation + || provider_evidence.candidate_digest != request.candidate.digest + || provider_evidence.platform != "macos_aarch64" + || provider_evidence.producer_request_sha256 != producer_digest + || provider_evidence.gradle_projection.len() != 6 + || provider_evidence.provider_snapshot.len() != 2 + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + validate_tool_acquisitions(&tool_manifest.tool_acquisition)?; + validate_tool_states(&observation.tool_state, request.evaluation_epoch)?; + validate_process_receipts(&provider_evidence.process_receipt, request.evaluation_epoch)?; + validate_nvd_trace(&producer, &trace, &provider_evidence.process_receipt)?; + Ok((tool_manifest, observation, trace, provider_evidence)) +} + +fn validate_nvd_trace( + producer: &TrustedProducerRequest, + trace: &NvdNetworkTrace, + receipts: &[TrustedProcessReceipt], +) -> Result<(), AdvisoryError> { + let update = process_receipt(receipts, "owasp_nvd_update")?; + let mut aggregate_bytes = 0_u64; + let mut previous_request_completed = None; + let mut previous_window: Option<(u64, u64, u64, u64, u64)> = None; + for (index, row) in trace.request.iter().enumerate() { + let values = row + .query + .iter() + .map(|query| (query.name.as_str(), query.value.as_str())) + .collect::<BTreeMap<_, _>>(); + let start = values + .get("lastModStartDate") + .and_then(|value| parse_report_epoch(value).ok()) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let end = values + .get("lastModEndDate") + .and_then(|value| parse_report_epoch(value).ok()) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let start_index = values + .get("startIndex") + .and_then(|value| parse_canonical_u64(value)) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let results_per_page = values + .get("resultsPerPage") + .and_then(|value| parse_canonical_u64(value)) + .filter(|value| (1..=2_000).contains(value)) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + aggregate_bytes = aggregate_bytes + .checked_add(row.response_byte_length) + .filter(|value| *value <= MAX_NVD_AGGREGATE_BYTES) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + if row.sequence != index as u64 + 1 + || row.method != "GET" + || row.scheme != "https" + || row.authority != "services.nvd.nist.gov" + || row.path != producer.nvd_endpoint + || row.query.len() != producer.nvd_query_keys.len() + || !row + .query + .iter() + .map(|query| query.name.as_str()) + .eq(producer.nvd_query_keys.iter().map(String::as_str)) + || row.query.iter().any(|query| { + !valid_bounded_text(&query.value, 128) || query.name.eq_ignore_ascii_case("apiKey") + }) + || start > end + || row.started_at_epoch < update.started_at_epoch + || previous_request_completed.is_some_and(|completed| completed > row.started_at_epoch) + || row.completed_at_epoch < row.started_at_epoch + || row.completed_at_epoch > update.completed_at_epoch + || row.response_status != 200 + || row.response_byte_length == 0 + || row.response_byte_length > MAX_NVD_RESPONSE_BYTES + || !valid_hex(&row.response_sha256, 64) + || row.response_start_index != start_index + || row.response_results_per_page != results_per_page + || row.response_total_results == 0 + || start_index >= row.response_total_results + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + if let Some((previous_start, previous_end, previous_index, previous_page, previous_total)) = + previous_window + { + if start == previous_start && end == previous_end { + if start_index != previous_index.saturating_add(previous_page) + || row.response_total_results != previous_total + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + } else if start != previous_end.saturating_add(1) || start_index != 0 { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + } else if start_index != 0 { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + let page_complete = start_index + .checked_add(results_per_page) + .is_some_and(|next| next >= row.response_total_results); + let next_same_window = trace.request.get(index + 1).is_some_and(|next| { + let next_values = next + .query + .iter() + .map(|query| (query.name.as_str(), query.value.as_str())) + .collect::<BTreeMap<_, _>>(); + next_values.get("lastModStartDate") == values.get("lastModStartDate") + && next_values.get("lastModEndDate") == values.get("lastModEndDate") + }); + if page_complete == next_same_window { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + previous_request_completed = Some(row.completed_at_epoch); + previous_window = Some(( + start, + end, + start_index, + results_per_page, + row.response_total_results, + )); + } + Ok(()) +} + +fn parse_canonical_u64(value: &str) -> Option<u64> { + if value.is_empty() + || !value.bytes().all(|byte| byte.is_ascii_digit()) + || (value.len() > 1 && value.starts_with('0')) + { + return None; + } + value.parse().ok() +} + +fn expected_process_receipt_ids() -> Vec<String> { + let mut ids = vec!["owasp_nvd_update".to_owned()]; + ids.extend( + WORKLOADS + .iter() + .filter(|workload| workload.package_manager == "gradle") + .map(|workload| format!("gradle:{}", workload.id)), + ); + ids.extend( + WORKLOADS + .iter() + .filter(|workload| workload.package_manager == "cargo") + .map(|workload| format!("cargo_audit:{}", workload.id)), + ); + ids.extend( + WORKLOADS + .iter() + .filter(|workload| workload.package_manager == "gradle") + .map(|workload| format!("owasp_analysis:{}", workload.id)), + ); + ids +} + +fn validate_process_receipts( + receipts: &[TrustedProcessReceipt], + evaluation_epoch: u64, +) -> Result<(), AdvisoryError> { + if !receipts + .iter() + .map(|receipt| receipt.id.as_str()) + .eq(expected_process_receipt_ids().iter().map(String::as_str)) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let mut previous_completed = None; + let mut working_directory_identities = BTreeSet::new(); + let mut private_environment_values = BTreeSet::new(); + let mut shared_environment_values = BTreeMap::<String, String>::new(); + for receipt in receipts { + require_complete(receipt.state)?; + validate_process_environment( + &receipt.id, + &receipt.environment, + &mut private_environment_values, + &mut shared_environment_values, + )?; + if !valid_exact_identity_token(&receipt.id, 256) + || !valid_hex(&receipt.program_sha256, 64) + || receipt.runtime_sha256 != expected_runtime_sha256(&receipt.id) + || !valid_hex(&receipt.arguments_sha256, 64) + || receipt.environment_sha256 != process_environment_digest(&receipt.environment)? + || validate_process_working_directory(&receipt.id, &receipt.working_directory).is_err() + || !working_directory_identities + .insert(receipt.working_directory.identity_sha256.clone()) + || receipt.working_directory_sha256 + != process_working_directory_digest(&receipt.working_directory)? + || receipt + .path_binding + .windows(2) + .any(|rows| rows[0].argument_index >= rows[1].argument_index) + || receipt.path_binding.iter().any(|binding| { + !valid_exact_identity_token(&binding.logical_role, 128) + || !valid_hex(&binding.identity_sha256, 64) + }) + || !receipt.stdin_closed + || receipt.deadline_seconds != ANALYSIS_DEADLINE_SECONDS + || receipt.started_at_epoch == 0 + || receipt.completed_at_epoch < receipt.started_at_epoch + || previous_completed.is_some_and(|previous| previous > receipt.started_at_epoch) + || receipt.completed_at_epoch > evaluation_epoch + || receipt.stdout_byte_length > 67_108_864 + || receipt.stderr_byte_length > 67_108_864 + || !valid_hex(&receipt.stdout_sha256, 64) + || !valid_hex(&receipt.stderr_sha256, 64) + || !valid_hex(&receipt.input_sha256, 64) + || !valid_hex(&receipt.output_sha256, 64) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + previous_completed = Some(receipt.completed_at_epoch); + } + if private_environment_values + .iter() + .any(|value| working_directory_identities.contains(value)) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + Ok(()) +} + +fn expected_process_environment(id: &str) -> Result<Vec<ProcessEnvironmentRow>, AdvisoryError> { + expected_environment_names(id) + .map(|name| { + let logical_value = expected_environment_logical_value(name)?; + let synthetic_value = match name { + "LC_ALL" => "C".to_owned(), + "TZ" => "UTC".to_owned(), + "JAVA_HOME" => "/governed/jdk-21.0.12.1".to_owned(), + "PATH" => "/governed/tool-manifest/bin".to_owned(), + _ => format!("/private/{id}/{name}"), + }; + Ok(ProcessEnvironmentRow { + name: name.to_owned(), + logical_value: logical_value.to_owned(), + value_sha256: sha256(synthetic_value.as_bytes()), + }) + }) + .collect() +} + +fn expected_environment_logical_value(name: &str) -> Result<&'static str, AdvisoryError> { + match name { + "CARGO_HOME" => Ok("private_empty_cargo_home"), + "GRADLE_USER_HOME" => Ok("private_seeded_gradle_user_home"), + "HOME" => Ok("private_empty_home"), + "JAVA_HOME" => Ok("pinned_java_21_0_12_1_home"), + "LC_ALL" => Ok("C"), + "PATH" => Ok("pinned_tool_manifest_only_path"), + "TMPDIR" => Ok("private_owner_only_attempt_tmp"), + "TZ" => Ok("UTC"), + _ => Err(AdvisoryError::new(AdvisoryFailureKind::InvalidContract)), + } +} + +fn validate_process_environment( + id: &str, + environment: &[ProcessEnvironmentRow], + private_values: &mut BTreeSet<String>, + shared_values: &mut BTreeMap<String, String>, +) -> Result<(), AdvisoryError> { + let expected_names = expected_environment_names(id).collect::<Vec<_>>(); + if environment.len() != expected_names.len() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + for (row, expected_name) in environment.iter().zip(expected_names) { + let expected_logical_value = expected_environment_logical_value(expected_name)?; + if row.name != expected_name + || row.logical_value != expected_logical_value + || !valid_hex(&row.value_sha256, 64) + || match expected_name { + "LC_ALL" => row.value_sha256 != sha256(b"C"), + "TZ" => row.value_sha256 != sha256(b"UTC"), + "JAVA_HOME" | "PATH" => { + shared_values + .entry(expected_name.to_owned()) + .or_insert_with(|| row.value_sha256.clone()) + != &row.value_sha256 + } + _ => !private_values.insert(row.value_sha256.clone()), + } + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + } + Ok(()) +} + +fn expected_process_working_directory(id: &str) -> Result<ProcessWorkingDirectory, AdvisoryError> { + let (logical_uri, kind, pre_entries, post_entries) = + if let Some(workload) = id.strip_prefix("cargo_audit:") { + ( + format!("rshr-private-cargo-audit://{workload}"), + "fresh_empty_config_free", + 0, + 0, + ) + } else if let Some(workload) = id.strip_prefix("gradle:") { + ( + format!("rshr-candidate-gradle-build://{workload}"), + "retained_candidate_source", + 1, + 1, + ) + } else if let Some(workload) = id.strip_prefix("owasp_analysis:") { + ( + format!("rshr-private-owasp-analysis://{workload}"), + "fresh_private_analysis", + 0, + 0, + ) + } else if id == "owasp_nvd_update" { + ( + "rshr-private-owasp-nvd-update://attempt".to_owned(), + "fresh_private_update", + 0, + 1, + ) + } else { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidContract)); + }; + let empty_tree = empty_directory_tree_sha256()?; + let nonempty_tree = domain_json_digest( + b"radroots-advisory-synthetic-working-directory-tree-v1\0", + &serde_json::json!({"kind": kind, "process_id": id}), + )?; + Ok(ProcessWorkingDirectory { + identity_sha256: domain_json_digest( + b"radroots-advisory-synthetic-retained-directory-identity-v1\0", + &serde_json::json!({"kind": kind, "logical_uri": logical_uri, "process_id": id}), + )?, + logical_uri, + kind: kind.to_owned(), + pre_execution_entry_count: pre_entries, + pre_execution_tree_sha256: if pre_entries == 0 { + empty_tree.clone() + } else { + nonempty_tree.clone() + }, + post_execution_entry_count: post_entries, + post_execution_tree_sha256: if post_entries == 0 { + empty_tree + } else { + nonempty_tree + }, + }) +} + +fn validate_process_working_directory( + id: &str, + directory: &ProcessWorkingDirectory, +) -> Result<(), AdvisoryError> { + let expected = expected_process_working_directory(id)?; + if directory.logical_uri != expected.logical_uri + || directory.kind != expected.kind + || !valid_hex(&directory.identity_sha256, 64) + || !valid_hex(&directory.pre_execution_tree_sha256, 64) + || !valid_hex(&directory.post_execution_tree_sha256, 64) + || match directory.kind.as_str() { + "fresh_empty_config_free" | "fresh_private_analysis" => { + directory.pre_execution_entry_count != 0 + || directory.post_execution_entry_count != 0 + || directory.pre_execution_tree_sha256 != empty_directory_tree_sha256()? + || directory.post_execution_tree_sha256 != empty_directory_tree_sha256()? + } + "retained_candidate_source" => { + directory.pre_execution_entry_count == 0 + || directory.pre_execution_entry_count != directory.post_execution_entry_count + || directory.pre_execution_tree_sha256 != directory.post_execution_tree_sha256 + } + "fresh_private_update" => { + directory.pre_execution_entry_count != 0 + || directory.post_execution_entry_count == 0 + || directory.pre_execution_tree_sha256 != empty_directory_tree_sha256()? + } + _ => true, + } + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + Ok(()) +} + +fn empty_directory_tree_sha256() -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-empty-directory-tree-v1\0", + &Vec::<Value>::new(), + ) +} + +fn expected_environment_names(id: &str) -> impl Iterator<Item = &'static str> { + let names: &'static [&'static str] = if id.starts_with("cargo_audit:") { + &["CARGO_HOME", "HOME", "LC_ALL", "PATH", "TMPDIR", "TZ"] + } else if id.starts_with("gradle:") { + &[ + "GRADLE_USER_HOME", + "HOME", + "JAVA_HOME", + "LC_ALL", + "PATH", + "TMPDIR", + "TZ", + ] + } else { + &["HOME", "JAVA_HOME", "LC_ALL", "PATH", "TMPDIR", "TZ"] + }; + names.iter().copied() +} + +fn expected_runtime_sha256(id: &str) -> Vec<String> { + let values = if id.starts_with("cargo_audit:") { + vec![TOOL_PINS[0].executable_sha256] + } else if id.starts_with("gradle:") { + vec![ + TOOL_PINS[3].executable_sha256, + TOOL_PINS[2].executable_sha256, + ] + } else { + vec![ + TOOL_PINS[1].executable_sha256, + TOOL_PINS[2].executable_sha256, + ] + }; + values.into_iter().map(str::to_owned).collect() +} + +fn process_environment_digest( + environment: &[ProcessEnvironmentRow], +) -> Result<String, AdvisoryError> { + domain_json_digest(b"radroots-advisory-process-environment-v1\0", environment) +} + +fn process_working_directory_digest( + working_directory: &ProcessWorkingDirectory, +) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-process-working-directory-v1\0", + working_directory, + ) +} + +fn process_receipt<'a>( + receipts: &'a [TrustedProcessReceipt], + id: &str, +) -> Result<&'a TrustedProcessReceipt, AdvisoryError> { + receipts + .iter() + .find(|receipt| receipt.id == id) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)) +} + +fn process_receipt_digest(receipt: &TrustedProcessReceipt) -> Result<String, AdvisoryError> { + domain_json_digest(b"radroots-advisory-bounded-process-receipt-v1\0", receipt) +} + +fn parse_canonical_authority<T>(bytes: &[u8]) -> Result<T, AdvisoryError> +where + T: for<'de> Deserialize<'de>, +{ + let value: Value = serde_json::from_slice(bytes) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + if canonical_json(&value) != bytes { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + serde_json::from_value(value) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)) +} + +fn validate_tool_acquisitions(actual: &[ToolAcquisition]) -> Result<(), AdvisoryError> { + if actual.len() != TOOL_PINS.len() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + for (tool, pin) in actual.iter().zip(TOOL_PINS) { + if tool.id != pin.id + || sha256(&canonical_json_without_lf(&tool.projection)) != pin.projection_sha256 + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidContract)); + } + } + Ok(()) +} + +fn validate_tool_states(actual: &[ToolState], evaluation_epoch: u64) -> Result<(), AdvisoryError> { + if actual.len() != TOOL_PINS.len() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + for (state, pin) in actual.iter().zip(TOOL_PINS) { + if state.id != pin.id + || state.normalized_version != pin.version + || state.executable_sha256 != pin.executable_sha256 + || state.source_sha256 != pin.source_sha256 + || state.package_receipt_sha256 != pin.receipt_sha256 + || state.reviewed_at_epoch == 0 + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + require_complete(state.state)?; + let age = evaluation_epoch + .checked_sub(state.reviewed_at_epoch) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::StaleSnapshot))?; + if age > TOOL_REVIEW_SECONDS { + return Err(AdvisoryError::new(AdvisoryFailureKind::StaleSnapshot)); + } + } + Ok(()) +} + +fn validate_gradle_projections( + projections: &[GradleProjection], + request: &AdmissionRequest, + receipts: &[TrustedProcessReceipt], +) -> Result<(), AdvisoryError> { + if sha256(GRADLE_INIT_SCRIPT_BYTES) != GRADLE_INIT_SCRIPT_SHA256 { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidContract)); + } + let expected = WORKLOADS + .iter() + .filter(|workload| workload.package_manager == "gradle") + .collect::<Vec<_>>(); + if projections.len() != expected.len() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let harvest_source = request + .sources + .iter() + .find(|source| source.repository == "oss/harvestcircle") + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + for ((projection, authority), admitted) in projections + .iter() + .zip(expected) + .zip(&request.admitted_gradle_graphs) + { + let inventory = request + .inventory + .iter() + .find(|row| row.id == authority.id) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + require_complete(projection.state)?; + let receipt = process_receipt(receipts, &format!("gradle:{}", authority.id))?; + admitted.revalidate()?; + if !admitted.matches_projection(projection) + || projection.workload_id != authority.id + || projection.raw_graph_byte_length == 0 + || projection.raw_graph_byte_length > MAX_GRADLE_GRAPH_BYTES + || !valid_hex(&projection.raw_graph_sha256, 64) + || projection.init_script_sha256 != GRADLE_INIT_SCRIPT_SHA256 + || projection.wrapper_arguments != expected_gradle_arguments(authority)? + || projection.environment_keys + != [ + "GRADLE_USER_HOME", + "HOME", + "JAVA_HOME", + "LC_ALL", + "PATH", + "TMPDIR", + "TZ", + ] + || !valid_hex(&projection.environment_sha256, 64) + || projection.exit_code != 0 + || projection.source_revision != harvest_source.revision + || projection.source_tree != harvest_source.tree + || projection.input_sha256 + != gradle_candidate_input_digest(request, inventory, harvest_source, projection)? + || projection.dependency_count != inventory.dependency_count + || projection.component_count != projection.components.len() as u64 + || projection.edge_count != projection.edges.len() as u64 + || projection.artifact_count != projection.artifacts.len() as u64 + || projection.component_count == 0 + || !valid_hex(&projection.materialized_tree_sha256, 64) + || !valid_hex(&projection.artifact_source_roots_sha256, 64) + || !valid_hex(&projection.seed_cache_inventory_sha256, 64) + || projection.wrapper_distribution_sha256 + != "553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746" + || projection.process_receipt_sha256 != process_receipt_digest(receipt)? + || projection.canonical_graph_sha256 != gradle_graph_digest(projection)? + || projection.normalization_receipt_sha256 + != gradle_normalization_receipt_digest( + &projection.workload_id, + projection.raw_graph_byte_length, + &projection.raw_graph_sha256, + &projection.canonical_graph_sha256, + &projection.materialized_tree_sha256, + &projection.artifact_source_roots_sha256, + )? + || receipt.program_sha256 != TOOL_PINS[3].executable_sha256 + || receipt.arguments_sha256 + != domain_json_digest( + b"radroots-advisory-process-arguments-v1\0", + &projection.wrapper_arguments, + )? + || receipt.environment_sha256 != projection.environment_sha256 + || receipt.input_sha256 != gradle_process_input_digest(projection)? + || receipt.output_sha256 != gradle_process_output_digest(projection)? + || receipt.path_binding + != vec![ + ProcessPathBinding { + argument_index: 5, + logical_role: "gradle_build_root".to_owned(), + identity_sha256: gradle_build_root_binding_digest(projection)?, + }, + ProcessPathBinding { + argument_index: 7, + logical_role: "gradle_init_script".to_owned(), + identity_sha256: projection.init_script_sha256.clone(), + }, + ProcessPathBinding { + argument_index: 10, + logical_role: "gradle_projection_output".to_owned(), + identity_sha256: projection.raw_graph_sha256.clone(), + }, + ] + || receipt.exit_code != projection.exit_code + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let mut component_ids = BTreeSet::new(); + let mut component_by_id = BTreeMap::new(); + let mut previous_component = None::<&str>; + for component in &projection.components { + if previous_component + .is_some_and(|previous| previous >= component.identity_sha256.as_str()) + || component.identity_sha256 != gradle_component_digest(component)? + || component.variant_sha256 != gradle_variant_digest(&component.variant)? + || !valid_gradle_variant_envelope(&component.variant) + || !valid_gradle_component(component) + || !component_ids.insert(component.identity_sha256.as_str()) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + component_by_id.insert(component.identity_sha256.as_str(), component); + previous_component = Some(&component.identity_sha256); + } + let roots = projection + .components + .iter() + .filter(|component| { + component.root + && component.kind == "project" + && component.build_root.as_deref() == Some(authority.build_root) + && component.project_path.as_deref() == authority.project_path + }) + .collect::<Vec<_>>(); + if roots.len() != 1 { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let mut previous_edge = None::<Vec<u8>>; + for edge in &projection.edges { + let key = canonical_row_key(edge)?; + if previous_edge + .as_ref() + .is_some_and(|previous| previous >= &key) + || !component_ids.contains(edge.from_identity_sha256.as_str()) + || !component_ids.contains(edge.to_identity_sha256.as_str()) + || edge.from_identity_sha256 == edge.to_identity_sha256 + || edge.requested_sha256 != gradle_request_digest(&edge.requested)? + || edge.selected_variant_sha256 != gradle_variant_digest(&edge.selected_variant)? + || !valid_gradle_request(&edge.requested) + || !valid_gradle_variant(&edge.selected_variant) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + previous_edge = Some(key); + } + let mut reachable = BTreeSet::from([roots[0].identity_sha256.as_str()]); + loop { + let before = reachable.len(); + for edge in &projection.edges { + if reachable.contains(edge.from_identity_sha256.as_str()) { + reachable.insert(edge.to_identity_sha256.as_str()); + } + } + if reachable.len() == before { + break; + } + } + if reachable.len() != projection.components.len() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let mut previous = None::<Vec<u8>>; + let mut materialized = BTreeMap::<(&str, &str), (&str, u64)>::new(); + for artifact in &projection.artifacts { + let key = canonical_row_key(artifact)?; + if previous.as_ref().is_some_and(|prior| prior >= &key) + || !component_ids.contains(artifact.component_identity_sha256.as_str()) + || !valid_bounded_text(&artifact.component, 512) + || artifact.package_ecosystem != "maven" + || !valid_bounded_text(&artifact.package_namespace, 256) + || !valid_bounded_text(&artifact.package_name, 256) + || !valid_bounded_text(&artifact.package_version, 128) + || !valid_hex(&artifact.artifact_sha256, 64) + || artifact.variant_sha256 != gradle_variant_digest(&artifact.variant)? + || !valid_gradle_variant(&artifact.variant) + || artifact.byte_length == 0 + || !valid_bounded_text(&artifact.artifact_name, 256) + || !valid_bounded_text(&artifact.logical_name, 256) + || !valid_bounded_text(&artifact.artifact_type, 64) + || artifact + .classifier + .as_deref() + .is_some_and(|classifier| !valid_bounded_text(classifier, 128)) + || !valid_artifact_extension(&artifact.extension) + || artifact.materialized_name + != format!("{}.{}", artifact.artifact_sha256, artifact.extension) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let component = component_by_id + .get(artifact.component_identity_sha256.as_str()) + .copied() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + if !artifact_matches_component(artifact, component, &harvest_source.revision) + || !projection.edges.iter().any(|edge| { + edge.to_identity_sha256 == artifact.component_identity_sha256 + && edge.selected_variant_sha256 == artifact.variant_sha256 + }) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let materialized_key = ( + artifact.artifact_sha256.as_str(), + artifact.extension.as_str(), + ); + if let Some((name, length)) = materialized.insert( + materialized_key, + (artifact.materialized_name.as_str(), artifact.byte_length), + ) && (name != artifact.materialized_name || length != artifact.byte_length) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + previous = Some(key); + } + admitted.revalidate()?; + } + Ok(()) +} + +fn expected_gradle_arguments(workload: &WorkloadAuthority) -> Result<Vec<String>, AdvisoryError> { + let project = workload + .project_path + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))?; + let configuration = workload + .configuration + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))?; + Ok([ + "--offline".to_owned(), + "--no-daemon".to_owned(), + "--no-parallel".to_owned(), + "--no-configuration-cache".to_owned(), + "--project-dir".to_owned(), + workload.build_root.to_owned(), + "--init-script".to_owned(), + "{private_evidenced_init_script}".to_owned(), + format!("{project}:rshrAdvisoryGraph"), + format!("-PrshrAdvisoryConfiguration={configuration}"), + "-PrshrAdvisoryOutput={private_create_new_projection}".to_owned(), + ] + .into_iter() + .collect()) +} + +fn gradle_graph_digest(projection: &GradleProjection) -> Result<String, AdvisoryError> { + gradle_graph_digest_parts( + &projection.workload_id, + &projection.components, + &projection.edges, + &projection.artifacts, + ) +} + +fn gradle_graph_digest_parts( + workload_id: &str, + components: &[GradleComponent], + edges: &[GradleEdge], + artifacts: &[GradleArtifact], +) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-gradle-graph-v1\0", + &serde_json::json!({ + "artifacts": artifacts, + "components": components, + "edges": edges, + "workload_id": workload_id, + }), + ) +} + +fn canonical_row_key<T: Serialize>(value: &T) -> Result<Vec<u8>, AdvisoryError> { + let value = serde_json::to_value(value) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + Ok(canonical_json_without_lf(&value)) +} + +fn gradle_process_input_digest(projection: &GradleProjection) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-gradle-process-input-v1\0", + &serde_json::json!({ + "artifact_source_roots_sha256": projection.artifact_source_roots_sha256, + "init_script_sha256": projection.init_script_sha256, + "input_sha256": projection.input_sha256, + "seed_cache_inventory_sha256": projection.seed_cache_inventory_sha256, + "source_revision": projection.source_revision, + "source_tree": projection.source_tree, + "wrapper_distribution_sha256": projection.wrapper_distribution_sha256, + "workload_id": projection.workload_id, + }), + ) +} + +fn gradle_build_root_binding_digest( + projection: &GradleProjection, +) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-gradle-build-root-binding-v1\0", + &serde_json::json!({ + "source_revision": projection.source_revision, + "source_tree": projection.source_tree, + "workload_id": projection.workload_id, + }), + ) +} + +fn gradle_candidate_input_digest( + request: &AdmissionRequest, + inventory: &WorkloadInventory, + source: &SourceIdentity, + projection: &GradleProjection, +) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-gradle-candidate-input-v1\0", + &serde_json::json!({ + "artifact_source_roots_sha256": projection.artifact_source_roots_sha256, + "candidate": request.candidate, + "dependency_control_sha256": inventory.input_sha256, + "fresh_tool_observation_sha256": sha256(&request.fresh_tool_observation), + "init_script_sha256": projection.init_script_sha256, + "seed_cache_inventory_sha256": projection.seed_cache_inventory_sha256, + "source": source, + "step_297_tool_manifest_sha256": sha256(&request.step_297_tool_manifest), + "workload": inventory, + "wrapper_distribution_sha256": projection.wrapper_distribution_sha256, + }), + ) +} + +fn gradle_process_output_digest(projection: &GradleProjection) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-gradle-process-output-v1\0", + &serde_json::json!({ + "raw_graph_byte_length": projection.raw_graph_byte_length, + "raw_graph_sha256": projection.raw_graph_sha256, + "workload_id": projection.workload_id, + }), + ) +} + +fn gradle_normalization_receipt_digest( + workload_id: &str, + raw_graph_byte_length: u64, + raw_graph_sha256: &str, + canonical_graph_sha256: &str, + materialized_tree_sha256: &str, + artifact_source_roots_sha256: &str, +) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-gradle-normalization-v1\0", + &serde_json::json!({ + "artifact_source_roots_sha256": artifact_source_roots_sha256, + "canonical_graph_sha256": canonical_graph_sha256, + "materialized_tree_sha256": materialized_tree_sha256, + "raw_graph_byte_length": raw_graph_byte_length, + "raw_graph_sha256": raw_graph_sha256, + "workload_id": workload_id, + }), + ) +} + +fn gradle_component_digest(component: &GradleComponent) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-gradle-component-v1\0", + &serde_json::json!({ + "build_root": component.build_root, + "group": component.group, + "kind": component.kind, + "name": component.name, + "project_path": component.project_path, + "root": component.root, + "variant": component.variant, + "version": component.version, + }), + ) +} + +fn gradle_variant_digest(variant: &Value) -> Result<String, AdvisoryError> { + domain_json_digest(b"radroots-advisory-gradle-variant-v1\0", variant) +} + +fn gradle_request_digest(request: &Value) -> Result<String, AdvisoryError> { + domain_json_digest(b"radroots-advisory-gradle-request-v1\0", request) +} + +fn valid_gradle_variant(value: &Value) -> bool { + serde_json::from_value::<RawGradleVariant>(value.clone()) + .ok() + .is_some_and(|variant| validate_raw_variant(&variant, 0).is_ok()) +} + +fn valid_gradle_variant_envelope(value: &Value) -> bool { + serde_json::from_value::<RawGradleVariantEnvelope>(value.clone()) + .ok() + .is_some_and(|envelope| validate_raw_variant_envelope(&envelope).is_ok()) +} + +fn valid_gradle_request(value: &Value) -> bool { + serde_json::from_value::<RawGradleSelector>(value.clone()) + .ok() + .is_some_and(|selector| validate_raw_selector(&selector).is_ok()) +} + +fn validate_raw_variant_envelope(envelope: &RawGradleVariantEnvelope) -> Result<(), AdvisoryError> { + if envelope.selected.is_empty() || envelope.selected.len() > MAX_GRADLE_VARIANTS { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + require_strict_canonical_order(&envelope.selected)?; + for variant in &envelope.selected { + validate_raw_variant(variant, 0)?; + } + Ok(()) +} + +fn validate_raw_variant(variant: &RawGradleVariant, depth: usize) -> Result<(), AdvisoryError> { + if depth > MAX_GRADLE_EXTERNAL_VARIANT_DEPTH + || variant.attributes.len() > MAX_GRADLE_ATTRIBUTES + || variant.capabilities.len() > MAX_GRADLE_CAPABILITIES + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + require_strict_canonical_order(&variant.attributes)?; + require_strict_canonical_order(&variant.capabilities)?; + let mut attribute_names = BTreeSet::new(); + for attribute in &variant.attributes { + if !valid_ascii_text(&attribute.name, 512, false) + || !valid_ascii_text(&attribute.value, 512, true) + || !attribute_names.insert(attribute.name.as_str()) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + } + for capability in &variant.capabilities { + if !valid_ascii_text(&capability.group, 256, true) + || !valid_ascii_text(&capability.name, 256, false) + || capability + .version + .as_deref() + .is_some_and(|value| !valid_ascii_text(value, 128, true)) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + } + if let Some(external) = &variant.external_variant { + validate_raw_variant(external, depth.saturating_add(1))?; + } + Ok(()) +} + +fn validate_raw_selector(selector: &RawGradleSelector) -> Result<(), AdvisoryError> { + if selector.attributes.len() > MAX_GRADLE_ATTRIBUTES + || selector.capabilities.len() > MAX_GRADLE_CAPABILITIES + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + require_strict_canonical_order(&selector.attributes)?; + require_strict_canonical_order(&selector.capabilities)?; + let variant = RawGradleVariant { + attributes: selector.attributes.clone(), + capabilities: selector.capabilities.clone(), + external_variant: None, + }; + validate_raw_variant(&variant, 0)?; + match selector.kind.as_str() { + "module" => { + if selector + .group + .as_deref() + .is_none_or(|value| !valid_ascii_text(value, 256, false)) + || selector + .name + .as_deref() + .is_none_or(|value| !valid_ascii_text(value, 256, false)) + || selector + .version + .as_deref() + .is_none_or(|value| !valid_ascii_text(value, 512, true)) + || selector.build_root.is_some() + || selector.project_path.is_some() + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let constraint = selector + .version_constraint + .as_ref() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + if constraint.rejected.len() > MAX_GRADLE_REJECTED_VERSIONS + || constraint + .branch + .as_deref() + .is_some_and(|value| !valid_ascii_text(value, 256, true)) + || !valid_ascii_text(&constraint.preferred, 512, true) + || !valid_ascii_text(&constraint.required, 512, true) + || !valid_ascii_text(&constraint.strict, 512, true) + || constraint + .rejected + .iter() + .any(|value| !valid_ascii_text(value, 512, false)) + || constraint + .rejected + .windows(2) + .any(|window| window[0] >= window[1]) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + } + "project" => { + if selector.group.is_some() + || selector.name.is_some() + || selector.version.is_some() + || selector.version_constraint.is_some() + || selector + .build_root + .as_deref() + .is_none_or(|value| !valid_gradle_build_root(value)) + || selector + .project_path + .as_deref() + .is_none_or(|value| !valid_gradle_project_path(value)) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + } + _ => { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + } + Ok(()) +} + +fn validate_raw_component_core(core: &RawGradleComponentCore) -> Result<(), AdvisoryError> { + match core.kind.as_str() { + "module" + if core + .group + .as_deref() + .is_some_and(|value| valid_ascii_text(value, 256, false)) + && core + .name + .as_deref() + .is_some_and(|value| valid_ascii_text(value, 256, false)) + && core + .version + .as_deref() + .is_some_and(|value| valid_ascii_text(value, 128, false)) + && core.build_root.is_none() + && core.project_path.is_none() => + { + Ok(()) + } + "project" + if core.group.is_none() + && core.name.is_none() + && core.version.is_none() + && core + .build_root + .as_deref() + .is_some_and(valid_gradle_build_root) + && core + .project_path + .as_deref() + .is_some_and(valid_gradle_project_path) => + { + Ok(()) + } + _ => Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)), + } +} + +fn require_strict_canonical_order<T: Serialize>(values: &[T]) -> Result<(), AdvisoryError> { + let mut previous = None::<Vec<u8>>; + for value in values { + let key = canonical_row_key(value)?; + if previous.as_ref().is_some_and(|prior| prior >= &key) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + previous = Some(key); + } + Ok(()) +} + +fn valid_ascii_text(value: &str, maximum: usize, allow_empty: bool) -> bool { + (allow_empty || !value.is_empty()) + && value.len() <= maximum + && value + .bytes() + .all(|byte| byte.is_ascii() && !byte.is_ascii_control()) +} + +fn valid_gradle_build_root(value: &str) -> bool { + matches!(value, "." | "build-logic") +} + +fn valid_gradle_project_path(value: &str) -> bool { + valid_ascii_text(value, 256, false) + && value.starts_with(':') + && !value.ends_with(':') + && value + .split(':') + .skip(1) + .all(|component| !component.is_empty() && valid_ascii_text(component, 128, false)) +} + +fn artifact_matches_component( + artifact: &GradleArtifact, + component: &GradleComponent, + _source_revision: &str, +) -> bool { + match component.kind.as_str() { + "module" => { + let (Some(group), Some(name), Some(version)) = ( + component.group.as_deref(), + component.name.as_deref(), + component.version.as_deref(), + ) else { + return false; + }; + artifact.component == format!("{group}:{name}:{version}") + && artifact.package_ecosystem == "maven" + && artifact.package_namespace == group + && artifact.package_name == name + && artifact.package_version == version + } + "project" => { + let (Some(root), Some(project)) = ( + component.build_root.as_deref(), + component.project_path.as_deref(), + ) else { + return false; + }; + artifact.component == format!("{root}:{project}") + && artifact.package_ecosystem == "maven" + && valid_project_package_identity( + root, + project, + &artifact.package_namespace, + &artifact.package_name, + &artifact.package_version, + ) + && artifact.classifier.is_none() + } + _ => false, + } +} + +fn valid_project_module_version( + build_root: &str, + project_path: &str, + module_version: &RawGradleModuleVersion, + source_revision: &str, +) -> bool { + valid_hex(source_revision, 40) + && valid_project_package_identity( + build_root, + project_path, + &module_version.group, + &module_version.name, + &module_version.version, + ) +} + +fn valid_project_package_identity( + build_root: &str, + project_path: &str, + group: &str, + name: &str, + version: &str, +) -> bool { + version == "unspecified" + && matches!( + (build_root, project_path, group, name), + ( + ".", + ":app:design_system", + "harvestcircle.app", + "design_system" + ) | (".", ":app:shared", "harvestcircle.app", "shared") + | ( + "build-logic", + ":contracts", + "harvestcircle-build-logic", + "contracts" + ) + ) +} + +fn valid_gradle_component(component: &GradleComponent) -> bool { + match component.kind.as_str() { + "module" => { + component + .group + .as_deref() + .is_some_and(|value| valid_bounded_text(value, 256)) + && component + .name + .as_deref() + .is_some_and(|value| valid_bounded_text(value, 256)) + && component + .version + .as_deref() + .is_some_and(|value| valid_bounded_text(value, 128)) + && component.build_root.is_none() + && component.project_path.is_none() + && !component.root + } + "project" => { + component.group.is_none() + && component.name.is_none() + && component.version.is_none() + && component + .build_root + .as_deref() + .is_some_and(|value| value == "." || valid_identifier(value)) + && component + .project_path + .as_deref() + .is_some_and(|value| value.starts_with(':') && valid_bounded_text(value, 256)) + } + _ => false, + } +} + +fn valid_artifact_extension(extension: &str) -> bool { + matches!( + extension, + "aar" | "jar" | "js" | "klib" | "module" | "pom" | "wasm" | "zip" + ) +} + +fn validate_provider( + provider: &ProviderSnapshot, + request: &AdmissionRequest, + trace: &NvdNetworkTrace, + receipts: &[TrustedProcessReceipt], +) -> Result<(), AdvisoryError> { + require_complete(provider.acquisition_state)?; + require_complete(provider.analysis_state)?; + if provider.acquisition_count != 1 + || provider.bounded_deadline_seconds != ANALYSIS_DEADLINE_SECONDS + || !valid_hex(&provider.network_trace_sha256, 64) + || !valid_hex(&provider.producer_request_sha256, 64) + || provider.producer_request_sha256 != sha256(&request.producer_request) + || !valid_hex(&provider.database_identity_sha256, 64) + || provider.archive_format + != "deterministic_tar_gzip_exact_bytes_normalized_headers_sorted_members" + || provider.archive_expanded_bytes == 0 + || provider.archive_member_count == 0 + || provider.archive_payload_bytes == 0 + || provider.acquired_at_epoch == 0 + || provider.digest_time_epoch == 0 + || provider.analyzed_at_epoch == 0 + || provider.acquired_at_epoch > provider.digest_time_epoch + || provider.digest_time_epoch > provider.analyzed_at_epoch + || provider.analyzed_at_epoch > request.evaluation_epoch + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + let maximum_age = match request.freshness { + FreshnessMode::Development => DEVELOPMENT_REUSE_SECONDS, + FreshnessMode::Qualification => QUALIFICATION_FRESHNESS_SECONDS, + }; + let age = request + .evaluation_epoch + .checked_sub(provider.digest_time_epoch) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::StaleSnapshot))?; + if age > maximum_age { + return Err(AdvisoryError::new(AdvisoryFailureKind::StaleSnapshot)); + } + let (kind, network, archive, report, acquisition_arguments, environment, expected_arguments) = + match provider.provider { + ProviderId::Rustsec => ( + "externally_admitted_immutable_snapshot", + "external_snapshot_admission", + RUSTSEC_ARCHIVE_NAME, + RUSTSEC_REPORT_NAME, + &[] as &[&str], + "fresh_private_config_free_workdir_home_and_cargo_home", + CARGO_AUDIT_ARGUMENTS.as_slice(), + ), + ProviderId::OwaspNvd => ( + "bounded_nvd_update_only", + "contracted_fetch_only", + NVD_ARCHIVE_NAME, + OWASP_REPORT_NAME, + OWASP_UPDATE_ARGUMENTS.as_slice(), + "replacement_allowlist_private_data_and_output", + OWASP_OFFLINE_ARGUMENTS.as_slice(), + ), + }; + if provider.acquisition_kind != kind + || provider.network_mode != network + || provider.archive.path != archive + || provider.report.path != report + || provider.acquisition_arguments + != acquisition_arguments + .iter() + .map(|argument| (*argument).to_owned()) + .collect::<Vec<_>>() + || provider.analysis_environment != environment + || provider.analysis_arguments + != expected_arguments + .iter() + .map(|argument| (*argument).to_owned()) + .collect::<Vec<_>>() + || !valid_provider_blob_binding(provider.provider, &provider.archive, true) + || !valid_provider_blob_binding(provider.provider, &provider.report, false) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + match provider.provider { + ProviderId::Rustsec => { + if provider.network_trace_sha256 != "0".repeat(64) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + } + ProviderId::OwaspNvd => { + let receipt = process_receipt(receipts, "owasp_nvd_update")?; + if provider.network_trace_sha256 != sha256(&request.nvd_network_trace) + || provider.producer_request_sha256 != trace.producer_request_sha256 + || receipt.program_sha256 != TOOL_PINS[1].executable_sha256 + || receipt.arguments_sha256 + != domain_json_digest( + b"radroots-advisory-process-arguments-v1\0", + &provider.acquisition_arguments, + )? + || receipt.output_sha256 + != nvd_update_output_digest( + &provider.materialized_tree_sha256, + &provider.network_trace_sha256, + )? + || receipt.input_sha256 != sha256(&request.producer_request) + || receipt.path_binding + != vec![ProcessPathBinding { + argument_index: 2, + logical_role: "nvd_update_data".to_owned(), + identity_sha256: provider.materialized_tree_sha256.clone(), + }] + || receipt.completed_at_epoch != provider.acquired_at_epoch + || receipt.exit_code != 0 + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + } + } + Ok(()) +} + +fn require_complete(state: OperationState) -> Result<(), AdvisoryError> { + match state { + OperationState::Complete => Ok(()), + OperationState::TimedOut => Err(AdvisoryError::new(AdvisoryFailureKind::TimedOut)), + OperationState::Unavailable => Err(AdvisoryError::new(AdvisoryFailureKind::Unavailable)), + OperationState::Failed => Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)), + } +} + +fn validate_temporal_order( + observation: &TrustedToolObservation, + providers: &[ProviderSnapshot], + receipts: &[TrustedProcessReceipt], +) -> Result<(), AdvisoryError> { + if observation.observed_at_epoch == 0 + || observation + .tool_state + .iter() + .any(|state| state.reviewed_at_epoch > observation.observed_at_epoch) + || providers + .iter() + .any(|provider| provider.acquired_at_epoch < observation.observed_at_epoch) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + for provider in providers { + let prefix = match provider.provider { + ProviderId::Rustsec => "cargo_audit:", + ProviderId::OwaspNvd => "owasp_analysis:", + }; + let scans = receipts + .iter() + .filter(|receipt| receipt.id.starts_with(prefix)) + .collect::<Vec<_>>(); + if scans.is_empty() + || scans + .iter() + .any(|receipt| receipt.started_at_epoch < provider.digest_time_epoch) + || scans.iter().map(|receipt| receipt.completed_at_epoch).max() + != Some(provider.analyzed_at_epoch) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + if provider.provider == ProviderId::OwaspNvd { + let update = process_receipt(receipts, "owasp_nvd_update")?; + if update.started_at_epoch < observation.observed_at_epoch + || update.completed_at_epoch != provider.acquired_at_epoch + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + for scan in scans { + let workload = scan + .id + .strip_prefix("owasp_analysis:") + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let gradle = process_receipt(receipts, &format!("gradle:{workload}"))?; + if gradle.completed_at_epoch > scan.started_at_epoch { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + } + } + } + Ok(()) +} + +fn parse_unsuppressed_report( + snapshot: &ProviderSnapshot, + bytes: &[u8], + request: &AdmissionRequest, + gradle_projections: &[GradleProjection], +) -> Result<Vec<Finding>, AdvisoryError> { + let (_, _, _, provider_evidence) = validate_trusted_authority(request)?; + let value: Value = serde_json::from_slice(bytes) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if canonical_json(&value) != bytes { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let report: ReportEnvelope = serde_json::from_value(value) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if report.schema != "radroots.advisory-unsuppressed-report.v1" + || report.provider != snapshot.provider + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let expected_ids = WORKLOADS + .iter() + .filter(|workload| match snapshot.provider { + ProviderId::Rustsec => workload.package_manager == "cargo", + ProviderId::OwaspNvd => workload.package_manager == "gradle", + }) + .map(|workload| workload.id) + .collect::<Vec<_>>(); + if report.workload_result.len() != expected_ids.len() + || !report + .workload_result + .iter() + .map(|result| result.workload_id.as_str()) + .eq(expected_ids) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let mut findings = Vec::new(); + let mut database_copy_roots = BTreeSet::new(); + for result in report.workload_result { + let inventory = request + .inventory + .iter() + .find(|inventory| inventory.id == result.workload_id) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + validate_workload_execution(snapshot, &result, inventory, request)?; + let admitted_output = request + .admitted_scanner_outputs + .iter() + .find(|output| { + output.provider == snapshot.provider && output.workload_id == result.workload_id + }) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + admitted_output.revalidate()?; + if admitted_output.bytes.as_slice() != result.raw_scanner_output.as_bytes() + || admitted_output.evidence.byte_length != result.raw_output_byte_length + || admitted_output.evidence.sha256 != result.raw_output_sha256 + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + if let Some(copy) = &result.database_copy + && !database_copy_roots.insert(copy.root_identity_sha256.clone()) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + let raw: Value = serde_json::from_slice(&admitted_output.bytes) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + match snapshot.provider { + ProviderId::Rustsec => parse_rustsec_output( + &result.workload_id, + &raw, + inventory.dependency_count, + &snapshot.database_identity_sha256, + &snapshot.materialized_tree_sha256, + snapshot.digest_time_epoch, + &mut findings, + )?, + ProviderId::OwaspNvd => { + let projection = gradle_projections + .iter() + .find(|projection| projection.workload_id == result.workload_id) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + parse_owasp_output( + &result.workload_id, + &raw, + projection, + &snapshot.database_identity_sha256, + &snapshot.materialized_tree_sha256, + result + .arguments + .get(4) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?, + process_receipt( + &provider_evidence.process_receipt, + &format!("owasp_analysis:{}", result.workload_id), + )? + .started_at_epoch, + process_receipt( + &provider_evidence.process_receipt, + &format!("owasp_analysis:{}", result.workload_id), + )? + .completed_at_epoch, + snapshot.digest_time_epoch, + &mut findings, + )? + } + } + admitted_output.revalidate()?; + } + findings.sort_by(|left, right| finding_key(left).cmp(&finding_key(right))); + if findings.windows(2).any(|window| window[0] == window[1]) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + Ok(findings) +} + +fn validate_workload_execution( + snapshot: &ProviderSnapshot, + result: &WorkloadResult, + inventory: &WorkloadInventory, + request: &AdmissionRequest, +) -> Result<(), AdvisoryError> { + let raw = result.raw_scanner_output.as_bytes(); + let (tool_manifest, observation, _, provider_evidence) = validate_trusted_authority(request)?; + let expected_tool_observation = + tool_observation_digest(&tool_manifest.tool_acquisition, &observation.tool_state)?; + let receipt_id = match snapshot.provider { + ProviderId::Rustsec => format!("cargo_audit:{}", result.workload_id), + ProviderId::OwaspNvd => format!("owasp_analysis:{}", result.workload_id), + }; + let receipt = process_receipt(&provider_evidence.process_receipt, &receipt_id)?; + let program_sha256 = match snapshot.provider { + ProviderId::Rustsec => TOOL_PINS[0].executable_sha256, + ProviderId::OwaspNvd => TOOL_PINS[1].executable_sha256, + }; + let database_copy_valid = match (snapshot.provider, result.database_copy.as_ref()) { + (ProviderId::Rustsec, None) => true, + (ProviderId::OwaspNvd, Some(copy)) => { + valid_hex(&copy.root_identity_sha256, 64) + && copy.source_tree_sha256 == snapshot.materialized_tree_sha256 + && copy.pre_scan_tree_sha256 == snapshot.materialized_tree_sha256 + && copy.post_scan_tree_sha256 == snapshot.materialized_tree_sha256 + } + _ => false, + }; + if raw.is_empty() + || raw.len() as u64 > MAX_RAW_WORKLOAD_REPORT_BYTES + || result.input_sha256 != inventory.input_sha256 + || result.dependency_count != inventory.dependency_count + || result.provider_archive_sha256 != snapshot.archive.sha256 + || result.materialized_tree_sha256 != snapshot.materialized_tree_sha256 + || result.tool_observation_sha256 != expected_tool_observation + || result.raw_output_byte_length != raw.len() as u64 + || result.raw_output_sha256 != sha256(raw) + || !valid_hex(&result.environment_sha256, 64) + || result.process_receipt_sha256 != process_receipt_digest(receipt)? + || !database_copy_valid + || receipt.program_sha256 != program_sha256 + || receipt.arguments_sha256 + != domain_json_digest( + b"radroots-advisory-process-arguments-v1\0", + &result.arguments, + )? + || receipt.environment_sha256 != result.environment_sha256 + || receipt.input_sha256 + != scanner_process_input_digest(snapshot.provider, result, &expected_tool_observation)? + || receipt.output_sha256 != result.raw_output_sha256 + || match snapshot.provider { + ProviderId::Rustsec => { + receipt.stdout_byte_length != result.raw_output_byte_length + || receipt.stdout_sha256 != result.raw_output_sha256 + } + ProviderId::OwaspNvd => { + receipt.stdout_byte_length != 0 || receipt.stdout_sha256 != sha256(&[]) + } + } + || receipt.exit_code != result.exit_code + || receipt.completed_at_epoch > snapshot.analyzed_at_epoch + || receipt.path_binding + != scanner_path_bindings(snapshot.provider, result, inventory, request)? + || !valid_scanner_arguments(snapshot.provider, &result.arguments) + || match snapshot.provider { + ProviderId::Rustsec => !matches!(result.exit_code, 0 | 1), + ProviderId::OwaspNvd => result.exit_code != 0, + } + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + Ok(()) +} + +fn scanner_path_bindings( + provider: ProviderId, + result: &WorkloadResult, + inventory: &WorkloadInventory, + request: &AdmissionRequest, +) -> Result<Vec<ProcessPathBinding>, AdvisoryError> { + match provider { + ProviderId::Rustsec => Ok(vec![ + ProcessPathBinding { + argument_index: 2, + logical_role: "rustsec_database".to_owned(), + identity_sha256: result.materialized_tree_sha256.clone(), + }, + ProcessPathBinding { + argument_index: 6, + logical_role: "cargo_lock".to_owned(), + identity_sha256: inventory.input_sha256.clone(), + }, + ]), + ProviderId::OwaspNvd => { + let projection = validate_projection_for_workload(&result.workload_id, request)?; + let database_copy = result + .database_copy + .as_ref() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + Ok(vec![ + ProcessPathBinding { + argument_index: 2, + logical_role: "nvd_database".to_owned(), + identity_sha256: scanner_database_copy_digest(database_copy)?, + }, + ProcessPathBinding { + argument_index: 4, + logical_role: "gradle_scan_projection".to_owned(), + identity_sha256: projection.materialized_tree_sha256.clone(), + }, + ProcessPathBinding { + argument_index: 10, + logical_role: "raw_report_output".to_owned(), + identity_sha256: result.raw_output_sha256.clone(), + }, + ]) + } + } +} + +fn scanner_database_copy_digest(copy: &ScannerDatabaseCopy) -> Result<String, AdvisoryError> { + domain_json_digest(b"radroots-advisory-scanner-database-copy-v1\0", copy) +} + +fn validate_projection_for_workload( + workload_id: &str, + request: &AdmissionRequest, +) -> Result<GradleProjection, AdvisoryError> { + let (_, _, _, evidence) = validate_trusted_authority(request)?; + evidence + .gradle_projection + .iter() + .find(|projection| projection.workload_id == workload_id) + .cloned() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)) +} + +fn nvd_update_output_digest( + materialized_tree_sha256: &str, + network_trace_sha256: &str, +) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-nvd-update-output-v1\0", + &serde_json::json!({ + "materialized_tree_sha256": materialized_tree_sha256, + "network_trace_sha256": network_trace_sha256, + }), + ) +} + +fn scanner_process_input_digest( + provider: ProviderId, + result: &WorkloadResult, + tool_observation_sha256: &str, +) -> Result<String, AdvisoryError> { + scanner_process_input_digest_fields( + provider, + &result.workload_id, + &result.input_sha256, + result.dependency_count, + &result.provider_archive_sha256, + &result.materialized_tree_sha256, + &result + .database_copy + .as_ref() + .map(scanner_database_copy_digest) + .transpose()? + .unwrap_or_else(|| "none".to_owned()), + tool_observation_sha256, + ) +} + +#[allow(clippy::too_many_arguments)] +fn scanner_process_input_digest_fields( + provider: ProviderId, + workload_id: &str, + input_sha256: &str, + dependency_count: u64, + provider_archive_sha256: &str, + materialized_tree_sha256: &str, + database_copy_sha256: &str, + tool_observation_sha256: &str, +) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-scanner-input-v1\0", + &serde_json::json!({ + "dependency_count": dependency_count, + "database_copy_sha256": database_copy_sha256, + "input_sha256": input_sha256, + "materialized_tree_sha256": materialized_tree_sha256, + "provider": provider, + "provider_archive_sha256": provider_archive_sha256, + "tool_observation_sha256": tool_observation_sha256, + "workload_id": workload_id, + }), + ) +} + +fn valid_scanner_arguments(provider: ProviderId, arguments: &[String]) -> bool { + match provider { + ProviderId::Rustsec => { + arguments.len() == CARGO_AUDIT_ARGUMENTS.len() + && arguments[0] == "audit" + && arguments[1] == "--db" + && absolute_path(&arguments[2]) + && arguments[3..6] == ["--no-fetch", "--json", "--file"] + && absolute_path(&arguments[6]) + && !arguments + .iter() + .any(|argument| matches!(argument.as_str(), "--ignore" | "--stale")) + } + ProviderId::OwaspNvd => { + if arguments.len() != OWASP_OFFLINE_ARGUMENTS.len() + || arguments[0] != "--noupdate" + || arguments[1] != "--data" + || !absolute_path(&arguments[2]) + || arguments[3] != "--scan" + || !absolute_path(&arguments[4]) + || arguments[5] != "--project" + || !WORKLOADS.iter().any(|workload| { + workload.package_manager == "gradle" && workload.id == arguments[6] + }) + || arguments[7] != "--format" + || arguments[8] != "JSON" + || arguments[9] != "--out" + || !absolute_path(&arguments[10]) + { + return false; + } + let required = &OWASP_OFFLINE_ARGUMENTS[11..]; + arguments[11..] + == required + .iter() + .map(|argument| (*argument).to_owned()) + .collect::<Vec<_>>() + && !arguments.iter().any(|argument| { + argument == "--suppression" || argument.starts_with("--failOnCVSS") + }) + } + } +} + +fn absolute_path(value: &str) -> bool { + !value.is_empty() && Path::new(value).is_absolute() && !value.contains('\0') +} + +fn parse_rustsec_output( + workload_id: &str, + output: &Value, + expected_dependency_count: u64, + expected_database_identity: &str, + materialized_tree_sha256: &str, + database_digest_time_epoch: u64, + findings: &mut Vec<Finding>, +) -> Result<(), AdvisoryError> { + validate_json_bounds(output)?; + let object = output + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let expected_keys = [ + "database", + "lockfile", + "settings", + "vulnerabilities", + "warnings", + ] + .into_iter() + .collect::<BTreeSet<_>>(); + if object.keys().map(String::as_str).collect::<BTreeSet<_>>() != expected_keys { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let database = object["database"] + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if database.keys().map(String::as_str).collect::<BTreeSet<_>>() + != ["advisory-count", "last-commit", "last-updated"] + .into_iter() + .collect() + || database["advisory-count"] + .as_u64() + .is_none_or(|count| count == 0) + || !database["last-commit"] + .as_str() + .is_some_and(|revision| valid_hex(revision, 40)) + || database["last-updated"] + .as_str() + .and_then(|timestamp| parse_report_epoch(timestamp).ok()) + != Some(database_digest_time_epoch) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let database_metadata_sha256 = domain_json_digest( + b"radroots-advisory-rustsec-database-metadata-v1\0", + database, + )?; + if provider_database_identity( + ProviderId::Rustsec, + materialized_tree_sha256, + &database_metadata_sha256, + )? != expected_database_identity + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + let lockfile = object["lockfile"] + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if lockfile.keys().map(String::as_str).collect::<Vec<_>>() != ["dependency-count"] + || lockfile["dependency-count"].as_u64() != Some(expected_dependency_count) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let settings = object["settings"] + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if settings.keys().map(String::as_str).collect::<BTreeSet<_>>() + != [ + "ignore", + "informational_warnings", + "severity", + "target_arch", + "target_os", + ] + .into_iter() + .collect() + || settings["ignore"] + .as_array() + .is_none_or(|value| !value.is_empty()) + || settings["target_arch"] + .as_array() + .is_none_or(|value| !value.is_empty()) + || settings["target_os"] + .as_array() + .is_none_or(|value| !value.is_empty()) + || !settings["severity"].is_null() + || settings["informational_warnings"] + != serde_json::json!(["unmaintained", "unsound", "notice"]) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + if !object["warnings"].is_object() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let vulnerabilities_object = object["vulnerabilities"] + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if vulnerabilities_object + .keys() + .map(String::as_str) + .collect::<BTreeSet<_>>() + != ["count", "found", "list"].into_iter().collect() + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let vulnerabilities = vulnerabilities_object["list"] + .as_array() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + for vulnerability in vulnerabilities { + let vulnerability_object = vulnerability + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if vulnerability_object + .keys() + .map(String::as_str) + .collect::<BTreeSet<_>>() + != ["advisory", "affected", "package", "versions"] + .into_iter() + .collect() + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let (package, version) = validate_rustsec_package(&vulnerability_object["package"])?; + let advisory_id = validate_rustsec_advisory(&vulnerability_object["advisory"], package)?; + validate_rustsec_affected(&vulnerability_object["affected"])?; + validate_rustsec_versions(&vulnerability_object["versions"])?; + findings.push(Finding { + provider: ProviderId::Rustsec, + advisory_id: advisory_id.to_owned(), + package_ecosystem: "cargo".to_owned(), + package_namespace: String::new(), + package_name: package.to_owned(), + package_version: version.to_owned(), + workload_id: workload_id.to_owned(), + }); + } + let found = vulnerabilities_object["found"] + .as_bool() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if found != !vulnerabilities.is_empty() + || vulnerabilities_object["count"].as_u64() != Some(vulnerabilities.len() as u64) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let warnings = object["warnings"] + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + for (kind, rows) in warnings { + if !matches!( + kind.as_str(), + "notice" | "unmaintained" | "unsound" | "yanked" + ) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let rows = rows + .as_array() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if kind == "yanked" && !rows.is_empty() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + for warning in rows { + let warning_object = warning + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if warning_object + .keys() + .map(String::as_str) + .collect::<BTreeSet<_>>() + != ["advisory", "affected", "kind", "package", "versions"] + .into_iter() + .collect() + || warning_object["kind"].as_str() != Some(kind) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let (package_name, package_version) = + validate_rustsec_package(&warning_object["package"])?; + let advisory_id = validate_rustsec_advisory(&warning_object["advisory"], package_name)?; + validate_rustsec_affected(&warning_object["affected"])?; + validate_rustsec_versions(&warning_object["versions"])?; + findings.push(Finding { + provider: ProviderId::Rustsec, + advisory_id: advisory_id.to_owned(), + package_ecosystem: "cargo".to_owned(), + package_namespace: String::new(), + package_name: package_name.to_owned(), + package_version: package_version.to_owned(), + workload_id: workload_id.to_owned(), + }); + } + } + Ok(()) +} + +fn validate_rustsec_advisory<'a>( + value: &'a Value, + package_name: &str, +) -> Result<&'a str, AdvisoryError> { + let object = value + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact( + object, + &[ + "aliases", + "categories", + "collection", + "cvss", + "date", + "description", + "expect-deleted", + "id", + "informational", + "keywords", + "license", + "package", + "references", + "related", + "source", + "title", + "url", + "withdrawn", + ], + &[], + ) || object["package"].as_str() != Some(package_name) + || !valid_rustsec_date(&object["date"]) + || !valid_text_allow_empty_value(&object["title"], 4_096) + || !valid_text_allow_empty_value(&object["description"], 1_048_576) + || !valid_exact_string_array(&object["aliases"], 4_096, 256) + || !valid_exact_string_array(&object["related"], 4_096, 256) + || !valid_exact_string_array(&object["categories"], 256, 128) + || !valid_exact_string_array(&object["keywords"], 4_096, 256) + || !valid_exact_string_array(&object["references"], 65_536, 4_096) + || !optional_rustsec_text(&object["collection"], 64) + || !optional_rustsec_text(&object["cvss"], 256) + || !optional_rustsec_text(&object["informational"], 64) + || !optional_rustsec_text(&object["source"], 4_096) + || !optional_rustsec_text(&object["url"], 4_096) + || !(object["withdrawn"].is_null() || valid_rustsec_date(&object["withdrawn"])) + || !object["license"] + .as_str() + .is_some_and(|value| valid_bounded_text(value, 128)) + || object["expect-deleted"].as_bool().is_none() + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let advisory_id = required_text(object.get("id"))?; + if !valid_advisory_id(ProviderId::Rustsec, advisory_id) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + Ok(advisory_id) +} + +fn validate_rustsec_package(value: &Value) -> Result<(&str, &str), AdvisoryError> { + let object = value + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact( + object, + &["checksum", "name", "replace", "source", "version"], + &["dependencies"], + ) || !optional_rustsec_text(&object["source"], 4_096) + || !(object["checksum"].is_null() + || object["checksum"] + .as_str() + .is_some_and(|value| valid_hex(value, 64))) + || !(object["replace"].is_null() || valid_rustsec_dependency(&object["replace"])) + || object.get("dependencies").is_some_and(|value| { + value.as_array().is_none_or(|rows| { + rows.is_empty() + || rows.len() > 65_536 + || rows.iter().any(|row| !valid_rustsec_dependency(row)) + }) + }) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let name = required_text(object.get("name"))?; + let version = required_text(object.get("version"))?; + if !valid_exact_identity_token(name, 256) || !valid_exact_identity_token(version, 128) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + Ok((name, version)) +} + +fn valid_rustsec_dependency(value: &Value) -> bool { + let Some(object) = value.as_object() else { + return false; + }; + object_keys_exact(object, &["name", "source", "version"], &[]) + && object["name"] + .as_str() + .is_some_and(|value| valid_exact_identity_token(value, 256)) + && object["version"] + .as_str() + .is_some_and(|value| valid_exact_identity_token(value, 128)) + && optional_rustsec_text(&object["source"], 4_096) +} + +fn validate_rustsec_versions(value: &Value) -> Result<(), AdvisoryError> { + let object = value + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact(object, &["patched", "unaffected"], &[]) + || !valid_exact_string_array(&object["patched"], 65_536, 256) + || !valid_exact_string_array(&object["unaffected"], 65_536, 256) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + Ok(()) +} + +fn validate_rustsec_affected(value: &Value) -> Result<(), AdvisoryError> { + if value.is_null() { + return Ok(()); + } + let object = value + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let functions = object["functions"] + .as_object() + .filter(|rows| rows.len() <= 65_536) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact(object, &["arch", "functions", "os"], &[]) + || !valid_exact_string_array(&object["arch"], 1_024, 128) + || !valid_exact_string_array(&object["os"], 1_024, 128) + || functions.iter().any(|(path, versions)| { + !valid_rust_function_path(path) || !valid_exact_string_array(versions, 65_536, 256) + }) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + Ok(()) +} + +fn valid_rust_function_path(value: &str) -> bool { + let mut components = value.split("::"); + let valid_component = |component: &str| { + let mut chars = component.chars(); + chars + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || matches!(first, '_' | '<')) + && chars.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '_' | '<' | '>' | ',') + }) + }; + let first = components.next().is_some_and(valid_component); + let rest = components.collect::<Vec<_>>(); + first && !rest.is_empty() && rest.into_iter().all(valid_component) +} + +fn valid_rustsec_date(value: &Value) -> bool { + value.as_str().is_some_and(|value| { + let bytes = value.as_bytes(); + bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) + && parse_report_epoch(&format!("{value}T00:00:00Z")).is_ok() + }) +} + +fn valid_text_allow_empty_value(value: &Value, maximum: usize) -> bool { + value + .as_str() + .is_some_and(|value| valid_text_allow_empty(value, maximum)) +} + +fn optional_rustsec_text(value: &Value, maximum: usize) -> bool { + value.is_null() + || value + .as_str() + .is_some_and(|value| valid_bounded_text(value, maximum)) +} + +fn valid_exact_string_array(value: &Value, maximum_rows: usize, maximum_text: usize) -> bool { + value + .as_array() + .filter(|rows| rows.len() <= maximum_rows) + .is_some_and(|rows| { + rows.iter().all(|row| { + row.as_str() + .is_some_and(|value| valid_bounded_text(value, maximum_text)) + }) + }) +} + +#[allow(clippy::too_many_arguments)] +fn parse_owasp_output( + workload_id: &str, + output: &Value, + projection: &GradleProjection, + expected_database_identity: &str, + materialized_tree_sha256: &str, + scan_root: &str, + scan_started_at_epoch: u64, + scan_completed_at_epoch: u64, + database_digest_time_epoch: u64, + findings: &mut Vec<Finding>, +) -> Result<(), AdvisoryError> { + validate_json_bounds(output)?; + let object = output + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact( + object, + &["dependencies", "projectInfo", "reportSchema", "scanInfo"], + &[], + ) || object["reportSchema"].as_str() != Some("1.1") + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let scan_info = object["scanInfo"] + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact(scan_info, &["dataSource", "engineVersion"], &[]) + || scan_info["engineVersion"].as_str() != Some("12.2.2") + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let data_source = scan_info["dataSource"] + .as_array() + .filter(|sources| !sources.is_empty()) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if data_source.len() > 64 { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let mut previous_source = None::<Vec<u8>>; + let mut observed_database_epoch = None::<u64>; + for source in data_source { + let source = source + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact(source, &["name", "timestamp"], &[]) + || !required_text(source.get("name"))?.starts_with("NVD CVE ") + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let epoch = parse_report_epoch(required_text(source.get("timestamp"))?)?; + if epoch == 0 || epoch > database_digest_time_epoch { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + observed_database_epoch = + Some(observed_database_epoch.map_or(epoch, |value| value.max(epoch))); + let key = canonical_json_without_lf(&Value::Object(source.clone())); + if previous_source + .as_ref() + .is_some_and(|previous| previous >= &key) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + previous_source = Some(key); + } + if observed_database_epoch != Some(database_digest_time_epoch) { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + let database_metadata_sha256 = domain_json_digest( + b"radroots-advisory-owasp-database-metadata-v1\0", + data_source, + )?; + if provider_database_identity( + ProviderId::OwaspNvd, + materialized_tree_sha256, + &database_metadata_sha256, + )? != expected_database_identity + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + let project = object["projectInfo"] + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact( + project, + &["credits", "name", "reportDate"], + &["artifactID", "groupID", "version"], + ) || project["name"].as_str() != Some(workload_id) + || project + .get("groupID") + .is_some_and(|value| !value.is_string()) + || project + .get("artifactID") + .is_some_and(|value| !value.is_string()) + || project + .get("version") + .is_some_and(|value| !value.is_string()) + || !valid_owasp_credits(&project["credits"]) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let report_epoch = parse_report_epoch(required_text(project.get("reportDate"))?)?; + if report_epoch < scan_started_at_epoch || report_epoch > scan_completed_at_epoch { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + if !absolute_path(scan_root) || scan_root.ends_with('/') { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let dependencies = object["dependencies"] + .as_array() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let mut expected = BTreeMap::<&str, Vec<&GradleArtifact>>::new(); + for artifact in &projection.artifacts { + expected + .entry(artifact.materialized_name.as_str()) + .or_default() + .push(artifact); + } + if dependencies.len() < expected.len() + || dependencies.len() > expected.len().saturating_add(65_536) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + let mut observed = BTreeSet::new(); + let mut parsed_dependencies = Vec::with_capacity(dependencies.len()); + for dependency in dependencies { + let dependency = dependency + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact( + dependency, + &["evidenceCollected", "fileName", "filePath", "isVirtual"], + &[ + "description", + "includedBy", + "license", + "md5", + "packages", + "projectReferences", + "relatedDependencies", + "sha1", + "sha256", + "vulnerabilities", + "vulnerabilityIds", + ], + ) || dependency.contains_key("suppressedVulnerabilities") + || dependency.contains_key("suppressedVulnerabilityIds") + || !valid_owasp_evidence(&dependency["evidenceCollected"]) + || !optional_bounded_string(dependency.get("description"), 65_536) + || !optional_bounded_string(dependency.get("license"), 65_536) + || !valid_sorted_string_array(dependency.get("projectReferences"), 16_384, 1024)? + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let file_name = required_text(dependency.get("fileName"))?; + let file_path = required_text(dependency.get("filePath"))?; + let is_virtual = dependency + .get("isVirtual") + .and_then(Value::as_bool) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let digest = dependency.get("sha256").and_then(Value::as_str); + if is_virtual { + if dependency.contains_key("md5") + || dependency.contains_key("sha1") + || dependency.contains_key("sha256") + || !file_path.starts_with(&format!("{scan_root}/")) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + } else if !dependency + .get("md5") + .and_then(Value::as_str) + .is_some_and(|value| valid_hex(value, 32)) + || !dependency + .get("sha1") + .and_then(Value::as_str) + .is_some_and(|value| valid_hex(value, 40)) + || !digest.is_some_and(|value| valid_hex(value, 64)) + || file_path != format!("{scan_root}/{file_name}") + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let package_ids = parse_owasp_identifiers(dependency.get("packages"), true)?; + let vulnerability_ids = parse_owasp_identifiers(dependency.get("vulnerabilityIds"), true)?; + let vulnerabilities = parse_owasp_vulnerabilities(dependency.get("vulnerabilities"))?; + let vulnerable_software_ids = vulnerabilities + .iter() + .flat_map(|vulnerability| vulnerability.vulnerable_software_ids.iter()) + .collect::<BTreeSet<_>>(); + if vulnerability_ids + .iter() + .any(|id| !valid_owasp_software_identifier(id)) + || (!vulnerability_ids.is_empty() && vulnerabilities.is_empty()) + || vulnerability_ids + .iter() + .any(|id| !vulnerable_software_ids.contains(id)) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let included_by = parse_owasp_included_by(dependency.get("includedBy"))?; + let related = parse_owasp_related(dependency.get("relatedDependencies"), &expected)?; + let mut aliases = package_ids.clone(); + aliases.extend(related.aliases); + aliases.sort(); + if aliases.windows(2).any(|rows| rows[0] == rows[1]) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + if is_virtual { + if package_ids.is_empty() || included_by.is_empty() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + if expected.contains_key(file_name) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + } else { + let artifacts = expected + .get(file_name) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let digest = + digest.ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if artifacts + .iter() + .any(|artifact| artifact.artifact_sha256 != digest) + || !observed.insert(file_name.to_owned()) + || package_ids.iter().any(|id| { + !artifacts + .iter() + .any(|artifact| package_identifier_compatible(id, artifact)) + }) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + } + parsed_dependencies.push(ParsedOwaspDependency { + aliases, + included_by, + is_virtual, + file_name: file_name.to_owned(), + package_ids, + vulnerabilities, + }); + } + if observed.len() != expected.len() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + validate_owasp_lineage(workload_id, &parsed_dependencies)?; + for dependency in &parsed_dependencies { + let advisory_ids = dependency + .vulnerabilities + .iter() + .map(|vulnerability| vulnerability.advisory_id.as_str()) + .collect::<BTreeSet<_>>(); + if dependency.is_virtual { + for package_id in &dependency.package_ids { + let (ecosystem, namespace, name, version) = parse_exact_package_url(package_id)?; + for advisory_id in &advisory_ids { + findings.push(Finding { + provider: ProviderId::OwaspNvd, + advisory_id: (*advisory_id).to_owned(), + package_ecosystem: ecosystem.clone(), + package_namespace: namespace.clone(), + package_name: name.clone(), + package_version: version.clone(), + workload_id: workload_id.to_owned(), + }); + } + } + } else { + let artifacts = expected + .get(dependency.file_name.as_str()) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + for artifact in artifacts { + for advisory_id in &advisory_ids { + findings.push(Finding { + provider: ProviderId::OwaspNvd, + advisory_id: (*advisory_id).to_owned(), + package_ecosystem: artifact.package_ecosystem.clone(), + package_namespace: artifact.package_namespace.clone(), + package_name: artifact.package_name.clone(), + package_version: artifact.package_version.clone(), + workload_id: workload_id.to_owned(), + }); + } + } + } + } + Ok(()) +} + +#[derive(Clone)] +struct ParsedOwaspVulnerability { + advisory_id: String, + vulnerable_software_ids: BTreeSet<String>, +} + +struct ParsedOwaspDependency { + aliases: Vec<String>, + included_by: Vec<String>, + is_virtual: bool, + file_name: String, + package_ids: Vec<String>, + vulnerabilities: Vec<ParsedOwaspVulnerability>, +} + +struct ParsedOwaspRelated { + aliases: Vec<String>, +} + +fn object_keys_exact( + object: &serde_json::Map<String, Value>, + required: &[&str], + optional: &[&str], +) -> bool { + required.iter().all(|key| object.contains_key(*key)) + && object + .keys() + .all(|key| required.contains(&key.as_str()) || optional.contains(&key.as_str())) + && object.len() >= required.len() + && object.len() <= required.len() + optional.len() +} + +fn validate_json_bounds(value: &Value) -> Result<(), AdvisoryError> { + fn visit(value: &Value, depth: usize, nodes: &mut usize) -> bool { + if depth > 32 || *nodes >= 1_000_000 { + return false; + } + *nodes += 1; + match value { + Value::String(value) => valid_bounded_text(value, 1_048_576), + Value::Array(values) => { + values.len() <= 65_536 && values.iter().all(|value| visit(value, depth + 1, nodes)) + } + Value::Object(values) => { + values.len() <= 1_024 + && values.keys().all(|key| valid_bounded_text(key, 256)) + && values.values().all(|value| visit(value, depth + 1, nodes)) + } + _ => true, + } + } + if visit(value, 0, &mut 0) { + Ok(()) + } else { + Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)) + } +} + +fn parse_report_epoch(value: &str) -> Result<u64, AdvisoryError> { + let bytes = value.as_bytes(); + if bytes.len() < 20 + || bytes.get(4) != Some(&b'-') + || bytes.get(7) != Some(&b'-') + || bytes.get(10) != Some(&b'T') + || bytes.get(13) != Some(&b':') + || bytes.get(16) != Some(&b':') + || bytes.last() != Some(&b'Z') + || (bytes.len() > 20 + && (bytes.get(19) != Some(&b'.') + || !bytes[20..bytes.len() - 1].iter().all(u8::is_ascii_digit))) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + fn number(bytes: &[u8]) -> Option<u64> { + bytes.iter().try_fold(0_u64, |value, byte| { + byte.is_ascii_digit() + .then(|| value * 10 + u64::from(byte - b'0')) + }) + } + let year = i64::try_from(number(&bytes[0..4]).unwrap_or_default()).unwrap_or_default(); + let month = number(&bytes[5..7]).unwrap_or_default(); + let day = number(&bytes[8..10]).unwrap_or_default(); + let hour = number(&bytes[11..13]).unwrap_or(24); + let minute = number(&bytes[14..16]).unwrap_or(60); + let second = number(&bytes[17..19]).unwrap_or(60); + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days_in_month = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => 0, + }; + if year < 1970 || day == 0 || day > days_in_month || hour > 23 || minute > 59 || second > 59 { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let adjusted_year = year - i64::from(month <= 2); + let era = adjusted_year.div_euclid(400); + let year_of_era = adjusted_year - era * 400; + let adjusted_month = i64::try_from(month).unwrap_or_default() + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * adjusted_month + 2) / 5 + i64::try_from(day).unwrap_or_default() - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + let days = era * 146_097 + day_of_era - 719_468; + u64::try_from(days) + .ok() + .and_then(|days| days.checked_mul(86_400)) + .and_then(|value| value.checked_add(hour * 3_600 + minute * 60 + second)) + .filter(|value| *value > 0) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport)) +} + +fn format_report_epoch(value: u64) -> Result<String, AdvisoryError> { + let days = i64::try_from(value / 86_400) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let seconds = value % 86_400; + let z = days + 719_468; + let era = z.div_euclid(146_097); + let day_of_era = z - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + Ok(format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + seconds / 3_600, + seconds % 3_600 / 60, + seconds % 60, + )) +} + +fn optional_bounded_string(value: Option<&Value>, maximum: usize) -> bool { + value.is_none_or(|value| { + value + .as_str() + .is_some_and(|value| valid_bounded_text(value, maximum)) + }) +} + +fn valid_text_allow_empty(value: &str, maximum: usize) -> bool { + value.len() <= maximum && !value.contains('\0') && !value.chars().any(char::is_control) +} + +fn valid_sorted_string_array( + value: Option<&Value>, + maximum_rows: usize, + maximum_bytes: usize, +) -> Result<bool, AdvisoryError> { + let Some(value) = value else { + return Ok(true); + }; + let rows = value + .as_array() + .filter(|rows| rows.len() <= maximum_rows) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let mut previous = None; + for row in rows { + let row = row + .as_str() + .filter(|row| valid_bounded_text(row, maximum_bytes)) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if previous.is_some_and(|previous| previous >= row) { + return Ok(false); + } + previous = Some(row); + } + Ok(true) +} + +fn valid_owasp_credits(value: &Value) -> bool { + let Some(object) = value.as_object() else { + return false; + }; + const CREDITS: [(&str, &str); 5] = [ + ( + "CISA", + "This report may contain data retrieved from the CISA Known Exploited Vulnerability Catalog: https://www.cisa.gov/known-exploited-vulnerabilities-catalog", + ), + ( + "NPM", + "This report may contain data retrieved from the Github Advisory Database (via NPM Audit API): https://github.com/advisories/", + ), + ( + "NVD", + "This product uses the NVD API but is not endorsed or certified by the NVD. This report contains data retrieved from the National Vulnerability Database: https://nvd.nist.gov", + ), + ( + "OSSINDEX", + "This report may contain data retrieved from the Sonatype Guide OSS Index API: https://www.sonatype.com/products/sonatype-guide/oss-index-users", + ), + ( + "RETIREJS", + "This report may contain data retrieved from the RetireJS community: https://retirejs.github.io/retire.js/", + ), + ]; + object.len() == CREDITS.len() + && CREDITS + .iter() + .all(|(key, value)| object.get(*key).and_then(Value::as_str) == Some(*value)) +} + +fn valid_owasp_evidence(value: &Value) -> bool { + let Some(object) = value.as_object() else { + return false; + }; + if !object_keys_exact( + object, + &["productEvidence", "vendorEvidence", "versionEvidence"], + &[], + ) { + return false; + } + [ + ("productEvidence", "product"), + ("vendorEvidence", "vendor"), + ("versionEvidence", "version"), + ] + .into_iter() + .all(|(key, kind)| { + object[key] + .as_array() + .filter(|rows| rows.len() <= 65_536) + .is_some_and(|rows| { + rows.iter().all(|row| { + row.as_object().is_some_and(|row| { + object_keys_exact( + row, + &["confidence", "name", "source", "type", "value"], + &[], + ) && row["type"].as_str() == Some(kind) + && ["confidence", "name", "source", "value"].iter().all(|key| { + row[*key] + .as_str() + .is_some_and(|value| valid_bounded_text(value, 4096)) + }) + }) + }) + }) + }) +} + +fn parse_owasp_identifiers( + value: Option<&Value>, + allow_confidence: bool, +) -> Result<Vec<String>, AdvisoryError> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let rows = value + .as_array() + .filter(|rows| !rows.is_empty() && rows.len() <= 65_536) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let mut result = Vec::with_capacity(rows.len()); + for row in rows { + let row = row + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let optional = if allow_confidence { + ["confidence", "notes", "url"].as_slice() + } else { + ["notes", "url"].as_slice() + }; + if !object_keys_exact(row, &["id"], optional) + || optional + .iter() + .any(|key| !optional_bounded_string(row.get(*key), 4096)) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + result.push(required_text(row.get("id"))?.to_owned()); + } + if result.windows(2).any(|rows| rows[0] >= rows[1]) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + Ok(result) +} + +fn parse_owasp_included_by(value: Option<&Value>) -> Result<Vec<String>, AdvisoryError> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let rows = value + .as_array() + .filter(|rows| !rows.is_empty() && rows.len() <= 65_536) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let mut result = Vec::with_capacity(rows.len()); + let mut previous = None::<Vec<u8>>; + for row in rows { + let row = row + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact(row, &["reference"], &["type"]) + || !optional_bounded_string(row.get("type"), 256) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let key = canonical_json_without_lf(&Value::Object(row.clone())); + if previous.as_ref().is_some_and(|previous| previous >= &key) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + previous = Some(key); + result.push(required_text(row.get("reference"))?.to_owned()); + } + Ok(result) +} + +fn parse_owasp_related( + value: Option<&Value>, + expected: &BTreeMap<&str, Vec<&GradleArtifact>>, +) -> Result<ParsedOwaspRelated, AdvisoryError> { + let Some(value) = value else { + return Ok(ParsedOwaspRelated { + aliases: Vec::new(), + }); + }; + let rows = value + .as_array() + .filter(|rows| !rows.is_empty() && rows.len() <= 65_536) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let mut aliases = Vec::new(); + let mut previous = None::<Vec<u8>>; + for row in rows { + let row = row + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact( + row, + &["fileName", "filePath", "isVirtual"], + &["md5", "packageIds", "sha1", "sha256"], + ) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let is_virtual = row["isVirtual"] + .as_bool() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let file_name = required_text(row.get("fileName"))?; + required_text(row.get("filePath"))?; + if is_virtual { + if row.contains_key("md5") || row.contains_key("sha1") || row.contains_key("sha256") { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + } else if !row + .get("md5") + .and_then(Value::as_str) + .is_some_and(|value| valid_hex(value, 32)) + || !row + .get("sha1") + .and_then(Value::as_str) + .is_some_and(|value| valid_hex(value, 40)) + || !row + .get("sha256") + .and_then(Value::as_str) + .is_some_and(|value| valid_hex(value, 64)) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + if let (Some(expected), Some(digest)) = ( + expected.get(file_name), + row.get("sha256").and_then(Value::as_str), + ) && expected + .iter() + .any(|artifact| artifact.artifact_sha256 != digest) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + aliases.extend(parse_owasp_identifiers(row.get("packageIds"), false)?); + let key = canonical_json_without_lf(&Value::Object(row.clone())); + if previous.as_ref().is_some_and(|previous| previous >= &key) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + previous = Some(key); + } + Ok(ParsedOwaspRelated { aliases }) +} + +fn parse_owasp_vulnerabilities( + value: Option<&Value>, +) -> Result<Vec<ParsedOwaspVulnerability>, AdvisoryError> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let rows = value + .as_array() + .filter(|rows| !rows.is_empty() && rows.len() <= 65_536) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let mut result = Vec::with_capacity(rows.len()); + for row in rows { + let row = row + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact( + row, + &[ + "description", + "name", + "notes", + "references", + "source", + "vulnerableSoftware", + ], + &["cvssv2", "cvssv3", "cvssv4", "cwes", "severity", "unscored"], + ) || row["source"].as_str() != Some("NVD") + || !optional_bounded_string(row.get("severity"), 128) + || row + .get("unscored") + .is_some_and(|value| value.as_str() != Some("true")) + || !["description", "notes"].iter().all(|key| { + row[*key] + .as_str() + .is_some_and(|value| valid_text_allow_empty(value, 1_048_576)) + }) + || !valid_owasp_references(&row["references"]) + || !valid_sorted_string_array(row.get("cwes"), 65_536, 256)? + || !valid_owasp_cvss(row.get("cvssv2"), OwaspCvssVersion::V2) + || !valid_owasp_cvss(row.get("cvssv3"), OwaspCvssVersion::V3) + || !valid_owasp_cvss(row.get("cvssv4"), OwaspCvssVersion::V4) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let advisory_id = required_text(row.get("name"))?; + let vulnerable_software_ids = parse_owasp_vulnerable_software(&row["vulnerableSoftware"])?; + if !valid_advisory_id(ProviderId::OwaspNvd, advisory_id) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + result.push(ParsedOwaspVulnerability { + advisory_id: advisory_id.to_owned(), + vulnerable_software_ids, + }); + } + if result + .windows(2) + .any(|rows| rows[0].advisory_id >= rows[1].advisory_id) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + Ok(result) +} + +fn valid_owasp_references(value: &Value) -> bool { + value + .as_array() + .filter(|rows| rows.len() <= 65_536) + .is_some_and(|rows| { + rows.iter().all(|row| { + row.as_object().is_some_and(|row| { + object_keys_exact(row, &["source"], &["name", "url"]) + && ["source", "name", "url"] + .iter() + .all(|key| optional_bounded_string(row.get(*key), 4096)) + }) + }) + }) +} + +fn parse_owasp_vulnerable_software(value: &Value) -> Result<BTreeSet<String>, AdvisoryError> { + let rows = value + .as_array() + .filter(|rows| !rows.is_empty() && rows.len() <= 65_536) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let mut identifiers = BTreeSet::new(); + for row in rows { + let row = row + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact(row, &["software"], &[]) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let software = row["software"] + .as_object() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !object_keys_exact( + software, + &["id"], + &[ + "versionEndExcluding", + "versionEndIncluding", + "versionStartExcluding", + "versionStartIncluding", + "vulnerabilityIdMatched", + "vulnerable", + ], + ) || software + .get("vulnerabilityIdMatched") + .is_some_and(|value| value.as_str() != Some("true")) + || software + .get("vulnerable") + .is_some_and(|value| value.as_str() != Some("false")) + || [ + "versionEndExcluding", + "versionEndIncluding", + "versionStartExcluding", + "versionStartIncluding", + ] + .iter() + .any(|key| !optional_bounded_string(software.get(*key), 256)) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let identifier = required_text(software.get("id"))?; + if !valid_owasp_software_identifier(identifier) + || !identifiers.insert(identifier.to_owned()) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + } + Ok(identifiers) +} + +fn valid_owasp_software_identifier(value: &str) -> bool { + valid_bounded_text(value, 4096) + && (value.starts_with("cpe:2.3:") + || value.starts_with("cpe:/") + || parse_exact_package_url(value).is_ok()) +} + +#[derive(Clone, Copy)] +enum OwaspCvssVersion { + V2, + V3, + V4, +} + +fn valid_owasp_cvss(value: Option<&Value>, version: OwaspCvssVersion) -> bool { + let Some(value) = value else { + return true; + }; + let Some(object) = value.as_object() else { + return false; + }; + let (required, optional, numeric): (&[&str], &[&str], &[&str]) = match version { + OwaspCvssVersion::V2 => ( + &[ + "accessComplexity", + "accessVector", + "authenticationr", + "availabilityImpact", + "confidentialityImpact", + "integrityImpact", + "score", + "severity", + ], + &[ + "acInsufInfo", + "exploitabilityScore", + "impactScore", + "obtainAllPrivilege", + "obtainOtherPrivilege", + "obtainUserPrivilege", + "userInteractionRequired", + "version", + ], + &["score"], + ), + OwaspCvssVersion::V3 => ( + &[ + "attackComplexity", + "attackVector", + "availabilityImpact", + "baseScore", + "baseSeverity", + "confidentialityImpact", + "integrityImpact", + "privilegesRequired", + "scope", + "userInteraction", + ], + &["exploitabilityScore", "impactScore", "version"], + &["baseScore"], + ), + OwaspCvssVersion::V4 => ( + &[], + &[ + "attackComplexity", + "attackRequirements", + "attackVector", + "automatable", + "availabilityRequirements", + "baseScore", + "baseSeverity", + "confidentialityRequirements", + "environmentalScore", + "environmentalSeverity", + "exploitMaturity", + "integrityRequirements", + "modifiedAttackComplexity", + "modifiedAttackRequirements", + "modifiedAttackVector", + "modifiedPrivilegesRequired", + "modifiedSubsequentSystemAvailability", + "modifiedSubsequentSystemConfidentiality", + "modifiedSubsequentSystemIntegrity", + "modifiedUserInteraction", + "modifiedVulnerableSystemAvailability", + "modifiedVulnerableSystemConfidentiality", + "modifiedVulnerableSystemIntegrity", + "privilegesRequired", + "providerUrgency", + "recovery", + "safety", + "source", + "subsequentSystemAvailability", + "subsequentSystemConfidentiality", + "subsequentSystemIntegrity", + "threatScore", + "threatSeverity", + "type", + "userInteraction", + "valueDensity", + "vectorString", + "version", + "vulnerabilityResponseEffort", + "vulnerableSystemAvailability", + "vulnerableSystemConfidentiality", + "vulnerableSystemIntegrity", + ], + &["baseScore", "environmentalScore", "threatScore"], + ), + }; + object_keys_exact(object, required, optional) + && object.iter().all(|(key, value)| { + if numeric.contains(&key.as_str()) { + value.as_f64().is_some_and(|value| value.is_finite()) + } else { + value + .as_str() + .is_some_and(|value| valid_bounded_text(value, 4096)) + } + }) +} + +fn validate_owasp_lineage( + workload_id: &str, + dependencies: &[ParsedOwaspDependency], +) -> Result<(), AdvisoryError> { + const ROOT: usize = usize::MAX; + let mut aliases = BTreeMap::<String, usize>::new(); + for root in [workload_id.to_owned(), format!("project:{workload_id}")] { + if aliases.insert(root, ROOT).is_some() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + } + for (index, dependency) in dependencies.iter().enumerate() { + for alias in &dependency.aliases { + if aliases.insert(alias.clone(), index).is_some() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + } + } + let mut states = vec![0_u8; dependencies.len()]; + fn reaches_root( + index: usize, + dependencies: &[ParsedOwaspDependency], + aliases: &BTreeMap<String, usize>, + states: &mut [u8], + ) -> Result<bool, AdvisoryError> { + if !dependencies[index].is_virtual { + return Ok(true); + } + match states[index] { + 1 => return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)), + 2 => return Ok(true), + 3 => return Ok(false), + _ => {} + } + states[index] = 1; + let mut reached = false; + for parent in &dependencies[index].included_by { + let target = aliases + .get(parent) + .copied() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + reached |= target == usize::MAX || reaches_root(target, dependencies, aliases, states)?; + } + states[index] = if reached { 2 } else { 3 }; + Ok(reached) + } + for index in 0..dependencies.len() { + if dependencies[index].is_virtual + && !reaches_root(index, dependencies, &aliases, &mut states)? + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); + } + } + Ok(()) +} + +fn parse_exact_package_url(value: &str) -> Result<(String, String, String, String), AdvisoryError> { + if !valid_bounded_text(value, 512) + || value.contains(['%', '?', '#']) + || !value.starts_with("pkg:maven/") + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let body = &value["pkg:maven/".len()..]; + let (path, version) = body + .split_once('@') + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let (namespace, name) = path + .rsplit_once('/') + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if !valid_exact_identity_token(namespace, 256) + || !valid_exact_identity_token(name, 256) + || !valid_exact_identity_token(version, 128) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + Ok(( + "maven".to_owned(), + namespace.to_owned(), + name.to_owned(), + version.to_owned(), + )) +} + +fn package_identifier_compatible(value: &str, artifact: &GradleArtifact) -> bool { + let parsed = parse_exact_package_url(value).ok(); + artifact.package_ecosystem == "maven" + && valid_exact_identity_token(&artifact.package_namespace, 256) + && parsed + == Some(( + "maven".to_owned(), + artifact.package_namespace.clone(), + artifact.package_name.clone(), + artifact.package_version.clone(), + )) + && value + == format!( + "pkg:maven/{}/{}@{}", + artifact.package_namespace, artifact.package_name, artifact.package_version + ) +} + +fn required_text(value: Option<&Value>) -> Result<&str, AdvisoryError> { + value + .and_then(Value::as_str) + .filter(|value| valid_bounded_text(value, 256)) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport)) +} + +fn validate_suppression_inventory( + suppressions: &[Suppression], + evaluation_epoch: u64, +) -> Result<(), AdvisoryError> { + let mut previous = None; + let mut identifiers = BTreeSet::new(); + for suppression in suppressions { + if suppression.advisory_id == NON_WAIVABLE_RUSTSEC + || !valid_advisory_id(suppression.provider, &suppression.advisory_id) + || !valid_exact_identity_token(&suppression.id, 128) + || !matches!(suppression.package_ecosystem.as_str(), "cargo" | "maven") + || match suppression.package_ecosystem.as_str() { + "cargo" => !suppression.package_namespace.is_empty(), + "maven" => !valid_exact_identity_token(&suppression.package_namespace, 256), + _ => true, + } + || !valid_exact_identity_token(&suppression.package_name, 256) + || !valid_exact_identity_token(&suppression.package_version, 128) + || !WORKLOADS + .iter() + .any(|workload| workload.id == suppression.workload_id) + || !valid_bounded_text(&suppression.owner, 256) + || !valid_bounded_text(&suppression.rationale, 1024) + || !identifiers.insert(suppression.id.as_str()) + || suppression.created_at_epoch == 0 + || suppression.created_at_epoch >= suppression.expires_at_epoch + || suppression.created_at_epoch > evaluation_epoch + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + if evaluation_epoch >= suppression.expires_at_epoch { + return Err(AdvisoryError::new(AdvisoryFailureKind::ExpiredSuppression)); + } + let key = suppression_key(suppression); + if previous.as_ref().is_some_and(|value| value >= &key) { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + previous = Some(key); + } + Ok(()) +} + +fn apply_suppressions( + findings: &mut Vec<Finding>, + suppressions: &[Suppression], + evaluation_epoch: u64, +) -> Result<(), AdvisoryError> { + validate_suppression_inventory(suppressions, evaluation_epoch)?; + for suppression in suppressions { + let matches = findings + .iter() + .enumerate() + .filter(|(_, finding)| suppression_matches(suppression, finding)) + .map(|(index, _)| index) + .collect::<Vec<_>>(); + if matches.len() != 1 { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + findings.remove(matches[0]); + } + Ok(()) +} + +fn suppression_matches(suppression: &Suppression, finding: &Finding) -> bool { + suppression.provider == finding.provider + && suppression.advisory_id == finding.advisory_id + && suppression.package_ecosystem == finding.package_ecosystem + && suppression.package_namespace == finding.package_namespace + && suppression.package_name == finding.package_name + && suppression.package_version == finding.package_version + && suppression.workload_id == finding.workload_id +} + +fn finding_key(finding: &Finding) -> (&str, &str, &str, &str, &str, &str, &str) { + ( + finding.provider.as_str(), + &finding.advisory_id, + &finding.package_ecosystem, + &finding.package_namespace, + &finding.package_name, + &finding.package_version, + &finding.workload_id, + ) +} + +fn suppression_key(suppression: &Suppression) -> (&str, &str, &str, &str, &str, &str, &str, &str) { + ( + suppression.provider.as_str(), + &suppression.advisory_id, + &suppression.workload_id, + &suppression.package_ecosystem, + &suppression.package_namespace, + &suppression.package_name, + &suppression.package_version, + &suppression.id, + ) +} + +fn validate_blob(expected: &BlobBinding, actual: &FileEvidence) -> Result<(), AdvisoryError> { + if expected.byte_length != actual.byte_length || expected.sha256 != actual.sha256 { + Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)) + } else { + Ok(()) + } +} + +fn valid_blob_binding(binding: &BlobBinding) -> bool { + let maximum = if binding.media_type == "application/gzip" { + 2_147_483_648 + } else { + MAX_REPORT_BYTES + }; + binding.byte_length > 0 + && binding.byte_length <= maximum + && valid_hex(&binding.sha256, 64) + && SNAPSHOT_FILE_NAMES.contains(&binding.path.as_str()) + && binding.logical_uri == format!("extbuild-cas://sha256/{}", binding.sha256) + && matches!( + binding.media_type.as_str(), + "application/gzip" | "application/json" + ) + && matches!( + binding.logical_role.as_str(), + "rustsec_database_snapshot" + | "owasp_nvd_database_snapshot" + | "rustsec_unsuppressed_report" + | "owasp_unsuppressed_report" + ) +} + +fn valid_provider_blob_binding(provider: ProviderId, binding: &BlobBinding, archive: bool) -> bool { + if !valid_blob_binding(binding) { + return false; + } + matches!( + ( + provider, + archive, + binding.path.as_str(), + binding.media_type.as_str(), + binding.logical_role.as_str() + ), + ( + ProviderId::Rustsec, + true, + RUSTSEC_ARCHIVE_NAME, + "application/gzip", + "rustsec_database_snapshot" + ) | ( + ProviderId::Rustsec, + false, + RUSTSEC_REPORT_NAME, + "application/json", + "rustsec_unsuppressed_report" + ) | ( + ProviderId::OwaspNvd, + true, + NVD_ARCHIVE_NAME, + "application/gzip", + "owasp_nvd_database_snapshot" + ) | ( + ProviderId::OwaspNvd, + false, + OWASP_REPORT_NAME, + "application/json", + "owasp_unsuppressed_report" + ) + ) +} + +fn inventory_digest(inventory: &[WorkloadInventory]) -> Result<String, AdvisoryError> { + domain_json_digest(b"radroots-advisory-workload-inventory-v1\0", inventory) +} + +fn source_digest(sources: &[SourceIdentity]) -> Result<String, AdvisoryError> { + domain_json_digest(b"radroots-advisory-source-inventory-v1\0", sources) +} + +fn tool_observation_digest( + acquisitions: &[ToolAcquisition], + states: &[ToolState], +) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-tool-observation-v1\0", + &serde_json::json!({"acquisition": acquisitions, "state": states}), + ) +} + +fn provider_trace_digest(providers: &[ProviderSnapshot]) -> Result<String, AdvisoryError> { + domain_json_digest(b"radroots-advisory-provider-trace-v1\0", providers) +} + +fn provider_database_identity( + provider: ProviderId, + materialized_tree_sha256: &str, + report_database_metadata_sha256: &str, +) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-provider-database-identity-v1\0", + &serde_json::json!({ + "materialized_tree_sha256": materialized_tree_sha256, + "provider": provider, + "report_database_metadata_sha256": report_database_metadata_sha256, + }), + ) +} + +fn database_identity_from_report( + provider: ProviderId, + report: &[u8], + materialized_tree_sha256: &str, +) -> Result<String, AdvisoryError> { + let envelope: ReportEnvelope = serde_json::from_slice(report) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if envelope.provider != provider || envelope.workload_result.is_empty() { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let mut metadata = None::<String>; + for result in envelope.workload_result { + let raw: Value = serde_json::from_str(&result.raw_scanner_output) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let current = match provider { + ProviderId::Rustsec => domain_json_digest( + b"radroots-advisory-rustsec-database-metadata-v1\0", + raw.get("database") + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?, + )?, + ProviderId::OwaspNvd => domain_json_digest( + b"radroots-advisory-owasp-database-metadata-v1\0", + raw.pointer("/scanInfo/dataSource") + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?, + )?, + }; + if metadata.as_ref().is_some_and(|value| value != &current) { + return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); + } + metadata = Some(current); + } + provider_database_identity( + provider, + materialized_tree_sha256, + &metadata.ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?, + ) +} + +fn finding_digest(findings: &[Finding]) -> Result<String, AdvisoryError> { + let rows = findings + .iter() + .map(|finding| { + serde_json::json!({ + "advisory_id": finding.advisory_id, + "package_ecosystem": finding.package_ecosystem, + "package_namespace": finding.package_namespace, + "package_name": finding.package_name, + "package_version": finding.package_version, + "provider": finding.provider, + "workload_id": finding.workload_id, + }) + }) + .collect::<Vec<_>>(); + domain_json_digest(b"radroots-advisory-finding-inventory-v1\0", &rows) +} + +fn suppression_digest(suppressions: &[Suppression]) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-suppression-inventory-v1\0", + suppressions, + ) +} + +fn gradle_projection_digest(projections: &[GradleProjection]) -> Result<String, AdvisoryError> { + domain_json_digest(b"radroots-advisory-gradle-projection-v1\0", projections) +} + +fn candidate_advisory_input_digest(manifest: &SnapshotManifest) -> Result<String, AdvisoryError> { + domain_json_digest( + b"radroots-advisory-candidate-input-v1\0", + &serde_json::json!({ + "candidate": manifest.candidate, + "fresh_tool_observation_sha256": manifest.fresh_tool_observation_sha256, + "gradle_projection": manifest.gradle_projection, + "inventory": manifest.inventory, + "provider_snapshot": manifest.provider_snapshot, + "sources": manifest.sources, + "step_297_tool_manifest_sha256": manifest.step_297_tool_manifest_sha256, + "suppressions": manifest.suppressions, + "tool_acquisition": manifest.tool_acquisition, + "tool_state": manifest.tool_state, + }), + ) +} + +fn domain_json_digest<T: Serialize + ?Sized>( + domain: &[u8], + value: &T, +) -> Result<String, AdvisoryError> { + let value = serde_json::to_value(value) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let bytes = canonical_json_without_lf(&value); + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update(bytes); + Ok(hex::encode(hasher.finalize())) +} + +fn materialized_tree_digest(snapshot: &TraversalSnapshot) -> Result<String, AdvisoryError> { + let directories = snapshot + .directories() + .map(|(path, mode)| { + path.to_str() + .map(|path| serde_json::json!({"mode": mode, "path": path})) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)) + }) + .collect::<Result<Vec<_>, _>>()?; + let mut files = Vec::with_capacity(snapshot.files().len()); + for file in snapshot.files() { + let evidence = snapshot + .hash(file, 17_179_869_184) + .map_err(map_binding_error)?; + let path = file + .relative_path() + .to_str() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + files.push(serde_json::json!({ + "byte_length": evidence.byte_length, + "mode": file.permission_mode(), + "path": path, + "sha256": evidence.sha256, + })); + } + snapshot.revalidate().map_err(map_binding_error)?; + domain_json_digest( + b"radroots-advisory-materialized-tree-v1\0", + &serde_json::json!({ + "directories": directories, + "entry_count": snapshot.entry_count(), + "files": files, + "total_bytes": snapshot.total_bytes(), + }), + ) +} + +fn archive_limits() -> TarGzipLimits { + TarGzipLimits { + max_compressed_bytes: 2_147_483_648, + max_expanded_bytes: 17_179_869_184, + max_members: 65_536, + max_member_bytes: 17_179_869_184, + max_payload_bytes: 17_179_869_184, + max_depth: 64, + max_path_bytes: 4_096, + } +} + +fn map_binding_error(error: safe_artifact_io::ArtifactIoError) -> AdvisoryError { + match error.kind() { + safe_artifact_io::ArtifactIoFailureKind::ChangedDuringRead => { + AdvisoryError::new(AdvisoryFailureKind::BindingChanged) + } + _ => AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot), + } +} + +fn map_archive_error(error: safe_artifact_io::ArtifactIoError) -> AdvisoryError { + match error.kind() { + safe_artifact_io::ArtifactIoFailureKind::ChangedDuringRead => { + AdvisoryError::new(AdvisoryFailureKind::BindingChanged) + } + _ => AdvisoryError::new(AdvisoryFailureKind::ArchiveRejected), + } +} + +fn canonical_json(value: &Value) -> Vec<u8> { + let mut bytes = canonical_json_without_lf(value); + bytes.push(b'\n'); + bytes +} + +fn canonical_pretty_json(value: &Value) -> Vec<u8> { + let mut bytes = serde_json::to_vec_pretty(value).unwrap_or_default(); + bytes.push(b'\n'); + bytes +} + +fn canonical_json_without_lf(value: &Value) -> Vec<u8> { + serde_json::to_vec(value).unwrap_or_default() +} + +fn sha256(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn valid_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn valid_identifier(value: &str) -> bool { + valid_bounded_text(value, 256) + && !Path::new(value).is_absolute() + && Path::new(value) + .components() + .all(|component| matches!(component, Component::Normal(_))) + && !value.contains('\\') +} + +fn valid_bounded_text(value: &str, maximum: usize) -> bool { + !value.is_empty() + && value.len() <= maximum + && !value.contains('\0') + && !value.chars().any(char::is_control) +} + +fn valid_exact_identity_token(value: &str, maximum: usize) -> bool { + valid_bounded_text(value, maximum) + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'-' | b'_' | b'.' | b'/' | b':' | b'@' | b'+') + }) + && !value.contains("..") +} + +fn valid_advisory_id(provider: ProviderId, value: &str) -> bool { + let (prefix, minimum_digits) = match provider { + ProviderId::Rustsec => ("RUSTSEC-", 9), + ProviderId::OwaspNvd => ("CVE-", 9), + }; + if !value.starts_with(prefix) || value.len() < prefix.len() + minimum_digits { + return false; + } + let remainder = &value[prefix.len()..]; + let Some((year, sequence)) = remainder.split_once('-') else { + return false; + }; + year.len() == 4 + && year.bytes().all(|byte| byte.is_ascii_digit()) + && sequence.len() >= 4 + && sequence.bytes().all(|byte| byte.is_ascii_digit()) +} + +pub(crate) fn self_test() -> Result<(), String> { + self_test_suite().map_err(|error| error.to_string())?; + verify_gradle_raw_bridge().map_err(|error| error.to_string())?; + verify_bounded_process_surface().map_err(|error| error.to_string()) +} + +fn verify_gradle_raw_bridge() -> Result<(), AdvisoryError> { + let fixture = trusted_tempdir(".radroots-gradle-bridge-")?; + let raw_root = fixture.path().join("raw"); + let source_root = fixture.path().join("source"); + let materialization_parent = fixture.path().join("materialized"); + for directory in [&raw_root, &source_root, &materialization_parent] { + std::fs::create_dir(directory) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + } + } + let artifact_path = source_root.join("example-1.0.0.jar"); + let artifact_bytes = b"synthetic-gradle-artifact"; + std::fs::write(&artifact_path, artifact_bytes) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&artifact_path, std::fs::Permissions::from_mode(0o600)) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + } + let variant = RawGradleVariant { + attributes: vec![RawGradleAttribute { + name: "org.gradle.usage".to_owned(), + value: "java-runtime".to_owned(), + }], + capabilities: Vec::new(), + external_variant: None, + }; + let root_core = RawGradleComponentCore { + build_root: Some(".".to_owned()), + group: None, + kind: "project".to_owned(), + name: None, + project_path: Some(":app:design_system".to_owned()), + version: None, + }; + let module_core = RawGradleComponentCore { + build_root: None, + group: Some("com.example".to_owned()), + kind: "module".to_owned(), + name: Some("example".to_owned()), + project_path: None, + version: Some("1.0.0".to_owned()), + }; + let mut components = vec![ + RawGradleComponent { + build_root: root_core.build_root.clone(), + group: root_core.group.clone(), + kind: root_core.kind.clone(), + name: root_core.name.clone(), + project_path: root_core.project_path.clone(), + root: true, + variant: RawGradleVariantEnvelope { + selected: vec![variant.clone()], + }, + version: root_core.version.clone(), + }, + RawGradleComponent { + build_root: module_core.build_root.clone(), + group: module_core.group.clone(), + kind: module_core.kind.clone(), + name: module_core.name.clone(), + project_path: module_core.project_path.clone(), + root: false, + variant: RawGradleVariantEnvelope { + selected: vec![variant.clone()], + }, + version: module_core.version.clone(), + }, + ]; + components.sort_by(|left, right| { + canonical_row_key(left) + .unwrap_or_default() + .cmp(&canonical_row_key(right).unwrap_or_default()) + }); + let raw = RawGradleGraph { + schema: "radroots.gradle-advisory-graph.v1".to_owned(), + workload_id: "app_design_system".to_owned(), + build_root: ".".to_owned(), + project_path: ":app:design_system".to_owned(), + configuration: "desktopRuntimeClasspath".to_owned(), + components, + edges: vec![RawGradleEdge { + constraint: false, + from: root_core, + requested: RawGradleSelector { + attributes: Vec::new(), + build_root: None, + capabilities: Vec::new(), + group: Some("com.example".to_owned()), + kind: "module".to_owned(), + name: Some("example".to_owned()), + project_path: None, + version: Some("1.0.0".to_owned()), + version_constraint: Some(RawGradleVersionConstraint { + branch: None, + preferred: String::new(), + rejected: Vec::new(), + required: "1.0.0".to_owned(), + strict: String::new(), + }), + }, + selected_variant: variant.clone(), + to: module_core.clone(), + }], + artifacts: vec![RawGradleArtifact { + artifact_name: "example-1.0.0.jar".to_owned(), + artifact_type: "jar".to_owned(), + classifier: None, + component: module_core, + extension: "jar".to_owned(), + group: Some("com.example".to_owned()), + logical_name: "example".to_owned(), + module_version: RawGradleModuleVersion { + group: "com.example".to_owned(), + name: "example".to_owned(), + version: "1.0.0".to_owned(), + }, + name: Some("example".to_owned()), + observed_byte_length: artifact_bytes.len() as u64, + observed_sha256: sha256(artifact_bytes), + source_path: artifact_path + .to_str() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))? + .to_owned(), + variant, + version: Some("1.0.0".to_owned()), + }], + }; + let raw_value = serde_json::to_value(raw) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + std::fs::write( + raw_root.join(GRADLE_RAW_GRAPH_NAME), + canonical_json_without_lf(&raw_value), + ) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + let authority = WORKLOADS + .iter() + .find(|workload| workload.id == "app_design_system") + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))?; + let source_root_identity = "3".repeat(64); + let source_roots = [GradleArtifactSourceRoot { + path: &source_root, + logical_role: "candidate_build_output", + identity_sha256: &source_root_identity, + }]; + let admitted = admit_raw_gradle_graph( + &raw_root, + &materialization_parent, + authority, + &"a".repeat(40), + 1, + &source_roots, + )?; + if admitted.workload_id != authority.id + || admitted.raw_evidence().byte_length == 0 + || admitted.components.len() != 2 + || admitted.edges.len() != 1 + || admitted.artifacts.len() != 1 + || admitted.artifacts[0].package_namespace != "com.example" + || !valid_hex(&admitted.canonical_graph_sha256, 64) + || !valid_hex(&admitted.materialized_tree_sha256, 64) + || !valid_hex(&admitted.normalization_receipt_sha256, 64) + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + admitted.revalidate() +} + +fn synthetic_admitted_gradle_graphs( + fixture_root: &Path, + materialization_parent: &Path, +) -> Result<Vec<AdmittedGradleGraph>, AdvisoryError> { + let mut admitted = Vec::new(); + for (index, authority) in WORKLOADS + .iter() + .filter(|workload| workload.package_manager == "gradle") + .enumerate() + { + let workload_root = fixture_root.join(authority.id); + let raw_root = workload_root.join("raw"); + let source_root = workload_root.join("source"); + for directory in [&workload_root, &raw_root, &source_root] { + std::fs::create_dir(directory) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + } + } + let package_name = format!("fixture-{}", authority.id.replace('_', "-")); + let artifact_name = format!("{package_name}-1.0.0.jar"); + let artifact_path = source_root.join(&artifact_name); + let artifact_bytes = format!("synthetic-gradle-artifact-{index}").into_bytes(); + std::fs::write(&artifact_path, &artifact_bytes) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&artifact_path, std::fs::Permissions::from_mode(0o600)) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + } + let variant = RawGradleVariant { + attributes: vec![RawGradleAttribute { + name: "org.gradle.usage".to_owned(), + value: "java-runtime".to_owned(), + }], + capabilities: Vec::new(), + external_variant: None, + }; + let root_core = RawGradleComponentCore { + build_root: Some(authority.build_root.to_owned()), + group: None, + kind: "project".to_owned(), + name: None, + project_path: authority.project_path.map(str::to_owned), + version: None, + }; + let module_core = RawGradleComponentCore { + build_root: None, + group: Some("com.example".to_owned()), + kind: "module".to_owned(), + name: Some(package_name.clone()), + project_path: None, + version: Some("1.0.0".to_owned()), + }; + let mut components = vec![ + RawGradleComponent { + build_root: root_core.build_root.clone(), + group: root_core.group.clone(), + kind: root_core.kind.clone(), + name: root_core.name.clone(), + project_path: root_core.project_path.clone(), + root: true, + variant: RawGradleVariantEnvelope { + selected: vec![variant.clone()], + }, + version: root_core.version.clone(), + }, + RawGradleComponent { + build_root: module_core.build_root.clone(), + group: module_core.group.clone(), + kind: module_core.kind.clone(), + name: module_core.name.clone(), + project_path: module_core.project_path.clone(), + root: false, + variant: RawGradleVariantEnvelope { + selected: vec![variant.clone()], + }, + version: module_core.version.clone(), + }, + ]; + components.sort_by(|left, right| { + canonical_row_key(left) + .unwrap_or_default() + .cmp(&canonical_row_key(right).unwrap_or_default()) + }); + let raw = RawGradleGraph { + schema: "radroots.gradle-advisory-graph.v1".to_owned(), + workload_id: authority.id.to_owned(), + build_root: authority.build_root.to_owned(), + project_path: authority + .project_path + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))? + .to_owned(), + configuration: authority + .configuration + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))? + .to_owned(), + components, + edges: vec![RawGradleEdge { + constraint: false, + from: root_core, + requested: RawGradleSelector { + attributes: Vec::new(), + build_root: None, + capabilities: Vec::new(), + group: Some("com.example".to_owned()), + kind: "module".to_owned(), + name: Some(package_name.clone()), + project_path: None, + version: Some("1.0.0".to_owned()), + version_constraint: Some(RawGradleVersionConstraint { + branch: None, + preferred: String::new(), + rejected: Vec::new(), + required: "1.0.0".to_owned(), + strict: String::new(), + }), + }, + selected_variant: variant.clone(), + to: module_core.clone(), + }], + artifacts: vec![RawGradleArtifact { + artifact_name, + artifact_type: "jar".to_owned(), + classifier: None, + component: module_core, + extension: "jar".to_owned(), + group: Some("com.example".to_owned()), + logical_name: package_name.clone(), + module_version: RawGradleModuleVersion { + group: "com.example".to_owned(), + name: package_name.clone(), + version: "1.0.0".to_owned(), + }, + name: Some(package_name), + observed_byte_length: artifact_bytes.len() as u64, + observed_sha256: sha256(&artifact_bytes), + source_path: artifact_path + .to_str() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))? + .to_owned(), + variant, + version: Some("1.0.0".to_owned()), + }], + }; + let value = serde_json::to_value(raw) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + std::fs::write( + raw_root.join(GRADLE_RAW_GRAPH_NAME), + canonical_json_without_lf(&value), + ) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + let source_root_identity = format!("{:064x}", index + 20_000); + let source_roots = [GradleArtifactSourceRoot { + path: &source_root, + logical_role: "candidate_build_output", + identity_sha256: &source_root_identity, + }]; + admitted.push(admit_raw_gradle_graph( + &raw_root, + materialization_parent, + authority, + &"1".repeat(40), + 1, + &source_roots, + )?); + } + Ok(admitted) +} + +pub(crate) fn write_fixture_scanner_output(mode: &str) -> Result<(), String> { + match mode { + "report" => { + println!("radroots-advisory-fixture-report-v1"); + Ok(()) + } + "timeout" => { + std::thread::sleep(Duration::from_secs(5)); + Ok(()) + } + _ => Err("unknown advisory fixture scanner mode".to_owned()), + } +} + +fn verify_bounded_process_surface() -> Result<(), AdvisoryError> { + let executable = std::env::current_exe() + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + let working = + tempfile::tempdir().map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + let environment = private_process_environment(working.path())?; + let output = crate::bounded_process::run( + &crate::bounded_process::ProcessRequest::new(executable.clone()) + .arg("advisory-snapshot-fixture-scanner") + .arg("--mode") + .arg("report") + .current_dir(working.path()) + .environment(environment) + .deadline(Duration::from_secs(2)) + .output_limits(1024, 1024), + ) + .map_err(map_process_error)?; + if !output.status().success() + || output.stdout() != b"radroots-advisory-fixture-report-v1\n" + || !output.stderr().is_empty() + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); + } + let error = crate::bounded_process::run( + &crate::bounded_process::ProcessRequest::new(executable) + .arg("advisory-snapshot-fixture-scanner") + .arg("--mode") + .arg("timeout") + .current_dir(working.path()) + .environment(private_process_environment(working.path())?) + .deadline(Duration::from_millis(50)) + .output_limits(1024, 1024), + ) + .err() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + if error.kind() != crate::bounded_process::ProcessFailureKind::DeadlineExceeded { + return Err(map_process_error(error)); + } + Ok(()) +} + +fn private_process_environment( + root: &Path, +) -> Result<crate::bounded_process::ReplacementEnvironment, AdvisoryError> { + let mut environment = crate::bounded_process::ReplacementEnvironment::default(); + for (name, value) in [ + ("CARGO_HOME", root.as_os_str().to_owned()), + ("GRADLE_USER_HOME", root.as_os_str().to_owned()), + ("HOME", root.as_os_str().to_owned()), + ("LC_ALL", OsString::from("C")), + ("PATH", OsString::from("/usr/bin:/bin")), + ("TMPDIR", root.as_os_str().to_owned()), + ("TZ", OsString::from("UTC")), + ] { + environment.insert(name, value).map_err(map_process_error)?; + } + Ok(environment) +} + +fn map_process_error(error: crate::bounded_process::ProcessError) -> AdvisoryError { + match error.kind() { + crate::bounded_process::ProcessFailureKind::DeadlineExceeded => { + AdvisoryError::new(AdvisoryFailureKind::TimedOut) + } + crate::bounded_process::ProcessFailureKind::Spawn => { + AdvisoryError::new(AdvisoryFailureKind::Unavailable) + } + _ => AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot), + } +} + +fn self_test_suite() -> Result<(), AdvisoryError> { + let fixture = SyntheticFixture::new()?; + let evidence = admit_snapshot( + fixture.root(), + fixture.materialization_parent.path(), + &fixture.request, + )?; + if evidence.candidate != fixture.request.candidate + || evidence.inventory_sha256 != inventory_digest(&fixture.request.inventory)? + || evidence.finding_count != 0 + || evidence.suppression_count != 0 + || evidence.rustsec_archive.compressed.byte_length == 0 + || evidence.nvd_archive.compressed.byte_length == 0 + || evidence.provider_freshness.len() != 2 + { + return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); + } + + missing_inventory_vector()?; + unavailable_provider_vector()?; + stale_snapshot_vector()?; + timed_out_operation_vector()?; + known_vulnerable_vector()?; + expired_suppression_vector()?; + Ok(()) +} + +fn missing_inventory_vector() -> Result<(), AdvisoryError> { + let mut missing = SyntheticFixture::new()?; + missing.manifest.inventory.pop(); + missing.seal()?; + require_failure(&missing, AdvisoryFailureKind::InventoryMismatch) +} + +fn unavailable_provider_vector() -> Result<(), AdvisoryError> { + let mut unavailable = SyntheticFixture::new()?; + unavailable.manifest.tool_state[0].state = OperationState::Unavailable; + unavailable.seal()?; + require_failure(&unavailable, AdvisoryFailureKind::Unavailable) +} + +fn stale_snapshot_vector() -> Result<(), AdvisoryError> { + let mut stale = SyntheticFixture::new()?; + stale.manifest.provider_snapshot[0].digest_time_epoch = stale + .request + .evaluation_epoch + .checked_sub(QUALIFICATION_FRESHNESS_SECONDS + 1) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + stale.manifest.provider_snapshot[0].acquired_at_epoch = stale.manifest.provider_snapshot[0] + .digest_time_epoch + .checked_sub(1) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + stale.seal()?; + require_failure(&stale, AdvisoryFailureKind::StaleSnapshot) +} + +fn timed_out_operation_vector() -> Result<(), AdvisoryError> { + let mut timeout = SyntheticFixture::new()?; + timeout.manifest.provider_snapshot[1].analysis_state = OperationState::TimedOut; + timeout.seal()?; + require_failure(&timeout, AdvisoryFailureKind::TimedOut) +} + +fn known_vulnerable_vector() -> Result<(), AdvisoryError> { + let mut vulnerable = SyntheticFixture::new()?; + vulnerable.set_rustsec_finding(NON_WAIVABLE_RUSTSEC, "lru", "0.16.4")?; + require_failure(&vulnerable, AdvisoryFailureKind::KnownVulnerability) +} + +fn expired_suppression_vector() -> Result<(), AdvisoryError> { + let mut expired = SyntheticFixture::new()?; + let expired_artifact = expired + .manifest + .gradle_projection + .first() + .and_then(|projection| projection.artifacts.first()) + .cloned() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + expired.set_owasp_finding( + "CVE-2099-0001", + &expired_artifact.package_namespace, + &expired_artifact.package_name, + &expired_artifact.package_version, + )?; + expired.manifest.suppressions.push(Suppression { + provider: ProviderId::OwaspNvd, + advisory_id: "CVE-2099-0001".to_owned(), + id: "synthetic-expired".to_owned(), + package_ecosystem: "maven".to_owned(), + package_namespace: expired_artifact.package_namespace, + package_name: expired_artifact.package_name, + package_version: expired_artifact.package_version, + workload_id: "app_design_system".to_owned(), + owner: "security@example.invalid".to_owned(), + rationale: "synthetic expiry vector".to_owned(), + created_at_epoch: expired.request.evaluation_epoch - 100, + expires_at_epoch: expired.request.evaluation_epoch, + }); + expired.seal()?; + require_failure(&expired, AdvisoryFailureKind::ExpiredSuppression) +} + +fn require_failure( + fixture: &SyntheticFixture, + expected: AdvisoryFailureKind, +) -> Result<(), AdvisoryError> { + let error = admit_snapshot( + fixture.root(), + fixture.materialization_parent.path(), + &fixture.request, + ) + .err() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + if error.kind() == expected { + Ok(()) + } else { + Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)) + } +} + +struct SyntheticFixture { + directory: tempfile::TempDir, + materialization_parent: tempfile::TempDir, + _gradle_fixture_root: tempfile::TempDir, + scanner_fixture_root: tempfile::TempDir, + scanner_generation: u64, + request: AdmissionRequest, + manifest: SnapshotManifest, + process_receipts: Vec<TrustedProcessReceipt>, +} + +impl SyntheticFixture { + fn new() -> Result<Self, AdvisoryError> { + use std::fs; + + let directory = tempfile::tempdir() + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let materialization_parent = trusted_tempdir(".rshr-advisory-materialization-")?; + let gradle_fixture_root = trusted_tempdir(".rshr-advisory-gradle-input-")?; + let scanner_fixture_root = trusted_tempdir(".rshr-advisory-scanner-output-")?; + let evaluation_epoch = 1_000_000; + let candidate = CandidateIdentity { + generation: 1, + digest: "9".repeat(64), + }; + let sources = synthetic_sources(); + let inventory = synthetic_inventory(); + let producer_request = synthetic_producer_request(&candidate); + let producer_request_bytes = canonical_authority_bytes(&producer_request)?; + let producer_request_sha256 = sha256(&producer_request_bytes); + let rustsec_archive = deterministic_archive(b"synthetic-rustsec")?; + let nvd_archive = deterministic_archive(b"synthetic-nvd")?; + fs::write( + directory.path().join(RUSTSEC_ARCHIVE_NAME), + &rustsec_archive, + ) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + fs::write(directory.path().join(NVD_ARCHIVE_NAME), &nvd_archive) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let tool_acquisition = expected_tool_acquisitions()?; + let tool_state = synthetic_tool_states(evaluation_epoch); + let tool_observation_sha256 = tool_observation_digest(&tool_acquisition, &tool_state)?; + let (rustsec_tree, rustsec_archive_evidence) = + synthetic_archive_tree_digest(&rustsec_archive, materialization_parent.path())?; + let (nvd_tree, nvd_archive_evidence) = + synthetic_archive_tree_digest(&nvd_archive, materialization_parent.path())?; + let nvd_trace = synthetic_nvd_trace(&candidate, &producer_request_sha256); + let nvd_trace_bytes = canonical_authority_bytes(&nvd_trace)?; + let admitted_gradle_graphs = synthetic_admitted_gradle_graphs( + gradle_fixture_root.path(), + materialization_parent.path(), + )?; + let mut gradle_projection = + synthetic_gradle_projections(&admitted_gradle_graphs, &inventory, &sources)?; + let mut process_receipts = vec![synthetic_process_receipt( + "owasp_nvd_update", + TOOL_PINS[1].executable_sha256, + &strings(&OWASP_UPDATE_ARGUMENTS), + &producer_request_sha256, + &nvd_update_output_digest(&nvd_tree, &sha256(&nvd_trace_bytes))?, + vec![ProcessPathBinding { + argument_index: 2, + logical_role: "nvd_update_data".to_owned(), + identity_sha256: nvd_tree.clone(), + }], + evaluation_epoch - 3_000, + evaluation_epoch - 2_990, + 0, + )?]; + attach_gradle_receipts( + &mut gradle_projection, + &mut process_receipts, + evaluation_epoch, + )?; + let rustsec_report = synthetic_report( + ProviderId::Rustsec, + &inventory, + &gradle_projection, + &sha256(&rustsec_archive), + &rustsec_tree, + &tool_observation_sha256, + &mut process_receipts, + evaluation_epoch, + )?; + let owasp_report = synthetic_report( + ProviderId::OwaspNvd, + &inventory, + &gradle_projection, + &sha256(&nvd_archive), + &nvd_tree, + &tool_observation_sha256, + &mut process_receipts, + evaluation_epoch, + )?; + fs::write(directory.path().join(RUSTSEC_REPORT_NAME), &rustsec_report) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + fs::write(directory.path().join(OWASP_REPORT_NAME), &owasp_report) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + + let provider_snapshot = vec![ + synthetic_provider( + ProviderId::Rustsec, + &rustsec_archive, + &rustsec_report, + &rustsec_tree, + &rustsec_archive_evidence, + &producer_request_sha256, + &nvd_trace_bytes, + evaluation_epoch, + )?, + synthetic_provider( + ProviderId::OwaspNvd, + &nvd_archive, + &owasp_report, + &nvd_tree, + &nvd_archive_evidence, + &producer_request_sha256, + &nvd_trace_bytes, + evaluation_epoch, + )?, + ]; + let mut manifest = SnapshotManifest { + schema: "radroots.advisory-snapshot.v1".to_owned(), + candidate: candidate.clone(), + sources: sources.clone(), + inventory_sha256: inventory_digest(&inventory)?, + inventory: inventory.clone(), + candidate_advisory_input_sha256: String::new(), + step_297_tool_manifest_sha256: String::new(), + fresh_tool_observation_sha256: String::new(), + tool_acquisition, + tool_state, + gradle_projection, + provider_snapshot, + suppressions: Vec::new(), + }; + let request = AdmissionRequest { + candidate, + sources, + inventory, + evaluation_epoch, + freshness: FreshnessMode::Qualification, + producer_request: producer_request_bytes, + step_297_tool_manifest: Vec::new(), + fresh_tool_observation: Vec::new(), + nvd_network_trace: nvd_trace_bytes, + provider_execution_evidence: Vec::new(), + admitted_gradle_graphs, + admitted_scanner_outputs: Vec::new(), + }; + manifest.candidate_advisory_input_sha256 = candidate_advisory_input_digest(&manifest)?; + let mut fixture = Self { + directory, + materialization_parent, + _gradle_fixture_root: gradle_fixture_root, + scanner_fixture_root, + scanner_generation: 0, + request, + manifest, + process_receipts, + }; + fixture.seal()?; + Ok(fixture) + } + + fn root(&self) -> &Path { + self.directory.path() + } + + fn seal(&mut self) -> Result<(), AdvisoryError> { + let producer_request = sha256(&self.request.producer_request); + let tool_manifest = TrustedToolManifest { + schema: "radroots.advisory-tool-manifest-projection.v1".to_owned(), + candidate_generation: self.request.candidate.generation, + candidate_digest: self.request.candidate.digest.clone(), + platform: "macos_aarch64".to_owned(), + producer_request_sha256: producer_request.clone(), + tool_acquisition: self.manifest.tool_acquisition.clone(), + }; + self.request.step_297_tool_manifest = canonical_authority_bytes(&tool_manifest)?; + let observation = TrustedToolObservation { + schema: "radroots.advisory-tool-observation.v1".to_owned(), + candidate_generation: self.request.candidate.generation, + candidate_digest: self.request.candidate.digest.clone(), + platform: "macos_aarch64".to_owned(), + producer_request_sha256: producer_request.clone(), + tool_manifest_sha256: sha256(&self.request.step_297_tool_manifest), + row_projection_sha256: tool_observation_digest( + &self.manifest.tool_acquisition, + &self.manifest.tool_state, + )?, + observed_at_epoch: self.request.evaluation_epoch - 4_000, + tool_state: self.manifest.tool_state.clone(), + }; + self.request.fresh_tool_observation = canonical_authority_bytes(&observation)?; + self.manifest.step_297_tool_manifest_sha256 = sha256(&self.request.step_297_tool_manifest); + self.manifest.fresh_tool_observation_sha256 = sha256(&self.request.fresh_tool_observation); + self.refresh_gradle_receipts()?; + self.refresh_admitted_scanner_outputs()?; + self.manifest.candidate_advisory_input_sha256 = + candidate_advisory_input_digest(&self.manifest)?; + let provider_evidence = TrustedProviderEvidence { + schema: "radroots.advisory-provider-execution-evidence.v1".to_owned(), + candidate_generation: self.request.candidate.generation, + candidate_digest: self.request.candidate.digest.clone(), + platform: "macos_aarch64".to_owned(), + producer_request_sha256: producer_request, + candidate_advisory_input_sha256: self.manifest.candidate_advisory_input_sha256.clone(), + gradle_projection: self.manifest.gradle_projection.clone(), + provider_snapshot: self.manifest.provider_snapshot.clone(), + process_receipt: self.process_receipts.clone(), + suppressions: self.manifest.suppressions.clone(), + }; + self.request.provider_execution_evidence = canonical_authority_bytes(&provider_evidence)?; + let value = serde_json::to_value(&self.manifest) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + std::fs::write(self.root().join(MANIFEST_NAME), canonical_json(&value)) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)) + } + + fn refresh_admitted_scanner_outputs(&mut self) -> Result<(), AdvisoryError> { + self.scanner_generation = self + .scanner_generation + .checked_add(1) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let generation_root = self + .scanner_fixture_root + .path() + .join(format!("generation-{}", self.scanner_generation)); + std::fs::create_dir(&generation_root) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + set_private_directory_mode(&generation_root)?; + let mut admitted = Vec::with_capacity(WORKLOADS.len()); + for provider in [ProviderId::Rustsec, ProviderId::OwaspNvd] { + let provider_root = generation_root.join(provider.as_str()); + std::fs::create_dir(&provider_root) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + set_private_directory_mode(&provider_root)?; + let report = self.read_report_value(provider)?; + let rows = report["workload_result"] + .as_array() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + for row in rows { + let workload_id = required_text(row.get("workload_id"))?; + let raw = row + .get("raw_scanner_output") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let workload_root = provider_root.join(workload_id); + std::fs::create_dir(&workload_root) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + set_private_directory_mode(&workload_root)?; + let output = workload_root.join(RAW_SCANNER_OUTPUT_NAME); + std::fs::write(&output, raw.as_bytes()) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + set_private_file_mode(&output)?; + admitted.push(admit_raw_scanner_output( + &workload_root, + provider, + workload_id, + )?); + } + } + self.request.admitted_scanner_outputs = admitted; + Ok(()) + } + + fn refresh_gradle_receipts(&mut self) -> Result<(), AdvisoryError> { + let source = self + .request + .sources + .iter() + .find(|source| source.repository == "oss/harvestcircle") + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + for projection in &mut self.manifest.gradle_projection { + let inventory = self + .request + .inventory + .iter() + .find(|row| row.id == projection.workload_id) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + projection.input_sha256 = + gradle_candidate_input_digest(&self.request, inventory, source, projection)?; + let receipt = self + .process_receipts + .iter_mut() + .find(|receipt| receipt.id == format!("gradle:{}", projection.workload_id)) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + receipt.input_sha256 = gradle_process_input_digest(projection)?; + receipt.output_sha256 = gradle_process_output_digest(projection)?; + receipt.path_binding = vec![ + ProcessPathBinding { + argument_index: 5, + logical_role: "gradle_build_root".to_owned(), + identity_sha256: gradle_build_root_binding_digest(projection)?, + }, + ProcessPathBinding { + argument_index: 7, + logical_role: "gradle_init_script".to_owned(), + identity_sha256: projection.init_script_sha256.clone(), + }, + ProcessPathBinding { + argument_index: 10, + logical_role: "gradle_projection_output".to_owned(), + identity_sha256: projection.raw_graph_sha256.clone(), + }, + ]; + projection.process_receipt_sha256 = process_receipt_digest(receipt)?; + } + Ok(()) + } + + fn set_rustsec_finding( + &mut self, + advisory_id: &str, + package: &str, + version: &str, + ) -> Result<(), AdvisoryError> { + let mut report = self.read_report_value(ProviderId::Rustsec)?; + let raw = report["workload_result"][0]["raw_scanner_output"] + .as_str() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let mut raw: Value = serde_json::from_str(raw) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + raw["vulnerabilities"] = serde_json::json!({ + "count": 1, + "found": true, + "list": [{ + "advisory": { + "aliases": [], + "categories": [], + "collection": "crates", + "cvss": null, + "date": "2099-01-01", + "description": "synthetic known-vulnerable RustSec fixture", + "expect-deleted": false, + "id": advisory_id, + "informational": null, + "keywords": [], + "license": "CC0-1.0", + "package": package, + "references": [], + "related": [], + "source": "registry+https://github.com/rust-lang/crates.io-index", + "title": "synthetic known-vulnerable fixture", + "url": null, + "withdrawn": null + }, + "affected": null, + "package": { + "checksum": null, + "name": package, + "replace": null, + "source": "registry+https://github.com/rust-lang/crates.io-index", + "version": version + }, + "versions": {"patched": [], "unaffected": []} + }] + }); + set_raw_output(&mut report["workload_result"][0], &raw, 1)?; + self.replace_report(ProviderId::Rustsec, report) + } + + fn set_owasp_finding( + &mut self, + advisory_id: &str, + package_namespace: &str, + package: &str, + version: &str, + ) -> Result<(), AdvisoryError> { + let mut report = self.read_report_value(ProviderId::OwaspNvd)?; + let raw = report["workload_result"][0]["raw_scanner_output"] + .as_str() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let mut raw: Value = serde_json::from_str(raw) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let dependency = raw["dependencies"] + .as_array_mut() + .and_then(|rows| rows.first_mut()) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + dependency["packages"] = serde_json::json!([{ + "id": format!("pkg:maven/{package_namespace}/{package}@{version}") + }]); + let matched_cpe = + format!("cpe:2.3:a:{package_namespace}:{package}:{version}:*:*:*:*:*:*:*"); + dependency["vulnerabilityIds"] = serde_json::json!([{ + "id": matched_cpe + }]); + dependency["vulnerabilities"] = serde_json::json!([{ + "description": "synthetic known-vulnerable fixture", + "name": advisory_id, + "notes": "synthetic unsuppressed scanner output", + "references": [], + "source": "NVD", + "vulnerableSoftware": [{"software": {"id": matched_cpe}}] + }]); + set_raw_output(&mut report["workload_result"][0], &raw, 0)?; + self.replace_report(ProviderId::OwaspNvd, report) + } + + fn read_report_value(&self, provider: ProviderId) -> Result<Value, AdvisoryError> { + let name = report_name(provider); + let bytes = std::fs::read(self.root().join(name)) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + serde_json::from_slice(&bytes) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport)) + } + + fn replace_report( + &mut self, + provider: ProviderId, + mut report: Value, + ) -> Result<(), AdvisoryError> { + self.refresh_report_receipts(provider, &mut report)?; + let bytes = canonical_json(&report); + let name = report_name(provider); + std::fs::write(self.root().join(name), &bytes) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let snapshot = self + .manifest + .provider_snapshot + .iter_mut() + .find(|snapshot| snapshot.provider == provider) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + snapshot.report = blob(name, &bytes)?; + self.seal() + } + + fn refresh_report_receipts( + &mut self, + provider: ProviderId, + report: &mut Value, + ) -> Result<(), AdvisoryError> { + let rows = report["workload_result"] + .as_array_mut() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + for row in rows { + let workload_id = required_text(row.get("workload_id"))?; + let prefix = match provider { + ProviderId::Rustsec => "cargo_audit", + ProviderId::OwaspNvd => "owasp_analysis", + }; + let receipt = self + .process_receipts + .iter_mut() + .find(|receipt| receipt.id == format!("{prefix}:{workload_id}")) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let raw = row + .get("raw_scanner_output") + .and_then(Value::as_str) + .filter(|raw| !raw.is_empty() && raw.len() <= MAX_REPORT_BYTES as usize) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + receipt.exit_code = row["exit_code"] + .as_i64() + .and_then(|value| i32::try_from(value).ok()) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + if provider == ProviderId::Rustsec { + receipt.stdout_byte_length = raw.len() as u64; + receipt.stdout_sha256 = sha256(raw.as_bytes()); + receipt.output_sha256 = receipt.stdout_sha256.clone(); + } else { + receipt.stdout_byte_length = 0; + receipt.stdout_sha256 = sha256(&[]); + receipt.output_sha256 = sha256(raw.as_bytes()); + let report_binding = receipt + .path_binding + .iter_mut() + .find(|binding| binding.logical_role == "raw_report_output") + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + report_binding.identity_sha256 = sha256(raw.as_bytes()); + } + row["process_receipt_sha256"] = Value::from(process_receipt_digest(receipt)?); + } + Ok(()) + } +} + +fn expected_tool_acquisitions() -> Result<Vec<ToolAcquisition>, AdvisoryError> { + let decision: Value = serde_json::from_slice(DECISION_BYTES) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))?; + let acquisitions = decision + .get("tool_acquisition") + .and_then(Value::as_object) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))?; + TOOL_PINS + .iter() + .map(|pin| { + acquisitions + .get(pin.id) + .cloned() + .map(|projection| ToolAcquisition { + id: pin.id.to_owned(), + projection, + }) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract)) + }) + .collect() +} + +fn synthetic_sources() -> Vec<SourceIdentity> { + CANDIDATE_REPOSITORIES + .into_iter() + .map(|repository| SourceIdentity { + repository: repository.to_owned(), + revision: "1".repeat(40), + tree: "2".repeat(40), + }) + .collect() +} + +fn synthetic_inventory() -> Vec<WorkloadInventory> { + WORKLOADS + .iter() + .enumerate() + .map(|(index, workload)| WorkloadInventory { + id: workload.id.to_owned(), + repository: workload.repository.to_owned(), + build_root: workload.build_root.to_owned(), + package_manager: workload.package_manager.to_owned(), + language: workload.language.to_owned(), + manifest_path: workload.manifest_path.map(str::to_owned), + lockfile_path: workload.lockfile_path.map(str::to_owned), + project_path: workload.project_path.map(str::to_owned), + configuration: workload.configuration.map(str::to_owned), + dependency_count: 1, + input_sha256: format!("{:064x}", index + 1), + }) + .collect() +} + +#[allow(clippy::too_many_arguments)] +fn synthetic_provider( + provider: ProviderId, + archive: &[u8], + report: &[u8], + materialized_tree_sha256: &str, + archive_evidence: &ArchiveEvidence, + producer_request_sha256: &str, + nvd_trace: &[u8], + evaluation_epoch: u64, +) -> Result<ProviderSnapshot, AdvisoryError> { + let ( + acquisition_kind, + archive_name, + report_name, + acquisition_arguments, + analysis_environment, + analysis_arguments, + ) = match provider { + ProviderId::Rustsec => ( + "externally_admitted_immutable_snapshot", + RUSTSEC_ARCHIVE_NAME, + RUSTSEC_REPORT_NAME, + Vec::new(), + "fresh_private_config_free_workdir_home_and_cargo_home", + strings(&CARGO_AUDIT_ARGUMENTS), + ), + ProviderId::OwaspNvd => ( + "bounded_nvd_update_only", + NVD_ARCHIVE_NAME, + OWASP_REPORT_NAME, + strings(&OWASP_UPDATE_ARGUMENTS), + "replacement_allowlist_private_data_and_output", + strings(&OWASP_OFFLINE_ARGUMENTS), + ), + }; + Ok(ProviderSnapshot { + provider, + acquisition_kind: acquisition_kind.to_owned(), + acquisition_count: 1, + acquisition_state: OperationState::Complete, + acquired_at_epoch: evaluation_epoch - 2_990, + digest_time_epoch: evaluation_epoch - 2_900, + database_identity_sha256: database_identity_from_report( + provider, + report, + materialized_tree_sha256, + )?, + materialized_tree_sha256: materialized_tree_sha256.to_owned(), + bounded_deadline_seconds: ANALYSIS_DEADLINE_SECONDS, + network_mode: match provider { + ProviderId::Rustsec => "external_snapshot_admission".to_owned(), + ProviderId::OwaspNvd => "contracted_fetch_only".to_owned(), + }, + network_trace_sha256: match provider { + ProviderId::Rustsec => "0".repeat(64), + ProviderId::OwaspNvd => sha256(nvd_trace), + }, + producer_request_sha256: producer_request_sha256.to_owned(), + acquisition_arguments, + archive_format: "deterministic_tar_gzip_exact_bytes_normalized_headers_sorted_members" + .to_owned(), + archive: blob(archive_name, archive)?, + archive_expanded_bytes: archive_evidence.expanded_bytes, + archive_member_count: archive_evidence.member_count, + archive_payload_bytes: archive_evidence.payload_bytes, + analysis_state: OperationState::Complete, + analyzed_at_epoch: evaluation_epoch + - match provider { + ProviderId::Rustsec => 2_505, + ProviderId::OwaspNvd => 2_245, + }, + analysis_environment: analysis_environment.to_owned(), + analysis_arguments, + report: blob(report_name, report)?, + }) +} + +fn blob(path: &str, bytes: &[u8]) -> Result<BlobBinding, AdvisoryError> { + let (media_type, logical_role) = match path { + RUSTSEC_ARCHIVE_NAME => ("application/gzip", "rustsec_database_snapshot"), + NVD_ARCHIVE_NAME => ("application/gzip", "owasp_nvd_database_snapshot"), + RUSTSEC_REPORT_NAME => ("application/json", "rustsec_unsuppressed_report"), + OWASP_REPORT_NAME => ("application/json", "owasp_unsuppressed_report"), + _ => return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)), + }; + let digest = sha256(bytes); + Ok(BlobBinding { + path: path.to_owned(), + byte_length: bytes.len() as u64, + logical_uri: format!("extbuild-cas://sha256/{digest}"), + media_type: media_type.to_owned(), + logical_role: logical_role.to_owned(), + sha256: digest, + }) +} + +fn strings<const N: usize>(values: &[&str; N]) -> Vec<String> { + values.iter().map(|value| (*value).to_owned()).collect() +} + +fn report_name(provider: ProviderId) -> &'static str { + match provider { + ProviderId::Rustsec => RUSTSEC_REPORT_NAME, + ProviderId::OwaspNvd => OWASP_REPORT_NAME, + } +} + +fn canonical_authority_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, AdvisoryError> { + let value = serde_json::to_value(value) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + Ok(canonical_json(&value)) +} + +fn synthetic_tool_states(evaluation_epoch: u64) -> Vec<ToolState> { + TOOL_PINS + .iter() + .map(|pin| ToolState { + id: pin.id.to_owned(), + state: OperationState::Complete, + reviewed_at_epoch: evaluation_epoch - 4_100, + normalized_version: pin.version.to_owned(), + executable_sha256: pin.executable_sha256.to_owned(), + source_sha256: pin.source_sha256.to_owned(), + package_receipt_sha256: pin.receipt_sha256.to_owned(), + }) + .collect() +} + +fn synthetic_nvd_trace( + candidate: &CandidateIdentity, + producer_request_sha256: &str, +) -> NvdNetworkTrace { + let enforcement_program_sha256 = "7".repeat(64); + let enforcement_configuration_sha256 = "8".repeat(64); + NvdNetworkTrace { + schema: "radroots.advisory-nvd-network-trace.v1".to_owned(), + candidate_generation: candidate.generation, + candidate_digest: candidate.digest.clone(), + producer_request_sha256: producer_request_sha256.to_owned(), + enforcement: "deny_by_default_nvd_api_get_only_proxy".to_owned(), + enforcement_program_sha256, + enforcement_configuration_sha256, + request: vec![NvdRequestTrace { + sequence: 1, + method: "GET".to_owned(), + scheme: "https".to_owned(), + authority: "services.nvd.nist.gov".to_owned(), + path: "/rest/json/cves/2.0".to_owned(), + started_at_epoch: 997_001, + completed_at_epoch: 997_009, + query: [ + ("lastModStartDate", "1970-01-11T10:00:00Z"), + ("lastModEndDate", "1970-01-11T10:01:40Z"), + ("startIndex", "0"), + ("resultsPerPage", "2000"), + ] + .into_iter() + .map(|(name, value)| NvdQueryTrace { + name: name.to_owned(), + value: value.to_owned(), + }) + .collect(), + response_status: 200, + response_byte_length: 128, + response_sha256: "5".repeat(64), + response_start_index: 0, + response_results_per_page: 2_000, + response_total_results: 1, + }], + } +} + +fn synthetic_producer_request(candidate: &CandidateIdentity) -> TrustedProducerRequest { + TrustedProducerRequest { + schema: "radroots.advisory-producer-request.v1".to_owned(), + candidate_generation: candidate.generation, + candidate_digest: candidate.digest.clone(), + platform: "macos_aarch64".to_owned(), + process_contract_sha256: BOUNDED_PROCESS_DECISION_SHA256.to_owned(), + process_runner_source_sha256: BOUNDED_PROCESS_SOURCE_SHA256.to_owned(), + nvd_endpoint: "/rest/json/cves/2.0".to_owned(), + nvd_enforcement_program_sha256: "7".repeat(64), + nvd_enforcement_configuration_sha256: "8".repeat(64), + nvd_query_keys: [ + "lastModStartDate", + "lastModEndDate", + "startIndex", + "resultsPerPage", + ] + .into_iter() + .map(str::to_owned) + .collect(), + workload_ids: WORKLOADS + .iter() + .map(|workload| workload.id.to_owned()) + .collect(), + } +} + +#[allow(clippy::too_many_arguments)] +fn synthetic_process_receipt( + id: &str, + program_sha256: &str, + arguments: &[String], + input_sha256: &str, + output_sha256: &str, + path_binding: Vec<ProcessPathBinding>, + started_at_epoch: u64, + completed_at_epoch: u64, + exit_code: i32, +) -> Result<TrustedProcessReceipt, AdvisoryError> { + let environment = expected_process_environment(id)?; + let working_directory = expected_process_working_directory(id)?; + Ok(TrustedProcessReceipt { + id: id.to_owned(), + state: OperationState::Complete, + program_sha256: program_sha256.to_owned(), + runtime_sha256: expected_runtime_sha256(id), + arguments_sha256: domain_json_digest( + b"radroots-advisory-process-arguments-v1\0", + arguments, + )?, + environment_sha256: process_environment_digest(&environment)?, + environment, + working_directory_sha256: process_working_directory_digest(&working_directory)?, + working_directory, + path_binding, + stdin_closed: true, + deadline_seconds: ANALYSIS_DEADLINE_SECONDS, + started_at_epoch, + completed_at_epoch, + exit_code, + stdout_byte_length: 0, + stdout_sha256: sha256(&[]), + stderr_byte_length: 0, + stderr_sha256: sha256(&[]), + input_sha256: input_sha256.to_owned(), + output_sha256: output_sha256.to_owned(), + }) +} + +fn attach_gradle_receipts( + projections: &mut [GradleProjection], + receipts: &mut Vec<TrustedProcessReceipt>, + evaluation_epoch: u64, +) -> Result<(), AdvisoryError> { + for (index, projection) in projections.iter_mut().enumerate() { + let receipt = synthetic_process_receipt( + &format!("gradle:{}", projection.workload_id), + TOOL_PINS[3].executable_sha256, + &projection.wrapper_arguments, + &gradle_process_input_digest(projection)?, + &gradle_process_output_digest(projection)?, + vec![ + ProcessPathBinding { + argument_index: 5, + logical_role: "gradle_build_root".to_owned(), + identity_sha256: gradle_build_root_binding_digest(projection)?, + }, + ProcessPathBinding { + argument_index: 7, + logical_role: "gradle_init_script".to_owned(), + identity_sha256: projection.init_script_sha256.clone(), + }, + ProcessPathBinding { + argument_index: 10, + logical_role: "gradle_projection_output".to_owned(), + identity_sha256: projection.raw_graph_sha256.clone(), + }, + ], + evaluation_epoch - 2_800 + index as u64 * 10, + evaluation_epoch - 2_795 + index as u64 * 10, + 0, + )?; + projection.environment_sha256 = receipt.environment_sha256.clone(); + projection.process_receipt_sha256 = process_receipt_digest(&receipt)?; + receipts.push(receipt); + } + Ok(()) +} + +fn synthetic_gradle_projections( + admitted_graphs: &[AdmittedGradleGraph], + inventory: &[WorkloadInventory], + sources: &[SourceIdentity], +) -> Result<Vec<GradleProjection>, AdvisoryError> { + let source = sources + .iter() + .find(|source| source.repository == "oss/harvestcircle") + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + WORKLOADS + .iter() + .enumerate() + .filter(|(_, workload)| workload.package_manager == "gradle") + .zip(admitted_graphs) + .map(|((index, workload), admitted)| { + let inventory = inventory + .iter() + .find(|row| row.id == workload.id) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + admitted.revalidate()?; + Ok(GradleProjection { + workload_id: workload.id.to_owned(), + state: OperationState::Complete, + raw_graph_byte_length: admitted.raw.byte_length, + raw_graph_sha256: admitted.raw.sha256.clone(), + init_script_sha256: GRADLE_INIT_SCRIPT_SHA256.to_owned(), + wrapper_arguments: expected_gradle_arguments(workload)?, + environment_keys: [ + "GRADLE_USER_HOME", + "HOME", + "JAVA_HOME", + "LC_ALL", + "PATH", + "TMPDIR", + "TZ", + ] + .into_iter() + .map(str::to_owned) + .collect(), + environment_sha256: format!("{:064x}", index + 4_000), + exit_code: 0, + source_revision: source.revision.clone(), + source_tree: source.tree.clone(), + input_sha256: inventory.input_sha256.clone(), + dependency_count: inventory.dependency_count, + component_count: admitted.components.len() as u64, + edge_count: admitted.edges.len() as u64, + artifact_count: admitted.artifacts.len() as u64, + components: admitted.components.clone(), + edges: admitted.edges.clone(), + artifacts: admitted.artifacts.clone(), + canonical_graph_sha256: admitted.canonical_graph_sha256.clone(), + materialized_tree_sha256: admitted.materialized_tree_sha256.clone(), + normalization_receipt_sha256: admitted.normalization_receipt_sha256.clone(), + artifact_source_roots_sha256: admitted.artifact_source_roots_sha256.clone(), + seed_cache_inventory_sha256: format!("{:064x}", index + 6_000), + wrapper_distribution_sha256: + "553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746".to_owned(), + process_receipt_sha256: format!("{:064x}", index + 7_000), + }) + }) + .collect() +} + +#[allow(clippy::too_many_arguments)] +fn synthetic_report( + provider: ProviderId, + inventory: &[WorkloadInventory], + gradle_projections: &[GradleProjection], + archive_sha256: &str, + materialized_tree_sha256: &str, + tool_observation_sha256: &str, + receipts: &mut Vec<TrustedProcessReceipt>, + evaluation_epoch: u64, +) -> Result<Vec<u8>, AdvisoryError> { + let mut workload_result = Vec::new(); + for workload in WORKLOADS.iter().filter(|workload| match provider { + ProviderId::Rustsec => workload.package_manager == "cargo", + ProviderId::OwaspNvd => workload.package_manager == "gradle", + }) { + let inventory = inventory + .iter() + .find(|row| row.id == workload.id) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let raw = match provider { + ProviderId::Rustsec => serde_json::json!({ + "database": { + "advisory-count": 1, + "last-commit": "6".repeat(40), + "last-updated": format_report_epoch(evaluation_epoch - 2_900)? + }, + "lockfile": {"dependency-count": inventory.dependency_count}, + "settings": { + "ignore": [], + "informational_warnings": ["unmaintained", "unsound", "notice"], + "severity": null, + "target_arch": [], + "target_os": [] + }, + "vulnerabilities": {"count": 0, "found": false, "list": []}, + "warnings": {} + }), + ProviderId::OwaspNvd => { + let projection = gradle_projections + .iter() + .find(|projection| projection.workload_id == workload.id) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + let dependencies = projection + .artifacts + .iter() + .map(|artifact| { + serde_json::json!({ + "evidenceCollected": { + "productEvidence": [], + "vendorEvidence": [], + "versionEvidence": [] + }, + "fileName": artifact.materialized_name, + "filePath": format!( + "/private/advisory/scan/{}/{}", + workload.id, + artifact.materialized_name, + ), + "isVirtual": false, + "md5": "0".repeat(32), + "packages": [{ + "id": format!( + "pkg:maven/{}/{}@{}", + artifact.package_namespace, + artifact.package_name, + artifact.package_version, + ) + }], + "sha1": "0".repeat(40), + "sha256": artifact.artifact_sha256 + }) + }) + .collect::<Vec<_>>(); + serde_json::json!({ + "dependencies": dependencies, + "projectInfo": { + "credits": { + "CISA": "This report may contain data retrieved from the CISA Known Exploited Vulnerability Catalog: https://www.cisa.gov/known-exploited-vulnerabilities-catalog", + "NPM": "This report may contain data retrieved from the Github Advisory Database (via NPM Audit API): https://github.com/advisories/", + "NVD": "This product uses the NVD API but is not endorsed or certified by the NVD. This report contains data retrieved from the National Vulnerability Database: https://nvd.nist.gov", + "OSSINDEX": "This report may contain data retrieved from the Sonatype Guide OSS Index API: https://www.sonatype.com/products/sonatype-guide/oss-index-users", + "RETIREJS": "This report may contain data retrieved from the RetireJS community: https://retirejs.github.io/retire.js/" + }, + "name": workload.id, + "reportDate": format_report_epoch( + evaluation_epoch - 2_300 + workload_result.len() as u64 * 10, + )? + }, + "reportSchema": "1.1", + "scanInfo": { + "dataSource": [{ + "name": "NVD CVE Checked", + "timestamp": format_report_epoch(evaluation_epoch - 2_900)? + }], + "engineVersion": "12.2.2" + } + }) + } + }; + let raw_scanner_output = serde_json::to_string(&raw) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + let arguments = synthetic_scanner_arguments(provider, workload.id); + let prefix = match provider { + ProviderId::Rustsec => "cargo_audit", + ProviderId::OwaspNvd => "owasp_analysis", + }; + let program_sha256 = match provider { + ProviderId::Rustsec => TOOL_PINS[0].executable_sha256, + ProviderId::OwaspNvd => TOOL_PINS[1].executable_sha256, + }; + let exit_code = 0; + let database_copy = (provider == ProviderId::OwaspNvd).then(|| ScannerDatabaseCopy { + root_identity_sha256: format!("{:064x}", workload_result.len() + 30_000), + source_tree_sha256: materialized_tree_sha256.to_owned(), + pre_scan_tree_sha256: materialized_tree_sha256.to_owned(), + post_scan_tree_sha256: materialized_tree_sha256.to_owned(), + }); + let path_binding = match provider { + ProviderId::Rustsec => vec![ + ProcessPathBinding { + argument_index: 2, + logical_role: "rustsec_database".to_owned(), + identity_sha256: materialized_tree_sha256.to_owned(), + }, + ProcessPathBinding { + argument_index: 6, + logical_role: "cargo_lock".to_owned(), + identity_sha256: inventory.input_sha256.clone(), + }, + ], + ProviderId::OwaspNvd => { + let projection = gradle_projections + .iter() + .find(|projection| projection.workload_id == workload.id) + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; + vec![ + ProcessPathBinding { + argument_index: 2, + logical_role: "nvd_database".to_owned(), + identity_sha256: scanner_database_copy_digest( + database_copy.as_ref().ok_or_else(|| { + AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot) + })?, + )?, + }, + ProcessPathBinding { + argument_index: 4, + logical_role: "gradle_scan_projection".to_owned(), + identity_sha256: projection.materialized_tree_sha256.clone(), + }, + ProcessPathBinding { + argument_index: 10, + logical_role: "raw_report_output".to_owned(), + identity_sha256: sha256(raw_scanner_output.as_bytes()), + }, + ] + } + }; + let timing_offset = match provider { + ProviderId::Rustsec => 2_600, + ProviderId::OwaspNvd => 2_300, + }; + let started_at_epoch = evaluation_epoch - timing_offset + workload_result.len() as u64 * 10; + let mut receipt = synthetic_process_receipt( + &format!("{prefix}:{}", workload.id), + program_sha256, + &arguments, + &scanner_process_input_digest_fields( + provider, + workload.id, + &inventory.input_sha256, + inventory.dependency_count, + archive_sha256, + materialized_tree_sha256, + &database_copy + .as_ref() + .map(scanner_database_copy_digest) + .transpose()? + .unwrap_or_else(|| "none".to_owned()), + tool_observation_sha256, + )?, + &sha256(raw_scanner_output.as_bytes()), + path_binding, + started_at_epoch, + started_at_epoch + 5, + exit_code, + )?; + if provider == ProviderId::Rustsec { + receipt.stdout_byte_length = raw_scanner_output.len() as u64; + receipt.stdout_sha256 = sha256(raw_scanner_output.as_bytes()); + } + let process_receipt_sha256 = process_receipt_digest(&receipt)?; + let environment_sha256 = receipt.environment_sha256.clone(); + receipts.push(receipt); + workload_result.push(WorkloadResultFixture { + workload_id: workload.id.to_owned(), + input_sha256: inventory.input_sha256.clone(), + dependency_count: inventory.dependency_count, + provider_archive_sha256: archive_sha256.to_owned(), + materialized_tree_sha256: materialized_tree_sha256.to_owned(), + tool_observation_sha256: tool_observation_sha256.to_owned(), + arguments, + environment_sha256, + process_receipt_sha256, + database_copy, + exit_code, + raw_output_byte_length: raw_scanner_output.len() as u64, + raw_output_sha256: sha256(raw_scanner_output.as_bytes()), + raw_scanner_output, + }); + } + let value = serde_json::json!({ + "provider": provider, + "schema": "radroots.advisory-unsuppressed-report.v1", + "workload_result": workload_result + }); + serde_json::from_value::<ReportEnvelope>(value.clone()) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + Ok(canonical_json(&value)) +} + +#[derive(Serialize)] +struct WorkloadResultFixture { + workload_id: String, + input_sha256: String, + dependency_count: u64, + provider_archive_sha256: String, + materialized_tree_sha256: String, + tool_observation_sha256: String, + arguments: Vec<String>, + environment_sha256: String, + process_receipt_sha256: String, + database_copy: Option<ScannerDatabaseCopy>, + exit_code: i32, + raw_output_byte_length: u64, + raw_output_sha256: String, + raw_scanner_output: String, +} + +fn synthetic_scanner_arguments(provider: ProviderId, workload_id: &str) -> Vec<String> { + match provider { + ProviderId::Rustsec => [ + "audit".to_owned(), + "--db".to_owned(), + "/private/advisory/rustsec-db".to_owned(), + "--no-fetch".to_owned(), + "--json".to_owned(), + "--file".to_owned(), + format!("/private/advisory/locks/{workload_id}/Cargo.lock"), + ] + .into_iter() + .collect(), + ProviderId::OwaspNvd => { + let mut arguments = strings(&OWASP_OFFLINE_ARGUMENTS); + arguments[2] = "/private/advisory/nvd-data".to_owned(); + arguments[4] = format!("/private/advisory/scan/{workload_id}"); + arguments[6] = workload_id.to_owned(); + arguments[10] = format!("/private/advisory/report/{workload_id}"); + arguments + } + } +} + +fn set_raw_output(result: &mut Value, raw: &Value, exit_code: i32) -> Result<(), AdvisoryError> { + let raw = serde_json::to_string(raw) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; + result["exit_code"] = Value::from(exit_code); + result["raw_output_byte_length"] = Value::from(raw.len() as u64); + result["raw_output_sha256"] = Value::from(sha256(raw.as_bytes())); + result["raw_scanner_output"] = Value::from(raw); + Ok(()) +} + +fn synthetic_archive_tree_digest( + archive: &[u8], + materialization_parent: &Path, +) -> Result<(String, ArchiveEvidence), AdvisoryError> { + let source = tempfile::tempdir() + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + std::fs::write(source.path().join("snapshot.tar.gz"), archive) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let snapshot = safe_artifact_io::traverse_regular_files( + source.path(), + TraversalLimits { + max_entries: 1, + max_files: 1, + max_total_bytes: archive.len() as u64, + max_file_bytes: archive.len() as u64, + max_depth: 1, + max_path_bytes: 64, + }, + &[], + ) + .map_err(map_archive_error)?; + let file = snapshot + .files() + .first() + .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::ArchiveRejected))?; + let materialized = snapshot + .materialize_deterministic_tar_gzip(file, materialization_parent, archive_limits()) + .map_err(map_archive_error)?; + let digest = materialized_tree_digest(materialized.snapshot())?; + let evidence = materialized.evidence().clone(); + materialized.revalidate().map_err(map_binding_error)?; + snapshot.revalidate().map_err(map_binding_error)?; + Ok((digest, evidence)) +} + +fn deterministic_archive(payload: &[u8]) -> Result<Vec<u8>, AdvisoryError> { + use flate2::{Compression, GzBuilder}; + use tar::{Builder, EntryType, Header}; + + let encoder = GzBuilder::new() + .mtime(0) + .operating_system(255) + .write(Vec::new(), Compression::new(6)); + let mut builder = Builder::new(encoder); + let mut directory = Header::new_gnu(); + directory + .set_path("snapshot/") + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + directory.set_entry_type(EntryType::Directory); + directory.set_mode(0o755); + directory.set_uid(0); + directory.set_gid(0); + directory.set_mtime(0); + directory.set_size(0); + directory.set_cksum(); + builder + .append(&directory, &[][..]) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let mut header = Header::new_gnu(); + header + .set_path("snapshot/data.json") + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + header.set_entry_type(EntryType::Regular); + header.set_mode(0o644); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_size(payload.len() as u64); + header.set_cksum(); + builder + .append(&header, payload) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + let encoder = builder + .into_inner() + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; + encoder + .finish() + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)) +} + +fn trusted_tempdir(prefix: &str) -> Result<tempfile::TempDir, AdvisoryError> { + let current = std::env::current_dir() + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; + tempfile::Builder::new() + .prefix(prefix) + .tempdir_in(current) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable)) +} + +fn set_private_directory_mode(path: &Path) -> Result<(), AdvisoryError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable)) + } + #[cfg(not(unix))] + { + let _ = path; + Err(AdvisoryError::new(AdvisoryFailureKind::Unavailable)) + } +} + +fn set_private_file_mode(path: &Path) -> Result<(), AdvisoryError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable)) + } + #[cfg(not(unix))] + { + let _ = path; + Err(AdvisoryError::new(AdvisoryFailureKind::Unavailable)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_vulnerable_fixture() { + let fixture = SyntheticFixture::new().expect("construct baseline advisory fixture"); + let provider: TrustedProviderEvidence = + parse_canonical_authority(&fixture.request.provider_execution_evidence) + .expect("parse provider evidence"); + validate_process_receipts(&provider.process_receipt, fixture.request.evaluation_epoch) + .expect("validate process receipts"); + validate_trusted_authority(&fixture.request).expect("validate trusted authority"); + validate_request(&fixture.request).expect("validate baseline request"); + validate_manifest(&fixture.manifest, &fixture.request).expect("validate baseline manifest"); + admit_snapshot( + fixture.root(), + fixture.materialization_parent.path(), + &fixture.request, + ) + .expect("admit baseline advisory fixture"); + + let mut fixture = SyntheticFixture::new().expect("construct vulnerable fixture"); + fixture + .set_rustsec_finding(NON_WAIVABLE_RUSTSEC, "lru", "0.16.4") + .expect("install known vulnerability"); + let error = admit_snapshot( + fixture.root(), + fixture.materialization_parent.path(), + &fixture.request, + ) + .expect_err("known vulnerability must fail closed"); + assert_eq!(error.kind(), AdvisoryFailureKind::KnownVulnerability); + } + + #[test] + fn missing_inventory_is_rejected() { + missing_inventory_vector().expect("missing inventory must fail closed"); + } + + #[test] + fn unavailable_provider_is_nonpass() { + unavailable_provider_vector().expect("unavailable provider must be non-pass"); + } + + #[test] + fn stale_snapshot_is_rejected() { + stale_snapshot_vector().expect("stale snapshot must fail closed"); + } + + #[test] + fn timed_out_operation_is_nonpass() { + timed_out_operation_vector().expect("timed-out operation must be non-pass"); + } + + #[test] + fn expired_suppression_is_rejected() { + expired_suppression_vector().expect("expired suppression must fail closed"); + } +} diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -7,6 +7,7 @@ // advisory scanners, SBOM generators, and target checks. They are exercised // by their dedicated release gates and must not recursively execute inside // xtask's unit-coverage process. +mod advisory_snapshot; #[cfg_attr(coverage_nightly, coverage(off))] mod api_qualification; #[cfg_attr(coverage_nightly, coverage(off))] @@ -65,6 +66,12 @@ struct Cli { #[derive(Debug, Subcommand)] enum XtaskCommand { + AdvisorySnapshotSelfTest, + #[command(hide = true)] + AdvisorySnapshotFixtureScanner { + #[arg(long, default_value = "report")] + mode: String, + }, Architecture, ArchitectureCi, ArchitectureSourceExportCi, @@ -304,6 +311,7 @@ fn usage() { eprintln!(" cargo xtask architecture"); eprintln!(" cargo xtask architecture-ci"); eprintln!(" cargo xtask architecture-source-export-ci"); + eprintln!(" cargo xtask advisory-snapshot-self-test"); eprintln!(" cargo xtask bounded-process-self-test"); eprintln!(" cargo xtask safe-artifact-io-self-test"); eprintln!(" cargo xtask check-api-boundaries"); @@ -447,7 +455,8 @@ fn release_preflight_at(root: &Path) -> Result<(), String> { LaneId::DtoRoots => dto_roots::check(root), LaneId::ProtocolFreshness => generate::protocol::check(root), LaneId::ArtifactContracts => contract::validate_artifact_contracts(root), - LaneId::ReleaseContracts => contract::validate_release_preflight(root), + LaneId::ReleaseContracts => advisory_snapshot::validate_decision(root) + .and_then(|_| contract::validate_release_preflight(root)), }; if result.is_ok() { LaneState::Pass @@ -503,6 +512,10 @@ fn run(args: &[String]) -> Result<(), String> { let cli = Cli::try_parse_from(std::iter::once("xtask").chain(args.iter().map(String::as_str))) .map_err(|error| error.to_string())?; match cli.command { + XtaskCommand::AdvisorySnapshotSelfTest => advisory_snapshot::self_test(), + XtaskCommand::AdvisorySnapshotFixtureScanner { mode } => { + advisory_snapshot::write_fixture_scanner_output(&mode) + } XtaskCommand::Architecture => architecture::validate(&workspace_root()), XtaskCommand::ArchitectureCi => { catalog::check(&workspace_root())?; diff --git a/tools/xtask/src/safe_artifact_io.rs b/tools/xtask/src/safe_artifact_io.rs @@ -24,6 +24,9 @@ const HARD_MAX_ARCHIVE_EXPANDED_BYTES: u64 = 17_179_869_184; const HARD_MAX_ARCHIVE_MEMBERS: u64 = 65_536; const HARD_MAX_ARCHIVE_MEMBER_BYTES: u64 = 17_179_869_184; const HARD_MAX_ARCHIVE_PAYLOAD_BYTES: u64 = 17_179_869_184; +const TAR_BLOCK_BYTES: usize = 512; +const TAR_TERMINATOR_BYTES: usize = TAR_BLOCK_BYTES * 2; +const CANONICAL_GZIP_COMPRESSION_LEVEL: u32 = 6; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum LimitKind { @@ -122,6 +125,70 @@ pub(crate) struct ArchiveEvidence { pub(crate) payload_bytes: u64, } +#[derive(Debug)] +pub(crate) struct MaterializedArchive { + root_descriptor: File, + root_identity: OutputDirectoryIdentity, + root_chain: Vec<DirectoryIdentity>, + parent_descriptor: File, + parent_identity: OutputDirectoryIdentity, + parent_path: PathBuf, + parent_chain: Vec<OutputDirectoryIdentity>, + snapshot: TraversalSnapshot, + evidence: ArchiveEvidence, + directory: tempfile::TempDir, +} + +impl MaterializedArchive { + pub(crate) fn root(&self) -> &Path { + self.directory.path() + } + + pub(crate) fn evidence(&self) -> &ArchiveEvidence { + &self.evidence + } + + pub(crate) fn snapshot(&self) -> &TraversalSnapshot { + &self.snapshot + } + + pub(crate) fn revalidate(&self) -> Result<(), ArtifactIoError> { + let changed = || ArtifactIoError::new(ArtifactIoFailureKind::ChangedDuringRead); + if output_directory_identity(identity(&self.parent_descriptor).map_err(|_| changed())?) + != self.parent_identity + { + return Err(ArtifactIoError::new( + ArtifactIoFailureKind::ChangedDuringRead, + )); + } + if output_directory_identity(identity(&self.root_descriptor).map_err(|_| changed())?) + != self.root_identity + { + return Err(ArtifactIoError::new( + ArtifactIoFailureKind::ChangedDuringRead, + )); + } + let (current_root, current_root_chain) = + open_absolute_directory(self.root()).map_err(|_| changed())?; + if output_directory_identity(identity(&current_root).map_err(|_| changed())?) + != self.root_identity + || current_root_chain != self.root_chain + { + return Err(ArtifactIoError::new( + ArtifactIoFailureKind::ChangedDuringRead, + )); + } + let (_, current_parent_chain) = + open_trusted_output_directory(&self.parent_path).map_err(|_| changed())?; + if current_parent_chain != self.parent_chain { + return Err(ArtifactIoError::new( + ArtifactIoFailureKind::ChangedDuringRead, + )); + } + self.snapshot.revalidate().map_err(|_| changed()) + } +} + #[derive(Clone, Debug)] pub(crate) struct TraversedFile { relative: PathBuf, @@ -161,6 +228,12 @@ impl TraversalSnapshot { self.total_bytes } + pub(crate) fn directories(&self) -> impl Iterator<Item = (&Path, u32)> { + self.directories + .iter() + .map(|directory| (directory.relative.as_path(), directory.permission_mode)) + } + pub(crate) fn root_permission_mode(&self) -> u32 { self.directories .iter() @@ -215,6 +288,35 @@ impl TraversalSnapshot { ) } + pub(crate) fn admit_deterministic_tar_gzip( + &self, + file: &TraversedFile, + limits: TarGzipLimits, + ) -> Result<ArchiveEvidence, ArtifactIoError> { + admit_tar_gzip_relative( + &self.root, + &file.relative, + limits, + Some(&file.identity), + TarGzipPolicy::DeterministicSnapshot, + ) + } + + pub(crate) fn materialize_deterministic_tar_gzip( + &self, + file: &TraversedFile, + trusted_parent: &Path, + limits: TarGzipLimits, + ) -> Result<MaterializedArchive, ArtifactIoError> { + materialize_tar_gzip_relative( + &self.root, + &file.relative, + trusted_parent, + limits, + Some(&file.identity), + ) + } + pub(crate) fn copy_to_new_path( &self, file: &TraversedFile, @@ -983,16 +1085,32 @@ pub(crate) fn admit_tar_gzip_path( path: &Path, limits: TarGzipLimits, ) -> Result<ArchiveEvidence, ArtifactIoError> { - validate_archive_limits(limits)?; let (root, relative) = split_absolute_file(path)?; + admit_tar_gzip_relative(&root, &relative, limits, None, TarGzipPolicy::Generic) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TarGzipPolicy { + Generic, + DeterministicSnapshot, +} + +fn admit_tar_gzip_relative( + root: &Path, + relative: &Path, + limits: TarGzipLimits, + expected: Option<&FileIdentity>, + policy: TarGzipPolicy, +) -> Result<ArchiveEvidence, ArtifactIoError> { + validate_archive_limits(limits)?; with_regular( - &root, - &relative, + root, + relative, limits.max_compressed_bytes, - None, + expected, || {}, |file, admitted_length| { - let evidence = admit_tar_gzip_reader(file, limits)?; + let evidence = admit_tar_gzip_reader(file, limits, policy, None)?; require_observed_length(evidence.compressed.byte_length, admitted_length)?; Ok(evidence) }, @@ -1005,6 +1123,126 @@ pub(crate) fn admit_tar_gzip_path( }) } +fn materialize_tar_gzip_relative( + root: &Path, + relative: &Path, + trusted_parent: &Path, + limits: TarGzipLimits, + expected: Option<&FileIdentity>, +) -> Result<MaterializedArchive, ArtifactIoError> { + validate_archive_limits(limits)?; + let (parent_descriptor, parent_chain) = open_trusted_output_directory(trusted_parent)?; + let parent_identity = *parent_chain + .last() + .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?; + let mut directory_builder = tempfile::Builder::new(); + directory_builder.prefix("radroots-advisory-materialized-"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + directory_builder.permissions(fs::Permissions::from_mode(0o700)); + } + let directory = directory_builder + .tempdir_in(trusted_parent) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; + let (_, rebound_parent_chain) = open_trusted_output_directory(trusted_parent)?; + if rebound_parent_chain != parent_chain + || output_directory_identity(identity(&parent_descriptor)?) != parent_identity + { + return Err(ArtifactIoError::new( + ArtifactIoFailureKind::ChangedDuringRead, + )); + } + let (output, root_chain) = open_absolute_directory(directory.path())?; + let output_identity = identity(&output)?; + let parent_directory_chain = parent_chain + .iter() + .map(|identity| identity.directory) + .collect::<Vec<_>>(); + if root_chain.len() != parent_directory_chain.len().saturating_add(1) + || root_chain[..parent_directory_chain.len()] != parent_directory_chain + { + return Err(ArtifactIoError::new( + ArtifactIoFailureKind::ChangedDuringRead, + )); + } + #[cfg(unix)] + if output_identity.owner != rustix::process::geteuid().as_raw() + || permission_mode(output_identity) != 0o700 + { + return Err(ArtifactIoError::new( + ArtifactIoFailureKind::ChangedDuringRead, + )); + } + parent_descriptor + .sync_all() + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; + let evidence = with_regular( + root, + relative, + limits.max_compressed_bytes, + expected, + || {}, + |file, admitted_length| { + let evidence = admit_tar_gzip_reader( + file, + limits, + TarGzipPolicy::DeterministicSnapshot, + Some(&output), + )?; + require_observed_length(evidence.compressed.byte_length, admitted_length)?; + output + .sync_all() + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; + Ok(evidence) + }, + ) + .map_err(|error| match error.kind() { + ArtifactIoFailureKind::LimitExceeded(LimitKind::FileBytes) => ArtifactIoError::new( + ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveCompressedBytes), + ), + _ => error, + })?; + let materialized_snapshot = traverse_regular_files( + directory.path(), + TraversalLimits { + max_entries: limits.max_members, + max_files: limits.max_members, + max_total_bytes: limits.max_payload_bytes, + max_file_bytes: limits.max_member_bytes, + max_depth: limits.max_depth, + max_path_bytes: limits.max_path_bytes, + }, + &[], + )?; + if materialized_snapshot.entry_count() != evidence.member_count + || materialized_snapshot.total_bytes() != evidence.payload_bytes + { + return Err(ArtifactIoError::new( + ArtifactIoFailureKind::ChangedDuringRead, + )); + } + materialized_snapshot.revalidate()?; + parent_descriptor + .sync_all() + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; + let materialized = MaterializedArchive { + root_descriptor: output, + root_identity: output_directory_identity(output_identity), + root_chain, + parent_descriptor, + parent_identity, + parent_path: trusted_parent.to_owned(), + parent_chain, + snapshot: materialized_snapshot, + evidence, + directory, + }; + materialized.revalidate()?; + Ok(materialized) +} + fn validate_archive_limits(limits: TarGzipLimits) -> Result<(), ArtifactIoError> { if limits.max_compressed_bytes == 0 || limits.max_compressed_bytes > HARD_MAX_ARCHIVE_COMPRESSED_BYTES @@ -1030,6 +1268,8 @@ fn validate_archive_limits(limits: TarGzipLimits) -> Result<(), ArtifactIoError> fn admit_tar_gzip_reader( file: &mut File, limits: TarGzipLimits, + policy: TarGzipPolicy, + materialization_root: Option<&File>, ) -> Result<ArchiveEvidence, ArtifactIoError> { let expected_compressed_bytes = file .metadata() @@ -1037,13 +1277,20 @@ fn admit_tar_gzip_reader( .len(); let compressed = HashingLimitReader::new(file, limits.max_compressed_bytes); let mut buffered = BufReader::new(compressed); - validate_minimal_gzip_header(&mut buffered)?; + validate_gzip_header(&mut buffered, policy)?; let decoder = GzDecoder::new(buffered); let expanded = LimitReader::new(decoder, limits.max_expanded_bytes); let mut archive = tar::Archive::new(expanded); let mut names = BTreeMap::<Vec<u8>, ArchiveMemberKind>::new(); let mut members = 0_u64; let mut payload = 0_u64; + let mut previous_name = None::<Vec<u8>>; + let mut canonical = (policy == TarGzipPolicy::DeterministicSnapshot).then(|| { + GzBuilder::new().mtime(0).operating_system(255).write( + HashingLimitWriter::new(limits.max_compressed_bytes), + Compression::new(CANONICAL_GZIP_COMPRESSION_LEVEL), + ) + }); let parse_result = (|| { let entries = archive @@ -1081,10 +1328,34 @@ fn admit_tar_gzip_reader( }; let name = entry.path_bytes().into_owned(); validate_archive_path(&name, limits.max_depth, limits.max_path_bytes)?; - if member_kind == ArchiveMemberKind::File && name.ends_with(b"/") { + let canonical_header = if policy == TarGzipPolicy::DeterministicSnapshot { + let header = validate_deterministic_tar_header( + entry.header(), + member_kind, + &name, + declared, + )?; + if previous_name + .as_ref() + .is_some_and(|previous| previous >= &name) + { + return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); + } + previous_name = Some(name.clone()); + Some(header) + } else { + None + }; + if match member_kind { + ArchiveMemberKind::Directory => !name.ends_with(b"/"), + ArchiveMemberKind::File => name.ends_with(b"/"), + } { return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); } - admit_archive_name(&mut names, name, member_kind)?; + admit_archive_name(&mut names, &name, member_kind)?; + if let (Some(encoder), Some(header)) = (canonical.as_mut(), canonical_header.as_ref()) { + write_canonical_archive_bytes(encoder, header.as_bytes())?; + } payload = payload.checked_add(declared).ok_or_else(|| { ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( LimitKind::ArchivePayloadBytes, @@ -1095,6 +1366,10 @@ fn admit_tar_gzip_reader( LimitKind::ArchivePayloadBytes, ))); } + let mut materialized = materialization_root + .map(|root| materialize_archive_member(root, member_kind, &name)) + .transpose()? + .flatten(); let mut actual = 0_u64; let mut buffer = [0_u8; STREAM_BUFFER_BYTES]; loop { @@ -1114,12 +1389,33 @@ fn admit_tar_gzip_reader( LimitKind::ArchiveMemberBytes, ))); } + if let Some(destination) = materialized.as_mut() { + destination + .write_all(&buffer[..read]) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; + } + if let Some(encoder) = canonical.as_mut() { + write_canonical_archive_bytes(encoder, &buffer[..read])?; + } } if actual != declared { return Err(ArtifactIoError::new( ArtifactIoFailureKind::MalformedArchive, )); } + if let Some(encoder) = canonical.as_mut() { + let padding = (TAR_BLOCK_BYTES as u64 - actual % TAR_BLOCK_BYTES as u64) + % TAR_BLOCK_BYTES as u64; + write_canonical_archive_bytes( + encoder, + &[0_u8; TAR_BLOCK_BYTES][..padding as usize], + )?; + } + if let Some(destination) = materialized { + destination + .sync_all() + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; + } } Ok(()) })(); @@ -1137,6 +1433,10 @@ fn admit_tar_gzip_reader( } parse_result?; + if let Some(encoder) = canonical.as_mut() { + write_canonical_archive_bytes(encoder, &[0_u8; TAR_TERMINATOR_BYTES])?; + } + let mut trailing = [0_u8; STREAM_BUFFER_BYTES]; let mut trailing_zero_bytes = 0_u64; loop { @@ -1198,20 +1498,145 @@ fn admit_tar_gzip_reader( ArtifactIoFailureKind::MalformedArchive, )); } - let compressed_hash = compressed.finalize(); + let compressed_evidence = FileEvidence { + byte_length: compressed_bytes, + sha256: compressed.finalize(), + }; + if let Some(encoder) = canonical { + let canonical_evidence = encoder + .finish() + .map_err(|_| { + ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( + LimitKind::ArchiveCompressedBytes, + )) + })? + .finalize(); + if canonical_evidence != compressed_evidence { + return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); + } + } Ok(ArchiveEvidence { - compressed: FileEvidence { - byte_length: compressed_bytes, - sha256: compressed_hash, - }, + compressed: compressed_evidence, expanded_bytes, member_count: members, payload_bytes: payload, }) } -fn validate_minimal_gzip_header( +#[cfg(unix)] +fn materialize_archive_member( + root: &File, + kind: ArchiveMemberKind, + raw_name: &[u8], +) -> Result<Option<File>, ArtifactIoError> { + use rustix::fs::{Mode, OFlags, fchmod, mkdirat, openat}; + + let name = std::str::from_utf8(raw_name) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))? + .trim_end_matches('/'); + let path = Path::new(name); + let mut components = path.components().peekable(); + let mut current = root + .try_clone() + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; + while let Some(component) = components.next() { + let Component::Normal(component) = component else { + return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); + }; + let is_leaf = components.peek().is_none(); + if !is_leaf { + current = File::from( + openat( + &current, + component, + OFlags::RDONLY + | OFlags::DIRECTORY + | OFlags::NOFOLLOW + | OFlags::CLOEXEC + | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?, + ); + continue; + } + return match kind { + ArchiveMemberKind::Directory => { + mkdirat(&current, component, Mode::RUSR | Mode::WUSR | Mode::XUSR) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; + let directory = File::from( + openat( + &current, + component, + OFlags::RDONLY + | OFlags::DIRECTORY + | OFlags::NOFOLLOW + | OFlags::CLOEXEC + | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?, + ); + fchmod( + &directory, + Mode::RUSR + | Mode::WUSR + | Mode::XUSR + | Mode::RGRP + | Mode::XGRP + | Mode::ROTH + | Mode::XOTH, + ) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; + directory + .sync_all() + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; + current + .sync_all() + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; + Ok(None) + } + ArchiveMemberKind::File => { + let file = File::from( + openat( + &current, + component, + OFlags::WRONLY + | OFlags::CREATE + | OFlags::EXCL + | OFlags::NOFOLLOW + | OFlags::CLOEXEC + | OFlags::NONBLOCK, + Mode::RUSR | Mode::WUSR, + ) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?, + ); + fchmod(&file, Mode::RUSR | Mode::WUSR | Mode::RGRP | Mode::ROTH) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; + current + .sync_all() + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; + Ok(Some(file)) + } + }; + } + Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)) +} + +#[cfg(not(unix))] +fn materialize_archive_member( + _root: &File, + _kind: ArchiveMemberKind, + _raw_name: &[u8], +) -> Result<Option<File>, ArtifactIoError> { + Err(ArtifactIoError::new( + ArtifactIoFailureKind::UnsupportedPlatform, + )) +} + +fn validate_gzip_header( reader: &mut BufReader<HashingLimitReader<'_>>, + policy: TarGzipPolicy, ) -> Result<(), ArtifactIoError> { if reader.fill_buf().is_err() { return Err(if reader.get_ref().exceeded() { @@ -1231,9 +1656,61 @@ fn validate_minimal_gzip_header( if header[3] != 0 { return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); } + if policy == TarGzipPolicy::DeterministicSnapshot + && (header[4..8] != [0, 0, 0, 0] || header[8] != 0 || header[9] != 255) + { + return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); + } Ok(()) } +fn validate_deterministic_tar_header( + header: &tar::Header, + kind: ArchiveMemberKind, + name: &[u8], + size: u64, +) -> Result<TarHeader, ArtifactIoError> { + let path = std::str::from_utf8(name) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; + let mut expected = TarHeader::new_gnu(); + expected + .set_path(path) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; + expected.set_entry_type(match kind { + ArchiveMemberKind::Directory => tar::EntryType::Directory, + ArchiveMemberKind::File => tar::EntryType::Regular, + }); + expected.set_mode(match kind { + ArchiveMemberKind::Directory => 0o755, + ArchiveMemberKind::File => 0o644, + }); + expected.set_uid(0); + expected.set_gid(0); + expected.set_mtime(0); + expected.set_size(size); + expected.set_cksum(); + if header.as_bytes() == expected.as_bytes() { + Ok(expected) + } else { + Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)) + } +} + +fn write_canonical_archive_bytes( + encoder: &mut flate2::write::GzEncoder<HashingLimitWriter>, + bytes: &[u8], +) -> Result<(), ArtifactIoError> { + encoder.write_all(bytes).map_err(|_| { + if encoder.get_ref().exceeded() { + ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( + LimitKind::ArchiveCompressedBytes, + )) + } else { + ArtifactIoError::new(ArtifactIoFailureKind::IoFailure) + } + }) +} + fn classify_expanded_error( reader: &LimitReader<GzDecoder<BufReader<HashingLimitReader<'_>>>>, ) -> ArtifactIoError { @@ -1303,9 +1780,10 @@ fn validate_archive_path( fn admit_archive_name( names: &mut BTreeMap<Vec<u8>, ArchiveMemberKind>, - mut path: Vec<u8>, + path: &[u8], kind: ArchiveMemberKind, ) -> Result<(), ArtifactIoError> { + let mut path = path.to_vec(); if kind == ArchiveMemberKind::Directory && path.ends_with(b"/") { path.pop(); } @@ -1454,6 +1932,55 @@ impl Read for HashingLimitReader<'_> { } } +struct HashingLimitWriter { + maximum: u64, + total: u64, + exceeded: bool, + hasher: Sha256, +} + +impl HashingLimitWriter { + fn new(maximum: u64) -> Self { + Self { + maximum, + total: 0, + exceeded: false, + hasher: Sha256::new(), + } + } + + const fn exceeded(&self) -> bool { + self.exceeded + } + + fn finalize(self) -> FileEvidence { + FileEvidence { + byte_length: self.total, + sha256: hex::encode(self.hasher.finalize()), + } + } +} + +impl Write for HashingLimitWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { + let next = self + .total + .checked_add(bytes.len() as u64) + .ok_or_else(|| io::Error::other("bounded stream length overflow"))?; + if next > self.maximum { + self.exceeded = true; + return Err(io::Error::other("bounded stream limit exceeded")); + } + self.hasher.update(bytes); + self.total = next; + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + fn split_absolute_file(path: &Path) -> Result<(PathBuf, PathBuf), ArtifactIoError> { if !path.is_absolute() { return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); @@ -1497,6 +2024,11 @@ fn open_absolute_directory(path: &Path) -> Result<(File, Vec<DirectoryIdentity>) Ok((current, chain)) } +pub(crate) fn validate_trusted_output_directory(path: &Path) -> Result<(), ArtifactIoError> { + let _ = open_trusted_output_directory(path)?; + Ok(()) +} + #[cfg(unix)] fn open_trusted_output_directory( path: &Path, @@ -2667,3 +3199,216 @@ mod tests { assert!(!diagnostic.contains("secret")); } } + +#[cfg(all(test, unix))] +mod step_294_tests { + use std::fs::{self, File}; + use std::io::Write as _; + use std::os::unix::fs::PermissionsExt as _; + use std::path::{Path, PathBuf}; + + use flate2::{Compression, GzBuilder}; + use sha2::{Digest as _, Sha256}; + use tar::{Builder, EntryType, Header}; + use tempfile::TempDir; + + use super::*; + + const REVIEWED_FLATE2_LOCK: &str = concat!( + "name = \"flate2\"\n", + "version = \"1.1.9\"\n", + "source = \"registry+https://github.com/rust-lang/crates.io-index\"\n", + "checksum = \"843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c\"", + ); + const REVIEWED_MINIZ_OXIDE_LOCK: &str = concat!( + "name = \"miniz_oxide\"\n", + "version = \"0.8.9\"\n", + "source = \"registry+https://github.com/rust-lang/crates.io-index\"\n", + "checksum = \"1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316\"", + ); + const CANONICAL_ARCHIVE_BYTE_LENGTH: u64 = 147; + const CANONICAL_ARCHIVE_SHA256: &str = + "6073c70a98ff9ab610085ed74b7886811da3fe64e9ec2771464300a94befd0fb"; + + fn root(directory: &TempDir) -> PathBuf { + directory + .path() + .canonicalize() + .expect("canonical Step 294 test root") + } + + fn trusted_tempdir(prefix: &str) -> TempDir { + let current = std::env::current_dir() + .expect("Step 294 current directory") + .canonicalize() + .expect("canonical Step 294 current directory"); + open_trusted_output_directory(&current).expect("trusted Step 294 current directory"); + let mut builder = tempfile::Builder::new(); + builder + .prefix(prefix) + .permissions(fs::Permissions::from_mode(0o700)); + let directory = builder + .tempdir_in(current) + .expect("private Step 294 tempdir"); + let canonical = root(&directory); + let (descriptor, _) = + open_trusted_output_directory(&canonical).expect("trusted private Step 294 tempdir"); + let actual = identity(&descriptor).expect("Step 294 tempdir identity"); + assert_eq!(actual.owner, rustix::process::geteuid().as_raw()); + assert_eq!(permission_mode(actual), 0o700); + directory + } + + fn archive_limits() -> TarGzipLimits { + TarGzipLimits { + max_compressed_bytes: 64 * 1024, + max_expanded_bytes: 128 * 1024, + max_members: 8, + max_member_bytes: 32 * 1024, + max_payload_bytes: 64 * 1024, + max_depth: 4, + max_path_bytes: 128, + } + } + + fn write_deterministic_archive(path: &Path, payload: &[u8], extra_terminator: bool) { + let output = File::create(path).expect("Step 294 archive output"); + let encoder = GzBuilder::new() + .mtime(0) + .operating_system(255) + .write(output, Compression::new(CANONICAL_GZIP_COMPRESSION_LEVEL)); + let mut builder = Builder::new(encoder); + + let mut directory = Header::new_gnu(); + directory + .set_path("snapshot/") + .expect("Step 294 directory path"); + directory.set_entry_type(EntryType::Directory); + directory.set_mode(0o755); + directory.set_uid(0); + directory.set_gid(0); + directory.set_mtime(0); + directory.set_size(0); + directory.set_cksum(); + builder + .append(&directory, &[][..]) + .expect("Step 294 directory member"); + + let mut file = Header::new_gnu(); + file.set_path("snapshot/data.json") + .expect("Step 294 file path"); + file.set_entry_type(EntryType::Regular); + file.set_mode(0o644); + file.set_uid(0); + file.set_gid(0); + file.set_mtime(0); + file.set_size(payload.len() as u64); + file.set_cksum(); + builder + .append(&file, payload) + .expect("Step 294 file member"); + + let mut encoder = builder.into_inner().expect("finish Step 294 tar"); + if extra_terminator { + encoder + .write_all(&[0_u8; TAR_BLOCK_BYTES]) + .expect("append noncanonical tar terminator"); + } + encoder.finish().expect("finish Step 294 gzip"); + } + + #[test] + fn canonical_tar_gzip_is_exactly_reencoded_before_admission() { + let directory = trusted_tempdir("radroots-step-294-source-"); + let root = root(&directory); + let canonical = root.join("canonical.tar.gz"); + let noncanonical = root.join("noncanonical.tar.gz"); + let payload = br#"{"schema":"radroots.step-294.test.v1"}"#; + write_deterministic_archive(&canonical, payload, false); + write_deterministic_archive(&noncanonical, payload, true); + + let evidence = admit_tar_gzip_relative( + &root, + Path::new("canonical.tar.gz"), + archive_limits(), + None, + TarGzipPolicy::DeterministicSnapshot, + ) + .expect("exact canonical archive admitted"); + let cargo_lock = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../Cargo.lock")); + assert_eq!(cargo_lock.matches(REVIEWED_FLATE2_LOCK).count(), 1); + assert_eq!(cargo_lock.matches(REVIEWED_MINIZ_OXIDE_LOCK).count(), 1); + assert_eq!( + evidence.compressed.byte_length, + CANONICAL_ARCHIVE_BYTE_LENGTH + ); + assert_eq!(evidence.compressed.sha256, CANONICAL_ARCHIVE_SHA256); + assert_eq!(evidence.member_count, 2); + assert_eq!(evidence.payload_bytes, payload.len() as u64); + assert_eq!( + evidence.compressed.sha256, + hex::encode(Sha256::digest( + fs::read(&canonical).expect("canonical bytes") + )) + ); + + admit_tar_gzip_path(&noncanonical, archive_limits()) + .expect("generic parser accepts safe extended terminator"); + assert_eq!( + admit_tar_gzip_relative( + &root, + Path::new("noncanonical.tar.gz"), + archive_limits(), + None, + TarGzipPolicy::DeterministicSnapshot, + ) + .expect_err("byte-noncanonical archive rejected") + .kind(), + ArtifactIoFailureKind::InvalidObject + ); + } + + #[test] + fn materialization_retains_exact_member_and_parent_bindings() { + let directory = trusted_tempdir("radroots-step-294-source-"); + let source_root = root(&directory); + let archive = source_root.join("canonical.tar.gz"); + let payload = br#"{"snapshot":"immutable"}"#; + write_deterministic_archive(&archive, payload, false); + + let parent = trusted_tempdir("radroots-step-294-materialization-"); + let parent_root = root(&parent); + let materialized = materialize_tar_gzip_relative( + &source_root, + Path::new("canonical.tar.gz"), + &parent_root, + archive_limits(), + None, + ) + .expect("descriptor-bound materialization"); + let snapshot = materialized.snapshot(); + assert_eq!(snapshot.entry_count(), 2); + assert_eq!(snapshot.files().len(), 1); + assert_eq!(snapshot.total_bytes(), payload.len() as u64); + assert_eq!( + snapshot + .hash(&snapshot.files()[0], payload.len() as u64) + .expect("bound member hash") + .sha256, + hex::encode(Sha256::digest(payload)) + ); + materialized + .revalidate() + .expect("unchanged materialization"); + + fs::write(materialized.root().join("snapshot/data.json"), b"changed") + .expect("mutate materialized member"); + assert_eq!( + materialized + .revalidate() + .expect_err("member mutation invalidates materialization") + .kind(), + ArtifactIoFailureKind::ChangedDuringRead + ); + } +} diff --git a/tools/xtask/tests/services_hardening_advisory_snapshot_decision.rs b/tools/xtask/tests/services_hardening_advisory_snapshot_decision.rs @@ -0,0 +1,282 @@ +#![forbid(unsafe_code)] + +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +const DECISION: &str = include_str!( + "../../../contracts/architecture/decisions/services_hardening_advisory_snapshots.v1.json" +); +const DECISION_SHA256: &str = "0eb19c747a160728f256329a36bd4000deca0fb433d4971f4472f6f792905212"; + +fn decision() -> Value { + serde_json::from_str(DECISION).expect("advisory-snapshot decision must be valid JSON") +} + +#[test] +fn authority_scope_and_complete_file_are_exact() { + let value = decision(); + assert_eq!(value.as_object().map(serde_json::Map::len), Some(19)); + assert_eq!( + hex::encode(Sha256::digest(DECISION.as_bytes())), + DECISION_SHA256 + ); + assert_eq!( + value["schema"], + "radroots.services-hardening.advisory-snapshot-decisions.v1" + ); + assert_eq!(value["contract_version"], 1); + assert_eq!(value["decision_state"], "active"); + assert_eq!(value["owner"], "tools/xtask"); + assert_eq!( + value["self_test_command"], + "cargo xtask advisory-snapshot-self-test" + ); + assert_eq!( + value["platform_scope"], + serde_json::json!({ + "implementation": ["macos_aarch64"], + "source_gate": ["macos_aarch64"], + "cross_platform_promotion_owner": "step-297" + }) + ); + assert_eq!( + value["scope"], + serde_json::json!({ + "claims": [ + "closed_advisory_workload_inventory", + "exact_step_287_tool_acquisition_binding", + "one_external_immutable_rustsec_snapshot_per_candidate", + "one_bounded_nvd_only_update_per_candidate", + "offline_no_update_analysis", + "digest_time_freshness", + "owned_exact_expiring_suppressions", + "descriptor_admitted_actual_scanner_outputs", + "deterministic_byte_canonical_snapshot_archives" + ], + "nonclaims": [ + "live_rustsec_snapshot_acquisition", + "live_nvd_update_or_provider_access_at_step_294", + "scanner_or_provider_current_availability_before_step_297", + "candidate_or_generation_instance_created", + "step_314_advisory_qualification_or_pass_evidence", + "oss_index_or_credential_authority", + "cross_platform_runtime_promotion_before_step_297", + "artifact_nix_oci_signing_notarization_publication_deployment_or_production_qualification" + ] + }) + ); +} + +#[test] +fn workload_inventory_and_tool_pins_are_exact() { + let value = decision(); + let cargo_ids = value["workload_inventory"]["cargo"] + .as_array() + .expect("Cargo inventory") + .iter() + .map(|row| row["id"].as_str().expect("Cargo workload id")) + .collect::<Vec<_>>(); + assert_eq!( + cargo_ids, + [ + "cli", + "harvest_core", + "harvest_xtask", + "ios_ffi", + "lib", + "myc", + "radrootsd", + "rhi", + "root", + "sdk" + ] + ); + let gradle_rows = value["workload_inventory"]["gradle_kotlin"] + .as_array() + .expect("Gradle inventory"); + assert_eq!( + gradle_rows + .iter() + .map(|row| ( + row["id"].as_str().expect("Gradle workload id"), + row["build_root"].as_str().expect("Gradle build root"), + row["project_path"].as_str().expect("Gradle project path"), + row["configuration"].as_str().expect("Gradle configuration") + )) + .collect::<Vec<_>>(), + [ + ( + "app_design_system", + ".", + ":app:design_system", + "desktopRuntimeClasspath" + ), + ("app_desktop", ".", ":app:desktop", "runtimeClasspath"), + ("app_shared", ".", ":app:shared", "desktopRuntimeClasspath"), + ( + "tools_design_catalog", + ".", + ":tools:design_catalog", + "desktopRuntimeClasspath" + ), + ( + "build_logic_contracts", + "build-logic", + ":contracts", + "runtimeClasspath" + ), + ( + "build_logic_plugins", + "build-logic", + ":plugins", + "runtimeClasspath" + ) + ] + ); + for (id, version, executable, source) in [ + ( + "cargo_audit", + "0.22.1", + "c5cd7c0da8a9d0dff338aa1a2a30b0c723fde8201c23481f49e75be0bb77fe74", + "2f4e27b0ab2d116c87c29db159ad42565cdcdccf77eb62ef0486ddd017a02da6", + ), + ( + "owasp_dependency_check", + "12.2.2", + "d683a49ec335eeca93d8707f3e8ce21d7ba63a1e619a325c6518f89c25efcdc4", + "bf07fefd81af3094c5f6850423b014df44db62ce2dbad0f79079a90df675e44a", + ), + ( + "java", + "21.0.12.1", + "9be1d0a740ff6502df1a762145e62860f5de4b7e17658d9cb9498da3acf9d16c", + "575bb8d9d604821d8f350325b28a35e49bcffd7ec33727b41edc8d709537dada", + ), + ( + "gradle_wrapper", + "9.5.0", + "ab5c0cad16305af2e619c159c1f58dd68d07fab9c11e36701e109c0277407f7a", + "553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746", + ), + ] { + let row = &value["tool_acquisition"][id]["platform"][0]; + assert_eq!(row["normalized_version"], version); + assert_eq!( + row["package_receipt_projection"]["selected_executable"]["exact_bytes_sha256"], + executable + ); + assert_eq!(row["source_sha256"], source); + } +} + +#[test] +fn immutable_archives_reports_and_execution_bounds_are_exact() { + let value = decision(); + assert_eq!( + value["snapshot_model"]["archive_reencoding_backend"], + serde_json::json!({ + "compression_level": 6, + "flate2": "1.1.9", + "flate2_checksum": "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c", + "miniz_oxide": "0.8.9", + "miniz_oxide_checksum": "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316", + "golden_fixture_byte_length": 147, + "golden_fixture_sha256": "6073c70a98ff9ab610085ed74b7886811da3fe64e9ec2771464300a94befd0fb" + }) + ); + assert_eq!( + value["owasp_dependency_check_nvd"]["report_template_binding"], + serde_json::json!({ + "distribution_package_receipt_sha256": "83d9a668179fb5e9ad6fd5c625a9ab58b8371a6d32b59808fc8d2973957f54a2", + "dependency_check_core_jar_relative_identity": "dependency-check-core-12.2.2.jar", + "dependency_check_core_jar_sha256": "1ebe55e542b2f4d2727380395843922381447dc8b9a4f1633e77096f47ebfb48", + "template_member": "templates/jsonReport.vsl", + "template_member_byte_length": 34639, + "template_member_sha256": "35881409102304d5a91d08566c1d0fda6fa5f9ec8810994fcdd5106aed3729b5", + "parser": "strict_exact_schema_for_report_scan_project_dependency_evidence_identifier_related_dependency_vulnerability_cvss_reference_and_vulnerable_software_objects_with_unknown_fields_rejected" + }) + ); + assert_eq!( + value["bounded_execution"], + serde_json::json!({ + "runner": "step_292_bounded_process_runner", + "environment": "replace_with_explicit_allowlist", + "stdin": "closed", + "deadline_clock": "monotonic", + "maximum_concurrent_top_level_operations": 1, + "required_process_receipts": 23, + "maximum_command_seconds": 3600, + "maximum_stdout_bytes": 67108864, + "maximum_stderr_bytes": 67108864, + "timeout_unavailable_interrupted_or_unparseable": "nonpass_without_fallback", + "exit_status_interpretation": "provider_specific_and_never_substitutes_for_complete_report_parsing", + "working_directories": "unique_opaque_identity_per_operation_with_kind_logical_uri_pre_and_post_entry_counts_and_tree_digests_cargo_audit_empty_config_free_gradle_retained_candidate_source_nvd_update_fresh_then_nonempty_and_odc_analysis_fresh_empty", + "environment_evidence": "exact_ordered_name_and_logical_role_allowlist_with_sha256_of_each_actual_replacement_value_fixed_C_and_UTC_values_shared_pinned_java_and_path_values_and_unique_private_roots_no_ambient_inheritance", + "authority_byte_limits": { + "snapshot_manifest": 67108864, + "producer_request": 1048576, + "tool_manifest": 16777216, + "tool_observation": 4194304, + "provider_execution_evidence": 67108864, + "raw_scanner_output_each": 33554432 + } + }) + ); +} + +#[test] +fn freshness_suppressions_results_and_required_vectors_are_exact() { + let value = decision(); + assert_eq!( + value["freshness"], + serde_json::json!({ + "clock": "caller_supplied_positive_utc_epoch_seconds", + "snapshot_time": "provider_digest_finalization_epoch_bound_to_the_accepted_snapshot_digest", + "future_snapshot_time": "reject", + "age_arithmetic": "checked_nonnegative_seconds", + "qualification_maximum_age_seconds": 86400, + "development_reuse_maximum_age_seconds": 604800, + "boundary": "fresh_when_age_is_less_than_or_equal_to_the_selected_maximum", + "providers_evaluated_independently": true, + "stale_or_missing_provider": "nonpass" + }) + ); + assert_eq!( + value["suppressions"], + serde_json::json!({ + "inventory": "canonical_ordered_exact_rows_bound_into_the_candidate_advisory_input_digest", + "required_fields": [ + "id", "provider", "advisory_id", "workload_id", "package_ecosystem", + "package_namespace", "package_name", "package_version", "owner", "rationale", + "created_at_epoch", "expires_at_epoch" + ], + "matching": "one_row_matches_one_finding_only_when_provider_advisory_id_workload_id_package_ecosystem_package_namespace_package_name_and_package_version_are_all_exact", + "wildcards_ranges_regex_and_lexical_patterns": "forbidden", + "owner_and_rationale": "nonempty_bounded_safe_text", + "expiry": "active_only_when_evaluation_epoch_is_strictly_less_than_expires_at_epoch", + "expiry_equality": "expired", + "future_creation_time": "reject", + "unused_duplicate_ambiguous_or_unknown_rows": "nonpass", + "non_waivable_advisory_ids": ["RUSTSEC-2026-0253"], + "non_waivable_match": "reject_even_when_a_suppression_row_is_otherwise_exact" + }) + ); + assert_eq!( + value["required_vectors"], + serde_json::json!([ + "known_vulnerable_fixture", + "missing_inventory", + "unavailable", + "stale", + "timeout", + "expired_suppression" + ]) + ); + assert_eq!( + value["result_model"]["pass"], + "both_provider_snapshots_fresh_all_sixteen_workloads_present_in_exact_order_all_scans_completed_and_every_finding_is_absent_or_exactly_suppressed" + ); + assert_eq!(value["result_model"]["unavailable"], "nonpass"); + assert_eq!(value["result_model"]["timeout"], "nonpass"); + assert_eq!(value["result_model"]["stale"], "nonpass"); +}