advisory_snapshot.rs (411222B)
1 //! Candidate-bound immutable advisory snapshot admission. 2 3 use std::collections::{BTreeMap, BTreeSet}; 4 use std::ffi::OsString; 5 use std::fmt; 6 use std::path::{Component, Path, PathBuf}; 7 use std::time::Duration; 8 9 use serde::{Deserialize, Serialize}; 10 use serde_json::Value; 11 use sha2::{Digest as _, Sha256}; 12 13 use crate::safe_artifact_io::{ 14 self, ArchiveEvidence, FileEvidence, TarGzipLimits, TraversalLimits, TraversalSnapshot, 15 TraversedFile, 16 }; 17 18 const DECISION_PATH: &str = 19 "contracts/architecture/decisions/services_hardening_advisory_snapshots.v1.json"; 20 const DECISION_BYTES: &[u8] = include_bytes!(concat!( 21 env!("CARGO_MANIFEST_DIR"), 22 "/../../contracts/architecture/decisions/services_hardening_advisory_snapshots.v1.json" 23 )); 24 const GRADLE_INIT_SCRIPT_BYTES: &[u8] = include_bytes!(concat!( 25 env!("CARGO_MANIFEST_DIR"), 26 "/assets/advisory_inventory.init.gradle" 27 )); 28 const GRADLE_INIT_SCRIPT_SHA256: &str = 29 "4c51de29877813dd679c970507602940fdf8c38a586f8d2f6d101b6a93a8b598"; 30 const MANIFEST_NAME: &str = "manifest.json"; 31 const RUSTSEC_ARCHIVE_NAME: &str = "rustsec-db.tar.gz"; 32 const RUSTSEC_REPORT_NAME: &str = "rustsec-report.json"; 33 const NVD_ARCHIVE_NAME: &str = "nvd-data.tar.gz"; 34 const OWASP_REPORT_NAME: &str = "owasp-report.json"; 35 const GRADLE_RAW_GRAPH_NAME: &str = "inventory.json"; 36 const RAW_SCANNER_OUTPUT_NAME: &str = "scanner-output.json"; 37 const SNAPSHOT_FILE_NAMES: [&str; 5] = [ 38 MANIFEST_NAME, 39 NVD_ARCHIVE_NAME, 40 OWASP_REPORT_NAME, 41 RUSTSEC_ARCHIVE_NAME, 42 RUSTSEC_REPORT_NAME, 43 ]; 44 const MAX_SNAPSHOT_MANIFEST_BYTES: u64 = 67_108_864; 45 const MAX_PRODUCER_REQUEST_BYTES: usize = 1_048_576; 46 const MAX_TOOL_MANIFEST_BYTES: usize = 16_777_216; 47 const MAX_TOOL_OBSERVATION_BYTES: usize = 4_194_304; 48 const MAX_NVD_TRACE_BYTES: usize = 16_777_216; 49 const MAX_PROVIDER_EVIDENCE_BYTES: usize = 67_108_864; 50 const MAX_REPORT_BYTES: u64 = 67_108_864; 51 const MAX_RAW_WORKLOAD_REPORT_BYTES: u64 = 33_554_432; 52 const MAX_GRADLE_GRAPH_BYTES: u64 = 8_388_608; 53 const MAX_GRADLE_ARTIFACT_BYTES: u64 = 17_179_869_184; 54 const MAX_GRADLE_COMPONENTS: usize = 65_536; 55 const MAX_GRADLE_EDGES: usize = 262_144; 56 const MAX_GRADLE_ARTIFACTS: usize = 65_536; 57 const MAX_GRADLE_VARIANTS: usize = 4_096; 58 const MAX_GRADLE_ATTRIBUTES: usize = 1_024; 59 const MAX_GRADLE_CAPABILITIES: usize = 1_024; 60 const MAX_GRADLE_REJECTED_VERSIONS: usize = 1_024; 61 const MAX_GRADLE_EXTERNAL_VARIANT_DEPTH: usize = 4; 62 const MAX_NVD_REQUESTS: usize = 1_024; 63 const MAX_NVD_RESPONSE_BYTES: u64 = 67_108_864; 64 const MAX_NVD_AGGREGATE_BYTES: u64 = 536_870_912; 65 const QUALIFICATION_FRESHNESS_SECONDS: u64 = 86_400; 66 const DEVELOPMENT_REUSE_SECONDS: u64 = 604_800; 67 const TOOL_REVIEW_SECONDS: u64 = 86_400; 68 const ANALYSIS_DEADLINE_SECONDS: u64 = 3_600; 69 const NON_WAIVABLE_RUSTSEC: &str = "RUSTSEC-2026-0253"; 70 const BOUNDED_PROCESS_DECISION_SHA256: &str = 71 "41e11fe9bfecfb946063bafcb4df7eb811abdf03b0eaf8df4bff0ccb382d9784"; 72 const BOUNDED_PROCESS_SOURCE_SHA256: &str = 73 "56b8de0c3c34b7481f0f8c792e37dfb0d9fc12a5b990287c979e89fd6989202e"; 74 75 const TOOL_PINS: [ToolPin; 4] = [ 76 ToolPin { 77 id: "cargo_audit", 78 version: "0.22.1", 79 executable_sha256: "c5cd7c0da8a9d0dff338aa1a2a30b0c723fde8201c23481f49e75be0bb77fe74", 80 source_sha256: "2f4e27b0ab2d116c87c29db159ad42565cdcdccf77eb62ef0486ddd017a02da6", 81 receipt_sha256: "74196dcade966311e535813d2e3bda2f9515186c700f3074acdc2849abce1303", 82 projection_sha256: "c945e827744d62b46e46826c8c066e3f8f354d972fa7f069d963e287b8e7b595", 83 }, 84 ToolPin { 85 id: "owasp_dependency_check", 86 version: "12.2.2", 87 executable_sha256: "d683a49ec335eeca93d8707f3e8ce21d7ba63a1e619a325c6518f89c25efcdc4", 88 source_sha256: "bf07fefd81af3094c5f6850423b014df44db62ce2dbad0f79079a90df675e44a", 89 receipt_sha256: "83d9a668179fb5e9ad6fd5c625a9ab58b8371a6d32b59808fc8d2973957f54a2", 90 projection_sha256: "1011327eeed9ed5c4835bdf8e08f5b1852cf118506563daa5ddb0a251e65fb9a", 91 }, 92 ToolPin { 93 id: "java", 94 version: "21.0.12.1", 95 executable_sha256: "9be1d0a740ff6502df1a762145e62860f5de4b7e17658d9cb9498da3acf9d16c", 96 source_sha256: "575bb8d9d604821d8f350325b28a35e49bcffd7ec33727b41edc8d709537dada", 97 receipt_sha256: "e7018157655e26443392ace45967e427aafa1dd98e2e67f94bdd04267d1d9296", 98 projection_sha256: "2e45aabf8388778d521d51e1ade0d4403fae4d656773bf547e2105dbd79ae655", 99 }, 100 ToolPin { 101 id: "gradle_wrapper", 102 version: "9.5.0", 103 executable_sha256: "ab5c0cad16305af2e619c159c1f58dd68d07fab9c11e36701e109c0277407f7a", 104 source_sha256: "553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746", 105 receipt_sha256: "a6538aaef9f5e2510cf4bbfe6d560e64ae4065e64a5c8ba0807d7448f6a89610", 106 projection_sha256: "f84dc2cb5139d080b28eaee9bc67fcce9bb9acc5e6f609fc00f247b1dd282ef8", 107 }, 108 ]; 109 110 const CANDIDATE_REPOSITORIES: [&str; 10] = [ 111 "_radroots", 112 "oss/lib", 113 "oss/sdk", 114 "oss/myc", 115 "oss/rhi", 116 "oss/apple_kit", 117 "oss/cli", 118 "oss/radrootsd", 119 "oss/ios_app", 120 "oss/harvestcircle", 121 ]; 122 123 const CARGO_AUDIT_ARGUMENTS: [&str; 7] = [ 124 "audit", 125 "--db", 126 "{absolute_rustsec_snapshot}", 127 "--no-fetch", 128 "--json", 129 "--file", 130 "{absolute_cargo_lock}", 131 ]; 132 const OWASP_UPDATE_ARGUMENTS: [&str; 12] = [ 133 "--updateonly", 134 "--data", 135 "{nvd_staging}", 136 "--disableKnownExploited", 137 "--disableRetireJs", 138 "--disableHostedSuppressions", 139 "--disableVersionCheck", 140 "--disableOssIndex", 141 "--disableCentral", 142 "--disableNodeAudit", 143 "--disableYarnAudit", 144 "--disablePnpmAudit", 145 ]; 146 const OWASP_OFFLINE_ARGUMENTS: [&str; 20] = [ 147 "--noupdate", 148 "--data", 149 "{nvd_snapshot}", 150 "--scan", 151 "{gradle_inventory}", 152 "--project", 153 "{exact_workload_id}", 154 "--format", 155 "JSON", 156 "--out", 157 "{report}", 158 "--disableKnownExploited", 159 "--disableRetireJs", 160 "--disableHostedSuppressions", 161 "--disableVersionCheck", 162 "--disableOssIndex", 163 "--disableCentral", 164 "--disableNodeAudit", 165 "--disableYarnAudit", 166 "--disablePnpmAudit", 167 ]; 168 169 #[derive(Clone, Copy)] 170 struct ToolPin { 171 id: &'static str, 172 version: &'static str, 173 executable_sha256: &'static str, 174 source_sha256: &'static str, 175 receipt_sha256: &'static str, 176 projection_sha256: &'static str, 177 } 178 179 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 180 pub(crate) enum AdvisoryFailureKind { 181 ArchiveRejected, 182 BindingChanged, 183 ExpiredSuppression, 184 InventoryMismatch, 185 InvalidContract, 186 InvalidReport, 187 InvalidSnapshot, 188 KnownVulnerability, 189 StaleSnapshot, 190 TimedOut, 191 Unavailable, 192 } 193 194 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 195 pub(crate) struct AdvisoryError { 196 kind: AdvisoryFailureKind, 197 } 198 199 impl AdvisoryError { 200 const fn new(kind: AdvisoryFailureKind) -> Self { 201 Self { kind } 202 } 203 204 pub(crate) const fn kind(self) -> AdvisoryFailureKind { 205 self.kind 206 } 207 } 208 209 impl fmt::Display for AdvisoryError { 210 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 211 formatter.write_str(match self.kind { 212 AdvisoryFailureKind::ArchiveRejected => "advisory archive admission failed", 213 AdvisoryFailureKind::BindingChanged => "advisory snapshot binding changed", 214 AdvisoryFailureKind::ExpiredSuppression => "advisory suppression is expired", 215 AdvisoryFailureKind::InventoryMismatch => "advisory inventory differs", 216 AdvisoryFailureKind::InvalidContract => "advisory contract differs", 217 AdvisoryFailureKind::InvalidReport => "advisory scanner report is invalid", 218 AdvisoryFailureKind::InvalidSnapshot => "advisory snapshot is invalid", 219 AdvisoryFailureKind::KnownVulnerability => "advisory findings remain unsuppressed", 220 AdvisoryFailureKind::StaleSnapshot => "advisory snapshot is stale", 221 AdvisoryFailureKind::TimedOut => "advisory operation timed out", 222 AdvisoryFailureKind::Unavailable => "mandatory advisory input is unavailable", 223 }) 224 } 225 } 226 227 impl std::error::Error for AdvisoryError {} 228 229 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 230 #[serde(deny_unknown_fields)] 231 pub(crate) struct CandidateIdentity { 232 pub(crate) generation: u64, 233 pub(crate) digest: String, 234 } 235 236 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)] 237 #[serde(deny_unknown_fields)] 238 pub(crate) struct SourceIdentity { 239 pub(crate) repository: String, 240 pub(crate) revision: String, 241 pub(crate) tree: String, 242 } 243 244 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 245 #[serde(deny_unknown_fields)] 246 pub(crate) struct WorkloadInventory { 247 pub(crate) id: String, 248 pub(crate) repository: String, 249 pub(crate) build_root: String, 250 pub(crate) package_manager: String, 251 pub(crate) language: String, 252 pub(crate) manifest_path: Option<String>, 253 pub(crate) lockfile_path: Option<String>, 254 pub(crate) project_path: Option<String>, 255 pub(crate) configuration: Option<String>, 256 pub(crate) dependency_count: u64, 257 pub(crate) input_sha256: String, 258 } 259 260 #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] 261 #[serde(rename_all = "snake_case")] 262 pub(crate) enum FreshnessMode { 263 Development, 264 Qualification, 265 } 266 267 pub(crate) struct AdmissionRequest { 268 pub(crate) candidate: CandidateIdentity, 269 pub(crate) sources: Vec<SourceIdentity>, 270 pub(crate) inventory: Vec<WorkloadInventory>, 271 pub(crate) evaluation_epoch: u64, 272 pub(crate) freshness: FreshnessMode, 273 pub(crate) producer_request: Vec<u8>, 274 pub(crate) step_297_tool_manifest: Vec<u8>, 275 pub(crate) fresh_tool_observation: Vec<u8>, 276 pub(crate) nvd_network_trace: Vec<u8>, 277 pub(crate) provider_execution_evidence: Vec<u8>, 278 pub(crate) admitted_gradle_graphs: Vec<AdmittedGradleGraph>, 279 pub(crate) admitted_scanner_outputs: Vec<AdmittedScannerOutput>, 280 } 281 282 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 283 #[serde(deny_unknown_fields)] 284 struct TrustedProducerRequest { 285 schema: String, 286 candidate_generation: u64, 287 candidate_digest: String, 288 platform: String, 289 process_contract_sha256: String, 290 process_runner_source_sha256: String, 291 nvd_endpoint: String, 292 nvd_enforcement_program_sha256: String, 293 nvd_enforcement_configuration_sha256: String, 294 nvd_query_keys: Vec<String>, 295 workload_ids: Vec<String>, 296 } 297 298 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 299 #[serde(deny_unknown_fields)] 300 struct TrustedToolManifest { 301 schema: String, 302 candidate_generation: u64, 303 candidate_digest: String, 304 platform: String, 305 producer_request_sha256: String, 306 tool_acquisition: Vec<ToolAcquisition>, 307 } 308 309 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 310 #[serde(deny_unknown_fields)] 311 struct TrustedToolObservation { 312 schema: String, 313 candidate_generation: u64, 314 candidate_digest: String, 315 platform: String, 316 producer_request_sha256: String, 317 tool_manifest_sha256: String, 318 row_projection_sha256: String, 319 observed_at_epoch: u64, 320 tool_state: Vec<ToolState>, 321 } 322 323 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 324 #[serde(deny_unknown_fields)] 325 struct NvdNetworkTrace { 326 schema: String, 327 candidate_generation: u64, 328 candidate_digest: String, 329 producer_request_sha256: String, 330 enforcement: String, 331 enforcement_program_sha256: String, 332 enforcement_configuration_sha256: String, 333 request: Vec<NvdRequestTrace>, 334 } 335 336 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 337 #[serde(deny_unknown_fields)] 338 struct NvdRequestTrace { 339 sequence: u64, 340 method: String, 341 scheme: String, 342 authority: String, 343 path: String, 344 started_at_epoch: u64, 345 completed_at_epoch: u64, 346 query: Vec<NvdQueryTrace>, 347 response_status: u16, 348 response_byte_length: u64, 349 response_sha256: String, 350 response_start_index: u64, 351 response_results_per_page: u64, 352 response_total_results: u64, 353 } 354 355 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 356 #[serde(deny_unknown_fields)] 357 struct NvdQueryTrace { 358 name: String, 359 value: String, 360 } 361 362 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 363 #[serde(deny_unknown_fields)] 364 struct TrustedProviderEvidence { 365 schema: String, 366 candidate_generation: u64, 367 candidate_digest: String, 368 platform: String, 369 producer_request_sha256: String, 370 candidate_advisory_input_sha256: String, 371 gradle_projection: Vec<GradleProjection>, 372 provider_snapshot: Vec<ProviderSnapshot>, 373 process_receipt: Vec<TrustedProcessReceipt>, 374 suppressions: Vec<Suppression>, 375 } 376 377 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 378 #[serde(deny_unknown_fields)] 379 struct TrustedProcessReceipt { 380 id: String, 381 state: OperationState, 382 program_sha256: String, 383 runtime_sha256: Vec<String>, 384 arguments_sha256: String, 385 environment_sha256: String, 386 environment: Vec<ProcessEnvironmentRow>, 387 working_directory_sha256: String, 388 working_directory: ProcessWorkingDirectory, 389 path_binding: Vec<ProcessPathBinding>, 390 stdin_closed: bool, 391 deadline_seconds: u64, 392 started_at_epoch: u64, 393 completed_at_epoch: u64, 394 exit_code: i32, 395 stdout_byte_length: u64, 396 stdout_sha256: String, 397 stderr_byte_length: u64, 398 stderr_sha256: String, 399 input_sha256: String, 400 output_sha256: String, 401 } 402 403 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 404 #[serde(deny_unknown_fields)] 405 struct ProcessEnvironmentRow { 406 name: String, 407 logical_value: String, 408 value_sha256: String, 409 } 410 411 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 412 #[serde(deny_unknown_fields)] 413 struct ProcessWorkingDirectory { 414 logical_uri: String, 415 kind: String, 416 identity_sha256: String, 417 pre_execution_entry_count: u64, 418 pre_execution_tree_sha256: String, 419 post_execution_entry_count: u64, 420 post_execution_tree_sha256: String, 421 } 422 423 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 424 #[serde(deny_unknown_fields)] 425 struct ProcessPathBinding { 426 argument_index: u16, 427 logical_role: String, 428 identity_sha256: String, 429 } 430 431 #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] 432 #[serde(rename_all = "snake_case")] 433 enum ProviderId { 434 OwaspNvd, 435 Rustsec, 436 } 437 438 impl ProviderId { 439 const fn as_str(self) -> &'static str { 440 match self { 441 Self::OwaspNvd => "owasp_nvd", 442 Self::Rustsec => "rustsec", 443 } 444 } 445 } 446 447 #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] 448 #[serde(rename_all = "snake_case")] 449 enum OperationState { 450 Complete, 451 Failed, 452 TimedOut, 453 Unavailable, 454 } 455 456 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 457 #[serde(deny_unknown_fields)] 458 struct ToolAcquisition { 459 id: String, 460 projection: Value, 461 } 462 463 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 464 #[serde(deny_unknown_fields)] 465 struct ToolState { 466 id: String, 467 state: OperationState, 468 reviewed_at_epoch: u64, 469 normalized_version: String, 470 executable_sha256: String, 471 source_sha256: String, 472 package_receipt_sha256: String, 473 } 474 475 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 476 #[serde(deny_unknown_fields)] 477 struct BlobBinding { 478 path: String, 479 byte_length: u64, 480 sha256: String, 481 logical_uri: String, 482 media_type: String, 483 logical_role: String, 484 } 485 486 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 487 #[serde(deny_unknown_fields)] 488 struct ProviderSnapshot { 489 provider: ProviderId, 490 acquisition_kind: String, 491 acquisition_count: u8, 492 acquisition_state: OperationState, 493 acquired_at_epoch: u64, 494 digest_time_epoch: u64, 495 database_identity_sha256: String, 496 materialized_tree_sha256: String, 497 bounded_deadline_seconds: u64, 498 network_mode: String, 499 producer_request_sha256: String, 500 network_trace_sha256: String, 501 acquisition_arguments: Vec<String>, 502 archive_format: String, 503 archive: BlobBinding, 504 archive_expanded_bytes: u64, 505 archive_member_count: u64, 506 archive_payload_bytes: u64, 507 analysis_state: OperationState, 508 analyzed_at_epoch: u64, 509 analysis_environment: String, 510 analysis_arguments: Vec<String>, 511 report: BlobBinding, 512 } 513 514 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 515 #[serde(deny_unknown_fields)] 516 struct GradleProjection { 517 workload_id: String, 518 state: OperationState, 519 raw_graph_byte_length: u64, 520 raw_graph_sha256: String, 521 init_script_sha256: String, 522 wrapper_arguments: Vec<String>, 523 environment_keys: Vec<String>, 524 environment_sha256: String, 525 exit_code: i32, 526 source_revision: String, 527 source_tree: String, 528 input_sha256: String, 529 dependency_count: u64, 530 component_count: u64, 531 edge_count: u64, 532 artifact_count: u64, 533 components: Vec<GradleComponent>, 534 edges: Vec<GradleEdge>, 535 artifacts: Vec<GradleArtifact>, 536 canonical_graph_sha256: String, 537 materialized_tree_sha256: String, 538 normalization_receipt_sha256: String, 539 artifact_source_roots_sha256: String, 540 seed_cache_inventory_sha256: String, 541 wrapper_distribution_sha256: String, 542 process_receipt_sha256: String, 543 } 544 545 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 546 #[serde(deny_unknown_fields)] 547 struct GradleComponent { 548 root: bool, 549 kind: String, 550 group: Option<String>, 551 name: Option<String>, 552 version: Option<String>, 553 build_root: Option<String>, 554 project_path: Option<String>, 555 variant: Value, 556 variant_sha256: String, 557 identity_sha256: String, 558 } 559 560 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 561 #[serde(deny_unknown_fields)] 562 struct GradleEdge { 563 from_identity_sha256: String, 564 to_identity_sha256: String, 565 requested: Value, 566 requested_sha256: String, 567 constraint: bool, 568 selected_variant: Value, 569 selected_variant_sha256: String, 570 } 571 572 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 573 #[serde(deny_unknown_fields)] 574 struct GradleArtifact { 575 component_identity_sha256: String, 576 component: String, 577 package_ecosystem: String, 578 package_namespace: String, 579 package_name: String, 580 package_version: String, 581 artifact_sha256: String, 582 artifact_name: String, 583 logical_name: String, 584 artifact_type: String, 585 classifier: Option<String>, 586 byte_length: u64, 587 extension: String, 588 materialized_name: String, 589 variant: Value, 590 variant_sha256: String, 591 } 592 593 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 594 #[serde(deny_unknown_fields)] 595 struct Suppression { 596 id: String, 597 provider: ProviderId, 598 advisory_id: String, 599 workload_id: String, 600 package_ecosystem: String, 601 package_namespace: String, 602 package_name: String, 603 package_version: String, 604 owner: String, 605 rationale: String, 606 created_at_epoch: u64, 607 expires_at_epoch: u64, 608 } 609 610 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 611 #[serde(deny_unknown_fields)] 612 struct SnapshotManifest { 613 schema: String, 614 candidate: CandidateIdentity, 615 sources: Vec<SourceIdentity>, 616 inventory_sha256: String, 617 inventory: Vec<WorkloadInventory>, 618 candidate_advisory_input_sha256: String, 619 step_297_tool_manifest_sha256: String, 620 fresh_tool_observation_sha256: String, 621 tool_acquisition: Vec<ToolAcquisition>, 622 tool_state: Vec<ToolState>, 623 gradle_projection: Vec<GradleProjection>, 624 provider_snapshot: Vec<ProviderSnapshot>, 625 suppressions: Vec<Suppression>, 626 } 627 628 #[derive(Clone, Debug, Eq, PartialEq)] 629 struct Finding { 630 provider: ProviderId, 631 advisory_id: String, 632 package_ecosystem: String, 633 package_namespace: String, 634 package_name: String, 635 package_version: String, 636 workload_id: String, 637 } 638 639 #[derive(Clone, Debug, Eq, PartialEq)] 640 pub(crate) struct AdvisorySnapshotEvidence { 641 pub(crate) candidate: CandidateIdentity, 642 pub(crate) manifest: FileEvidence, 643 pub(crate) source_inventory_sha256: String, 644 pub(crate) inventory_sha256: String, 645 pub(crate) fresh_tool_observation_sha256: String, 646 pub(crate) tool_row_projection_sha256: String, 647 pub(crate) rustsec_archive: ArchiveEvidence, 648 pub(crate) nvd_archive: ArchiveEvidence, 649 pub(crate) rustsec_report: FileEvidence, 650 pub(crate) owasp_report: FileEvidence, 651 pub(crate) provider_time_trace_sha256: String, 652 pub(crate) finding_inventory_sha256: String, 653 pub(crate) suppression_inventory_sha256: String, 654 pub(crate) candidate_advisory_input_sha256: String, 655 pub(crate) gradle_projection_sha256: String, 656 pub(crate) provider_freshness: Vec<ProviderFreshnessEvidence>, 657 pub(crate) finding_count: usize, 658 pub(crate) suppression_count: usize, 659 } 660 661 #[derive(Clone, Debug, Eq, PartialEq)] 662 pub(crate) struct ProviderFreshnessEvidence { 663 pub(crate) provider: String, 664 pub(crate) acquired_at_epoch: u64, 665 pub(crate) digest_time_epoch: u64, 666 pub(crate) analyzed_at_epoch: u64, 667 pub(crate) database_identity_sha256: String, 668 pub(crate) archive_sha256: String, 669 pub(crate) report_sha256: String, 670 pub(crate) network_trace_sha256: String, 671 } 672 673 #[derive(Debug, Deserialize)] 674 #[serde(deny_unknown_fields)] 675 struct ReportEnvelope { 676 schema: String, 677 provider: ProviderId, 678 workload_result: Vec<WorkloadResult>, 679 } 680 681 #[derive(Debug, Deserialize)] 682 #[serde(deny_unknown_fields)] 683 struct WorkloadResult { 684 workload_id: String, 685 input_sha256: String, 686 dependency_count: u64, 687 provider_archive_sha256: String, 688 materialized_tree_sha256: String, 689 tool_observation_sha256: String, 690 arguments: Vec<String>, 691 environment_sha256: String, 692 process_receipt_sha256: String, 693 database_copy: Option<ScannerDatabaseCopy>, 694 exit_code: i32, 695 raw_output_byte_length: u64, 696 raw_output_sha256: String, 697 raw_scanner_output: String, 698 } 699 700 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 701 #[serde(deny_unknown_fields)] 702 struct ScannerDatabaseCopy { 703 root_identity_sha256: String, 704 source_tree_sha256: String, 705 pre_scan_tree_sha256: String, 706 post_scan_tree_sha256: String, 707 } 708 709 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 710 #[serde(deny_unknown_fields)] 711 struct RawGradleGraph { 712 schema: String, 713 workload_id: String, 714 build_root: String, 715 project_path: String, 716 configuration: String, 717 components: Vec<RawGradleComponent>, 718 edges: Vec<RawGradleEdge>, 719 artifacts: Vec<RawGradleArtifact>, 720 } 721 722 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 723 #[serde(deny_unknown_fields)] 724 struct RawGradleComponentCore { 725 build_root: Option<String>, 726 group: Option<String>, 727 kind: String, 728 name: Option<String>, 729 project_path: Option<String>, 730 version: Option<String>, 731 } 732 733 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 734 #[serde(deny_unknown_fields)] 735 struct RawGradleComponent { 736 build_root: Option<String>, 737 group: Option<String>, 738 kind: String, 739 name: Option<String>, 740 project_path: Option<String>, 741 root: bool, 742 variant: RawGradleVariantEnvelope, 743 version: Option<String>, 744 } 745 746 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 747 #[serde(deny_unknown_fields)] 748 struct RawGradleVariantEnvelope { 749 selected: Vec<RawGradleVariant>, 750 } 751 752 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 753 #[serde(deny_unknown_fields)] 754 struct RawGradleVariant { 755 attributes: Vec<RawGradleAttribute>, 756 capabilities: Vec<RawGradleCapability>, 757 external_variant: Option<Box<RawGradleVariant>>, 758 } 759 760 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 761 #[serde(deny_unknown_fields)] 762 struct RawGradleAttribute { 763 name: String, 764 value: String, 765 } 766 767 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 768 #[serde(deny_unknown_fields)] 769 struct RawGradleCapability { 770 group: String, 771 name: String, 772 version: Option<String>, 773 } 774 775 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 776 #[serde(deny_unknown_fields)] 777 struct RawGradleEdge { 778 constraint: bool, 779 from: RawGradleComponentCore, 780 requested: RawGradleSelector, 781 selected_variant: RawGradleVariant, 782 to: RawGradleComponentCore, 783 } 784 785 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 786 #[serde(deny_unknown_fields)] 787 struct RawGradleSelector { 788 attributes: Vec<RawGradleAttribute>, 789 build_root: Option<String>, 790 capabilities: Vec<RawGradleCapability>, 791 group: Option<String>, 792 kind: String, 793 name: Option<String>, 794 project_path: Option<String>, 795 version: Option<String>, 796 version_constraint: Option<RawGradleVersionConstraint>, 797 } 798 799 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 800 #[serde(deny_unknown_fields)] 801 struct RawGradleVersionConstraint { 802 branch: Option<String>, 803 preferred: String, 804 rejected: Vec<String>, 805 required: String, 806 strict: String, 807 } 808 809 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 810 #[serde(deny_unknown_fields)] 811 struct RawGradleArtifact { 812 artifact_name: String, 813 artifact_type: String, 814 classifier: Option<String>, 815 component: RawGradleComponentCore, 816 extension: String, 817 group: Option<String>, 818 logical_name: String, 819 module_version: RawGradleModuleVersion, 820 name: Option<String>, 821 observed_byte_length: u64, 822 observed_sha256: String, 823 source_path: String, 824 variant: RawGradleVariant, 825 version: Option<String>, 826 } 827 828 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 829 #[serde(deny_unknown_fields)] 830 struct RawGradleModuleVersion { 831 group: String, 832 name: String, 833 version: String, 834 } 835 836 pub(crate) struct AdmittedGradleGraph { 837 raw: FileEvidence, 838 workload_id: String, 839 components: Vec<GradleComponent>, 840 edges: Vec<GradleEdge>, 841 artifacts: Vec<GradleArtifact>, 842 canonical_graph_sha256: String, 843 materialized_tree_sha256: String, 844 normalization_receipt_sha256: String, 845 artifact_source_roots_sha256: String, 846 source_snapshot: TraversalSnapshot, 847 materialized_snapshot: TraversalSnapshot, 848 _materialization: tempfile::TempDir, 849 } 850 851 impl AdmittedGradleGraph { 852 pub(crate) fn revalidate(&self) -> Result<(), AdvisoryError> { 853 self.source_snapshot 854 .revalidate() 855 .map_err(map_binding_error)?; 856 self.materialized_snapshot 857 .revalidate() 858 .map_err(map_binding_error) 859 } 860 861 fn raw_evidence(&self) -> &FileEvidence { 862 &self.raw 863 } 864 865 fn matches_projection(&self, projection: &GradleProjection) -> bool { 866 projection.workload_id == self.workload_id 867 && projection.raw_graph_byte_length == self.raw.byte_length 868 && projection.raw_graph_sha256 == self.raw.sha256 869 && projection.components == self.components 870 && projection.edges == self.edges 871 && projection.artifacts == self.artifacts 872 && projection.canonical_graph_sha256 == self.canonical_graph_sha256 873 && projection.materialized_tree_sha256 == self.materialized_tree_sha256 874 && projection.normalization_receipt_sha256 == self.normalization_receipt_sha256 875 && projection.artifact_source_roots_sha256 == self.artifact_source_roots_sha256 876 } 877 } 878 879 pub(crate) struct AdmittedScannerOutput { 880 provider: ProviderId, 881 workload_id: String, 882 bytes: Vec<u8>, 883 evidence: FileEvidence, 884 snapshot: TraversalSnapshot, 885 } 886 887 impl AdmittedScannerOutput { 888 fn revalidate(&self) -> Result<(), AdvisoryError> { 889 self.snapshot.revalidate().map_err(map_binding_error) 890 } 891 } 892 893 fn admit_raw_scanner_output( 894 root: &Path, 895 provider: ProviderId, 896 workload_id: &str, 897 ) -> Result<AdmittedScannerOutput, AdvisoryError> { 898 let snapshot = safe_artifact_io::traverse_regular_files( 899 root, 900 TraversalLimits { 901 max_entries: 1, 902 max_files: 1, 903 max_total_bytes: MAX_RAW_WORKLOAD_REPORT_BYTES, 904 max_file_bytes: MAX_RAW_WORKLOAD_REPORT_BYTES, 905 max_depth: 1, 906 max_path_bytes: 64, 907 }, 908 &[], 909 ) 910 .map_err(map_binding_error)?; 911 let file = snapshot 912 .files() 913 .first() 914 .filter(|file| { 915 snapshot.entry_count() == 1 916 && snapshot.files().len() == 1 917 && file.relative_path() == Path::new(RAW_SCANNER_OUTPUT_NAME) 918 }) 919 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 920 let (bytes, evidence) = snapshot 921 .read_evidenced(file, MAX_RAW_WORKLOAD_REPORT_BYTES) 922 .map_err(map_binding_error)?; 923 if bytes.is_empty() || serde_json::from_slice::<Value>(&bytes).is_err() { 924 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 925 } 926 snapshot.revalidate().map_err(map_binding_error)?; 927 Ok(AdmittedScannerOutput { 928 provider, 929 workload_id: workload_id.to_owned(), 930 bytes, 931 evidence, 932 snapshot, 933 }) 934 } 935 936 struct GradleArtifactSourceRoot<'a> { 937 path: &'a Path, 938 logical_role: &'static str, 939 identity_sha256: &'a str, 940 } 941 942 struct NormalizedRawGradleGraph { 943 components: Vec<GradleComponent>, 944 edges: Vec<GradleEdge>, 945 artifacts: Vec<GradleArtifact>, 946 source_paths: Vec<PathBuf>, 947 } 948 949 fn admit_raw_gradle_graph( 950 raw_root: &Path, 951 materialization_parent: &Path, 952 authority: &WorkloadAuthority, 953 source_revision: &str, 954 expected_dependency_count: u64, 955 artifact_source_roots: &[GradleArtifactSourceRoot<'_>], 956 ) -> Result<AdmittedGradleGraph, AdvisoryError> { 957 let source_snapshot = safe_artifact_io::traverse_regular_files( 958 raw_root, 959 TraversalLimits { 960 max_entries: 1, 961 max_files: 1, 962 max_total_bytes: MAX_GRADLE_GRAPH_BYTES, 963 max_file_bytes: MAX_GRADLE_GRAPH_BYTES, 964 max_depth: 1, 965 max_path_bytes: 64, 966 }, 967 &[], 968 ) 969 .map_err(map_binding_error)?; 970 let raw_file = source_snapshot 971 .files() 972 .first() 973 .filter(|file| { 974 source_snapshot.entry_count() == 1 975 && source_snapshot.files().len() == 1 976 && file.relative_path() == Path::new(GRADLE_RAW_GRAPH_NAME) 977 }) 978 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 979 let (raw_bytes, raw_evidence) = source_snapshot 980 .read_evidenced(raw_file, MAX_GRADLE_GRAPH_BYTES) 981 .map_err(map_binding_error)?; 982 let raw_value: Value = serde_json::from_slice(&raw_bytes) 983 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 984 if canonical_json_without_lf(&raw_value) != raw_bytes { 985 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 986 } 987 let raw: RawGradleGraph = serde_json::from_value(raw_value) 988 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 989 let artifact_source_roots_sha256 = artifact_source_roots_digest(artifact_source_roots)?; 990 let normalized = normalize_raw_gradle_graph( 991 &raw, 992 authority, 993 source_revision, 994 expected_dependency_count, 995 artifact_source_roots, 996 )?; 997 998 if !materialization_parent.is_absolute() { 999 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 1000 } 1001 safe_artifact_io::validate_trusted_output_directory(materialization_parent) 1002 .map_err(map_binding_error)?; 1003 let materialization = tempfile::Builder::new() 1004 .prefix(".radroots-advisory-scan-") 1005 .tempdir_in(materialization_parent) 1006 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 1007 #[cfg(unix)] 1008 { 1009 use std::os::unix::fs::PermissionsExt as _; 1010 std::fs::set_permissions( 1011 materialization.path(), 1012 std::fs::Permissions::from_mode(0o700), 1013 ) 1014 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 1015 } 1016 #[cfg(not(unix))] 1017 { 1018 return Err(AdvisoryError::new(AdvisoryFailureKind::Unavailable)); 1019 } 1020 1021 let mut materialized = BTreeMap::<(String, String), FileEvidence>::new(); 1022 for (artifact, source) in normalized.artifacts.iter().zip(&normalized.source_paths) { 1023 let key = (artifact.artifact_sha256.clone(), artifact.extension.clone()); 1024 let evidence = if let Some(existing) = materialized.get(&key) { 1025 let observed = safe_artifact_io::hash_regular_path(source, MAX_GRADLE_ARTIFACT_BYTES) 1026 .map_err(map_binding_error)?; 1027 if &observed != existing { 1028 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 1029 } 1030 observed 1031 } else { 1032 let observed = safe_artifact_io::copy_regular_to_new_path( 1033 source, 1034 &materialization.path().join(&artifact.materialized_name), 1035 MAX_GRADLE_ARTIFACT_BYTES, 1036 ) 1037 .map_err(map_binding_error)?; 1038 materialized.insert(key, observed.clone()); 1039 observed 1040 }; 1041 if evidence.byte_length != artifact.byte_length 1042 || evidence.sha256 != artifact.artifact_sha256 1043 { 1044 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 1045 } 1046 } 1047 let maximum_files = u64::try_from(materialized.len().max(1)) 1048 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1049 let materialized_snapshot = safe_artifact_io::traverse_regular_files( 1050 materialization.path(), 1051 TraversalLimits { 1052 max_entries: maximum_files, 1053 max_files: maximum_files, 1054 max_total_bytes: MAX_GRADLE_ARTIFACT_BYTES, 1055 max_file_bytes: MAX_GRADLE_ARTIFACT_BYTES, 1056 max_depth: 1, 1057 max_path_bytes: 128, 1058 }, 1059 &[], 1060 ) 1061 .map_err(map_binding_error)?; 1062 if materialized_snapshot.files().len() != materialized.len() 1063 || materialized_snapshot.root_permission_mode() != 0o700 1064 || materialized_snapshot 1065 .files() 1066 .iter() 1067 .any(|file| file.permission_mode() != 0o600) 1068 { 1069 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 1070 } 1071 let materialized_tree_sha256 = materialized_tree_digest(&materialized_snapshot)?; 1072 let canonical_graph_sha256 = gradle_graph_digest_parts( 1073 authority.id, 1074 &normalized.components, 1075 &normalized.edges, 1076 &normalized.artifacts, 1077 )?; 1078 let normalization_receipt_sha256 = gradle_normalization_receipt_digest( 1079 authority.id, 1080 raw_evidence.byte_length, 1081 &raw_evidence.sha256, 1082 &canonical_graph_sha256, 1083 &materialized_tree_sha256, 1084 &artifact_source_roots_sha256, 1085 )?; 1086 source_snapshot.revalidate().map_err(map_binding_error)?; 1087 materialized_snapshot 1088 .revalidate() 1089 .map_err(map_binding_error)?; 1090 Ok(AdmittedGradleGraph { 1091 raw: raw_evidence, 1092 workload_id: authority.id.to_owned(), 1093 components: normalized.components, 1094 edges: normalized.edges, 1095 artifacts: normalized.artifacts, 1096 canonical_graph_sha256, 1097 materialized_tree_sha256, 1098 normalization_receipt_sha256, 1099 artifact_source_roots_sha256, 1100 source_snapshot, 1101 materialized_snapshot, 1102 _materialization: materialization, 1103 }) 1104 } 1105 1106 fn normalize_raw_gradle_graph( 1107 raw: &RawGradleGraph, 1108 authority: &WorkloadAuthority, 1109 source_revision: &str, 1110 expected_dependency_count: u64, 1111 artifact_source_roots: &[GradleArtifactSourceRoot<'_>], 1112 ) -> Result<NormalizedRawGradleGraph, AdvisoryError> { 1113 if raw.schema != "radroots.gradle-advisory-graph.v1" 1114 || authority.package_manager != "gradle" 1115 || raw.workload_id != authority.id 1116 || raw.build_root != authority.build_root 1117 || raw.project_path.as_str() 1118 != authority 1119 .project_path 1120 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))? 1121 || raw.configuration.as_str() 1122 != authority 1123 .configuration 1124 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))? 1125 || raw.components.is_empty() 1126 || raw.components.len() > MAX_GRADLE_COMPONENTS 1127 || raw.edges.len() > MAX_GRADLE_EDGES 1128 || raw.artifacts.len() > MAX_GRADLE_ARTIFACTS 1129 || raw.edges.len() as u64 != expected_dependency_count 1130 { 1131 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1132 } 1133 require_strict_canonical_order(&raw.components)?; 1134 require_strict_canonical_order(&raw.edges)?; 1135 require_strict_canonical_order(&raw.artifacts)?; 1136 1137 let mut components = Vec::with_capacity(raw.components.len()); 1138 let mut component_by_core = BTreeMap::<Vec<u8>, (String, RawGradleVariantEnvelope)>::new(); 1139 for component in &raw.components { 1140 let core = raw_component_core(component); 1141 validate_raw_component_core(&core)?; 1142 validate_raw_variant_envelope(&component.variant)?; 1143 let variant = serde_json::to_value(&component.variant) 1144 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1145 let mut normalized = GradleComponent { 1146 root: component.root, 1147 kind: component.kind.clone(), 1148 group: component.group.clone(), 1149 name: component.name.clone(), 1150 version: component.version.clone(), 1151 build_root: component.build_root.clone(), 1152 project_path: component.project_path.clone(), 1153 variant, 1154 variant_sha256: String::new(), 1155 identity_sha256: String::new(), 1156 }; 1157 normalized.variant_sha256 = gradle_variant_digest(&normalized.variant)?; 1158 normalized.identity_sha256 = gradle_component_digest(&normalized)?; 1159 let core_key = canonical_row_key(&core)?; 1160 if component_by_core 1161 .insert( 1162 core_key, 1163 ( 1164 normalized.identity_sha256.clone(), 1165 component.variant.clone(), 1166 ), 1167 ) 1168 .is_some() 1169 { 1170 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1171 } 1172 components.push(normalized); 1173 } 1174 components.sort_by(|left, right| left.identity_sha256.cmp(&right.identity_sha256)); 1175 if components 1176 .windows(2) 1177 .any(|window| window[0].identity_sha256 >= window[1].identity_sha256) 1178 { 1179 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1180 } 1181 let roots = components 1182 .iter() 1183 .filter(|component| component.root) 1184 .collect::<Vec<_>>(); 1185 if roots.len() != 1 1186 || roots[0].kind != "project" 1187 || roots[0].build_root.as_deref() != Some(authority.build_root) 1188 || roots[0].project_path.as_deref() != authority.project_path 1189 { 1190 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1191 } 1192 1193 let mut edges = Vec::with_capacity(raw.edges.len()); 1194 for edge in &raw.edges { 1195 validate_raw_component_core(&edge.from)?; 1196 validate_raw_component_core(&edge.to)?; 1197 validate_raw_selector(&edge.requested)?; 1198 validate_raw_variant(&edge.selected_variant, 0)?; 1199 let (from_identity, _) = component_by_core 1200 .get(&canonical_row_key(&edge.from)?) 1201 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1202 let (to_identity, to_variants) = component_by_core 1203 .get(&canonical_row_key(&edge.to)?) 1204 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1205 if from_identity == to_identity || !to_variants.selected.contains(&edge.selected_variant) { 1206 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1207 } 1208 let requested = serde_json::to_value(&edge.requested) 1209 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1210 let selected_variant = serde_json::to_value(&edge.selected_variant) 1211 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1212 edges.push(GradleEdge { 1213 from_identity_sha256: from_identity.clone(), 1214 to_identity_sha256: to_identity.clone(), 1215 requested_sha256: gradle_request_digest(&requested)?, 1216 requested, 1217 constraint: edge.constraint, 1218 selected_variant_sha256: gradle_variant_digest(&selected_variant)?, 1219 selected_variant, 1220 }); 1221 } 1222 edges.sort_by(|left, right| { 1223 canonical_row_key(left) 1224 .unwrap_or_default() 1225 .cmp(&canonical_row_key(right).unwrap_or_default()) 1226 }); 1227 require_strict_canonical_order(&edges)?; 1228 let mut reachable = BTreeSet::from([roots[0].identity_sha256.as_str()]); 1229 loop { 1230 let before = reachable.len(); 1231 for edge in &edges { 1232 if reachable.contains(edge.from_identity_sha256.as_str()) { 1233 reachable.insert(edge.to_identity_sha256.as_str()); 1234 } 1235 } 1236 if reachable.len() == before { 1237 break; 1238 } 1239 } 1240 if reachable.len() != components.len() { 1241 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1242 } 1243 1244 let mut artifacts = Vec::with_capacity(raw.artifacts.len()); 1245 let mut source_paths = Vec::with_capacity(raw.artifacts.len()); 1246 let mut total_artifact_bytes = 0_u64; 1247 for artifact in &raw.artifacts { 1248 validate_raw_component_core(&artifact.component)?; 1249 validate_raw_variant(&artifact.variant, 0)?; 1250 let (component_identity, variants) = component_by_core 1251 .get(&canonical_row_key(&artifact.component)?) 1252 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1253 if !variants.selected.contains(&artifact.variant) 1254 || artifact.group != artifact.component.group 1255 || artifact.name != artifact.component.name 1256 || artifact.version != artifact.component.version 1257 || !valid_ascii_text(&artifact.module_version.group, 256, false) 1258 || !valid_ascii_text(&artifact.module_version.name, 256, false) 1259 || !valid_ascii_text(&artifact.module_version.version, 128, false) 1260 || artifact.observed_byte_length == 0 1261 || artifact.observed_byte_length > MAX_GRADLE_ARTIFACT_BYTES 1262 || !valid_hex(&artifact.observed_sha256, 64) 1263 || !valid_ascii_text(&artifact.artifact_name, 256, false) 1264 || artifact.artifact_name.contains(['/', '\\']) 1265 || !valid_ascii_text(&artifact.logical_name, 256, false) 1266 || !valid_ascii_text(&artifact.artifact_type, 64, false) 1267 || !valid_artifact_extension(&artifact.extension) 1268 || !artifact 1269 .artifact_name 1270 .ends_with(&format!(".{}", artifact.extension)) 1271 || artifact 1272 .classifier 1273 .as_deref() 1274 .is_some_and(|value| !valid_ascii_text(value, 128, false)) 1275 || !valid_ascii_text(&artifact.source_path, 4_096, false) 1276 { 1277 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1278 } 1279 let source_path = PathBuf::from(&artifact.source_path); 1280 let source_roots = artifact_source_roots 1281 .iter() 1282 .filter_map(|root| { 1283 source_path 1284 .strip_prefix(root.path) 1285 .ok() 1286 .filter(|relative| { 1287 !relative.as_os_str().is_empty() 1288 && relative 1289 .components() 1290 .all(|component| matches!(component, Component::Normal(_))) 1291 }) 1292 .map(|relative| (root, relative)) 1293 }) 1294 .collect::<Vec<_>>(); 1295 if !valid_absolute_normal_path(&source_path) || source_roots.len() != 1 { 1296 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1297 } 1298 let (source_root, relative) = source_roots[0]; 1299 let source_evidence = 1300 safe_artifact_io::hash_regular(source_root.path, relative, MAX_GRADLE_ARTIFACT_BYTES) 1301 .map_err(map_binding_error)?; 1302 if source_evidence.byte_length != artifact.observed_byte_length 1303 || source_evidence.sha256 != artifact.observed_sha256 1304 { 1305 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 1306 } 1307 total_artifact_bytes = total_artifact_bytes 1308 .checked_add(artifact.observed_byte_length) 1309 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1310 if total_artifact_bytes > MAX_GRADLE_ARTIFACT_BYTES { 1311 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1312 } 1313 let component = components 1314 .iter() 1315 .find(|component| &component.identity_sha256 == component_identity) 1316 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1317 let component_label = 1318 match component.kind.as_str() { 1319 "module" => { 1320 let group = component.group.as_deref().ok_or_else(|| { 1321 AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch) 1322 })?; 1323 let name = component.name.as_deref().ok_or_else(|| { 1324 AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch) 1325 })?; 1326 let version = component.version.as_deref().ok_or_else(|| { 1327 AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch) 1328 })?; 1329 if artifact.module_version.group != group 1330 || artifact.module_version.name != name 1331 || artifact.module_version.version != version 1332 { 1333 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1334 } 1335 format!("{group}:{name}:{version}") 1336 } 1337 "project" => { 1338 let root = component.build_root.as_deref().ok_or_else(|| { 1339 AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch) 1340 })?; 1341 let project = component.project_path.as_deref().ok_or_else(|| { 1342 AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch) 1343 })?; 1344 if !valid_project_module_version( 1345 root, 1346 project, 1347 &artifact.module_version, 1348 source_revision, 1349 ) { 1350 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1351 } 1352 format!("{root}:{project}") 1353 } 1354 _ => { 1355 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1356 } 1357 }; 1358 let variant = serde_json::to_value(&artifact.variant) 1359 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1360 artifacts.push(GradleArtifact { 1361 component_identity_sha256: component_identity.clone(), 1362 component: component_label, 1363 package_ecosystem: "maven".to_owned(), 1364 package_namespace: artifact.module_version.group.clone(), 1365 package_name: artifact.module_version.name.clone(), 1366 package_version: artifact.module_version.version.clone(), 1367 artifact_sha256: artifact.observed_sha256.clone(), 1368 artifact_name: artifact.artifact_name.clone(), 1369 logical_name: artifact.logical_name.clone(), 1370 artifact_type: artifact.artifact_type.clone(), 1371 classifier: artifact.classifier.clone(), 1372 byte_length: artifact.observed_byte_length, 1373 extension: artifact.extension.clone(), 1374 materialized_name: format!("{}.{}", artifact.observed_sha256, artifact.extension), 1375 variant_sha256: gradle_variant_digest(&variant)?, 1376 variant, 1377 }); 1378 source_paths.push(source_path); 1379 } 1380 let mut paired = artifacts.into_iter().zip(source_paths).collect::<Vec<_>>(); 1381 paired.sort_by(|left, right| { 1382 canonical_row_key(&left.0) 1383 .unwrap_or_default() 1384 .cmp(&canonical_row_key(&right.0).unwrap_or_default()) 1385 }); 1386 if paired.windows(2).any(|window| window[0].0 == window[1].0) { 1387 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1388 } 1389 let (artifacts, source_paths): (Vec<_>, Vec<_>) = paired.into_iter().unzip(); 1390 Ok(NormalizedRawGradleGraph { 1391 components, 1392 edges, 1393 artifacts, 1394 source_paths, 1395 }) 1396 } 1397 1398 fn raw_component_core(component: &RawGradleComponent) -> RawGradleComponentCore { 1399 RawGradleComponentCore { 1400 build_root: component.build_root.clone(), 1401 group: component.group.clone(), 1402 kind: component.kind.clone(), 1403 name: component.name.clone(), 1404 project_path: component.project_path.clone(), 1405 version: component.version.clone(), 1406 } 1407 } 1408 1409 fn valid_absolute_normal_path(path: &Path) -> bool { 1410 path.is_absolute() 1411 && path 1412 .components() 1413 .all(|component| matches!(component, Component::RootDir | Component::Normal(_))) 1414 } 1415 1416 fn artifact_source_roots_digest( 1417 roots: &[GradleArtifactSourceRoot<'_>], 1418 ) -> Result<String, AdvisoryError> { 1419 if roots.is_empty() || roots.len() > 2 { 1420 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1421 } 1422 let mut rows = Vec::with_capacity(roots.len()); 1423 let mut roles = BTreeSet::new(); 1424 for root in roots { 1425 if !valid_absolute_normal_path(root.path) 1426 || !matches!( 1427 root.logical_role, 1428 "candidate_build_output" | "governed_seed_cache" 1429 ) 1430 || !roles.insert(root.logical_role) 1431 || !valid_hex(root.identity_sha256, 64) 1432 { 1433 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1434 } 1435 rows.push(serde_json::json!({ 1436 "identity_sha256": root.identity_sha256, 1437 "logical_role": root.logical_role, 1438 })); 1439 } 1440 rows.sort_by(|left, right| { 1441 canonical_json_without_lf(left).cmp(&canonical_json_without_lf(right)) 1442 }); 1443 domain_json_digest( 1444 b"radroots-advisory-gradle-artifact-source-roots-v1\0", 1445 &rows, 1446 ) 1447 } 1448 1449 #[derive(Clone, Copy)] 1450 struct WorkloadAuthority { 1451 id: &'static str, 1452 repository: &'static str, 1453 build_root: &'static str, 1454 package_manager: &'static str, 1455 language: &'static str, 1456 manifest_path: Option<&'static str>, 1457 lockfile_path: Option<&'static str>, 1458 project_path: Option<&'static str>, 1459 configuration: Option<&'static str>, 1460 } 1461 1462 const WORKLOADS: [WorkloadAuthority; 16] = [ 1463 cargo_workload("cli", "oss/cli", ".", "Cargo.toml", "Cargo.lock"), 1464 cargo_workload( 1465 "harvest_core", 1466 "oss/harvestcircle", 1467 "core", 1468 "core/Cargo.toml", 1469 "core/Cargo.lock", 1470 ), 1471 cargo_workload( 1472 "harvest_xtask", 1473 "oss/harvestcircle", 1474 "tools/xtask", 1475 "tools/xtask/Cargo.toml", 1476 "tools/xtask/Cargo.lock", 1477 ), 1478 cargo_workload( 1479 "ios_ffi", 1480 "oss/ios_app", 1481 "RadrootsFFI", 1482 "Cargo.toml", 1483 "Cargo.lock", 1484 ), 1485 cargo_workload("lib", "oss/lib", ".", "Cargo.toml", "Cargo.lock"), 1486 cargo_workload("myc", "oss/myc", ".", "Cargo.toml", "Cargo.lock"), 1487 cargo_workload( 1488 "radrootsd", 1489 "oss/radrootsd", 1490 ".", 1491 "Cargo.toml", 1492 "Cargo.lock", 1493 ), 1494 cargo_workload("rhi", "oss/rhi", ".", "Cargo.toml", "Cargo.lock"), 1495 cargo_workload("root", "_radroots", ".", "Cargo.toml", "Cargo.lock"), 1496 cargo_workload("sdk", "oss/sdk", ".", "Cargo.toml", "Cargo.lock"), 1497 gradle_workload( 1498 "app_design_system", 1499 ".", 1500 ":app:design_system", 1501 "desktopRuntimeClasspath", 1502 ), 1503 gradle_workload("app_desktop", ".", ":app:desktop", "runtimeClasspath"), 1504 gradle_workload("app_shared", ".", ":app:shared", "desktopRuntimeClasspath"), 1505 gradle_workload( 1506 "tools_design_catalog", 1507 ".", 1508 ":tools:design_catalog", 1509 "desktopRuntimeClasspath", 1510 ), 1511 gradle_workload( 1512 "build_logic_contracts", 1513 "build-logic", 1514 ":contracts", 1515 "runtimeClasspath", 1516 ), 1517 gradle_workload( 1518 "build_logic_plugins", 1519 "build-logic", 1520 ":plugins", 1521 "runtimeClasspath", 1522 ), 1523 ]; 1524 1525 const fn cargo_workload( 1526 id: &'static str, 1527 repository: &'static str, 1528 build_root: &'static str, 1529 manifest_path: &'static str, 1530 lockfile_path: &'static str, 1531 ) -> WorkloadAuthority { 1532 WorkloadAuthority { 1533 id, 1534 repository, 1535 build_root, 1536 package_manager: "cargo", 1537 language: "rust", 1538 manifest_path: Some(manifest_path), 1539 lockfile_path: Some(lockfile_path), 1540 project_path: None, 1541 configuration: None, 1542 } 1543 } 1544 1545 const fn gradle_workload( 1546 id: &'static str, 1547 build_root: &'static str, 1548 project_path: &'static str, 1549 configuration: &'static str, 1550 ) -> WorkloadAuthority { 1551 WorkloadAuthority { 1552 id, 1553 repository: "oss/harvestcircle", 1554 build_root, 1555 package_manager: "gradle", 1556 language: "kotlin", 1557 manifest_path: None, 1558 lockfile_path: None, 1559 project_path: Some(project_path), 1560 configuration: Some(configuration), 1561 } 1562 } 1563 1564 pub(crate) fn validate_decision(root: &Path) -> Result<(), String> { 1565 let bytes = safe_artifact_io::read_regular_path(&root.join(DECISION_PATH), 1_048_576) 1566 .map_err(|_| "advisory snapshot decision admission failed".to_owned())?; 1567 if bytes != DECISION_BYTES { 1568 return Err("advisory snapshot decision differs from compiled authority".to_owned()); 1569 } 1570 let _: Value = serde_json::from_slice(&bytes) 1571 .map_err(|_| "advisory snapshot decision is invalid".to_owned())?; 1572 // Exact equality with the compiled authority above already binds whitespace 1573 // and object-member order. Re-serializing through `Value` would instead sort 1574 // object keys and reject the repository's intentional semantic key order. 1575 Ok(()) 1576 } 1577 1578 pub(crate) fn admit_snapshot( 1579 root: &Path, 1580 materialization_parent: &Path, 1581 request: &AdmissionRequest, 1582 ) -> Result<AdvisorySnapshotEvidence, AdvisoryError> { 1583 validate_request(request)?; 1584 let snapshot = safe_artifact_io::traverse_regular_files( 1585 root, 1586 TraversalLimits { 1587 max_entries: SNAPSHOT_FILE_NAMES.len() as u64, 1588 max_files: SNAPSHOT_FILE_NAMES.len() as u64, 1589 max_total_bytes: 4_430_233_600, 1590 max_file_bytes: 2_147_483_648, 1591 max_depth: 1, 1592 max_path_bytes: 128, 1593 }, 1594 &[], 1595 ) 1596 .map_err(map_binding_error)?; 1597 let files = exact_snapshot_files(&snapshot)?; 1598 let manifest_file = files 1599 .get(MANIFEST_NAME) 1600 .copied() 1601 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1602 let (manifest_bytes, manifest_evidence) = snapshot 1603 .read_evidenced(manifest_file, MAX_SNAPSHOT_MANIFEST_BYTES) 1604 .map_err(map_binding_error)?; 1605 let manifest: SnapshotManifest = serde_json::from_slice(&manifest_bytes) 1606 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 1607 let manifest_value: Value = serde_json::from_slice(&manifest_bytes) 1608 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 1609 if canonical_json(&manifest_value) != manifest_bytes { 1610 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 1611 } 1612 validate_manifest(&manifest, request)?; 1613 1614 let mut archive_evidence = BTreeMap::new(); 1615 let mut materialized_archives = Vec::new(); 1616 let mut report_evidences = BTreeMap::new(); 1617 let mut findings = Vec::new(); 1618 for graph in &request.admitted_gradle_graphs { 1619 graph.revalidate()?; 1620 } 1621 for provider in &manifest.provider_snapshot { 1622 let archive_file = files 1623 .get(provider.archive.path.as_str()) 1624 .copied() 1625 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1626 let archive = snapshot 1627 .admit_deterministic_tar_gzip(archive_file, archive_limits()) 1628 .map_err(map_archive_error)?; 1629 validate_blob(&provider.archive, &archive.compressed)?; 1630 if provider.archive_expanded_bytes != archive.expanded_bytes 1631 || provider.archive_member_count != archive.member_count 1632 || provider.archive_payload_bytes != archive.payload_bytes 1633 { 1634 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 1635 } 1636 let materialized = snapshot 1637 .materialize_deterministic_tar_gzip( 1638 archive_file, 1639 materialization_parent, 1640 archive_limits(), 1641 ) 1642 .map_err(map_archive_error)?; 1643 let materialized_tree = materialized_tree_digest(materialized.snapshot())?; 1644 if materialized.evidence() != &archive 1645 || materialized_tree != provider.materialized_tree_sha256 1646 || !valid_hex(&provider.database_identity_sha256, 64) 1647 { 1648 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 1649 } 1650 materialized.revalidate().map_err(map_binding_error)?; 1651 archive_evidence.insert(provider.provider, archive); 1652 materialized_archives.push(materialized); 1653 1654 let report_file = files 1655 .get(provider.report.path.as_str()) 1656 .copied() 1657 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1658 let (report_bytes, report_evidence) = snapshot 1659 .read_evidenced(report_file, MAX_REPORT_BYTES) 1660 .map_err(map_binding_error)?; 1661 validate_blob(&provider.report, &report_evidence)?; 1662 findings.extend(parse_unsuppressed_report( 1663 provider, 1664 &report_bytes, 1665 request, 1666 &manifest.gradle_projection, 1667 )?); 1668 report_evidences.insert(provider.provider, report_evidence); 1669 } 1670 for graph in &request.admitted_gradle_graphs { 1671 graph.revalidate()?; 1672 } 1673 for materialized in &materialized_archives { 1674 materialized.revalidate().map_err(map_binding_error)?; 1675 } 1676 for output in &request.admitted_scanner_outputs { 1677 output.revalidate()?; 1678 } 1679 1680 let unsuppressed_count = findings.len(); 1681 let finding_inventory_sha256 = finding_digest(&findings)?; 1682 apply_suppressions( 1683 &mut findings, 1684 &manifest.suppressions, 1685 request.evaluation_epoch, 1686 )?; 1687 if !findings.is_empty() { 1688 return Err(AdvisoryError::new(AdvisoryFailureKind::KnownVulnerability)); 1689 } 1690 snapshot.revalidate().map_err(map_binding_error)?; 1691 for materialized in &materialized_archives { 1692 materialized.revalidate().map_err(map_binding_error)?; 1693 } 1694 for output in &request.admitted_scanner_outputs { 1695 output.revalidate()?; 1696 } 1697 let rustsec = archive_evidence 1698 .remove(&ProviderId::Rustsec) 1699 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1700 let nvd = archive_evidence 1701 .remove(&ProviderId::OwaspNvd) 1702 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1703 let rustsec_report = report_evidences 1704 .remove(&ProviderId::Rustsec) 1705 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1706 let owasp_report = report_evidences 1707 .remove(&ProviderId::OwaspNvd) 1708 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1709 let provider_freshness = manifest 1710 .provider_snapshot 1711 .iter() 1712 .map(|provider| ProviderFreshnessEvidence { 1713 provider: provider.provider.as_str().to_owned(), 1714 acquired_at_epoch: provider.acquired_at_epoch, 1715 digest_time_epoch: provider.digest_time_epoch, 1716 analyzed_at_epoch: provider.analyzed_at_epoch, 1717 database_identity_sha256: provider.database_identity_sha256.clone(), 1718 archive_sha256: provider.archive.sha256.clone(), 1719 report_sha256: provider.report.sha256.clone(), 1720 network_trace_sha256: provider.network_trace_sha256.clone(), 1721 }) 1722 .collect(); 1723 Ok(AdvisorySnapshotEvidence { 1724 candidate: manifest.candidate, 1725 manifest: manifest_evidence, 1726 source_inventory_sha256: source_digest(&manifest.sources)?, 1727 inventory_sha256: manifest.inventory_sha256, 1728 fresh_tool_observation_sha256: manifest.fresh_tool_observation_sha256, 1729 tool_row_projection_sha256: tool_observation_digest( 1730 &manifest.tool_acquisition, 1731 &manifest.tool_state, 1732 )?, 1733 rustsec_archive: rustsec, 1734 nvd_archive: nvd, 1735 rustsec_report, 1736 owasp_report, 1737 provider_time_trace_sha256: provider_trace_digest(&manifest.provider_snapshot)?, 1738 finding_inventory_sha256, 1739 suppression_inventory_sha256: suppression_digest(&manifest.suppressions)?, 1740 candidate_advisory_input_sha256: manifest.candidate_advisory_input_sha256, 1741 gradle_projection_sha256: gradle_projection_digest(&manifest.gradle_projection)?, 1742 provider_freshness, 1743 finding_count: unsuppressed_count, 1744 suppression_count: manifest.suppressions.len(), 1745 }) 1746 } 1747 1748 fn exact_snapshot_files( 1749 snapshot: &TraversalSnapshot, 1750 ) -> Result<BTreeMap<&str, &TraversedFile>, AdvisoryError> { 1751 if snapshot.entry_count() != SNAPSHOT_FILE_NAMES.len() as u64 1752 || snapshot.files().len() != SNAPSHOT_FILE_NAMES.len() 1753 { 1754 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1755 } 1756 let mut files = BTreeMap::new(); 1757 for file in snapshot.files() { 1758 let relative = file 1759 .relative_path() 1760 .to_str() 1761 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 1762 if Path::new(relative).components().count() != 1 1763 || !SNAPSHOT_FILE_NAMES.contains(&relative) 1764 || files.insert(relative, file).is_some() 1765 { 1766 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1767 } 1768 } 1769 Ok(files) 1770 } 1771 1772 fn validate_request(request: &AdmissionRequest) -> Result<(), AdvisoryError> { 1773 if request.candidate.generation == 0 1774 || !valid_hex(&request.candidate.digest, 64) 1775 || request.evaluation_epoch == 0 1776 || request.sources.is_empty() 1777 || request.inventory.len() != WORKLOADS.len() 1778 || request.admitted_gradle_graphs.len() 1779 != WORKLOADS 1780 .iter() 1781 .filter(|workload| workload.package_manager == "gradle") 1782 .count() 1783 || request.admitted_scanner_outputs.len() != WORKLOADS.len() 1784 { 1785 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 1786 } 1787 validate_trusted_authority(request)?; 1788 if request.sources.len() != CANDIDATE_REPOSITORIES.len() 1789 || !request 1790 .sources 1791 .iter() 1792 .map(|source| source.repository.as_str()) 1793 .eq(CANDIDATE_REPOSITORIES) 1794 { 1795 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1796 } 1797 let mut repositories = BTreeSet::new(); 1798 for source in &request.sources { 1799 if !valid_identifier(&source.repository) 1800 || !valid_hex(&source.revision, 40) 1801 || !valid_hex(&source.tree, 40) 1802 || !repositories.insert(source.repository.as_str()) 1803 { 1804 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 1805 } 1806 } 1807 for (actual, expected) in request.inventory.iter().zip(WORKLOADS) { 1808 if actual.id != expected.id 1809 || actual.repository != expected.repository 1810 || actual.build_root != expected.build_root 1811 || actual.package_manager != expected.package_manager 1812 || actual.language != expected.language 1813 || actual.manifest_path.as_deref() != expected.manifest_path 1814 || actual.lockfile_path.as_deref() != expected.lockfile_path 1815 || actual.project_path.as_deref() != expected.project_path 1816 || actual.configuration.as_deref() != expected.configuration 1817 || actual.dependency_count == 0 1818 || !valid_hex(&actual.input_sha256, 64) 1819 || !repositories.contains(actual.repository.as_str()) 1820 { 1821 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1822 } 1823 } 1824 if !request 1825 .admitted_gradle_graphs 1826 .iter() 1827 .map(|graph| graph.workload_id.as_str()) 1828 .eq(WORKLOADS 1829 .iter() 1830 .filter(|workload| workload.package_manager == "gradle") 1831 .map(|workload| workload.id)) 1832 { 1833 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1834 } 1835 for graph in &request.admitted_gradle_graphs { 1836 graph.revalidate()?; 1837 } 1838 let expected_scanner_outputs = [ProviderId::Rustsec, ProviderId::OwaspNvd] 1839 .into_iter() 1840 .flat_map(|provider| { 1841 WORKLOADS 1842 .iter() 1843 .filter(move |workload| match provider { 1844 ProviderId::Rustsec => workload.package_manager == "cargo", 1845 ProviderId::OwaspNvd => workload.package_manager == "gradle", 1846 }) 1847 .map(move |workload| (provider, workload.id)) 1848 }); 1849 if !request 1850 .admitted_scanner_outputs 1851 .iter() 1852 .map(|output| (output.provider, output.workload_id.as_str())) 1853 .eq(expected_scanner_outputs) 1854 { 1855 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1856 } 1857 for output in &request.admitted_scanner_outputs { 1858 output.revalidate()?; 1859 } 1860 Ok(()) 1861 } 1862 1863 fn validate_manifest( 1864 manifest: &SnapshotManifest, 1865 request: &AdmissionRequest, 1866 ) -> Result<(), AdvisoryError> { 1867 if manifest.schema != "radroots.advisory-snapshot.v1" 1868 || manifest.candidate != request.candidate 1869 || manifest.sources != request.sources 1870 || manifest.inventory != request.inventory 1871 || manifest.inventory_sha256 != inventory_digest(&request.inventory)? 1872 { 1873 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1874 } 1875 let (tool_manifest, tool_observation, trace, provider_evidence) = 1876 validate_trusted_authority(request)?; 1877 if manifest.step_297_tool_manifest_sha256 != sha256(&request.step_297_tool_manifest) 1878 || manifest.fresh_tool_observation_sha256 != sha256(&request.fresh_tool_observation) 1879 || manifest.tool_acquisition != tool_manifest.tool_acquisition 1880 || manifest.tool_state != tool_observation.tool_state 1881 || manifest.gradle_projection != provider_evidence.gradle_projection 1882 || manifest.provider_snapshot != provider_evidence.provider_snapshot 1883 || manifest.suppressions != provider_evidence.suppressions 1884 || manifest.candidate_advisory_input_sha256 1885 != provider_evidence.candidate_advisory_input_sha256 1886 { 1887 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 1888 } 1889 validate_tool_acquisitions(&manifest.tool_acquisition)?; 1890 validate_tool_states(&manifest.tool_state, request.evaluation_epoch)?; 1891 validate_gradle_projections( 1892 &manifest.gradle_projection, 1893 request, 1894 &provider_evidence.process_receipt, 1895 )?; 1896 if manifest.provider_snapshot.len() != 2 1897 || manifest.provider_snapshot[0].provider != ProviderId::Rustsec 1898 || manifest.provider_snapshot[1].provider != ProviderId::OwaspNvd 1899 { 1900 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 1901 } 1902 for provider in &manifest.provider_snapshot { 1903 validate_provider( 1904 provider, 1905 request, 1906 &trace, 1907 &provider_evidence.process_receipt, 1908 )?; 1909 } 1910 validate_temporal_order( 1911 &tool_observation, 1912 &manifest.provider_snapshot, 1913 &provider_evidence.process_receipt, 1914 )?; 1915 if manifest.candidate_advisory_input_sha256 != candidate_advisory_input_digest(manifest)? { 1916 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 1917 } 1918 Ok(()) 1919 } 1920 1921 fn validate_trusted_authority( 1922 request: &AdmissionRequest, 1923 ) -> Result< 1924 ( 1925 TrustedToolManifest, 1926 TrustedToolObservation, 1927 NvdNetworkTrace, 1928 TrustedProviderEvidence, 1929 ), 1930 AdvisoryError, 1931 > { 1932 if request.producer_request.len() > MAX_PRODUCER_REQUEST_BYTES 1933 || request.step_297_tool_manifest.len() > MAX_TOOL_MANIFEST_BYTES 1934 || request.fresh_tool_observation.len() > MAX_TOOL_OBSERVATION_BYTES 1935 || request.nvd_network_trace.len() > MAX_NVD_TRACE_BYTES 1936 || request.provider_execution_evidence.len() > MAX_PROVIDER_EVIDENCE_BYTES 1937 { 1938 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 1939 } 1940 let producer: TrustedProducerRequest = parse_canonical_authority(&request.producer_request)?; 1941 let producer_digest = sha256(&request.producer_request); 1942 let tool_manifest: TrustedToolManifest = 1943 parse_canonical_authority(&request.step_297_tool_manifest)?; 1944 let observation: TrustedToolObservation = 1945 parse_canonical_authority(&request.fresh_tool_observation)?; 1946 let trace: NvdNetworkTrace = parse_canonical_authority(&request.nvd_network_trace)?; 1947 let provider_evidence: TrustedProviderEvidence = 1948 parse_canonical_authority(&request.provider_execution_evidence)?; 1949 let row_projection = 1950 tool_observation_digest(&tool_manifest.tool_acquisition, &observation.tool_state)?; 1951 let expected_workloads = WORKLOADS 1952 .iter() 1953 .map(|workload| workload.id.to_owned()) 1954 .collect::<Vec<_>>(); 1955 let expected_query_keys = [ 1956 "lastModStartDate", 1957 "lastModEndDate", 1958 "startIndex", 1959 "resultsPerPage", 1960 ] 1961 .into_iter() 1962 .map(str::to_owned) 1963 .collect::<Vec<_>>(); 1964 if producer.schema != "radroots.advisory-producer-request.v1" 1965 || producer.candidate_generation != request.candidate.generation 1966 || producer.candidate_digest != request.candidate.digest 1967 || producer.platform != "macos_aarch64" 1968 || producer.process_contract_sha256 != BOUNDED_PROCESS_DECISION_SHA256 1969 || producer.process_runner_source_sha256 != BOUNDED_PROCESS_SOURCE_SHA256 1970 || producer.nvd_endpoint != "/rest/json/cves/2.0" 1971 || !valid_hex(&producer.nvd_enforcement_program_sha256, 64) 1972 || !valid_hex(&producer.nvd_enforcement_configuration_sha256, 64) 1973 || producer.nvd_query_keys != expected_query_keys 1974 || producer.workload_ids != expected_workloads 1975 || tool_manifest.schema != "radroots.advisory-tool-manifest-projection.v1" 1976 || tool_manifest.candidate_generation != request.candidate.generation 1977 || tool_manifest.candidate_digest != request.candidate.digest 1978 || tool_manifest.platform != "macos_aarch64" 1979 || tool_manifest.producer_request_sha256 != producer_digest 1980 || observation.schema != "radroots.advisory-tool-observation.v1" 1981 || observation.candidate_generation != request.candidate.generation 1982 || observation.candidate_digest != request.candidate.digest 1983 || observation.platform != "macos_aarch64" 1984 || observation.producer_request_sha256 != producer_digest 1985 || observation.tool_manifest_sha256 != sha256(&request.step_297_tool_manifest) 1986 || observation.row_projection_sha256 != row_projection 1987 || observation.observed_at_epoch > request.evaluation_epoch 1988 || request 1989 .evaluation_epoch 1990 .checked_sub(observation.observed_at_epoch) 1991 .is_none_or(|age| age > TOOL_REVIEW_SECONDS) 1992 || trace.schema != "radroots.advisory-nvd-network-trace.v1" 1993 || trace.candidate_generation != request.candidate.generation 1994 || trace.candidate_digest != request.candidate.digest 1995 || trace.producer_request_sha256 != producer_digest 1996 || trace.enforcement != "deny_by_default_nvd_api_get_only_proxy" 1997 || trace.enforcement_program_sha256 != producer.nvd_enforcement_program_sha256 1998 || trace.enforcement_configuration_sha256 != producer.nvd_enforcement_configuration_sha256 1999 || trace.request.is_empty() 2000 || trace.request.len() > MAX_NVD_REQUESTS 2001 || provider_evidence.schema != "radroots.advisory-provider-execution-evidence.v1" 2002 || provider_evidence.candidate_generation != request.candidate.generation 2003 || provider_evidence.candidate_digest != request.candidate.digest 2004 || provider_evidence.platform != "macos_aarch64" 2005 || provider_evidence.producer_request_sha256 != producer_digest 2006 || provider_evidence.gradle_projection.len() != 6 2007 || provider_evidence.provider_snapshot.len() != 2 2008 { 2009 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 2010 } 2011 validate_tool_acquisitions(&tool_manifest.tool_acquisition)?; 2012 validate_tool_states(&observation.tool_state, request.evaluation_epoch)?; 2013 validate_process_receipts(&provider_evidence.process_receipt, request.evaluation_epoch)?; 2014 validate_nvd_trace(&producer, &trace, &provider_evidence.process_receipt)?; 2015 Ok((tool_manifest, observation, trace, provider_evidence)) 2016 } 2017 2018 fn validate_nvd_trace( 2019 producer: &TrustedProducerRequest, 2020 trace: &NvdNetworkTrace, 2021 receipts: &[TrustedProcessReceipt], 2022 ) -> Result<(), AdvisoryError> { 2023 let update = process_receipt(receipts, "owasp_nvd_update")?; 2024 let mut aggregate_bytes = 0_u64; 2025 let mut previous_request_completed = None; 2026 let mut previous_window: Option<(u64, u64, u64, u64, u64)> = None; 2027 for (index, row) in trace.request.iter().enumerate() { 2028 let values = row 2029 .query 2030 .iter() 2031 .map(|query| (query.name.as_str(), query.value.as_str())) 2032 .collect::<BTreeMap<_, _>>(); 2033 let start = values 2034 .get("lastModStartDate") 2035 .and_then(|value| parse_report_epoch(value).ok()) 2036 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 2037 let end = values 2038 .get("lastModEndDate") 2039 .and_then(|value| parse_report_epoch(value).ok()) 2040 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 2041 let start_index = values 2042 .get("startIndex") 2043 .and_then(|value| parse_canonical_u64(value)) 2044 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 2045 let results_per_page = values 2046 .get("resultsPerPage") 2047 .and_then(|value| parse_canonical_u64(value)) 2048 .filter(|value| (1..=2_000).contains(value)) 2049 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 2050 aggregate_bytes = aggregate_bytes 2051 .checked_add(row.response_byte_length) 2052 .filter(|value| *value <= MAX_NVD_AGGREGATE_BYTES) 2053 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 2054 if row.sequence != index as u64 + 1 2055 || row.method != "GET" 2056 || row.scheme != "https" 2057 || row.authority != "services.nvd.nist.gov" 2058 || row.path != producer.nvd_endpoint 2059 || row.query.len() != producer.nvd_query_keys.len() 2060 || !row 2061 .query 2062 .iter() 2063 .map(|query| query.name.as_str()) 2064 .eq(producer.nvd_query_keys.iter().map(String::as_str)) 2065 || row.query.iter().any(|query| { 2066 !valid_bounded_text(&query.value, 128) || query.name.eq_ignore_ascii_case("apiKey") 2067 }) 2068 || start > end 2069 || row.started_at_epoch < update.started_at_epoch 2070 || previous_request_completed.is_some_and(|completed| completed > row.started_at_epoch) 2071 || row.completed_at_epoch < row.started_at_epoch 2072 || row.completed_at_epoch > update.completed_at_epoch 2073 || row.response_status != 200 2074 || row.response_byte_length == 0 2075 || row.response_byte_length > MAX_NVD_RESPONSE_BYTES 2076 || !valid_hex(&row.response_sha256, 64) 2077 || row.response_start_index != start_index 2078 || row.response_results_per_page != results_per_page 2079 || row.response_total_results == 0 2080 || start_index >= row.response_total_results 2081 { 2082 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 2083 } 2084 if let Some((previous_start, previous_end, previous_index, previous_page, previous_total)) = 2085 previous_window 2086 { 2087 if start == previous_start && end == previous_end { 2088 if start_index != previous_index.saturating_add(previous_page) 2089 || row.response_total_results != previous_total 2090 { 2091 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 2092 } 2093 } else if start != previous_end.saturating_add(1) || start_index != 0 { 2094 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 2095 } 2096 } else if start_index != 0 { 2097 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 2098 } 2099 let page_complete = start_index 2100 .checked_add(results_per_page) 2101 .is_some_and(|next| next >= row.response_total_results); 2102 let next_same_window = trace.request.get(index + 1).is_some_and(|next| { 2103 let next_values = next 2104 .query 2105 .iter() 2106 .map(|query| (query.name.as_str(), query.value.as_str())) 2107 .collect::<BTreeMap<_, _>>(); 2108 next_values.get("lastModStartDate") == values.get("lastModStartDate") 2109 && next_values.get("lastModEndDate") == values.get("lastModEndDate") 2110 }); 2111 if page_complete == next_same_window { 2112 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 2113 } 2114 previous_request_completed = Some(row.completed_at_epoch); 2115 previous_window = Some(( 2116 start, 2117 end, 2118 start_index, 2119 results_per_page, 2120 row.response_total_results, 2121 )); 2122 } 2123 Ok(()) 2124 } 2125 2126 fn parse_canonical_u64(value: &str) -> Option<u64> { 2127 if value.is_empty() 2128 || !value.bytes().all(|byte| byte.is_ascii_digit()) 2129 || (value.len() > 1 && value.starts_with('0')) 2130 { 2131 return None; 2132 } 2133 value.parse().ok() 2134 } 2135 2136 fn expected_process_receipt_ids() -> Vec<String> { 2137 let mut ids = vec!["owasp_nvd_update".to_owned()]; 2138 ids.extend( 2139 WORKLOADS 2140 .iter() 2141 .filter(|workload| workload.package_manager == "gradle") 2142 .map(|workload| format!("gradle:{}", workload.id)), 2143 ); 2144 ids.extend( 2145 WORKLOADS 2146 .iter() 2147 .filter(|workload| workload.package_manager == "cargo") 2148 .map(|workload| format!("cargo_audit:{}", workload.id)), 2149 ); 2150 ids.extend( 2151 WORKLOADS 2152 .iter() 2153 .filter(|workload| workload.package_manager == "gradle") 2154 .map(|workload| format!("owasp_analysis:{}", workload.id)), 2155 ); 2156 ids 2157 } 2158 2159 fn validate_process_receipts( 2160 receipts: &[TrustedProcessReceipt], 2161 evaluation_epoch: u64, 2162 ) -> Result<(), AdvisoryError> { 2163 if !receipts 2164 .iter() 2165 .map(|receipt| receipt.id.as_str()) 2166 .eq(expected_process_receipt_ids().iter().map(String::as_str)) 2167 { 2168 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2169 } 2170 let mut previous_completed = None; 2171 let mut working_directory_identities = BTreeSet::new(); 2172 let mut private_environment_values = BTreeSet::new(); 2173 let mut shared_environment_values = BTreeMap::<String, String>::new(); 2174 for receipt in receipts { 2175 require_complete(receipt.state)?; 2176 validate_process_environment( 2177 &receipt.id, 2178 &receipt.environment, 2179 &mut private_environment_values, 2180 &mut shared_environment_values, 2181 )?; 2182 if !valid_exact_identity_token(&receipt.id, 256) 2183 || !valid_hex(&receipt.program_sha256, 64) 2184 || receipt.runtime_sha256 != expected_runtime_sha256(&receipt.id) 2185 || !valid_hex(&receipt.arguments_sha256, 64) 2186 || receipt.environment_sha256 != process_environment_digest(&receipt.environment)? 2187 || validate_process_working_directory(&receipt.id, &receipt.working_directory).is_err() 2188 || !working_directory_identities 2189 .insert(receipt.working_directory.identity_sha256.clone()) 2190 || receipt.working_directory_sha256 2191 != process_working_directory_digest(&receipt.working_directory)? 2192 || receipt 2193 .path_binding 2194 .windows(2) 2195 .any(|rows| rows[0].argument_index >= rows[1].argument_index) 2196 || receipt.path_binding.iter().any(|binding| { 2197 !valid_exact_identity_token(&binding.logical_role, 128) 2198 || !valid_hex(&binding.identity_sha256, 64) 2199 }) 2200 || !receipt.stdin_closed 2201 || receipt.deadline_seconds != ANALYSIS_DEADLINE_SECONDS 2202 || receipt.started_at_epoch == 0 2203 || receipt.completed_at_epoch < receipt.started_at_epoch 2204 || previous_completed.is_some_and(|previous| previous > receipt.started_at_epoch) 2205 || receipt.completed_at_epoch > evaluation_epoch 2206 || receipt.stdout_byte_length > 67_108_864 2207 || receipt.stderr_byte_length > 67_108_864 2208 || !valid_hex(&receipt.stdout_sha256, 64) 2209 || !valid_hex(&receipt.stderr_sha256, 64) 2210 || !valid_hex(&receipt.input_sha256, 64) 2211 || !valid_hex(&receipt.output_sha256, 64) 2212 { 2213 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 2214 } 2215 previous_completed = Some(receipt.completed_at_epoch); 2216 } 2217 if private_environment_values 2218 .iter() 2219 .any(|value| working_directory_identities.contains(value)) 2220 { 2221 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 2222 } 2223 Ok(()) 2224 } 2225 2226 fn expected_process_environment(id: &str) -> Result<Vec<ProcessEnvironmentRow>, AdvisoryError> { 2227 expected_environment_names(id) 2228 .map(|name| { 2229 let logical_value = expected_environment_logical_value(name)?; 2230 let synthetic_value = match name { 2231 "LC_ALL" => "C".to_owned(), 2232 "TZ" => "UTC".to_owned(), 2233 "JAVA_HOME" => "/governed/jdk-21.0.12.1".to_owned(), 2234 "PATH" => "/governed/tool-manifest/bin".to_owned(), 2235 _ => format!("/private/{id}/{name}"), 2236 }; 2237 Ok(ProcessEnvironmentRow { 2238 name: name.to_owned(), 2239 logical_value: logical_value.to_owned(), 2240 value_sha256: sha256(synthetic_value.as_bytes()), 2241 }) 2242 }) 2243 .collect() 2244 } 2245 2246 fn expected_environment_logical_value(name: &str) -> Result<&'static str, AdvisoryError> { 2247 match name { 2248 "CARGO_HOME" => Ok("private_empty_cargo_home"), 2249 "GRADLE_USER_HOME" => Ok("private_seeded_gradle_user_home"), 2250 "HOME" => Ok("private_empty_home"), 2251 "JAVA_HOME" => Ok("pinned_java_21_0_12_1_home"), 2252 "LC_ALL" => Ok("C"), 2253 "PATH" => Ok("pinned_tool_manifest_only_path"), 2254 "TMPDIR" => Ok("private_owner_only_attempt_tmp"), 2255 "TZ" => Ok("UTC"), 2256 _ => Err(AdvisoryError::new(AdvisoryFailureKind::InvalidContract)), 2257 } 2258 } 2259 2260 fn validate_process_environment( 2261 id: &str, 2262 environment: &[ProcessEnvironmentRow], 2263 private_values: &mut BTreeSet<String>, 2264 shared_values: &mut BTreeMap<String, String>, 2265 ) -> Result<(), AdvisoryError> { 2266 let expected_names = expected_environment_names(id).collect::<Vec<_>>(); 2267 if environment.len() != expected_names.len() { 2268 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 2269 } 2270 for (row, expected_name) in environment.iter().zip(expected_names) { 2271 let expected_logical_value = expected_environment_logical_value(expected_name)?; 2272 if row.name != expected_name 2273 || row.logical_value != expected_logical_value 2274 || !valid_hex(&row.value_sha256, 64) 2275 || match expected_name { 2276 "LC_ALL" => row.value_sha256 != sha256(b"C"), 2277 "TZ" => row.value_sha256 != sha256(b"UTC"), 2278 "JAVA_HOME" | "PATH" => { 2279 shared_values 2280 .entry(expected_name.to_owned()) 2281 .or_insert_with(|| row.value_sha256.clone()) 2282 != &row.value_sha256 2283 } 2284 _ => !private_values.insert(row.value_sha256.clone()), 2285 } 2286 { 2287 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 2288 } 2289 } 2290 Ok(()) 2291 } 2292 2293 fn expected_process_working_directory(id: &str) -> Result<ProcessWorkingDirectory, AdvisoryError> { 2294 let (logical_uri, kind, pre_entries, post_entries) = 2295 if let Some(workload) = id.strip_prefix("cargo_audit:") { 2296 ( 2297 format!("rshr-private-cargo-audit://{workload}"), 2298 "fresh_empty_config_free", 2299 0, 2300 0, 2301 ) 2302 } else if let Some(workload) = id.strip_prefix("gradle:") { 2303 ( 2304 format!("rshr-candidate-gradle-build://{workload}"), 2305 "retained_candidate_source", 2306 1, 2307 1, 2308 ) 2309 } else if let Some(workload) = id.strip_prefix("owasp_analysis:") { 2310 ( 2311 format!("rshr-private-owasp-analysis://{workload}"), 2312 "fresh_private_analysis", 2313 0, 2314 0, 2315 ) 2316 } else if id == "owasp_nvd_update" { 2317 ( 2318 "rshr-private-owasp-nvd-update://attempt".to_owned(), 2319 "fresh_private_update", 2320 0, 2321 1, 2322 ) 2323 } else { 2324 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidContract)); 2325 }; 2326 let empty_tree = empty_directory_tree_sha256()?; 2327 let nonempty_tree = domain_json_digest( 2328 b"radroots-advisory-synthetic-working-directory-tree-v1\0", 2329 &serde_json::json!({"kind": kind, "process_id": id}), 2330 )?; 2331 Ok(ProcessWorkingDirectory { 2332 identity_sha256: domain_json_digest( 2333 b"radroots-advisory-synthetic-retained-directory-identity-v1\0", 2334 &serde_json::json!({"kind": kind, "logical_uri": logical_uri, "process_id": id}), 2335 )?, 2336 logical_uri, 2337 kind: kind.to_owned(), 2338 pre_execution_entry_count: pre_entries, 2339 pre_execution_tree_sha256: if pre_entries == 0 { 2340 empty_tree.clone() 2341 } else { 2342 nonempty_tree.clone() 2343 }, 2344 post_execution_entry_count: post_entries, 2345 post_execution_tree_sha256: if post_entries == 0 { 2346 empty_tree 2347 } else { 2348 nonempty_tree 2349 }, 2350 }) 2351 } 2352 2353 fn validate_process_working_directory( 2354 id: &str, 2355 directory: &ProcessWorkingDirectory, 2356 ) -> Result<(), AdvisoryError> { 2357 let expected = expected_process_working_directory(id)?; 2358 if directory.logical_uri != expected.logical_uri 2359 || directory.kind != expected.kind 2360 || !valid_hex(&directory.identity_sha256, 64) 2361 || !valid_hex(&directory.pre_execution_tree_sha256, 64) 2362 || !valid_hex(&directory.post_execution_tree_sha256, 64) 2363 || match directory.kind.as_str() { 2364 "fresh_empty_config_free" | "fresh_private_analysis" => { 2365 directory.pre_execution_entry_count != 0 2366 || directory.post_execution_entry_count != 0 2367 || directory.pre_execution_tree_sha256 != empty_directory_tree_sha256()? 2368 || directory.post_execution_tree_sha256 != empty_directory_tree_sha256()? 2369 } 2370 "retained_candidate_source" => { 2371 directory.pre_execution_entry_count == 0 2372 || directory.pre_execution_entry_count != directory.post_execution_entry_count 2373 || directory.pre_execution_tree_sha256 != directory.post_execution_tree_sha256 2374 } 2375 "fresh_private_update" => { 2376 directory.pre_execution_entry_count != 0 2377 || directory.post_execution_entry_count == 0 2378 || directory.pre_execution_tree_sha256 != empty_directory_tree_sha256()? 2379 } 2380 _ => true, 2381 } 2382 { 2383 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 2384 } 2385 Ok(()) 2386 } 2387 2388 fn empty_directory_tree_sha256() -> Result<String, AdvisoryError> { 2389 domain_json_digest( 2390 b"radroots-advisory-empty-directory-tree-v1\0", 2391 &Vec::<Value>::new(), 2392 ) 2393 } 2394 2395 fn expected_environment_names(id: &str) -> impl Iterator<Item = &'static str> { 2396 let names: &'static [&'static str] = if id.starts_with("cargo_audit:") { 2397 &["CARGO_HOME", "HOME", "LC_ALL", "PATH", "TMPDIR", "TZ"] 2398 } else if id.starts_with("gradle:") { 2399 &[ 2400 "GRADLE_USER_HOME", 2401 "HOME", 2402 "JAVA_HOME", 2403 "LC_ALL", 2404 "PATH", 2405 "TMPDIR", 2406 "TZ", 2407 ] 2408 } else { 2409 &["HOME", "JAVA_HOME", "LC_ALL", "PATH", "TMPDIR", "TZ"] 2410 }; 2411 names.iter().copied() 2412 } 2413 2414 fn expected_runtime_sha256(id: &str) -> Vec<String> { 2415 let values = if id.starts_with("cargo_audit:") { 2416 vec![TOOL_PINS[0].executable_sha256] 2417 } else if id.starts_with("gradle:") { 2418 vec![ 2419 TOOL_PINS[3].executable_sha256, 2420 TOOL_PINS[2].executable_sha256, 2421 ] 2422 } else { 2423 vec![ 2424 TOOL_PINS[1].executable_sha256, 2425 TOOL_PINS[2].executable_sha256, 2426 ] 2427 }; 2428 values.into_iter().map(str::to_owned).collect() 2429 } 2430 2431 fn process_environment_digest( 2432 environment: &[ProcessEnvironmentRow], 2433 ) -> Result<String, AdvisoryError> { 2434 domain_json_digest(b"radroots-advisory-process-environment-v1\0", environment) 2435 } 2436 2437 fn process_working_directory_digest( 2438 working_directory: &ProcessWorkingDirectory, 2439 ) -> Result<String, AdvisoryError> { 2440 domain_json_digest( 2441 b"radroots-advisory-process-working-directory-v1\0", 2442 working_directory, 2443 ) 2444 } 2445 2446 fn process_receipt<'a>( 2447 receipts: &'a [TrustedProcessReceipt], 2448 id: &str, 2449 ) -> Result<&'a TrustedProcessReceipt, AdvisoryError> { 2450 receipts 2451 .iter() 2452 .find(|receipt| receipt.id == id) 2453 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)) 2454 } 2455 2456 fn process_receipt_digest(receipt: &TrustedProcessReceipt) -> Result<String, AdvisoryError> { 2457 domain_json_digest(b"radroots-advisory-bounded-process-receipt-v1\0", receipt) 2458 } 2459 2460 fn parse_canonical_authority<T>(bytes: &[u8]) -> Result<T, AdvisoryError> 2461 where 2462 T: for<'de> Deserialize<'de>, 2463 { 2464 let value: Value = serde_json::from_slice(bytes) 2465 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 2466 if canonical_json(&value) != bytes { 2467 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 2468 } 2469 serde_json::from_value(value) 2470 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)) 2471 } 2472 2473 fn validate_tool_acquisitions(actual: &[ToolAcquisition]) -> Result<(), AdvisoryError> { 2474 if actual.len() != TOOL_PINS.len() { 2475 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2476 } 2477 for (tool, pin) in actual.iter().zip(TOOL_PINS) { 2478 if tool.id != pin.id 2479 || sha256(&canonical_json_without_lf(&tool.projection)) != pin.projection_sha256 2480 { 2481 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidContract)); 2482 } 2483 } 2484 Ok(()) 2485 } 2486 2487 fn validate_tool_states(actual: &[ToolState], evaluation_epoch: u64) -> Result<(), AdvisoryError> { 2488 if actual.len() != TOOL_PINS.len() { 2489 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2490 } 2491 for (state, pin) in actual.iter().zip(TOOL_PINS) { 2492 if state.id != pin.id 2493 || state.normalized_version != pin.version 2494 || state.executable_sha256 != pin.executable_sha256 2495 || state.source_sha256 != pin.source_sha256 2496 || state.package_receipt_sha256 != pin.receipt_sha256 2497 || state.reviewed_at_epoch == 0 2498 { 2499 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2500 } 2501 require_complete(state.state)?; 2502 let age = evaluation_epoch 2503 .checked_sub(state.reviewed_at_epoch) 2504 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::StaleSnapshot))?; 2505 if age > TOOL_REVIEW_SECONDS { 2506 return Err(AdvisoryError::new(AdvisoryFailureKind::StaleSnapshot)); 2507 } 2508 } 2509 Ok(()) 2510 } 2511 2512 fn validate_gradle_projections( 2513 projections: &[GradleProjection], 2514 request: &AdmissionRequest, 2515 receipts: &[TrustedProcessReceipt], 2516 ) -> Result<(), AdvisoryError> { 2517 if sha256(GRADLE_INIT_SCRIPT_BYTES) != GRADLE_INIT_SCRIPT_SHA256 { 2518 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidContract)); 2519 } 2520 let expected = WORKLOADS 2521 .iter() 2522 .filter(|workload| workload.package_manager == "gradle") 2523 .collect::<Vec<_>>(); 2524 if projections.len() != expected.len() { 2525 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2526 } 2527 let harvest_source = request 2528 .sources 2529 .iter() 2530 .find(|source| source.repository == "oss/harvestcircle") 2531 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 2532 for ((projection, authority), admitted) in projections 2533 .iter() 2534 .zip(expected) 2535 .zip(&request.admitted_gradle_graphs) 2536 { 2537 let inventory = request 2538 .inventory 2539 .iter() 2540 .find(|row| row.id == authority.id) 2541 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 2542 require_complete(projection.state)?; 2543 let receipt = process_receipt(receipts, &format!("gradle:{}", authority.id))?; 2544 admitted.revalidate()?; 2545 if !admitted.matches_projection(projection) { 2546 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2547 } 2548 validate_gradle_projection_bindings( 2549 projection, 2550 authority, 2551 inventory, 2552 harvest_source, 2553 request, 2554 receipt, 2555 )?; 2556 validate_gradle_graph_structure(projection, authority, &harvest_source.revision)?; 2557 admitted.revalidate()?; 2558 } 2559 Ok(()) 2560 } 2561 2562 fn validate_gradle_projection_bindings( 2563 projection: &GradleProjection, 2564 authority: &WorkloadAuthority, 2565 inventory: &WorkloadInventory, 2566 harvest_source: &SourceIdentity, 2567 request: &AdmissionRequest, 2568 receipt: &TrustedProcessReceipt, 2569 ) -> Result<(), AdvisoryError> { 2570 if projection.workload_id != authority.id 2571 || projection.raw_graph_byte_length == 0 2572 || projection.raw_graph_byte_length > MAX_GRADLE_GRAPH_BYTES 2573 || !valid_hex(&projection.raw_graph_sha256, 64) 2574 || projection.init_script_sha256 != GRADLE_INIT_SCRIPT_SHA256 2575 || projection.wrapper_arguments != expected_gradle_arguments(authority)? 2576 || projection.environment_keys 2577 != [ 2578 "GRADLE_USER_HOME", 2579 "HOME", 2580 "JAVA_HOME", 2581 "LC_ALL", 2582 "PATH", 2583 "TMPDIR", 2584 "TZ", 2585 ] 2586 || !valid_hex(&projection.environment_sha256, 64) 2587 || projection.exit_code != 0 2588 || projection.source_revision != harvest_source.revision 2589 || projection.source_tree != harvest_source.tree 2590 || projection.input_sha256 2591 != gradle_candidate_input_digest(request, inventory, harvest_source, projection)? 2592 || projection.dependency_count != inventory.dependency_count 2593 || projection.component_count != projection.components.len() as u64 2594 || projection.edge_count != projection.edges.len() as u64 2595 || projection.artifact_count != projection.artifacts.len() as u64 2596 || projection.component_count == 0 2597 || !valid_hex(&projection.materialized_tree_sha256, 64) 2598 || !valid_hex(&projection.artifact_source_roots_sha256, 64) 2599 || !valid_hex(&projection.seed_cache_inventory_sha256, 64) 2600 || projection.wrapper_distribution_sha256 2601 != "553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746" 2602 || projection.process_receipt_sha256 != process_receipt_digest(receipt)? 2603 || projection.canonical_graph_sha256 != gradle_graph_digest(projection)? 2604 || projection.normalization_receipt_sha256 2605 != gradle_normalization_receipt_digest( 2606 &projection.workload_id, 2607 projection.raw_graph_byte_length, 2608 &projection.raw_graph_sha256, 2609 &projection.canonical_graph_sha256, 2610 &projection.materialized_tree_sha256, 2611 &projection.artifact_source_roots_sha256, 2612 )? 2613 || receipt.program_sha256 != TOOL_PINS[3].executable_sha256 2614 || receipt.arguments_sha256 2615 != domain_json_digest( 2616 b"radroots-advisory-process-arguments-v1\0", 2617 &projection.wrapper_arguments, 2618 )? 2619 || receipt.environment_sha256 != projection.environment_sha256 2620 || receipt.input_sha256 != gradle_process_input_digest(projection)? 2621 || receipt.output_sha256 != gradle_process_output_digest(projection)? 2622 || receipt.path_binding 2623 != vec![ 2624 ProcessPathBinding { 2625 argument_index: 5, 2626 logical_role: "gradle_build_root".to_owned(), 2627 identity_sha256: gradle_build_root_binding_digest(projection)?, 2628 }, 2629 ProcessPathBinding { 2630 argument_index: 7, 2631 logical_role: "gradle_init_script".to_owned(), 2632 identity_sha256: projection.init_script_sha256.clone(), 2633 }, 2634 ProcessPathBinding { 2635 argument_index: 10, 2636 logical_role: "gradle_projection_output".to_owned(), 2637 identity_sha256: projection.raw_graph_sha256.clone(), 2638 }, 2639 ] 2640 || receipt.exit_code != projection.exit_code 2641 { 2642 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2643 } 2644 Ok(()) 2645 } 2646 2647 fn validate_gradle_graph_structure( 2648 projection: &GradleProjection, 2649 authority: &WorkloadAuthority, 2650 source_revision: &str, 2651 ) -> Result<(), AdvisoryError> { 2652 let mut component_ids = BTreeSet::new(); 2653 let mut component_by_id = BTreeMap::new(); 2654 let mut previous_component = None::<&str>; 2655 for component in &projection.components { 2656 if previous_component.is_some_and(|previous| previous >= component.identity_sha256.as_str()) 2657 || component.identity_sha256 != gradle_component_digest(component)? 2658 || component.variant_sha256 != gradle_variant_digest(&component.variant)? 2659 || !valid_gradle_variant_envelope(&component.variant) 2660 || !valid_gradle_component(component) 2661 || !component_ids.insert(component.identity_sha256.as_str()) 2662 { 2663 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2664 } 2665 component_by_id.insert(component.identity_sha256.as_str(), component); 2666 previous_component = Some(&component.identity_sha256); 2667 } 2668 let roots = projection 2669 .components 2670 .iter() 2671 .filter(|component| { 2672 component.root 2673 && component.kind == "project" 2674 && component.build_root.as_deref() == Some(authority.build_root) 2675 && component.project_path.as_deref() == authority.project_path 2676 }) 2677 .collect::<Vec<_>>(); 2678 if roots.len() != 1 { 2679 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2680 } 2681 let mut previous_edge = None::<Vec<u8>>; 2682 for edge in &projection.edges { 2683 let key = canonical_row_key(edge)?; 2684 if previous_edge 2685 .as_ref() 2686 .is_some_and(|previous| previous >= &key) 2687 || !component_ids.contains(edge.from_identity_sha256.as_str()) 2688 || !component_ids.contains(edge.to_identity_sha256.as_str()) 2689 || edge.from_identity_sha256 == edge.to_identity_sha256 2690 || edge.requested_sha256 != gradle_request_digest(&edge.requested)? 2691 || edge.selected_variant_sha256 != gradle_variant_digest(&edge.selected_variant)? 2692 || !valid_gradle_request(&edge.requested) 2693 || !valid_gradle_variant(&edge.selected_variant) 2694 { 2695 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2696 } 2697 previous_edge = Some(key); 2698 } 2699 let mut reachable = BTreeSet::from([roots[0].identity_sha256.as_str()]); 2700 loop { 2701 let before = reachable.len(); 2702 for edge in &projection.edges { 2703 if reachable.contains(edge.from_identity_sha256.as_str()) { 2704 reachable.insert(edge.to_identity_sha256.as_str()); 2705 } 2706 } 2707 if reachable.len() == before { 2708 break; 2709 } 2710 } 2711 if reachable.len() != projection.components.len() { 2712 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2713 } 2714 let mut previous = None::<Vec<u8>>; 2715 let mut materialized = BTreeMap::<(&str, &str), (&str, u64)>::new(); 2716 for artifact in &projection.artifacts { 2717 let key = canonical_row_key(artifact)?; 2718 if previous.as_ref().is_some_and(|prior| prior >= &key) 2719 || !component_ids.contains(artifact.component_identity_sha256.as_str()) 2720 || !valid_bounded_text(&artifact.component, 512) 2721 || artifact.package_ecosystem != "maven" 2722 || !valid_bounded_text(&artifact.package_namespace, 256) 2723 || !valid_bounded_text(&artifact.package_name, 256) 2724 || !valid_bounded_text(&artifact.package_version, 128) 2725 || !valid_hex(&artifact.artifact_sha256, 64) 2726 || artifact.variant_sha256 != gradle_variant_digest(&artifact.variant)? 2727 || !valid_gradle_variant(&artifact.variant) 2728 || artifact.byte_length == 0 2729 || !valid_bounded_text(&artifact.artifact_name, 256) 2730 || !valid_bounded_text(&artifact.logical_name, 256) 2731 || !valid_bounded_text(&artifact.artifact_type, 64) 2732 || artifact 2733 .classifier 2734 .as_deref() 2735 .is_some_and(|classifier| !valid_bounded_text(classifier, 128)) 2736 || !valid_artifact_extension(&artifact.extension) 2737 || artifact.materialized_name 2738 != format!("{}.{}", artifact.artifact_sha256, artifact.extension) 2739 { 2740 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2741 } 2742 let component = component_by_id 2743 .get(artifact.component_identity_sha256.as_str()) 2744 .copied() 2745 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 2746 if !artifact_matches_component(artifact, component, source_revision) 2747 || !projection.edges.iter().any(|edge| { 2748 edge.to_identity_sha256 == artifact.component_identity_sha256 2749 && edge.selected_variant_sha256 == artifact.variant_sha256 2750 }) 2751 { 2752 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2753 } 2754 let materialized_key = ( 2755 artifact.artifact_sha256.as_str(), 2756 artifact.extension.as_str(), 2757 ); 2758 if let Some((name, length)) = materialized.insert( 2759 materialized_key, 2760 (artifact.materialized_name.as_str(), artifact.byte_length), 2761 ) && (name != artifact.materialized_name || length != artifact.byte_length) 2762 { 2763 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2764 } 2765 previous = Some(key); 2766 } 2767 Ok(()) 2768 } 2769 2770 fn expected_gradle_arguments(workload: &WorkloadAuthority) -> Result<Vec<String>, AdvisoryError> { 2771 let project = workload 2772 .project_path 2773 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))?; 2774 let configuration = workload 2775 .configuration 2776 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))?; 2777 Ok([ 2778 "--offline".to_owned(), 2779 "--no-daemon".to_owned(), 2780 "--no-parallel".to_owned(), 2781 "--no-configuration-cache".to_owned(), 2782 "--project-dir".to_owned(), 2783 workload.build_root.to_owned(), 2784 "--init-script".to_owned(), 2785 "{private_evidenced_init_script}".to_owned(), 2786 format!("{project}:rshrAdvisoryGraph"), 2787 format!("-PrshrAdvisoryConfiguration={configuration}"), 2788 "-PrshrAdvisoryOutput={private_create_new_projection}".to_owned(), 2789 ] 2790 .into_iter() 2791 .collect()) 2792 } 2793 2794 fn gradle_graph_digest(projection: &GradleProjection) -> Result<String, AdvisoryError> { 2795 gradle_graph_digest_parts( 2796 &projection.workload_id, 2797 &projection.components, 2798 &projection.edges, 2799 &projection.artifacts, 2800 ) 2801 } 2802 2803 fn gradle_graph_digest_parts( 2804 workload_id: &str, 2805 components: &[GradleComponent], 2806 edges: &[GradleEdge], 2807 artifacts: &[GradleArtifact], 2808 ) -> Result<String, AdvisoryError> { 2809 domain_json_digest( 2810 b"radroots-advisory-gradle-graph-v1\0", 2811 &serde_json::json!({ 2812 "artifacts": artifacts, 2813 "components": components, 2814 "edges": edges, 2815 "workload_id": workload_id, 2816 }), 2817 ) 2818 } 2819 2820 fn canonical_row_key<T: Serialize>(value: &T) -> Result<Vec<u8>, AdvisoryError> { 2821 let value = serde_json::to_value(value) 2822 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 2823 Ok(canonical_json_without_lf(&value)) 2824 } 2825 2826 fn gradle_process_input_digest(projection: &GradleProjection) -> Result<String, AdvisoryError> { 2827 domain_json_digest( 2828 b"radroots-advisory-gradle-process-input-v1\0", 2829 &serde_json::json!({ 2830 "artifact_source_roots_sha256": projection.artifact_source_roots_sha256, 2831 "init_script_sha256": projection.init_script_sha256, 2832 "input_sha256": projection.input_sha256, 2833 "seed_cache_inventory_sha256": projection.seed_cache_inventory_sha256, 2834 "source_revision": projection.source_revision, 2835 "source_tree": projection.source_tree, 2836 "wrapper_distribution_sha256": projection.wrapper_distribution_sha256, 2837 "workload_id": projection.workload_id, 2838 }), 2839 ) 2840 } 2841 2842 fn gradle_build_root_binding_digest( 2843 projection: &GradleProjection, 2844 ) -> Result<String, AdvisoryError> { 2845 domain_json_digest( 2846 b"radroots-advisory-gradle-build-root-binding-v1\0", 2847 &serde_json::json!({ 2848 "source_revision": projection.source_revision, 2849 "source_tree": projection.source_tree, 2850 "workload_id": projection.workload_id, 2851 }), 2852 ) 2853 } 2854 2855 fn gradle_candidate_input_digest( 2856 request: &AdmissionRequest, 2857 inventory: &WorkloadInventory, 2858 source: &SourceIdentity, 2859 projection: &GradleProjection, 2860 ) -> Result<String, AdvisoryError> { 2861 domain_json_digest( 2862 b"radroots-advisory-gradle-candidate-input-v1\0", 2863 &serde_json::json!({ 2864 "artifact_source_roots_sha256": projection.artifact_source_roots_sha256, 2865 "candidate": request.candidate, 2866 "dependency_control_sha256": inventory.input_sha256, 2867 "fresh_tool_observation_sha256": sha256(&request.fresh_tool_observation), 2868 "init_script_sha256": projection.init_script_sha256, 2869 "seed_cache_inventory_sha256": projection.seed_cache_inventory_sha256, 2870 "source": source, 2871 "step_297_tool_manifest_sha256": sha256(&request.step_297_tool_manifest), 2872 "workload": inventory, 2873 "wrapper_distribution_sha256": projection.wrapper_distribution_sha256, 2874 }), 2875 ) 2876 } 2877 2878 fn gradle_process_output_digest(projection: &GradleProjection) -> Result<String, AdvisoryError> { 2879 domain_json_digest( 2880 b"radroots-advisory-gradle-process-output-v1\0", 2881 &serde_json::json!({ 2882 "raw_graph_byte_length": projection.raw_graph_byte_length, 2883 "raw_graph_sha256": projection.raw_graph_sha256, 2884 "workload_id": projection.workload_id, 2885 }), 2886 ) 2887 } 2888 2889 fn gradle_normalization_receipt_digest( 2890 workload_id: &str, 2891 raw_graph_byte_length: u64, 2892 raw_graph_sha256: &str, 2893 canonical_graph_sha256: &str, 2894 materialized_tree_sha256: &str, 2895 artifact_source_roots_sha256: &str, 2896 ) -> Result<String, AdvisoryError> { 2897 domain_json_digest( 2898 b"radroots-advisory-gradle-normalization-v1\0", 2899 &serde_json::json!({ 2900 "artifact_source_roots_sha256": artifact_source_roots_sha256, 2901 "canonical_graph_sha256": canonical_graph_sha256, 2902 "materialized_tree_sha256": materialized_tree_sha256, 2903 "raw_graph_byte_length": raw_graph_byte_length, 2904 "raw_graph_sha256": raw_graph_sha256, 2905 "workload_id": workload_id, 2906 }), 2907 ) 2908 } 2909 2910 fn gradle_component_digest(component: &GradleComponent) -> Result<String, AdvisoryError> { 2911 domain_json_digest( 2912 b"radroots-advisory-gradle-component-v1\0", 2913 &serde_json::json!({ 2914 "build_root": component.build_root, 2915 "group": component.group, 2916 "kind": component.kind, 2917 "name": component.name, 2918 "project_path": component.project_path, 2919 "root": component.root, 2920 "variant": component.variant, 2921 "version": component.version, 2922 }), 2923 ) 2924 } 2925 2926 fn gradle_variant_digest(variant: &Value) -> Result<String, AdvisoryError> { 2927 domain_json_digest(b"radroots-advisory-gradle-variant-v1\0", variant) 2928 } 2929 2930 fn gradle_request_digest(request: &Value) -> Result<String, AdvisoryError> { 2931 domain_json_digest(b"radroots-advisory-gradle-request-v1\0", request) 2932 } 2933 2934 fn valid_gradle_variant(value: &Value) -> bool { 2935 serde_json::from_value::<RawGradleVariant>(value.clone()) 2936 .ok() 2937 .is_some_and(|variant| validate_raw_variant(&variant, 0).is_ok()) 2938 } 2939 2940 fn valid_gradle_variant_envelope(value: &Value) -> bool { 2941 serde_json::from_value::<RawGradleVariantEnvelope>(value.clone()) 2942 .ok() 2943 .is_some_and(|envelope| validate_raw_variant_envelope(&envelope).is_ok()) 2944 } 2945 2946 fn valid_gradle_request(value: &Value) -> bool { 2947 serde_json::from_value::<RawGradleSelector>(value.clone()) 2948 .ok() 2949 .is_some_and(|selector| validate_raw_selector(&selector).is_ok()) 2950 } 2951 2952 fn validate_raw_variant_envelope(envelope: &RawGradleVariantEnvelope) -> Result<(), AdvisoryError> { 2953 if envelope.selected.is_empty() || envelope.selected.len() > MAX_GRADLE_VARIANTS { 2954 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2955 } 2956 require_strict_canonical_order(&envelope.selected)?; 2957 for variant in &envelope.selected { 2958 validate_raw_variant(variant, 0)?; 2959 } 2960 Ok(()) 2961 } 2962 2963 fn validate_raw_variant(variant: &RawGradleVariant, depth: usize) -> Result<(), AdvisoryError> { 2964 if depth > MAX_GRADLE_EXTERNAL_VARIANT_DEPTH 2965 || variant.attributes.len() > MAX_GRADLE_ATTRIBUTES 2966 || variant.capabilities.len() > MAX_GRADLE_CAPABILITIES 2967 { 2968 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2969 } 2970 require_strict_canonical_order(&variant.attributes)?; 2971 require_strict_canonical_order(&variant.capabilities)?; 2972 let mut attribute_names = BTreeSet::new(); 2973 for attribute in &variant.attributes { 2974 if !valid_ascii_text(&attribute.name, 512, false) 2975 || !valid_ascii_text(&attribute.value, 512, true) 2976 || !attribute_names.insert(attribute.name.as_str()) 2977 { 2978 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2979 } 2980 } 2981 for capability in &variant.capabilities { 2982 if !valid_ascii_text(&capability.group, 256, true) 2983 || !valid_ascii_text(&capability.name, 256, false) 2984 || capability 2985 .version 2986 .as_deref() 2987 .is_some_and(|value| !valid_ascii_text(value, 128, true)) 2988 { 2989 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 2990 } 2991 } 2992 if let Some(external) = &variant.external_variant { 2993 validate_raw_variant(external, depth.saturating_add(1))?; 2994 } 2995 Ok(()) 2996 } 2997 2998 fn validate_raw_selector(selector: &RawGradleSelector) -> Result<(), AdvisoryError> { 2999 if selector.attributes.len() > MAX_GRADLE_ATTRIBUTES 3000 || selector.capabilities.len() > MAX_GRADLE_CAPABILITIES 3001 { 3002 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 3003 } 3004 require_strict_canonical_order(&selector.attributes)?; 3005 require_strict_canonical_order(&selector.capabilities)?; 3006 let variant = RawGradleVariant { 3007 attributes: selector.attributes.clone(), 3008 capabilities: selector.capabilities.clone(), 3009 external_variant: None, 3010 }; 3011 validate_raw_variant(&variant, 0)?; 3012 match selector.kind.as_str() { 3013 "module" => { 3014 if selector 3015 .group 3016 .as_deref() 3017 .is_none_or(|value| !valid_ascii_text(value, 256, false)) 3018 || selector 3019 .name 3020 .as_deref() 3021 .is_none_or(|value| !valid_ascii_text(value, 256, false)) 3022 || selector 3023 .version 3024 .as_deref() 3025 .is_none_or(|value| !valid_ascii_text(value, 512, true)) 3026 || selector.build_root.is_some() 3027 || selector.project_path.is_some() 3028 { 3029 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 3030 } 3031 let constraint = selector 3032 .version_constraint 3033 .as_ref() 3034 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 3035 if constraint.rejected.len() > MAX_GRADLE_REJECTED_VERSIONS 3036 || constraint 3037 .branch 3038 .as_deref() 3039 .is_some_and(|value| !valid_ascii_text(value, 256, true)) 3040 || !valid_ascii_text(&constraint.preferred, 512, true) 3041 || !valid_ascii_text(&constraint.required, 512, true) 3042 || !valid_ascii_text(&constraint.strict, 512, true) 3043 || constraint 3044 .rejected 3045 .iter() 3046 .any(|value| !valid_ascii_text(value, 512, false)) 3047 || constraint 3048 .rejected 3049 .windows(2) 3050 .any(|window| window[0] >= window[1]) 3051 { 3052 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 3053 } 3054 } 3055 "project" => { 3056 if selector.group.is_some() 3057 || selector.name.is_some() 3058 || selector.version.is_some() 3059 || selector.version_constraint.is_some() 3060 || selector 3061 .build_root 3062 .as_deref() 3063 .is_none_or(|value| !valid_gradle_build_root(value)) 3064 || selector 3065 .project_path 3066 .as_deref() 3067 .is_none_or(|value| !valid_gradle_project_path(value)) 3068 { 3069 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 3070 } 3071 } 3072 _ => { 3073 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 3074 } 3075 } 3076 Ok(()) 3077 } 3078 3079 fn validate_raw_component_core(core: &RawGradleComponentCore) -> Result<(), AdvisoryError> { 3080 match core.kind.as_str() { 3081 "module" 3082 if core 3083 .group 3084 .as_deref() 3085 .is_some_and(|value| valid_ascii_text(value, 256, false)) 3086 && core 3087 .name 3088 .as_deref() 3089 .is_some_and(|value| valid_ascii_text(value, 256, false)) 3090 && core 3091 .version 3092 .as_deref() 3093 .is_some_and(|value| valid_ascii_text(value, 128, false)) 3094 && core.build_root.is_none() 3095 && core.project_path.is_none() => 3096 { 3097 Ok(()) 3098 } 3099 "project" 3100 if core.group.is_none() 3101 && core.name.is_none() 3102 && core.version.is_none() 3103 && core 3104 .build_root 3105 .as_deref() 3106 .is_some_and(valid_gradle_build_root) 3107 && core 3108 .project_path 3109 .as_deref() 3110 .is_some_and(valid_gradle_project_path) => 3111 { 3112 Ok(()) 3113 } 3114 _ => Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)), 3115 } 3116 } 3117 3118 fn require_strict_canonical_order<T: Serialize>(values: &[T]) -> Result<(), AdvisoryError> { 3119 let mut previous = None::<Vec<u8>>; 3120 for value in values { 3121 let key = canonical_row_key(value)?; 3122 if previous.as_ref().is_some_and(|prior| prior >= &key) { 3123 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 3124 } 3125 previous = Some(key); 3126 } 3127 Ok(()) 3128 } 3129 3130 fn valid_ascii_text(value: &str, maximum: usize, allow_empty: bool) -> bool { 3131 (allow_empty || !value.is_empty()) 3132 && value.len() <= maximum 3133 && value 3134 .bytes() 3135 .all(|byte| byte.is_ascii() && !byte.is_ascii_control()) 3136 } 3137 3138 fn valid_gradle_build_root(value: &str) -> bool { 3139 matches!(value, "." | "build-logic") 3140 } 3141 3142 fn valid_gradle_project_path(value: &str) -> bool { 3143 valid_ascii_text(value, 256, false) 3144 && value.starts_with(':') 3145 && !value.ends_with(':') 3146 && value 3147 .split(':') 3148 .skip(1) 3149 .all(|component| !component.is_empty() && valid_ascii_text(component, 128, false)) 3150 } 3151 3152 fn artifact_matches_component( 3153 artifact: &GradleArtifact, 3154 component: &GradleComponent, 3155 _source_revision: &str, 3156 ) -> bool { 3157 match component.kind.as_str() { 3158 "module" => { 3159 let (Some(group), Some(name), Some(version)) = ( 3160 component.group.as_deref(), 3161 component.name.as_deref(), 3162 component.version.as_deref(), 3163 ) else { 3164 return false; 3165 }; 3166 artifact.component == format!("{group}:{name}:{version}") 3167 && artifact.package_ecosystem == "maven" 3168 && artifact.package_namespace == group 3169 && artifact.package_name == name 3170 && artifact.package_version == version 3171 } 3172 "project" => { 3173 let (Some(root), Some(project)) = ( 3174 component.build_root.as_deref(), 3175 component.project_path.as_deref(), 3176 ) else { 3177 return false; 3178 }; 3179 artifact.component == format!("{root}:{project}") 3180 && artifact.package_ecosystem == "maven" 3181 && valid_project_package_identity( 3182 root, 3183 project, 3184 &artifact.package_namespace, 3185 &artifact.package_name, 3186 &artifact.package_version, 3187 ) 3188 && artifact.classifier.is_none() 3189 } 3190 _ => false, 3191 } 3192 } 3193 3194 fn valid_project_module_version( 3195 build_root: &str, 3196 project_path: &str, 3197 module_version: &RawGradleModuleVersion, 3198 source_revision: &str, 3199 ) -> bool { 3200 valid_hex(source_revision, 40) 3201 && valid_project_package_identity( 3202 build_root, 3203 project_path, 3204 &module_version.group, 3205 &module_version.name, 3206 &module_version.version, 3207 ) 3208 } 3209 3210 fn valid_project_package_identity( 3211 build_root: &str, 3212 project_path: &str, 3213 group: &str, 3214 name: &str, 3215 version: &str, 3216 ) -> bool { 3217 version == "unspecified" 3218 && matches!( 3219 (build_root, project_path, group, name), 3220 ( 3221 ".", 3222 ":app:design_system", 3223 "harvestcircle.app", 3224 "design_system" 3225 ) | (".", ":app:shared", "harvestcircle.app", "shared") 3226 | ( 3227 "build-logic", 3228 ":contracts", 3229 "harvestcircle-build-logic", 3230 "contracts" 3231 ) 3232 ) 3233 } 3234 3235 fn valid_gradle_component(component: &GradleComponent) -> bool { 3236 match component.kind.as_str() { 3237 "module" => { 3238 component 3239 .group 3240 .as_deref() 3241 .is_some_and(|value| valid_bounded_text(value, 256)) 3242 && component 3243 .name 3244 .as_deref() 3245 .is_some_and(|value| valid_bounded_text(value, 256)) 3246 && component 3247 .version 3248 .as_deref() 3249 .is_some_and(|value| valid_bounded_text(value, 128)) 3250 && component.build_root.is_none() 3251 && component.project_path.is_none() 3252 && !component.root 3253 } 3254 "project" => { 3255 component.group.is_none() 3256 && component.name.is_none() 3257 && component.version.is_none() 3258 && component 3259 .build_root 3260 .as_deref() 3261 .is_some_and(|value| value == "." || valid_identifier(value)) 3262 && component 3263 .project_path 3264 .as_deref() 3265 .is_some_and(|value| value.starts_with(':') && valid_bounded_text(value, 256)) 3266 } 3267 _ => false, 3268 } 3269 } 3270 3271 fn valid_artifact_extension(extension: &str) -> bool { 3272 matches!( 3273 extension, 3274 "aar" | "jar" | "js" | "klib" | "module" | "pom" | "wasm" | "zip" 3275 ) 3276 } 3277 3278 fn validate_provider( 3279 provider: &ProviderSnapshot, 3280 request: &AdmissionRequest, 3281 trace: &NvdNetworkTrace, 3282 receipts: &[TrustedProcessReceipt], 3283 ) -> Result<(), AdvisoryError> { 3284 require_complete(provider.acquisition_state)?; 3285 require_complete(provider.analysis_state)?; 3286 if provider.acquisition_count != 1 3287 || provider.bounded_deadline_seconds != ANALYSIS_DEADLINE_SECONDS 3288 || !valid_hex(&provider.network_trace_sha256, 64) 3289 || !valid_hex(&provider.producer_request_sha256, 64) 3290 || provider.producer_request_sha256 != sha256(&request.producer_request) 3291 || !valid_hex(&provider.database_identity_sha256, 64) 3292 || provider.archive_format 3293 != "deterministic_tar_gzip_exact_bytes_normalized_headers_sorted_members" 3294 || provider.archive_expanded_bytes == 0 3295 || provider.archive_member_count == 0 3296 || provider.archive_payload_bytes == 0 3297 || provider.acquired_at_epoch == 0 3298 || provider.digest_time_epoch == 0 3299 || provider.analyzed_at_epoch == 0 3300 || provider.acquired_at_epoch > provider.digest_time_epoch 3301 || provider.digest_time_epoch > provider.analyzed_at_epoch 3302 || provider.analyzed_at_epoch > request.evaluation_epoch 3303 { 3304 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 3305 } 3306 let maximum_age = match request.freshness { 3307 FreshnessMode::Development => DEVELOPMENT_REUSE_SECONDS, 3308 FreshnessMode::Qualification => QUALIFICATION_FRESHNESS_SECONDS, 3309 }; 3310 let age = request 3311 .evaluation_epoch 3312 .checked_sub(provider.digest_time_epoch) 3313 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::StaleSnapshot))?; 3314 if age > maximum_age { 3315 return Err(AdvisoryError::new(AdvisoryFailureKind::StaleSnapshot)); 3316 } 3317 let (kind, network, archive, report, acquisition_arguments, environment, expected_arguments) = 3318 match provider.provider { 3319 ProviderId::Rustsec => ( 3320 "externally_admitted_immutable_snapshot", 3321 "external_snapshot_admission", 3322 RUSTSEC_ARCHIVE_NAME, 3323 RUSTSEC_REPORT_NAME, 3324 &[] as &[&str], 3325 "fresh_private_config_free_workdir_home_and_cargo_home", 3326 CARGO_AUDIT_ARGUMENTS.as_slice(), 3327 ), 3328 ProviderId::OwaspNvd => ( 3329 "bounded_nvd_update_only", 3330 "contracted_fetch_only", 3331 NVD_ARCHIVE_NAME, 3332 OWASP_REPORT_NAME, 3333 OWASP_UPDATE_ARGUMENTS.as_slice(), 3334 "replacement_allowlist_private_data_and_output", 3335 OWASP_OFFLINE_ARGUMENTS.as_slice(), 3336 ), 3337 }; 3338 if provider.acquisition_kind != kind 3339 || provider.network_mode != network 3340 || provider.archive.path != archive 3341 || provider.report.path != report 3342 || provider.acquisition_arguments 3343 != acquisition_arguments 3344 .iter() 3345 .map(|argument| (*argument).to_owned()) 3346 .collect::<Vec<_>>() 3347 || provider.analysis_environment != environment 3348 || provider.analysis_arguments 3349 != expected_arguments 3350 .iter() 3351 .map(|argument| (*argument).to_owned()) 3352 .collect::<Vec<_>>() 3353 || !valid_provider_blob_binding(provider.provider, &provider.archive, true) 3354 || !valid_provider_blob_binding(provider.provider, &provider.report, false) 3355 { 3356 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 3357 } 3358 match provider.provider { 3359 ProviderId::Rustsec => { 3360 if provider.network_trace_sha256 != "0".repeat(64) { 3361 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 3362 } 3363 } 3364 ProviderId::OwaspNvd => { 3365 let receipt = process_receipt(receipts, "owasp_nvd_update")?; 3366 if provider.network_trace_sha256 != sha256(&request.nvd_network_trace) 3367 || provider.producer_request_sha256 != trace.producer_request_sha256 3368 || receipt.program_sha256 != TOOL_PINS[1].executable_sha256 3369 || receipt.arguments_sha256 3370 != domain_json_digest( 3371 b"radroots-advisory-process-arguments-v1\0", 3372 &provider.acquisition_arguments, 3373 )? 3374 || receipt.output_sha256 3375 != nvd_update_output_digest( 3376 &provider.materialized_tree_sha256, 3377 &provider.network_trace_sha256, 3378 )? 3379 || receipt.input_sha256 != sha256(&request.producer_request) 3380 || receipt.path_binding 3381 != vec![ProcessPathBinding { 3382 argument_index: 2, 3383 logical_role: "nvd_update_data".to_owned(), 3384 identity_sha256: provider.materialized_tree_sha256.clone(), 3385 }] 3386 || receipt.completed_at_epoch != provider.acquired_at_epoch 3387 || receipt.exit_code != 0 3388 { 3389 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 3390 } 3391 } 3392 } 3393 Ok(()) 3394 } 3395 3396 fn require_complete(state: OperationState) -> Result<(), AdvisoryError> { 3397 match state { 3398 OperationState::Complete => Ok(()), 3399 OperationState::TimedOut => Err(AdvisoryError::new(AdvisoryFailureKind::TimedOut)), 3400 OperationState::Unavailable => Err(AdvisoryError::new(AdvisoryFailureKind::Unavailable)), 3401 OperationState::Failed => Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)), 3402 } 3403 } 3404 3405 fn validate_temporal_order( 3406 observation: &TrustedToolObservation, 3407 providers: &[ProviderSnapshot], 3408 receipts: &[TrustedProcessReceipt], 3409 ) -> Result<(), AdvisoryError> { 3410 if observation.observed_at_epoch == 0 3411 || observation 3412 .tool_state 3413 .iter() 3414 .any(|state| state.reviewed_at_epoch > observation.observed_at_epoch) 3415 || providers 3416 .iter() 3417 .any(|provider| provider.acquired_at_epoch < observation.observed_at_epoch) 3418 { 3419 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 3420 } 3421 for provider in providers { 3422 let prefix = match provider.provider { 3423 ProviderId::Rustsec => "cargo_audit:", 3424 ProviderId::OwaspNvd => "owasp_analysis:", 3425 }; 3426 let scans = receipts 3427 .iter() 3428 .filter(|receipt| receipt.id.starts_with(prefix)) 3429 .collect::<Vec<_>>(); 3430 if scans.is_empty() 3431 || scans 3432 .iter() 3433 .any(|receipt| receipt.started_at_epoch < provider.digest_time_epoch) 3434 || scans.iter().map(|receipt| receipt.completed_at_epoch).max() 3435 != Some(provider.analyzed_at_epoch) 3436 { 3437 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 3438 } 3439 if provider.provider == ProviderId::OwaspNvd { 3440 let update = process_receipt(receipts, "owasp_nvd_update")?; 3441 if update.started_at_epoch < observation.observed_at_epoch 3442 || update.completed_at_epoch != provider.acquired_at_epoch 3443 { 3444 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 3445 } 3446 for scan in scans { 3447 let workload = scan 3448 .id 3449 .strip_prefix("owasp_analysis:") 3450 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 3451 let gradle = process_receipt(receipts, &format!("gradle:{workload}"))?; 3452 if gradle.completed_at_epoch > scan.started_at_epoch { 3453 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 3454 } 3455 } 3456 } 3457 } 3458 Ok(()) 3459 } 3460 3461 fn parse_unsuppressed_report( 3462 snapshot: &ProviderSnapshot, 3463 bytes: &[u8], 3464 request: &AdmissionRequest, 3465 gradle_projections: &[GradleProjection], 3466 ) -> Result<Vec<Finding>, AdvisoryError> { 3467 let (_, _, _, provider_evidence) = validate_trusted_authority(request)?; 3468 let value: Value = serde_json::from_slice(bytes) 3469 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3470 if canonical_json(&value) != bytes { 3471 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3472 } 3473 let report: ReportEnvelope = serde_json::from_value(value) 3474 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3475 if report.schema != "radroots.advisory-unsuppressed-report.v1" 3476 || report.provider != snapshot.provider 3477 { 3478 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3479 } 3480 let expected_ids = WORKLOADS 3481 .iter() 3482 .filter(|workload| match snapshot.provider { 3483 ProviderId::Rustsec => workload.package_manager == "cargo", 3484 ProviderId::OwaspNvd => workload.package_manager == "gradle", 3485 }) 3486 .map(|workload| workload.id) 3487 .collect::<Vec<_>>(); 3488 if report.workload_result.len() != expected_ids.len() 3489 || !report 3490 .workload_result 3491 .iter() 3492 .map(|result| result.workload_id.as_str()) 3493 .eq(expected_ids) 3494 { 3495 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 3496 } 3497 let mut findings = Vec::new(); 3498 let mut database_copy_roots = BTreeSet::new(); 3499 for result in report.workload_result { 3500 let inventory = request 3501 .inventory 3502 .iter() 3503 .find(|inventory| inventory.id == result.workload_id) 3504 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 3505 validate_workload_execution(snapshot, &result, inventory, request)?; 3506 let admitted_output = request 3507 .admitted_scanner_outputs 3508 .iter() 3509 .find(|output| { 3510 output.provider == snapshot.provider && output.workload_id == result.workload_id 3511 }) 3512 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 3513 admitted_output.revalidate()?; 3514 if admitted_output.bytes.as_slice() != result.raw_scanner_output.as_bytes() 3515 || admitted_output.evidence.byte_length != result.raw_output_byte_length 3516 || admitted_output.evidence.sha256 != result.raw_output_sha256 3517 { 3518 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 3519 } 3520 if let Some(copy) = &result.database_copy 3521 && !database_copy_roots.insert(copy.root_identity_sha256.clone()) 3522 { 3523 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 3524 } 3525 let raw: Value = serde_json::from_slice(&admitted_output.bytes) 3526 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3527 match snapshot.provider { 3528 ProviderId::Rustsec => parse_rustsec_output( 3529 &result.workload_id, 3530 &raw, 3531 inventory.dependency_count, 3532 &snapshot.database_identity_sha256, 3533 &snapshot.materialized_tree_sha256, 3534 snapshot.digest_time_epoch, 3535 &mut findings, 3536 )?, 3537 ProviderId::OwaspNvd => { 3538 let projection = gradle_projections 3539 .iter() 3540 .find(|projection| projection.workload_id == result.workload_id) 3541 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 3542 parse_owasp_output( 3543 &result.workload_id, 3544 &raw, 3545 projection, 3546 &snapshot.database_identity_sha256, 3547 &snapshot.materialized_tree_sha256, 3548 result 3549 .arguments 3550 .get(4) 3551 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?, 3552 process_receipt( 3553 &provider_evidence.process_receipt, 3554 &format!("owasp_analysis:{}", result.workload_id), 3555 )? 3556 .started_at_epoch, 3557 process_receipt( 3558 &provider_evidence.process_receipt, 3559 &format!("owasp_analysis:{}", result.workload_id), 3560 )? 3561 .completed_at_epoch, 3562 snapshot.digest_time_epoch, 3563 &mut findings, 3564 )? 3565 } 3566 } 3567 admitted_output.revalidate()?; 3568 } 3569 findings.sort_by(|left, right| finding_key(left).cmp(&finding_key(right))); 3570 if findings.windows(2).any(|window| window[0] == window[1]) { 3571 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3572 } 3573 Ok(findings) 3574 } 3575 3576 fn validate_workload_execution( 3577 snapshot: &ProviderSnapshot, 3578 result: &WorkloadResult, 3579 inventory: &WorkloadInventory, 3580 request: &AdmissionRequest, 3581 ) -> Result<(), AdvisoryError> { 3582 let raw = result.raw_scanner_output.as_bytes(); 3583 let (tool_manifest, observation, _, provider_evidence) = validate_trusted_authority(request)?; 3584 let expected_tool_observation = 3585 tool_observation_digest(&tool_manifest.tool_acquisition, &observation.tool_state)?; 3586 let receipt_id = match snapshot.provider { 3587 ProviderId::Rustsec => format!("cargo_audit:{}", result.workload_id), 3588 ProviderId::OwaspNvd => format!("owasp_analysis:{}", result.workload_id), 3589 }; 3590 let receipt = process_receipt(&provider_evidence.process_receipt, &receipt_id)?; 3591 let program_sha256 = match snapshot.provider { 3592 ProviderId::Rustsec => TOOL_PINS[0].executable_sha256, 3593 ProviderId::OwaspNvd => TOOL_PINS[1].executable_sha256, 3594 }; 3595 let database_copy_valid = match (snapshot.provider, result.database_copy.as_ref()) { 3596 (ProviderId::Rustsec, None) => true, 3597 (ProviderId::OwaspNvd, Some(copy)) => { 3598 valid_hex(©.root_identity_sha256, 64) 3599 && copy.source_tree_sha256 == snapshot.materialized_tree_sha256 3600 && copy.pre_scan_tree_sha256 == snapshot.materialized_tree_sha256 3601 && copy.post_scan_tree_sha256 == snapshot.materialized_tree_sha256 3602 } 3603 _ => false, 3604 }; 3605 if raw.is_empty() 3606 || raw.len() as u64 > MAX_RAW_WORKLOAD_REPORT_BYTES 3607 || result.input_sha256 != inventory.input_sha256 3608 || result.dependency_count != inventory.dependency_count 3609 || result.provider_archive_sha256 != snapshot.archive.sha256 3610 || result.materialized_tree_sha256 != snapshot.materialized_tree_sha256 3611 || result.tool_observation_sha256 != expected_tool_observation 3612 || result.raw_output_byte_length != raw.len() as u64 3613 || result.raw_output_sha256 != sha256(raw) 3614 || !valid_hex(&result.environment_sha256, 64) 3615 || result.process_receipt_sha256 != process_receipt_digest(receipt)? 3616 || !database_copy_valid 3617 || receipt.program_sha256 != program_sha256 3618 || receipt.arguments_sha256 3619 != domain_json_digest( 3620 b"radroots-advisory-process-arguments-v1\0", 3621 &result.arguments, 3622 )? 3623 || receipt.environment_sha256 != result.environment_sha256 3624 || receipt.input_sha256 3625 != scanner_process_input_digest(snapshot.provider, result, &expected_tool_observation)? 3626 || receipt.output_sha256 != result.raw_output_sha256 3627 || match snapshot.provider { 3628 ProviderId::Rustsec => { 3629 receipt.stdout_byte_length != result.raw_output_byte_length 3630 || receipt.stdout_sha256 != result.raw_output_sha256 3631 } 3632 ProviderId::OwaspNvd => { 3633 receipt.stdout_byte_length != 0 || receipt.stdout_sha256 != sha256(&[]) 3634 } 3635 } 3636 || receipt.exit_code != result.exit_code 3637 || receipt.completed_at_epoch > snapshot.analyzed_at_epoch 3638 || receipt.path_binding 3639 != scanner_path_bindings(snapshot.provider, result, inventory, request)? 3640 || !valid_scanner_arguments(snapshot.provider, &result.arguments) 3641 || match snapshot.provider { 3642 ProviderId::Rustsec => !matches!(result.exit_code, 0 | 1), 3643 ProviderId::OwaspNvd => result.exit_code != 0, 3644 } 3645 { 3646 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3647 } 3648 Ok(()) 3649 } 3650 3651 fn scanner_path_bindings( 3652 provider: ProviderId, 3653 result: &WorkloadResult, 3654 inventory: &WorkloadInventory, 3655 request: &AdmissionRequest, 3656 ) -> Result<Vec<ProcessPathBinding>, AdvisoryError> { 3657 match provider { 3658 ProviderId::Rustsec => Ok(vec![ 3659 ProcessPathBinding { 3660 argument_index: 2, 3661 logical_role: "rustsec_database".to_owned(), 3662 identity_sha256: result.materialized_tree_sha256.clone(), 3663 }, 3664 ProcessPathBinding { 3665 argument_index: 6, 3666 logical_role: "cargo_lock".to_owned(), 3667 identity_sha256: inventory.input_sha256.clone(), 3668 }, 3669 ]), 3670 ProviderId::OwaspNvd => { 3671 let projection = validate_projection_for_workload(&result.workload_id, request)?; 3672 let database_copy = result 3673 .database_copy 3674 .as_ref() 3675 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3676 Ok(vec![ 3677 ProcessPathBinding { 3678 argument_index: 2, 3679 logical_role: "nvd_database".to_owned(), 3680 identity_sha256: scanner_database_copy_digest(database_copy)?, 3681 }, 3682 ProcessPathBinding { 3683 argument_index: 4, 3684 logical_role: "gradle_scan_projection".to_owned(), 3685 identity_sha256: projection.materialized_tree_sha256.clone(), 3686 }, 3687 ProcessPathBinding { 3688 argument_index: 10, 3689 logical_role: "raw_report_output".to_owned(), 3690 identity_sha256: result.raw_output_sha256.clone(), 3691 }, 3692 ]) 3693 } 3694 } 3695 } 3696 3697 fn scanner_database_copy_digest(copy: &ScannerDatabaseCopy) -> Result<String, AdvisoryError> { 3698 domain_json_digest(b"radroots-advisory-scanner-database-copy-v1\0", copy) 3699 } 3700 3701 fn validate_projection_for_workload( 3702 workload_id: &str, 3703 request: &AdmissionRequest, 3704 ) -> Result<GradleProjection, AdvisoryError> { 3705 let (_, _, _, evidence) = validate_trusted_authority(request)?; 3706 evidence 3707 .gradle_projection 3708 .iter() 3709 .find(|projection| projection.workload_id == workload_id) 3710 .cloned() 3711 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)) 3712 } 3713 3714 fn nvd_update_output_digest( 3715 materialized_tree_sha256: &str, 3716 network_trace_sha256: &str, 3717 ) -> Result<String, AdvisoryError> { 3718 domain_json_digest( 3719 b"radroots-advisory-nvd-update-output-v1\0", 3720 &serde_json::json!({ 3721 "materialized_tree_sha256": materialized_tree_sha256, 3722 "network_trace_sha256": network_trace_sha256, 3723 }), 3724 ) 3725 } 3726 3727 fn scanner_process_input_digest( 3728 provider: ProviderId, 3729 result: &WorkloadResult, 3730 tool_observation_sha256: &str, 3731 ) -> Result<String, AdvisoryError> { 3732 scanner_process_input_digest_fields( 3733 provider, 3734 &result.workload_id, 3735 &result.input_sha256, 3736 result.dependency_count, 3737 &result.provider_archive_sha256, 3738 &result.materialized_tree_sha256, 3739 &result 3740 .database_copy 3741 .as_ref() 3742 .map(scanner_database_copy_digest) 3743 .transpose()? 3744 .unwrap_or_else(|| "none".to_owned()), 3745 tool_observation_sha256, 3746 ) 3747 } 3748 3749 #[allow(clippy::too_many_arguments)] 3750 fn scanner_process_input_digest_fields( 3751 provider: ProviderId, 3752 workload_id: &str, 3753 input_sha256: &str, 3754 dependency_count: u64, 3755 provider_archive_sha256: &str, 3756 materialized_tree_sha256: &str, 3757 database_copy_sha256: &str, 3758 tool_observation_sha256: &str, 3759 ) -> Result<String, AdvisoryError> { 3760 domain_json_digest( 3761 b"radroots-advisory-scanner-input-v1\0", 3762 &serde_json::json!({ 3763 "dependency_count": dependency_count, 3764 "database_copy_sha256": database_copy_sha256, 3765 "input_sha256": input_sha256, 3766 "materialized_tree_sha256": materialized_tree_sha256, 3767 "provider": provider, 3768 "provider_archive_sha256": provider_archive_sha256, 3769 "tool_observation_sha256": tool_observation_sha256, 3770 "workload_id": workload_id, 3771 }), 3772 ) 3773 } 3774 3775 fn valid_scanner_arguments(provider: ProviderId, arguments: &[String]) -> bool { 3776 match provider { 3777 ProviderId::Rustsec => { 3778 arguments.len() == CARGO_AUDIT_ARGUMENTS.len() 3779 && arguments[0] == "audit" 3780 && arguments[1] == "--db" 3781 && absolute_path(&arguments[2]) 3782 && arguments[3..6] == ["--no-fetch", "--json", "--file"] 3783 && absolute_path(&arguments[6]) 3784 && !arguments 3785 .iter() 3786 .any(|argument| matches!(argument.as_str(), "--ignore" | "--stale")) 3787 } 3788 ProviderId::OwaspNvd => { 3789 if arguments.len() != OWASP_OFFLINE_ARGUMENTS.len() 3790 || arguments[0] != "--noupdate" 3791 || arguments[1] != "--data" 3792 || !absolute_path(&arguments[2]) 3793 || arguments[3] != "--scan" 3794 || !absolute_path(&arguments[4]) 3795 || arguments[5] != "--project" 3796 || !WORKLOADS.iter().any(|workload| { 3797 workload.package_manager == "gradle" && workload.id == arguments[6] 3798 }) 3799 || arguments[7] != "--format" 3800 || arguments[8] != "JSON" 3801 || arguments[9] != "--out" 3802 || !absolute_path(&arguments[10]) 3803 { 3804 return false; 3805 } 3806 let required = &OWASP_OFFLINE_ARGUMENTS[11..]; 3807 arguments[11..] 3808 == required 3809 .iter() 3810 .map(|argument| (*argument).to_owned()) 3811 .collect::<Vec<_>>() 3812 && !arguments.iter().any(|argument| { 3813 argument == "--suppression" || argument.starts_with("--failOnCVSS") 3814 }) 3815 } 3816 } 3817 } 3818 3819 fn absolute_path(value: &str) -> bool { 3820 !value.is_empty() && Path::new(value).is_absolute() && !value.contains('\0') 3821 } 3822 3823 fn parse_rustsec_output( 3824 workload_id: &str, 3825 output: &Value, 3826 expected_dependency_count: u64, 3827 expected_database_identity: &str, 3828 materialized_tree_sha256: &str, 3829 database_digest_time_epoch: u64, 3830 findings: &mut Vec<Finding>, 3831 ) -> Result<(), AdvisoryError> { 3832 validate_json_bounds(output)?; 3833 let object = output 3834 .as_object() 3835 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3836 let expected_keys = [ 3837 "database", 3838 "lockfile", 3839 "settings", 3840 "vulnerabilities", 3841 "warnings", 3842 ] 3843 .into_iter() 3844 .collect::<BTreeSet<_>>(); 3845 if object.keys().map(String::as_str).collect::<BTreeSet<_>>() != expected_keys { 3846 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3847 } 3848 let database = object["database"] 3849 .as_object() 3850 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3851 if database.keys().map(String::as_str).collect::<BTreeSet<_>>() 3852 != ["advisory-count", "last-commit", "last-updated"] 3853 .into_iter() 3854 .collect() 3855 || database["advisory-count"] 3856 .as_u64() 3857 .is_none_or(|count| count == 0) 3858 || !database["last-commit"] 3859 .as_str() 3860 .is_some_and(|revision| valid_hex(revision, 40)) 3861 || database["last-updated"] 3862 .as_str() 3863 .and_then(|timestamp| parse_report_epoch(timestamp).ok()) 3864 != Some(database_digest_time_epoch) 3865 { 3866 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3867 } 3868 let database_metadata_sha256 = domain_json_digest( 3869 b"radroots-advisory-rustsec-database-metadata-v1\0", 3870 database, 3871 )?; 3872 if provider_database_identity( 3873 ProviderId::Rustsec, 3874 materialized_tree_sha256, 3875 &database_metadata_sha256, 3876 )? != expected_database_identity 3877 { 3878 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 3879 } 3880 let lockfile = object["lockfile"] 3881 .as_object() 3882 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3883 if lockfile.keys().map(String::as_str).collect::<Vec<_>>() != ["dependency-count"] 3884 || lockfile["dependency-count"].as_u64() != Some(expected_dependency_count) 3885 { 3886 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 3887 } 3888 let settings = object["settings"] 3889 .as_object() 3890 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3891 if settings.keys().map(String::as_str).collect::<BTreeSet<_>>() 3892 != [ 3893 "ignore", 3894 "informational_warnings", 3895 "severity", 3896 "target_arch", 3897 "target_os", 3898 ] 3899 .into_iter() 3900 .collect() 3901 || settings["ignore"] 3902 .as_array() 3903 .is_none_or(|value| !value.is_empty()) 3904 || settings["target_arch"] 3905 .as_array() 3906 .is_none_or(|value| !value.is_empty()) 3907 || settings["target_os"] 3908 .as_array() 3909 .is_none_or(|value| !value.is_empty()) 3910 || !settings["severity"].is_null() 3911 || settings["informational_warnings"] 3912 != serde_json::json!(["unmaintained", "unsound", "notice"]) 3913 { 3914 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3915 } 3916 if !object["warnings"].is_object() { 3917 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3918 } 3919 let vulnerabilities_object = object["vulnerabilities"] 3920 .as_object() 3921 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3922 if vulnerabilities_object 3923 .keys() 3924 .map(String::as_str) 3925 .collect::<BTreeSet<_>>() 3926 != ["count", "found", "list"].into_iter().collect() 3927 { 3928 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3929 } 3930 let vulnerabilities = vulnerabilities_object["list"] 3931 .as_array() 3932 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3933 for vulnerability in vulnerabilities { 3934 let vulnerability_object = vulnerability 3935 .as_object() 3936 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3937 if vulnerability_object 3938 .keys() 3939 .map(String::as_str) 3940 .collect::<BTreeSet<_>>() 3941 != ["advisory", "affected", "package", "versions"] 3942 .into_iter() 3943 .collect() 3944 { 3945 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3946 } 3947 let (package, version) = validate_rustsec_package(&vulnerability_object["package"])?; 3948 let advisory_id = validate_rustsec_advisory(&vulnerability_object["advisory"], package)?; 3949 validate_rustsec_affected(&vulnerability_object["affected"])?; 3950 validate_rustsec_versions(&vulnerability_object["versions"])?; 3951 findings.push(Finding { 3952 provider: ProviderId::Rustsec, 3953 advisory_id: advisory_id.to_owned(), 3954 package_ecosystem: "cargo".to_owned(), 3955 package_namespace: String::new(), 3956 package_name: package.to_owned(), 3957 package_version: version.to_owned(), 3958 workload_id: workload_id.to_owned(), 3959 }); 3960 } 3961 let found = vulnerabilities_object["found"] 3962 .as_bool() 3963 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3964 if found != !vulnerabilities.is_empty() 3965 || vulnerabilities_object["count"].as_u64() != Some(vulnerabilities.len() as u64) 3966 { 3967 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3968 } 3969 let warnings = object["warnings"] 3970 .as_object() 3971 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3972 for (kind, rows) in warnings { 3973 if !matches!( 3974 kind.as_str(), 3975 "notice" | "unmaintained" | "unsound" | "yanked" 3976 ) { 3977 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3978 } 3979 let rows = rows 3980 .as_array() 3981 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3982 if kind == "yanked" && !rows.is_empty() { 3983 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3984 } 3985 for warning in rows { 3986 let warning_object = warning 3987 .as_object() 3988 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 3989 if warning_object 3990 .keys() 3991 .map(String::as_str) 3992 .collect::<BTreeSet<_>>() 3993 != ["advisory", "affected", "kind", "package", "versions"] 3994 .into_iter() 3995 .collect() 3996 || warning_object["kind"].as_str() != Some(kind) 3997 { 3998 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 3999 } 4000 let (package_name, package_version) = 4001 validate_rustsec_package(&warning_object["package"])?; 4002 let advisory_id = validate_rustsec_advisory(&warning_object["advisory"], package_name)?; 4003 validate_rustsec_affected(&warning_object["affected"])?; 4004 validate_rustsec_versions(&warning_object["versions"])?; 4005 findings.push(Finding { 4006 provider: ProviderId::Rustsec, 4007 advisory_id: advisory_id.to_owned(), 4008 package_ecosystem: "cargo".to_owned(), 4009 package_namespace: String::new(), 4010 package_name: package_name.to_owned(), 4011 package_version: package_version.to_owned(), 4012 workload_id: workload_id.to_owned(), 4013 }); 4014 } 4015 } 4016 Ok(()) 4017 } 4018 4019 fn validate_rustsec_advisory<'a>( 4020 value: &'a Value, 4021 package_name: &str, 4022 ) -> Result<&'a str, AdvisoryError> { 4023 let object = value 4024 .as_object() 4025 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4026 if !object_keys_exact( 4027 object, 4028 &[ 4029 "aliases", 4030 "categories", 4031 "collection", 4032 "cvss", 4033 "date", 4034 "description", 4035 "expect-deleted", 4036 "id", 4037 "informational", 4038 "keywords", 4039 "license", 4040 "package", 4041 "references", 4042 "related", 4043 "source", 4044 "title", 4045 "url", 4046 "withdrawn", 4047 ], 4048 &[], 4049 ) || object["package"].as_str() != Some(package_name) 4050 || !valid_rustsec_date(&object["date"]) 4051 || !valid_text_allow_empty_value(&object["title"], 4_096) 4052 || !valid_text_allow_empty_value(&object["description"], 1_048_576) 4053 || !valid_exact_string_array(&object["aliases"], 4_096, 256) 4054 || !valid_exact_string_array(&object["related"], 4_096, 256) 4055 || !valid_exact_string_array(&object["categories"], 256, 128) 4056 || !valid_exact_string_array(&object["keywords"], 4_096, 256) 4057 || !valid_exact_string_array(&object["references"], 65_536, 4_096) 4058 || !optional_rustsec_text(&object["collection"], 64) 4059 || !optional_rustsec_text(&object["cvss"], 256) 4060 || !optional_rustsec_text(&object["informational"], 64) 4061 || !optional_rustsec_text(&object["source"], 4_096) 4062 || !optional_rustsec_text(&object["url"], 4_096) 4063 || !(object["withdrawn"].is_null() || valid_rustsec_date(&object["withdrawn"])) 4064 || !object["license"] 4065 .as_str() 4066 .is_some_and(|value| valid_bounded_text(value, 128)) 4067 || object["expect-deleted"].as_bool().is_none() 4068 { 4069 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4070 } 4071 let advisory_id = required_text(object.get("id"))?; 4072 if !valid_advisory_id(ProviderId::Rustsec, advisory_id) { 4073 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4074 } 4075 Ok(advisory_id) 4076 } 4077 4078 fn validate_rustsec_package(value: &Value) -> Result<(&str, &str), AdvisoryError> { 4079 let object = value 4080 .as_object() 4081 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4082 if !object_keys_exact( 4083 object, 4084 &["checksum", "name", "replace", "source", "version"], 4085 &["dependencies"], 4086 ) || !optional_rustsec_text(&object["source"], 4_096) 4087 || !(object["checksum"].is_null() 4088 || object["checksum"] 4089 .as_str() 4090 .is_some_and(|value| valid_hex(value, 64))) 4091 || !(object["replace"].is_null() || valid_rustsec_dependency(&object["replace"])) 4092 || object.get("dependencies").is_some_and(|value| { 4093 value.as_array().is_none_or(|rows| { 4094 rows.is_empty() 4095 || rows.len() > 65_536 4096 || rows.iter().any(|row| !valid_rustsec_dependency(row)) 4097 }) 4098 }) 4099 { 4100 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4101 } 4102 let name = required_text(object.get("name"))?; 4103 let version = required_text(object.get("version"))?; 4104 if !valid_exact_identity_token(name, 256) || !valid_exact_identity_token(version, 128) { 4105 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4106 } 4107 Ok((name, version)) 4108 } 4109 4110 fn valid_rustsec_dependency(value: &Value) -> bool { 4111 let Some(object) = value.as_object() else { 4112 return false; 4113 }; 4114 object_keys_exact(object, &["name", "source", "version"], &[]) 4115 && object["name"] 4116 .as_str() 4117 .is_some_and(|value| valid_exact_identity_token(value, 256)) 4118 && object["version"] 4119 .as_str() 4120 .is_some_and(|value| valid_exact_identity_token(value, 128)) 4121 && optional_rustsec_text(&object["source"], 4_096) 4122 } 4123 4124 fn validate_rustsec_versions(value: &Value) -> Result<(), AdvisoryError> { 4125 let object = value 4126 .as_object() 4127 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4128 if !object_keys_exact(object, &["patched", "unaffected"], &[]) 4129 || !valid_exact_string_array(&object["patched"], 65_536, 256) 4130 || !valid_exact_string_array(&object["unaffected"], 65_536, 256) 4131 { 4132 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4133 } 4134 Ok(()) 4135 } 4136 4137 fn validate_rustsec_affected(value: &Value) -> Result<(), AdvisoryError> { 4138 if value.is_null() { 4139 return Ok(()); 4140 } 4141 let object = value 4142 .as_object() 4143 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4144 let functions = object 4145 .get("functions") 4146 .and_then(Value::as_object) 4147 .filter(|rows| rows.len() <= 65_536) 4148 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4149 if !object_keys_exact(object, &["arch", "functions", "os"], &[]) 4150 || !valid_exact_string_array(&object["arch"], 1_024, 128) 4151 || !valid_exact_string_array(&object["os"], 1_024, 128) 4152 || functions.iter().any(|(path, versions)| { 4153 !valid_rust_function_path(path) || !valid_exact_string_array(versions, 65_536, 256) 4154 }) 4155 { 4156 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4157 } 4158 Ok(()) 4159 } 4160 4161 fn valid_rust_function_path(value: &str) -> bool { 4162 let mut components = value.split("::"); 4163 let valid_component = |component: &str| { 4164 let mut chars = component.chars(); 4165 chars 4166 .next() 4167 .is_some_and(|first| first.is_ascii_alphabetic() || matches!(first, '_' | '<')) 4168 && chars.all(|character| { 4169 character.is_ascii_alphanumeric() || matches!(character, '_' | '<' | '>' | ',') 4170 }) 4171 }; 4172 let first = components.next().is_some_and(valid_component); 4173 let rest = components.collect::<Vec<_>>(); 4174 first && !rest.is_empty() && rest.into_iter().all(valid_component) 4175 } 4176 4177 fn valid_rustsec_date(value: &Value) -> bool { 4178 value.as_str().is_some_and(|value| { 4179 let bytes = value.as_bytes(); 4180 bytes.len() == 10 4181 && bytes[4] == b'-' 4182 && bytes[7] == b'-' 4183 && bytes 4184 .iter() 4185 .enumerate() 4186 .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) 4187 && parse_report_epoch(&format!("{value}T00:00:00Z")).is_ok() 4188 }) 4189 } 4190 4191 fn valid_text_allow_empty_value(value: &Value, maximum: usize) -> bool { 4192 value 4193 .as_str() 4194 .is_some_and(|value| valid_text_allow_empty(value, maximum)) 4195 } 4196 4197 fn optional_rustsec_text(value: &Value, maximum: usize) -> bool { 4198 value.is_null() 4199 || value 4200 .as_str() 4201 .is_some_and(|value| valid_bounded_text(value, maximum)) 4202 } 4203 4204 fn valid_exact_string_array(value: &Value, maximum_rows: usize, maximum_text: usize) -> bool { 4205 value 4206 .as_array() 4207 .filter(|rows| rows.len() <= maximum_rows) 4208 .is_some_and(|rows| { 4209 rows.iter().all(|row| { 4210 row.as_str() 4211 .is_some_and(|value| valid_bounded_text(value, maximum_text)) 4212 }) 4213 }) 4214 } 4215 4216 #[allow(clippy::too_many_arguments)] 4217 fn parse_owasp_output( 4218 workload_id: &str, 4219 output: &Value, 4220 projection: &GradleProjection, 4221 expected_database_identity: &str, 4222 materialized_tree_sha256: &str, 4223 scan_root: &str, 4224 scan_started_at_epoch: u64, 4225 scan_completed_at_epoch: u64, 4226 database_digest_time_epoch: u64, 4227 findings: &mut Vec<Finding>, 4228 ) -> Result<(), AdvisoryError> { 4229 validate_json_bounds(output)?; 4230 let object = output 4231 .as_object() 4232 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4233 if !object_keys_exact( 4234 object, 4235 &["dependencies", "projectInfo", "reportSchema", "scanInfo"], 4236 &[], 4237 ) || object["reportSchema"].as_str() != Some("1.1") 4238 { 4239 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4240 } 4241 let scan_info = object["scanInfo"] 4242 .as_object() 4243 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4244 if !object_keys_exact(scan_info, &["dataSource", "engineVersion"], &[]) 4245 || scan_info["engineVersion"].as_str() != Some("12.2.2") 4246 { 4247 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4248 } 4249 let data_source = scan_info["dataSource"] 4250 .as_array() 4251 .filter(|sources| !sources.is_empty()) 4252 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4253 if data_source.len() > 64 { 4254 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4255 } 4256 let mut previous_source = None::<Vec<u8>>; 4257 let mut observed_database_epoch = None::<u64>; 4258 for source in data_source { 4259 let source = source 4260 .as_object() 4261 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4262 if !object_keys_exact(source, &["name", "timestamp"], &[]) 4263 || !required_text(source.get("name"))?.starts_with("NVD CVE ") 4264 { 4265 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4266 } 4267 let epoch = parse_report_epoch(required_text(source.get("timestamp"))?)?; 4268 if epoch == 0 || epoch > database_digest_time_epoch { 4269 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4270 } 4271 observed_database_epoch = 4272 Some(observed_database_epoch.map_or(epoch, |value| value.max(epoch))); 4273 let key = canonical_json_without_lf(&Value::Object(source.clone())); 4274 if previous_source 4275 .as_ref() 4276 .is_some_and(|previous| previous >= &key) 4277 { 4278 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4279 } 4280 previous_source = Some(key); 4281 } 4282 if observed_database_epoch != Some(database_digest_time_epoch) { 4283 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 4284 } 4285 let database_metadata_sha256 = domain_json_digest( 4286 b"radroots-advisory-owasp-database-metadata-v1\0", 4287 data_source, 4288 )?; 4289 if provider_database_identity( 4290 ProviderId::OwaspNvd, 4291 materialized_tree_sha256, 4292 &database_metadata_sha256, 4293 )? != expected_database_identity 4294 { 4295 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 4296 } 4297 let project = object["projectInfo"] 4298 .as_object() 4299 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4300 if !object_keys_exact( 4301 project, 4302 &["credits", "name", "reportDate"], 4303 &["artifactID", "groupID", "version"], 4304 ) || project["name"].as_str() != Some(workload_id) 4305 || project 4306 .get("groupID") 4307 .is_some_and(|value| !value.is_string()) 4308 || project 4309 .get("artifactID") 4310 .is_some_and(|value| !value.is_string()) 4311 || project 4312 .get("version") 4313 .is_some_and(|value| !value.is_string()) 4314 || !valid_owasp_credits(&project["credits"]) 4315 { 4316 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4317 } 4318 let report_epoch = parse_report_epoch(required_text(project.get("reportDate"))?)?; 4319 if report_epoch < scan_started_at_epoch || report_epoch > scan_completed_at_epoch { 4320 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 4321 } 4322 if !absolute_path(scan_root) || scan_root.ends_with('/') { 4323 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4324 } 4325 let dependencies = object["dependencies"] 4326 .as_array() 4327 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4328 let mut expected = BTreeMap::<&str, Vec<&GradleArtifact>>::new(); 4329 for artifact in &projection.artifacts { 4330 expected 4331 .entry(artifact.materialized_name.as_str()) 4332 .or_default() 4333 .push(artifact); 4334 } 4335 if dependencies.len() < expected.len() 4336 || dependencies.len() > expected.len().saturating_add(65_536) 4337 { 4338 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 4339 } 4340 let mut observed = BTreeSet::new(); 4341 let mut parsed_dependencies = Vec::with_capacity(dependencies.len()); 4342 for dependency in dependencies { 4343 let dependency = dependency 4344 .as_object() 4345 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4346 if !object_keys_exact( 4347 dependency, 4348 &["evidenceCollected", "fileName", "filePath", "isVirtual"], 4349 &[ 4350 "description", 4351 "includedBy", 4352 "license", 4353 "md5", 4354 "packages", 4355 "projectReferences", 4356 "relatedDependencies", 4357 "sha1", 4358 "sha256", 4359 "vulnerabilities", 4360 "vulnerabilityIds", 4361 ], 4362 ) || dependency.contains_key("suppressedVulnerabilities") 4363 || dependency.contains_key("suppressedVulnerabilityIds") 4364 || !valid_owasp_evidence(&dependency["evidenceCollected"]) 4365 || !optional_bounded_string(dependency.get("description"), 65_536) 4366 || !optional_bounded_string(dependency.get("license"), 65_536) 4367 || !valid_sorted_string_array(dependency.get("projectReferences"), 16_384, 1024)? 4368 { 4369 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4370 } 4371 let file_name = required_text(dependency.get("fileName"))?; 4372 let file_path = required_text(dependency.get("filePath"))?; 4373 let is_virtual = dependency 4374 .get("isVirtual") 4375 .and_then(Value::as_bool) 4376 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4377 let digest = dependency.get("sha256").and_then(Value::as_str); 4378 if is_virtual { 4379 if dependency.contains_key("md5") 4380 || dependency.contains_key("sha1") 4381 || dependency.contains_key("sha256") 4382 || !file_path.starts_with(&format!("{scan_root}/")) 4383 { 4384 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4385 } 4386 } else if !dependency 4387 .get("md5") 4388 .and_then(Value::as_str) 4389 .is_some_and(|value| valid_hex(value, 32)) 4390 || !dependency 4391 .get("sha1") 4392 .and_then(Value::as_str) 4393 .is_some_and(|value| valid_hex(value, 40)) 4394 || !digest.is_some_and(|value| valid_hex(value, 64)) 4395 || file_path != format!("{scan_root}/{file_name}") 4396 { 4397 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4398 } 4399 let package_ids = parse_owasp_identifiers(dependency.get("packages"), true)?; 4400 let vulnerability_ids = parse_owasp_identifiers(dependency.get("vulnerabilityIds"), true)?; 4401 let vulnerabilities = parse_owasp_vulnerabilities(dependency.get("vulnerabilities"))?; 4402 let vulnerable_software_ids = vulnerabilities 4403 .iter() 4404 .flat_map(|vulnerability| vulnerability.vulnerable_software_ids.iter()) 4405 .collect::<BTreeSet<_>>(); 4406 if vulnerability_ids 4407 .iter() 4408 .any(|id| !valid_owasp_software_identifier(id)) 4409 || (!vulnerability_ids.is_empty() && vulnerabilities.is_empty()) 4410 || vulnerability_ids 4411 .iter() 4412 .any(|id| !vulnerable_software_ids.contains(id)) 4413 { 4414 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4415 } 4416 let included_by = parse_owasp_included_by(dependency.get("includedBy"))?; 4417 let related = parse_owasp_related(dependency.get("relatedDependencies"), &expected)?; 4418 let mut aliases = package_ids.clone(); 4419 aliases.extend(related.aliases); 4420 aliases.sort(); 4421 if aliases.windows(2).any(|rows| rows[0] == rows[1]) { 4422 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4423 } 4424 if is_virtual { 4425 if package_ids.is_empty() || included_by.is_empty() { 4426 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 4427 } 4428 if expected.contains_key(file_name) { 4429 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 4430 } 4431 } else { 4432 let artifacts = expected 4433 .get(file_name) 4434 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 4435 let digest = 4436 digest.ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4437 if artifacts 4438 .iter() 4439 .any(|artifact| artifact.artifact_sha256 != digest) 4440 || !observed.insert(file_name.to_owned()) 4441 || package_ids.iter().any(|id| { 4442 !artifacts 4443 .iter() 4444 .any(|artifact| package_identifier_compatible(id, artifact)) 4445 }) 4446 { 4447 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4448 } 4449 } 4450 parsed_dependencies.push(ParsedOwaspDependency { 4451 aliases, 4452 included_by, 4453 is_virtual, 4454 file_name: file_name.to_owned(), 4455 package_ids, 4456 vulnerabilities, 4457 }); 4458 } 4459 if observed.len() != expected.len() { 4460 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 4461 } 4462 validate_owasp_lineage(workload_id, &parsed_dependencies)?; 4463 for dependency in &parsed_dependencies { 4464 let advisory_ids = dependency 4465 .vulnerabilities 4466 .iter() 4467 .map(|vulnerability| vulnerability.advisory_id.as_str()) 4468 .collect::<BTreeSet<_>>(); 4469 if dependency.is_virtual { 4470 for package_id in &dependency.package_ids { 4471 let (ecosystem, namespace, name, version) = parse_exact_package_url(package_id)?; 4472 for advisory_id in &advisory_ids { 4473 findings.push(Finding { 4474 provider: ProviderId::OwaspNvd, 4475 advisory_id: (*advisory_id).to_owned(), 4476 package_ecosystem: ecosystem.clone(), 4477 package_namespace: namespace.clone(), 4478 package_name: name.clone(), 4479 package_version: version.clone(), 4480 workload_id: workload_id.to_owned(), 4481 }); 4482 } 4483 } 4484 } else { 4485 let artifacts = expected 4486 .get(dependency.file_name.as_str()) 4487 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 4488 for artifact in artifacts { 4489 for advisory_id in &advisory_ids { 4490 findings.push(Finding { 4491 provider: ProviderId::OwaspNvd, 4492 advisory_id: (*advisory_id).to_owned(), 4493 package_ecosystem: artifact.package_ecosystem.clone(), 4494 package_namespace: artifact.package_namespace.clone(), 4495 package_name: artifact.package_name.clone(), 4496 package_version: artifact.package_version.clone(), 4497 workload_id: workload_id.to_owned(), 4498 }); 4499 } 4500 } 4501 } 4502 } 4503 Ok(()) 4504 } 4505 4506 #[derive(Clone)] 4507 struct ParsedOwaspVulnerability { 4508 advisory_id: String, 4509 vulnerable_software_ids: BTreeSet<String>, 4510 } 4511 4512 struct ParsedOwaspDependency { 4513 aliases: Vec<String>, 4514 included_by: Vec<String>, 4515 is_virtual: bool, 4516 file_name: String, 4517 package_ids: Vec<String>, 4518 vulnerabilities: Vec<ParsedOwaspVulnerability>, 4519 } 4520 4521 struct ParsedOwaspRelated { 4522 aliases: Vec<String>, 4523 } 4524 4525 fn object_keys_exact( 4526 object: &serde_json::Map<String, Value>, 4527 required: &[&str], 4528 optional: &[&str], 4529 ) -> bool { 4530 required.iter().all(|key| object.contains_key(*key)) 4531 && object 4532 .keys() 4533 .all(|key| required.contains(&key.as_str()) || optional.contains(&key.as_str())) 4534 && object.len() >= required.len() 4535 && object.len() <= required.len() + optional.len() 4536 } 4537 4538 fn validate_json_bounds(value: &Value) -> Result<(), AdvisoryError> { 4539 fn visit(value: &Value, depth: usize, nodes: &mut usize) -> bool { 4540 if depth > 32 || *nodes >= 1_000_000 { 4541 return false; 4542 } 4543 *nodes += 1; 4544 match value { 4545 Value::String(value) => valid_bounded_text(value, 1_048_576), 4546 Value::Array(values) => { 4547 values.len() <= 65_536 && values.iter().all(|value| visit(value, depth + 1, nodes)) 4548 } 4549 Value::Object(values) => { 4550 values.len() <= 1_024 4551 && values.keys().all(|key| valid_bounded_text(key, 256)) 4552 && values.values().all(|value| visit(value, depth + 1, nodes)) 4553 } 4554 _ => true, 4555 } 4556 } 4557 if visit(value, 0, &mut 0) { 4558 Ok(()) 4559 } else { 4560 Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)) 4561 } 4562 } 4563 4564 fn parse_report_epoch(value: &str) -> Result<u64, AdvisoryError> { 4565 let bytes = value.as_bytes(); 4566 if bytes.len() < 20 4567 || bytes.get(4) != Some(&b'-') 4568 || bytes.get(7) != Some(&b'-') 4569 || bytes.get(10) != Some(&b'T') 4570 || bytes.get(13) != Some(&b':') 4571 || bytes.get(16) != Some(&b':') 4572 || bytes.last() != Some(&b'Z') 4573 || (bytes.len() > 20 4574 && (bytes.get(19) != Some(&b'.') 4575 || !bytes[20..bytes.len() - 1].iter().all(u8::is_ascii_digit))) 4576 { 4577 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4578 } 4579 fn number(bytes: &[u8]) -> Option<u64> { 4580 bytes.iter().try_fold(0_u64, |value, byte| { 4581 byte.is_ascii_digit() 4582 .then(|| value * 10 + u64::from(byte - b'0')) 4583 }) 4584 } 4585 let year = i64::try_from(number(&bytes[0..4]).unwrap_or_default()).unwrap_or_default(); 4586 let month = number(&bytes[5..7]).unwrap_or_default(); 4587 let day = number(&bytes[8..10]).unwrap_or_default(); 4588 let hour = number(&bytes[11..13]).unwrap_or(24); 4589 let minute = number(&bytes[14..16]).unwrap_or(60); 4590 let second = number(&bytes[17..19]).unwrap_or(60); 4591 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); 4592 let days_in_month = match month { 4593 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 4594 4 | 6 | 9 | 11 => 30, 4595 2 if leap => 29, 4596 2 => 28, 4597 _ => 0, 4598 }; 4599 if year < 1970 || day == 0 || day > days_in_month || hour > 23 || minute > 59 || second > 59 { 4600 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4601 } 4602 let adjusted_year = year - i64::from(month <= 2); 4603 let era = adjusted_year.div_euclid(400); 4604 let year_of_era = adjusted_year - era * 400; 4605 let adjusted_month = i64::try_from(month).unwrap_or_default() + if month > 2 { -3 } else { 9 }; 4606 let day_of_year = (153 * adjusted_month + 2) / 5 + i64::try_from(day).unwrap_or_default() - 1; 4607 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; 4608 let days = era * 146_097 + day_of_era - 719_468; 4609 u64::try_from(days) 4610 .ok() 4611 .and_then(|days| days.checked_mul(86_400)) 4612 .and_then(|value| value.checked_add(hour * 3_600 + minute * 60 + second)) 4613 .filter(|value| *value > 0) 4614 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport)) 4615 } 4616 4617 fn format_report_epoch(value: u64) -> Result<String, AdvisoryError> { 4618 let days = i64::try_from(value / 86_400) 4619 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 4620 let seconds = value % 86_400; 4621 let z = days + 719_468; 4622 let era = z.div_euclid(146_097); 4623 let day_of_era = z - era * 146_097; 4624 let year_of_era = 4625 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; 4626 let mut year = year_of_era + era * 400; 4627 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); 4628 let month_prime = (5 * day_of_year + 2) / 153; 4629 let day = day_of_year - (153 * month_prime + 2) / 5 + 1; 4630 let month = month_prime + if month_prime < 10 { 3 } else { -9 }; 4631 year += i64::from(month <= 2); 4632 Ok(format!( 4633 "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", 4634 seconds / 3_600, 4635 seconds % 3_600 / 60, 4636 seconds % 60, 4637 )) 4638 } 4639 4640 fn optional_bounded_string(value: Option<&Value>, maximum: usize) -> bool { 4641 value.is_none_or(|value| { 4642 value 4643 .as_str() 4644 .is_some_and(|value| valid_bounded_text(value, maximum)) 4645 }) 4646 } 4647 4648 fn valid_text_allow_empty(value: &str, maximum: usize) -> bool { 4649 value.len() <= maximum && !value.contains('\0') && !value.chars().any(char::is_control) 4650 } 4651 4652 fn valid_sorted_string_array( 4653 value: Option<&Value>, 4654 maximum_rows: usize, 4655 maximum_bytes: usize, 4656 ) -> Result<bool, AdvisoryError> { 4657 let Some(value) = value else { 4658 return Ok(true); 4659 }; 4660 let rows = value 4661 .as_array() 4662 .filter(|rows| rows.len() <= maximum_rows) 4663 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4664 let mut previous = None; 4665 for row in rows { 4666 let row = row 4667 .as_str() 4668 .filter(|row| valid_bounded_text(row, maximum_bytes)) 4669 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4670 if previous.is_some_and(|previous| previous >= row) { 4671 return Ok(false); 4672 } 4673 previous = Some(row); 4674 } 4675 Ok(true) 4676 } 4677 4678 fn valid_owasp_credits(value: &Value) -> bool { 4679 let Some(object) = value.as_object() else { 4680 return false; 4681 }; 4682 const CREDITS: [(&str, &str); 5] = [ 4683 ( 4684 "CISA", 4685 "This report may contain data retrieved from the CISA Known Exploited Vulnerability Catalog: https://www.cisa.gov/known-exploited-vulnerabilities-catalog", 4686 ), 4687 ( 4688 "NPM", 4689 "This report may contain data retrieved from the Github Advisory Database (via NPM Audit API): https://github.com/advisories/", 4690 ), 4691 ( 4692 "NVD", 4693 "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", 4694 ), 4695 ( 4696 "OSSINDEX", 4697 "This report may contain data retrieved from the Sonatype Guide OSS Index API: https://www.sonatype.com/products/sonatype-guide/oss-index-users", 4698 ), 4699 ( 4700 "RETIREJS", 4701 "This report may contain data retrieved from the RetireJS community: https://retirejs.github.io/retire.js/", 4702 ), 4703 ]; 4704 object.len() == CREDITS.len() 4705 && CREDITS 4706 .iter() 4707 .all(|(key, value)| object.get(*key).and_then(Value::as_str) == Some(*value)) 4708 } 4709 4710 fn valid_owasp_evidence(value: &Value) -> bool { 4711 let Some(object) = value.as_object() else { 4712 return false; 4713 }; 4714 if !object_keys_exact( 4715 object, 4716 &["productEvidence", "vendorEvidence", "versionEvidence"], 4717 &[], 4718 ) { 4719 return false; 4720 } 4721 [ 4722 ("productEvidence", "product"), 4723 ("vendorEvidence", "vendor"), 4724 ("versionEvidence", "version"), 4725 ] 4726 .into_iter() 4727 .all(|(key, kind)| { 4728 object[key] 4729 .as_array() 4730 .filter(|rows| rows.len() <= 65_536) 4731 .is_some_and(|rows| { 4732 rows.iter().all(|row| { 4733 row.as_object().is_some_and(|row| { 4734 object_keys_exact( 4735 row, 4736 &["confidence", "name", "source", "type", "value"], 4737 &[], 4738 ) && row["type"].as_str() == Some(kind) 4739 && ["confidence", "name", "source", "value"].iter().all(|key| { 4740 row[*key] 4741 .as_str() 4742 .is_some_and(|value| valid_bounded_text(value, 4096)) 4743 }) 4744 }) 4745 }) 4746 }) 4747 }) 4748 } 4749 4750 fn parse_owasp_identifiers( 4751 value: Option<&Value>, 4752 allow_confidence: bool, 4753 ) -> Result<Vec<String>, AdvisoryError> { 4754 let Some(value) = value else { 4755 return Ok(Vec::new()); 4756 }; 4757 let rows = value 4758 .as_array() 4759 .filter(|rows| !rows.is_empty() && rows.len() <= 65_536) 4760 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4761 let mut result = Vec::with_capacity(rows.len()); 4762 for row in rows { 4763 let row = row 4764 .as_object() 4765 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4766 let optional = if allow_confidence { 4767 ["confidence", "notes", "url"].as_slice() 4768 } else { 4769 ["notes", "url"].as_slice() 4770 }; 4771 if !object_keys_exact(row, &["id"], optional) 4772 || optional 4773 .iter() 4774 .any(|key| !optional_bounded_string(row.get(*key), 4096)) 4775 { 4776 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4777 } 4778 result.push(required_text(row.get("id"))?.to_owned()); 4779 } 4780 if result.windows(2).any(|rows| rows[0] >= rows[1]) { 4781 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4782 } 4783 Ok(result) 4784 } 4785 4786 fn parse_owasp_included_by(value: Option<&Value>) -> Result<Vec<String>, AdvisoryError> { 4787 let Some(value) = value else { 4788 return Ok(Vec::new()); 4789 }; 4790 let rows = value 4791 .as_array() 4792 .filter(|rows| !rows.is_empty() && rows.len() <= 65_536) 4793 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4794 let mut result = Vec::with_capacity(rows.len()); 4795 let mut previous = None::<Vec<u8>>; 4796 for row in rows { 4797 let row = row 4798 .as_object() 4799 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4800 if !object_keys_exact(row, &["reference"], &["type"]) 4801 || !optional_bounded_string(row.get("type"), 256) 4802 { 4803 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4804 } 4805 let key = canonical_json_without_lf(&Value::Object(row.clone())); 4806 if previous.as_ref().is_some_and(|previous| previous >= &key) { 4807 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4808 } 4809 previous = Some(key); 4810 result.push(required_text(row.get("reference"))?.to_owned()); 4811 } 4812 Ok(result) 4813 } 4814 4815 fn parse_owasp_related( 4816 value: Option<&Value>, 4817 expected: &BTreeMap<&str, Vec<&GradleArtifact>>, 4818 ) -> Result<ParsedOwaspRelated, AdvisoryError> { 4819 let Some(value) = value else { 4820 return Ok(ParsedOwaspRelated { 4821 aliases: Vec::new(), 4822 }); 4823 }; 4824 let rows = value 4825 .as_array() 4826 .filter(|rows| !rows.is_empty() && rows.len() <= 65_536) 4827 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4828 let mut aliases = Vec::new(); 4829 let mut previous = None::<Vec<u8>>; 4830 for row in rows { 4831 let row = row 4832 .as_object() 4833 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4834 if !object_keys_exact( 4835 row, 4836 &["fileName", "filePath", "isVirtual"], 4837 &["md5", "packageIds", "sha1", "sha256"], 4838 ) { 4839 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4840 } 4841 let is_virtual = row["isVirtual"] 4842 .as_bool() 4843 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4844 let file_name = required_text(row.get("fileName"))?; 4845 required_text(row.get("filePath"))?; 4846 if is_virtual { 4847 if row.contains_key("md5") || row.contains_key("sha1") || row.contains_key("sha256") { 4848 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4849 } 4850 } else if !row 4851 .get("md5") 4852 .and_then(Value::as_str) 4853 .is_some_and(|value| valid_hex(value, 32)) 4854 || !row 4855 .get("sha1") 4856 .and_then(Value::as_str) 4857 .is_some_and(|value| valid_hex(value, 40)) 4858 || !row 4859 .get("sha256") 4860 .and_then(Value::as_str) 4861 .is_some_and(|value| valid_hex(value, 64)) 4862 { 4863 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4864 } 4865 if let (Some(expected), Some(digest)) = ( 4866 expected.get(file_name), 4867 row.get("sha256").and_then(Value::as_str), 4868 ) && expected 4869 .iter() 4870 .any(|artifact| artifact.artifact_sha256 != digest) 4871 { 4872 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 4873 } 4874 aliases.extend(parse_owasp_identifiers(row.get("packageIds"), false)?); 4875 let key = canonical_json_without_lf(&Value::Object(row.clone())); 4876 if previous.as_ref().is_some_and(|previous| previous >= &key) { 4877 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4878 } 4879 previous = Some(key); 4880 } 4881 Ok(ParsedOwaspRelated { aliases }) 4882 } 4883 4884 fn parse_owasp_vulnerabilities( 4885 value: Option<&Value>, 4886 ) -> Result<Vec<ParsedOwaspVulnerability>, AdvisoryError> { 4887 let Some(value) = value else { 4888 return Ok(Vec::new()); 4889 }; 4890 let rows = value 4891 .as_array() 4892 .filter(|rows| !rows.is_empty() && rows.len() <= 65_536) 4893 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4894 let mut result = Vec::with_capacity(rows.len()); 4895 for row in rows { 4896 let row = row 4897 .as_object() 4898 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4899 if !object_keys_exact( 4900 row, 4901 &[ 4902 "description", 4903 "name", 4904 "notes", 4905 "references", 4906 "source", 4907 "vulnerableSoftware", 4908 ], 4909 &["cvssv2", "cvssv3", "cvssv4", "cwes", "severity", "unscored"], 4910 ) || row["source"].as_str() != Some("NVD") 4911 || !optional_bounded_string(row.get("severity"), 128) 4912 || row 4913 .get("unscored") 4914 .is_some_and(|value| value.as_str() != Some("true")) 4915 || !["description", "notes"].iter().all(|key| { 4916 row[*key] 4917 .as_str() 4918 .is_some_and(|value| valid_text_allow_empty(value, 1_048_576)) 4919 }) 4920 || !valid_owasp_references(&row["references"]) 4921 || !valid_sorted_string_array(row.get("cwes"), 65_536, 256)? 4922 || !valid_owasp_cvss(row.get("cvssv2"), OwaspCvssVersion::V2) 4923 || !valid_owasp_cvss(row.get("cvssv3"), OwaspCvssVersion::V3) 4924 || !valid_owasp_cvss(row.get("cvssv4"), OwaspCvssVersion::V4) 4925 { 4926 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4927 } 4928 let advisory_id = required_text(row.get("name"))?; 4929 let vulnerable_software_ids = parse_owasp_vulnerable_software(&row["vulnerableSoftware"])?; 4930 if !valid_advisory_id(ProviderId::OwaspNvd, advisory_id) { 4931 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4932 } 4933 result.push(ParsedOwaspVulnerability { 4934 advisory_id: advisory_id.to_owned(), 4935 vulnerable_software_ids, 4936 }); 4937 } 4938 if result 4939 .windows(2) 4940 .any(|rows| rows[0].advisory_id >= rows[1].advisory_id) 4941 { 4942 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4943 } 4944 Ok(result) 4945 } 4946 4947 fn valid_owasp_references(value: &Value) -> bool { 4948 value 4949 .as_array() 4950 .filter(|rows| rows.len() <= 65_536) 4951 .is_some_and(|rows| { 4952 rows.iter().all(|row| { 4953 row.as_object().is_some_and(|row| { 4954 object_keys_exact(row, &["source"], &["name", "url"]) 4955 && ["source", "name", "url"] 4956 .iter() 4957 .all(|key| optional_bounded_string(row.get(*key), 4096)) 4958 }) 4959 }) 4960 }) 4961 } 4962 4963 fn parse_owasp_vulnerable_software(value: &Value) -> Result<BTreeSet<String>, AdvisoryError> { 4964 let rows = value 4965 .as_array() 4966 .filter(|rows| !rows.is_empty() && rows.len() <= 65_536) 4967 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4968 let mut identifiers = BTreeSet::new(); 4969 for row in rows { 4970 let row = row 4971 .as_object() 4972 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4973 if !object_keys_exact(row, &["software"], &[]) { 4974 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 4975 } 4976 let software = row["software"] 4977 .as_object() 4978 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 4979 if !object_keys_exact( 4980 software, 4981 &["id"], 4982 &[ 4983 "versionEndExcluding", 4984 "versionEndIncluding", 4985 "versionStartExcluding", 4986 "versionStartIncluding", 4987 "vulnerabilityIdMatched", 4988 "vulnerable", 4989 ], 4990 ) || software 4991 .get("vulnerabilityIdMatched") 4992 .is_some_and(|value| value.as_str() != Some("true")) 4993 || software 4994 .get("vulnerable") 4995 .is_some_and(|value| value.as_str() != Some("false")) 4996 || [ 4997 "versionEndExcluding", 4998 "versionEndIncluding", 4999 "versionStartExcluding", 5000 "versionStartIncluding", 5001 ] 5002 .iter() 5003 .any(|key| !optional_bounded_string(software.get(*key), 256)) 5004 { 5005 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 5006 } 5007 let identifier = required_text(software.get("id"))?; 5008 if !valid_owasp_software_identifier(identifier) 5009 || !identifiers.insert(identifier.to_owned()) 5010 { 5011 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 5012 } 5013 } 5014 Ok(identifiers) 5015 } 5016 5017 fn valid_owasp_software_identifier(value: &str) -> bool { 5018 valid_bounded_text(value, 4096) 5019 && (value.starts_with("cpe:2.3:") 5020 || value.starts_with("cpe:/") 5021 || parse_exact_package_url(value).is_ok()) 5022 } 5023 5024 #[derive(Clone, Copy)] 5025 enum OwaspCvssVersion { 5026 V2, 5027 V3, 5028 V4, 5029 } 5030 5031 fn valid_owasp_cvss(value: Option<&Value>, version: OwaspCvssVersion) -> bool { 5032 let Some(value) = value else { 5033 return true; 5034 }; 5035 let Some(object) = value.as_object() else { 5036 return false; 5037 }; 5038 let (required, optional, numeric): (&[&str], &[&str], &[&str]) = match version { 5039 OwaspCvssVersion::V2 => ( 5040 &[ 5041 "accessComplexity", 5042 "accessVector", 5043 "authenticationr", 5044 "availabilityImpact", 5045 "confidentialityImpact", 5046 "integrityImpact", 5047 "score", 5048 "severity", 5049 ], 5050 &[ 5051 "acInsufInfo", 5052 "exploitabilityScore", 5053 "impactScore", 5054 "obtainAllPrivilege", 5055 "obtainOtherPrivilege", 5056 "obtainUserPrivilege", 5057 "userInteractionRequired", 5058 "version", 5059 ], 5060 &["score"], 5061 ), 5062 OwaspCvssVersion::V3 => ( 5063 &[ 5064 "attackComplexity", 5065 "attackVector", 5066 "availabilityImpact", 5067 "baseScore", 5068 "baseSeverity", 5069 "confidentialityImpact", 5070 "integrityImpact", 5071 "privilegesRequired", 5072 "scope", 5073 "userInteraction", 5074 ], 5075 &["exploitabilityScore", "impactScore", "version"], 5076 &["baseScore"], 5077 ), 5078 OwaspCvssVersion::V4 => ( 5079 &[], 5080 &[ 5081 "attackComplexity", 5082 "attackRequirements", 5083 "attackVector", 5084 "automatable", 5085 "availabilityRequirements", 5086 "baseScore", 5087 "baseSeverity", 5088 "confidentialityRequirements", 5089 "environmentalScore", 5090 "environmentalSeverity", 5091 "exploitMaturity", 5092 "integrityRequirements", 5093 "modifiedAttackComplexity", 5094 "modifiedAttackRequirements", 5095 "modifiedAttackVector", 5096 "modifiedPrivilegesRequired", 5097 "modifiedSubsequentSystemAvailability", 5098 "modifiedSubsequentSystemConfidentiality", 5099 "modifiedSubsequentSystemIntegrity", 5100 "modifiedUserInteraction", 5101 "modifiedVulnerableSystemAvailability", 5102 "modifiedVulnerableSystemConfidentiality", 5103 "modifiedVulnerableSystemIntegrity", 5104 "privilegesRequired", 5105 "providerUrgency", 5106 "recovery", 5107 "safety", 5108 "source", 5109 "subsequentSystemAvailability", 5110 "subsequentSystemConfidentiality", 5111 "subsequentSystemIntegrity", 5112 "threatScore", 5113 "threatSeverity", 5114 "type", 5115 "userInteraction", 5116 "valueDensity", 5117 "vectorString", 5118 "version", 5119 "vulnerabilityResponseEffort", 5120 "vulnerableSystemAvailability", 5121 "vulnerableSystemConfidentiality", 5122 "vulnerableSystemIntegrity", 5123 ], 5124 &["baseScore", "environmentalScore", "threatScore"], 5125 ), 5126 }; 5127 object_keys_exact(object, required, optional) 5128 && object.iter().all(|(key, value)| { 5129 if numeric.contains(&key.as_str()) { 5130 value.as_f64().is_some_and(|value| value.is_finite()) 5131 } else { 5132 value 5133 .as_str() 5134 .is_some_and(|value| valid_bounded_text(value, 4096)) 5135 } 5136 }) 5137 } 5138 5139 fn validate_owasp_lineage( 5140 workload_id: &str, 5141 dependencies: &[ParsedOwaspDependency], 5142 ) -> Result<(), AdvisoryError> { 5143 const ROOT: usize = usize::MAX; 5144 let mut aliases = BTreeMap::<String, usize>::new(); 5145 for root in [workload_id.to_owned(), format!("project:{workload_id}")] { 5146 if aliases.insert(root, ROOT).is_some() { 5147 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 5148 } 5149 } 5150 for (index, dependency) in dependencies.iter().enumerate() { 5151 for alias in &dependency.aliases { 5152 if aliases.insert(alias.clone(), index).is_some() { 5153 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 5154 } 5155 } 5156 } 5157 let mut states = vec![0_u8; dependencies.len()]; 5158 fn reaches_root( 5159 index: usize, 5160 dependencies: &[ParsedOwaspDependency], 5161 aliases: &BTreeMap<String, usize>, 5162 states: &mut [u8], 5163 ) -> Result<bool, AdvisoryError> { 5164 if !dependencies[index].is_virtual { 5165 return Ok(true); 5166 } 5167 match states[index] { 5168 1 => return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)), 5169 2 => return Ok(true), 5170 3 => return Ok(false), 5171 _ => {} 5172 } 5173 states[index] = 1; 5174 let mut reached = false; 5175 for parent in &dependencies[index].included_by { 5176 let target = aliases 5177 .get(parent) 5178 .copied() 5179 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 5180 reached |= target == usize::MAX || reaches_root(target, dependencies, aliases, states)?; 5181 } 5182 states[index] = if reached { 2 } else { 3 }; 5183 Ok(reached) 5184 } 5185 for index in 0..dependencies.len() { 5186 if dependencies[index].is_virtual 5187 && !reaches_root(index, dependencies, &aliases, &mut states)? 5188 { 5189 return Err(AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch)); 5190 } 5191 } 5192 Ok(()) 5193 } 5194 5195 fn parse_exact_package_url(value: &str) -> Result<(String, String, String, String), AdvisoryError> { 5196 if !valid_bounded_text(value, 512) 5197 || value.contains(['%', '?', '#']) 5198 || !value.starts_with("pkg:maven/") 5199 { 5200 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 5201 } 5202 let body = &value["pkg:maven/".len()..]; 5203 let (path, version) = body 5204 .split_once('@') 5205 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 5206 let (namespace, name) = path 5207 .rsplit_once('/') 5208 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 5209 if !valid_exact_identity_token(namespace, 256) 5210 || !valid_exact_identity_token(name, 256) 5211 || !valid_exact_identity_token(version, 128) 5212 { 5213 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 5214 } 5215 Ok(( 5216 "maven".to_owned(), 5217 namespace.to_owned(), 5218 name.to_owned(), 5219 version.to_owned(), 5220 )) 5221 } 5222 5223 fn package_identifier_compatible(value: &str, artifact: &GradleArtifact) -> bool { 5224 let parsed = parse_exact_package_url(value).ok(); 5225 artifact.package_ecosystem == "maven" 5226 && valid_exact_identity_token(&artifact.package_namespace, 256) 5227 && parsed 5228 == Some(( 5229 "maven".to_owned(), 5230 artifact.package_namespace.clone(), 5231 artifact.package_name.clone(), 5232 artifact.package_version.clone(), 5233 )) 5234 && value 5235 == format!( 5236 "pkg:maven/{}/{}@{}", 5237 artifact.package_namespace, artifact.package_name, artifact.package_version 5238 ) 5239 } 5240 5241 fn required_text(value: Option<&Value>) -> Result<&str, AdvisoryError> { 5242 value 5243 .and_then(Value::as_str) 5244 .filter(|value| valid_bounded_text(value, 256)) 5245 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport)) 5246 } 5247 5248 fn validate_suppression_inventory( 5249 suppressions: &[Suppression], 5250 evaluation_epoch: u64, 5251 ) -> Result<(), AdvisoryError> { 5252 let mut previous = None; 5253 let mut identifiers = BTreeSet::new(); 5254 for suppression in suppressions { 5255 if suppression.advisory_id == NON_WAIVABLE_RUSTSEC 5256 || !valid_advisory_id(suppression.provider, &suppression.advisory_id) 5257 || !valid_exact_identity_token(&suppression.id, 128) 5258 || !matches!(suppression.package_ecosystem.as_str(), "cargo" | "maven") 5259 || match suppression.package_ecosystem.as_str() { 5260 "cargo" => !suppression.package_namespace.is_empty(), 5261 "maven" => !valid_exact_identity_token(&suppression.package_namespace, 256), 5262 _ => true, 5263 } 5264 || !valid_exact_identity_token(&suppression.package_name, 256) 5265 || !valid_exact_identity_token(&suppression.package_version, 128) 5266 || !WORKLOADS 5267 .iter() 5268 .any(|workload| workload.id == suppression.workload_id) 5269 || !valid_bounded_text(&suppression.owner, 256) 5270 || !valid_bounded_text(&suppression.rationale, 1024) 5271 || !identifiers.insert(suppression.id.as_str()) 5272 || suppression.created_at_epoch == 0 5273 || suppression.created_at_epoch >= suppression.expires_at_epoch 5274 || suppression.created_at_epoch > evaluation_epoch 5275 { 5276 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 5277 } 5278 if evaluation_epoch >= suppression.expires_at_epoch { 5279 return Err(AdvisoryError::new(AdvisoryFailureKind::ExpiredSuppression)); 5280 } 5281 let key = suppression_key(suppression); 5282 if previous.as_ref().is_some_and(|value| value >= &key) { 5283 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 5284 } 5285 previous = Some(key); 5286 } 5287 Ok(()) 5288 } 5289 5290 fn apply_suppressions( 5291 findings: &mut Vec<Finding>, 5292 suppressions: &[Suppression], 5293 evaluation_epoch: u64, 5294 ) -> Result<(), AdvisoryError> { 5295 validate_suppression_inventory(suppressions, evaluation_epoch)?; 5296 for suppression in suppressions { 5297 let matches = findings 5298 .iter() 5299 .enumerate() 5300 .filter(|(_, finding)| suppression_matches(suppression, finding)) 5301 .map(|(index, _)| index) 5302 .collect::<Vec<_>>(); 5303 if matches.len() != 1 { 5304 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 5305 } 5306 findings.remove(matches[0]); 5307 } 5308 Ok(()) 5309 } 5310 5311 fn suppression_matches(suppression: &Suppression, finding: &Finding) -> bool { 5312 suppression.provider == finding.provider 5313 && suppression.advisory_id == finding.advisory_id 5314 && suppression.package_ecosystem == finding.package_ecosystem 5315 && suppression.package_namespace == finding.package_namespace 5316 && suppression.package_name == finding.package_name 5317 && suppression.package_version == finding.package_version 5318 && suppression.workload_id == finding.workload_id 5319 } 5320 5321 fn finding_key(finding: &Finding) -> (&str, &str, &str, &str, &str, &str, &str) { 5322 ( 5323 finding.provider.as_str(), 5324 &finding.advisory_id, 5325 &finding.package_ecosystem, 5326 &finding.package_namespace, 5327 &finding.package_name, 5328 &finding.package_version, 5329 &finding.workload_id, 5330 ) 5331 } 5332 5333 fn suppression_key(suppression: &Suppression) -> (&str, &str, &str, &str, &str, &str, &str, &str) { 5334 ( 5335 suppression.provider.as_str(), 5336 &suppression.advisory_id, 5337 &suppression.workload_id, 5338 &suppression.package_ecosystem, 5339 &suppression.package_namespace, 5340 &suppression.package_name, 5341 &suppression.package_version, 5342 &suppression.id, 5343 ) 5344 } 5345 5346 fn validate_blob(expected: &BlobBinding, actual: &FileEvidence) -> Result<(), AdvisoryError> { 5347 if expected.byte_length != actual.byte_length || expected.sha256 != actual.sha256 { 5348 Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)) 5349 } else { 5350 Ok(()) 5351 } 5352 } 5353 5354 fn valid_blob_binding(binding: &BlobBinding) -> bool { 5355 let maximum = if binding.media_type == "application/gzip" { 5356 2_147_483_648 5357 } else { 5358 MAX_REPORT_BYTES 5359 }; 5360 binding.byte_length > 0 5361 && binding.byte_length <= maximum 5362 && valid_hex(&binding.sha256, 64) 5363 && SNAPSHOT_FILE_NAMES.contains(&binding.path.as_str()) 5364 && binding.logical_uri == format!("extbuild-cas://sha256/{}", binding.sha256) 5365 && matches!( 5366 binding.media_type.as_str(), 5367 "application/gzip" | "application/json" 5368 ) 5369 && matches!( 5370 binding.logical_role.as_str(), 5371 "rustsec_database_snapshot" 5372 | "owasp_nvd_database_snapshot" 5373 | "rustsec_unsuppressed_report" 5374 | "owasp_unsuppressed_report" 5375 ) 5376 } 5377 5378 fn valid_provider_blob_binding(provider: ProviderId, binding: &BlobBinding, archive: bool) -> bool { 5379 if !valid_blob_binding(binding) { 5380 return false; 5381 } 5382 matches!( 5383 ( 5384 provider, 5385 archive, 5386 binding.path.as_str(), 5387 binding.media_type.as_str(), 5388 binding.logical_role.as_str() 5389 ), 5390 ( 5391 ProviderId::Rustsec, 5392 true, 5393 RUSTSEC_ARCHIVE_NAME, 5394 "application/gzip", 5395 "rustsec_database_snapshot" 5396 ) | ( 5397 ProviderId::Rustsec, 5398 false, 5399 RUSTSEC_REPORT_NAME, 5400 "application/json", 5401 "rustsec_unsuppressed_report" 5402 ) | ( 5403 ProviderId::OwaspNvd, 5404 true, 5405 NVD_ARCHIVE_NAME, 5406 "application/gzip", 5407 "owasp_nvd_database_snapshot" 5408 ) | ( 5409 ProviderId::OwaspNvd, 5410 false, 5411 OWASP_REPORT_NAME, 5412 "application/json", 5413 "owasp_unsuppressed_report" 5414 ) 5415 ) 5416 } 5417 5418 fn inventory_digest(inventory: &[WorkloadInventory]) -> Result<String, AdvisoryError> { 5419 domain_json_digest(b"radroots-advisory-workload-inventory-v1\0", inventory) 5420 } 5421 5422 fn source_digest(sources: &[SourceIdentity]) -> Result<String, AdvisoryError> { 5423 domain_json_digest(b"radroots-advisory-source-inventory-v1\0", sources) 5424 } 5425 5426 fn tool_observation_digest( 5427 acquisitions: &[ToolAcquisition], 5428 states: &[ToolState], 5429 ) -> Result<String, AdvisoryError> { 5430 domain_json_digest( 5431 b"radroots-advisory-tool-observation-v1\0", 5432 &serde_json::json!({"acquisition": acquisitions, "state": states}), 5433 ) 5434 } 5435 5436 fn provider_trace_digest(providers: &[ProviderSnapshot]) -> Result<String, AdvisoryError> { 5437 domain_json_digest(b"radroots-advisory-provider-trace-v1\0", providers) 5438 } 5439 5440 fn provider_database_identity( 5441 provider: ProviderId, 5442 materialized_tree_sha256: &str, 5443 report_database_metadata_sha256: &str, 5444 ) -> Result<String, AdvisoryError> { 5445 domain_json_digest( 5446 b"radroots-advisory-provider-database-identity-v1\0", 5447 &serde_json::json!({ 5448 "materialized_tree_sha256": materialized_tree_sha256, 5449 "provider": provider, 5450 "report_database_metadata_sha256": report_database_metadata_sha256, 5451 }), 5452 ) 5453 } 5454 5455 fn database_identity_from_report( 5456 provider: ProviderId, 5457 report: &[u8], 5458 materialized_tree_sha256: &str, 5459 ) -> Result<String, AdvisoryError> { 5460 let envelope: ReportEnvelope = serde_json::from_slice(report) 5461 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 5462 if envelope.provider != provider || envelope.workload_result.is_empty() { 5463 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 5464 } 5465 let mut metadata = None::<String>; 5466 for result in envelope.workload_result { 5467 let raw: Value = serde_json::from_str(&result.raw_scanner_output) 5468 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 5469 let current = match provider { 5470 ProviderId::Rustsec => domain_json_digest( 5471 b"radroots-advisory-rustsec-database-metadata-v1\0", 5472 raw.get("database") 5473 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?, 5474 )?, 5475 ProviderId::OwaspNvd => domain_json_digest( 5476 b"radroots-advisory-owasp-database-metadata-v1\0", 5477 raw.pointer("/scanInfo/dataSource") 5478 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?, 5479 )?, 5480 }; 5481 if metadata.as_ref().is_some_and(|value| value != ¤t) { 5482 return Err(AdvisoryError::new(AdvisoryFailureKind::BindingChanged)); 5483 } 5484 metadata = Some(current); 5485 } 5486 provider_database_identity( 5487 provider, 5488 materialized_tree_sha256, 5489 &metadata.ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?, 5490 ) 5491 } 5492 5493 fn finding_digest(findings: &[Finding]) -> Result<String, AdvisoryError> { 5494 let rows = findings 5495 .iter() 5496 .map(|finding| { 5497 serde_json::json!({ 5498 "advisory_id": finding.advisory_id, 5499 "package_ecosystem": finding.package_ecosystem, 5500 "package_namespace": finding.package_namespace, 5501 "package_name": finding.package_name, 5502 "package_version": finding.package_version, 5503 "provider": finding.provider, 5504 "workload_id": finding.workload_id, 5505 }) 5506 }) 5507 .collect::<Vec<_>>(); 5508 domain_json_digest(b"radroots-advisory-finding-inventory-v1\0", &rows) 5509 } 5510 5511 fn suppression_digest(suppressions: &[Suppression]) -> Result<String, AdvisoryError> { 5512 domain_json_digest( 5513 b"radroots-advisory-suppression-inventory-v1\0", 5514 suppressions, 5515 ) 5516 } 5517 5518 fn gradle_projection_digest(projections: &[GradleProjection]) -> Result<String, AdvisoryError> { 5519 domain_json_digest(b"radroots-advisory-gradle-projection-v1\0", projections) 5520 } 5521 5522 fn candidate_advisory_input_digest(manifest: &SnapshotManifest) -> Result<String, AdvisoryError> { 5523 domain_json_digest( 5524 b"radroots-advisory-candidate-input-v1\0", 5525 &serde_json::json!({ 5526 "candidate": manifest.candidate, 5527 "fresh_tool_observation_sha256": manifest.fresh_tool_observation_sha256, 5528 "gradle_projection": manifest.gradle_projection, 5529 "inventory": manifest.inventory, 5530 "provider_snapshot": manifest.provider_snapshot, 5531 "sources": manifest.sources, 5532 "step_297_tool_manifest_sha256": manifest.step_297_tool_manifest_sha256, 5533 "suppressions": manifest.suppressions, 5534 "tool_acquisition": manifest.tool_acquisition, 5535 "tool_state": manifest.tool_state, 5536 }), 5537 ) 5538 } 5539 5540 fn domain_json_digest<T: Serialize + ?Sized>( 5541 domain: &[u8], 5542 value: &T, 5543 ) -> Result<String, AdvisoryError> { 5544 let value = serde_json::to_value(value) 5545 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 5546 let bytes = canonical_json_without_lf(&value); 5547 let mut hasher = Sha256::new(); 5548 hasher.update(domain); 5549 hasher.update(bytes); 5550 Ok(hex::encode(hasher.finalize())) 5551 } 5552 5553 fn materialized_tree_digest(snapshot: &TraversalSnapshot) -> Result<String, AdvisoryError> { 5554 let directories = snapshot 5555 .directories() 5556 .map(|(path, mode)| { 5557 path.to_str() 5558 .map(|path| serde_json::json!({"mode": mode, "path": path})) 5559 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)) 5560 }) 5561 .collect::<Result<Vec<_>, _>>()?; 5562 let mut files = Vec::with_capacity(snapshot.files().len()); 5563 for file in snapshot.files() { 5564 let evidence = snapshot 5565 .hash(file, 17_179_869_184) 5566 .map_err(map_binding_error)?; 5567 let path = file 5568 .relative_path() 5569 .to_str() 5570 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 5571 files.push(serde_json::json!({ 5572 "byte_length": evidence.byte_length, 5573 "mode": file.permission_mode(), 5574 "path": path, 5575 "sha256": evidence.sha256, 5576 })); 5577 } 5578 snapshot.revalidate().map_err(map_binding_error)?; 5579 domain_json_digest( 5580 b"radroots-advisory-materialized-tree-v1\0", 5581 &serde_json::json!({ 5582 "directories": directories, 5583 "entry_count": snapshot.entry_count(), 5584 "files": files, 5585 "total_bytes": snapshot.total_bytes(), 5586 }), 5587 ) 5588 } 5589 5590 fn archive_limits() -> TarGzipLimits { 5591 TarGzipLimits { 5592 max_compressed_bytes: 2_147_483_648, 5593 max_expanded_bytes: 17_179_869_184, 5594 max_members: 65_536, 5595 max_member_bytes: 17_179_869_184, 5596 max_payload_bytes: 17_179_869_184, 5597 max_depth: 64, 5598 max_path_bytes: 4_096, 5599 } 5600 } 5601 5602 fn map_binding_error(error: safe_artifact_io::ArtifactIoError) -> AdvisoryError { 5603 match error.kind() { 5604 safe_artifact_io::ArtifactIoFailureKind::ChangedDuringRead => { 5605 AdvisoryError::new(AdvisoryFailureKind::BindingChanged) 5606 } 5607 _ => AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot), 5608 } 5609 } 5610 5611 fn map_archive_error(error: safe_artifact_io::ArtifactIoError) -> AdvisoryError { 5612 match error.kind() { 5613 safe_artifact_io::ArtifactIoFailureKind::ChangedDuringRead => { 5614 AdvisoryError::new(AdvisoryFailureKind::BindingChanged) 5615 } 5616 _ => AdvisoryError::new(AdvisoryFailureKind::ArchiveRejected), 5617 } 5618 } 5619 5620 fn canonical_json(value: &Value) -> Vec<u8> { 5621 let mut bytes = canonical_json_without_lf(value); 5622 bytes.push(b'\n'); 5623 bytes 5624 } 5625 5626 fn canonical_json_without_lf(value: &Value) -> Vec<u8> { 5627 serde_json::to_vec(value).unwrap_or_default() 5628 } 5629 5630 fn sha256(bytes: &[u8]) -> String { 5631 hex::encode(Sha256::digest(bytes)) 5632 } 5633 5634 fn valid_hex(value: &str, length: usize) -> bool { 5635 value.len() == length 5636 && value 5637 .bytes() 5638 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 5639 } 5640 5641 fn valid_identifier(value: &str) -> bool { 5642 valid_bounded_text(value, 256) 5643 && !Path::new(value).is_absolute() 5644 && Path::new(value) 5645 .components() 5646 .all(|component| matches!(component, Component::Normal(_))) 5647 && !value.contains('\\') 5648 } 5649 5650 fn valid_bounded_text(value: &str, maximum: usize) -> bool { 5651 !value.is_empty() 5652 && value.len() <= maximum 5653 && !value.contains('\0') 5654 && !value.chars().any(char::is_control) 5655 } 5656 5657 fn valid_exact_identity_token(value: &str, maximum: usize) -> bool { 5658 valid_bounded_text(value, maximum) 5659 && value.bytes().all(|byte| { 5660 byte.is_ascii_alphanumeric() 5661 || matches!(byte, b'-' | b'_' | b'.' | b'/' | b':' | b'@' | b'+') 5662 }) 5663 && !value.contains("..") 5664 } 5665 5666 fn valid_advisory_id(provider: ProviderId, value: &str) -> bool { 5667 let (prefix, minimum_digits) = match provider { 5668 ProviderId::Rustsec => ("RUSTSEC-", 9), 5669 ProviderId::OwaspNvd => ("CVE-", 9), 5670 }; 5671 if !value.starts_with(prefix) || value.len() < prefix.len() + minimum_digits { 5672 return false; 5673 } 5674 let remainder = &value[prefix.len()..]; 5675 let Some((year, sequence)) = remainder.split_once('-') else { 5676 return false; 5677 }; 5678 year.len() == 4 5679 && year.bytes().all(|byte| byte.is_ascii_digit()) 5680 && sequence.len() >= 4 5681 && sequence.bytes().all(|byte| byte.is_ascii_digit()) 5682 } 5683 5684 pub(crate) fn self_test() -> Result<(), String> { 5685 self_test_suite().map_err(|error| error.to_string())?; 5686 verify_gradle_raw_bridge().map_err(|error| error.to_string())?; 5687 verify_bounded_process_surface().map_err(|error| error.to_string()) 5688 } 5689 5690 fn verify_gradle_raw_bridge() -> Result<(), AdvisoryError> { 5691 let fixture = trusted_tempdir(".radroots-gradle-bridge-")?; 5692 let raw_root = fixture.path().join("raw"); 5693 let source_root = fixture.path().join("source"); 5694 let materialization_parent = fixture.path().join("materialized"); 5695 for directory in [&raw_root, &source_root, &materialization_parent] { 5696 std::fs::create_dir(directory) 5697 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 5698 #[cfg(unix)] 5699 { 5700 use std::os::unix::fs::PermissionsExt as _; 5701 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)) 5702 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 5703 } 5704 } 5705 let artifact_path = source_root.join("example-1.0.0.jar"); 5706 let artifact_bytes = b"synthetic-gradle-artifact"; 5707 std::fs::write(&artifact_path, artifact_bytes) 5708 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 5709 #[cfg(unix)] 5710 { 5711 use std::os::unix::fs::PermissionsExt as _; 5712 std::fs::set_permissions(&artifact_path, std::fs::Permissions::from_mode(0o600)) 5713 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 5714 } 5715 let variant = RawGradleVariant { 5716 attributes: vec![RawGradleAttribute { 5717 name: "org.gradle.usage".to_owned(), 5718 value: "java-runtime".to_owned(), 5719 }], 5720 capabilities: Vec::new(), 5721 external_variant: None, 5722 }; 5723 let root_core = RawGradleComponentCore { 5724 build_root: Some(".".to_owned()), 5725 group: None, 5726 kind: "project".to_owned(), 5727 name: None, 5728 project_path: Some(":app:design_system".to_owned()), 5729 version: None, 5730 }; 5731 let module_core = RawGradleComponentCore { 5732 build_root: None, 5733 group: Some("com.example".to_owned()), 5734 kind: "module".to_owned(), 5735 name: Some("example".to_owned()), 5736 project_path: None, 5737 version: Some("1.0.0".to_owned()), 5738 }; 5739 let mut components = vec![ 5740 RawGradleComponent { 5741 build_root: root_core.build_root.clone(), 5742 group: root_core.group.clone(), 5743 kind: root_core.kind.clone(), 5744 name: root_core.name.clone(), 5745 project_path: root_core.project_path.clone(), 5746 root: true, 5747 variant: RawGradleVariantEnvelope { 5748 selected: vec![variant.clone()], 5749 }, 5750 version: root_core.version.clone(), 5751 }, 5752 RawGradleComponent { 5753 build_root: module_core.build_root.clone(), 5754 group: module_core.group.clone(), 5755 kind: module_core.kind.clone(), 5756 name: module_core.name.clone(), 5757 project_path: module_core.project_path.clone(), 5758 root: false, 5759 variant: RawGradleVariantEnvelope { 5760 selected: vec![variant.clone()], 5761 }, 5762 version: module_core.version.clone(), 5763 }, 5764 ]; 5765 components.sort_by(|left, right| { 5766 canonical_row_key(left) 5767 .unwrap_or_default() 5768 .cmp(&canonical_row_key(right).unwrap_or_default()) 5769 }); 5770 let raw = RawGradleGraph { 5771 schema: "radroots.gradle-advisory-graph.v1".to_owned(), 5772 workload_id: "app_design_system".to_owned(), 5773 build_root: ".".to_owned(), 5774 project_path: ":app:design_system".to_owned(), 5775 configuration: "desktopRuntimeClasspath".to_owned(), 5776 components, 5777 edges: vec![RawGradleEdge { 5778 constraint: false, 5779 from: root_core, 5780 requested: RawGradleSelector { 5781 attributes: Vec::new(), 5782 build_root: None, 5783 capabilities: Vec::new(), 5784 group: Some("com.example".to_owned()), 5785 kind: "module".to_owned(), 5786 name: Some("example".to_owned()), 5787 project_path: None, 5788 version: Some("1.0.0".to_owned()), 5789 version_constraint: Some(RawGradleVersionConstraint { 5790 branch: None, 5791 preferred: String::new(), 5792 rejected: Vec::new(), 5793 required: "1.0.0".to_owned(), 5794 strict: String::new(), 5795 }), 5796 }, 5797 selected_variant: variant.clone(), 5798 to: module_core.clone(), 5799 }], 5800 artifacts: vec![RawGradleArtifact { 5801 artifact_name: "example-1.0.0.jar".to_owned(), 5802 artifact_type: "jar".to_owned(), 5803 classifier: None, 5804 component: module_core, 5805 extension: "jar".to_owned(), 5806 group: Some("com.example".to_owned()), 5807 logical_name: "example".to_owned(), 5808 module_version: RawGradleModuleVersion { 5809 group: "com.example".to_owned(), 5810 name: "example".to_owned(), 5811 version: "1.0.0".to_owned(), 5812 }, 5813 name: Some("example".to_owned()), 5814 observed_byte_length: artifact_bytes.len() as u64, 5815 observed_sha256: sha256(artifact_bytes), 5816 source_path: artifact_path 5817 .to_str() 5818 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))? 5819 .to_owned(), 5820 variant, 5821 version: Some("1.0.0".to_owned()), 5822 }], 5823 }; 5824 let raw_value = serde_json::to_value(raw) 5825 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 5826 std::fs::write( 5827 raw_root.join(GRADLE_RAW_GRAPH_NAME), 5828 canonical_json_without_lf(&raw_value), 5829 ) 5830 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 5831 let authority = WORKLOADS 5832 .iter() 5833 .find(|workload| workload.id == "app_design_system") 5834 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))?; 5835 let source_root_identity = "3".repeat(64); 5836 let source_roots = [GradleArtifactSourceRoot { 5837 path: &source_root, 5838 logical_role: "candidate_build_output", 5839 identity_sha256: &source_root_identity, 5840 }]; 5841 let admitted = admit_raw_gradle_graph( 5842 &raw_root, 5843 &materialization_parent, 5844 authority, 5845 &"a".repeat(40), 5846 1, 5847 &source_roots, 5848 )?; 5849 if admitted.workload_id != authority.id 5850 || admitted.raw_evidence().byte_length == 0 5851 || admitted.components.len() != 2 5852 || admitted.edges.len() != 1 5853 || admitted.artifacts.len() != 1 5854 || admitted.artifacts[0].package_namespace != "com.example" 5855 || !valid_hex(&admitted.canonical_graph_sha256, 64) 5856 || !valid_hex(&admitted.materialized_tree_sha256, 64) 5857 || !valid_hex(&admitted.normalization_receipt_sha256, 64) 5858 { 5859 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 5860 } 5861 admitted.revalidate() 5862 } 5863 5864 fn synthetic_admitted_gradle_graphs( 5865 fixture_root: &Path, 5866 materialization_parent: &Path, 5867 ) -> Result<Vec<AdmittedGradleGraph>, AdvisoryError> { 5868 let mut admitted = Vec::new(); 5869 for (index, authority) in WORKLOADS 5870 .iter() 5871 .filter(|workload| workload.package_manager == "gradle") 5872 .enumerate() 5873 { 5874 let workload_root = fixture_root.join(authority.id); 5875 let raw_root = workload_root.join("raw"); 5876 let source_root = workload_root.join("source"); 5877 for directory in [&workload_root, &raw_root, &source_root] { 5878 std::fs::create_dir(directory) 5879 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 5880 #[cfg(unix)] 5881 { 5882 use std::os::unix::fs::PermissionsExt as _; 5883 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)) 5884 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 5885 } 5886 } 5887 let package_name = format!("fixture-{}", authority.id.replace('_', "-")); 5888 let artifact_name = format!("{package_name}-1.0.0.jar"); 5889 let artifact_path = source_root.join(&artifact_name); 5890 let artifact_bytes = format!("synthetic-gradle-artifact-{index}").into_bytes(); 5891 std::fs::write(&artifact_path, &artifact_bytes) 5892 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 5893 #[cfg(unix)] 5894 { 5895 use std::os::unix::fs::PermissionsExt as _; 5896 std::fs::set_permissions(&artifact_path, std::fs::Permissions::from_mode(0o600)) 5897 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 5898 } 5899 let variant = RawGradleVariant { 5900 attributes: vec![RawGradleAttribute { 5901 name: "org.gradle.usage".to_owned(), 5902 value: "java-runtime".to_owned(), 5903 }], 5904 capabilities: Vec::new(), 5905 external_variant: None, 5906 }; 5907 let root_core = RawGradleComponentCore { 5908 build_root: Some(authority.build_root.to_owned()), 5909 group: None, 5910 kind: "project".to_owned(), 5911 name: None, 5912 project_path: authority.project_path.map(str::to_owned), 5913 version: None, 5914 }; 5915 let module_core = RawGradleComponentCore { 5916 build_root: None, 5917 group: Some("com.example".to_owned()), 5918 kind: "module".to_owned(), 5919 name: Some(package_name.clone()), 5920 project_path: None, 5921 version: Some("1.0.0".to_owned()), 5922 }; 5923 let mut components = vec![ 5924 RawGradleComponent { 5925 build_root: root_core.build_root.clone(), 5926 group: root_core.group.clone(), 5927 kind: root_core.kind.clone(), 5928 name: root_core.name.clone(), 5929 project_path: root_core.project_path.clone(), 5930 root: true, 5931 variant: RawGradleVariantEnvelope { 5932 selected: vec![variant.clone()], 5933 }, 5934 version: root_core.version.clone(), 5935 }, 5936 RawGradleComponent { 5937 build_root: module_core.build_root.clone(), 5938 group: module_core.group.clone(), 5939 kind: module_core.kind.clone(), 5940 name: module_core.name.clone(), 5941 project_path: module_core.project_path.clone(), 5942 root: false, 5943 variant: RawGradleVariantEnvelope { 5944 selected: vec![variant.clone()], 5945 }, 5946 version: module_core.version.clone(), 5947 }, 5948 ]; 5949 components.sort_by(|left, right| { 5950 canonical_row_key(left) 5951 .unwrap_or_default() 5952 .cmp(&canonical_row_key(right).unwrap_or_default()) 5953 }); 5954 let raw = RawGradleGraph { 5955 schema: "radroots.gradle-advisory-graph.v1".to_owned(), 5956 workload_id: authority.id.to_owned(), 5957 build_root: authority.build_root.to_owned(), 5958 project_path: authority 5959 .project_path 5960 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))? 5961 .to_owned(), 5962 configuration: authority 5963 .configuration 5964 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))? 5965 .to_owned(), 5966 components, 5967 edges: vec![RawGradleEdge { 5968 constraint: false, 5969 from: root_core, 5970 requested: RawGradleSelector { 5971 attributes: Vec::new(), 5972 build_root: None, 5973 capabilities: Vec::new(), 5974 group: Some("com.example".to_owned()), 5975 kind: "module".to_owned(), 5976 name: Some(package_name.clone()), 5977 project_path: None, 5978 version: Some("1.0.0".to_owned()), 5979 version_constraint: Some(RawGradleVersionConstraint { 5980 branch: None, 5981 preferred: String::new(), 5982 rejected: Vec::new(), 5983 required: "1.0.0".to_owned(), 5984 strict: String::new(), 5985 }), 5986 }, 5987 selected_variant: variant.clone(), 5988 to: module_core.clone(), 5989 }], 5990 artifacts: vec![RawGradleArtifact { 5991 artifact_name, 5992 artifact_type: "jar".to_owned(), 5993 classifier: None, 5994 component: module_core, 5995 extension: "jar".to_owned(), 5996 group: Some("com.example".to_owned()), 5997 logical_name: package_name.clone(), 5998 module_version: RawGradleModuleVersion { 5999 group: "com.example".to_owned(), 6000 name: package_name.clone(), 6001 version: "1.0.0".to_owned(), 6002 }, 6003 name: Some(package_name), 6004 observed_byte_length: artifact_bytes.len() as u64, 6005 observed_sha256: sha256(&artifact_bytes), 6006 source_path: artifact_path 6007 .to_str() 6008 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))? 6009 .to_owned(), 6010 variant, 6011 version: Some("1.0.0".to_owned()), 6012 }], 6013 }; 6014 let value = serde_json::to_value(raw) 6015 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6016 std::fs::write( 6017 raw_root.join(GRADLE_RAW_GRAPH_NAME), 6018 canonical_json_without_lf(&value), 6019 ) 6020 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 6021 let source_root_identity = format!("{:064x}", index + 20_000); 6022 let source_roots = [GradleArtifactSourceRoot { 6023 path: &source_root, 6024 logical_role: "candidate_build_output", 6025 identity_sha256: &source_root_identity, 6026 }]; 6027 admitted.push(admit_raw_gradle_graph( 6028 &raw_root, 6029 materialization_parent, 6030 authority, 6031 &"1".repeat(40), 6032 1, 6033 &source_roots, 6034 )?); 6035 } 6036 Ok(admitted) 6037 } 6038 6039 pub(crate) fn write_fixture_scanner_output(mode: &str) -> Result<(), String> { 6040 match mode { 6041 "report" => { 6042 println!("radroots-advisory-fixture-report-v1"); 6043 Ok(()) 6044 } 6045 "timeout" => { 6046 std::thread::sleep(Duration::from_secs(5)); 6047 Ok(()) 6048 } 6049 _ => Err("unknown advisory fixture scanner mode".to_owned()), 6050 } 6051 } 6052 6053 fn verify_bounded_process_surface() -> Result<(), AdvisoryError> { 6054 let executable = std::env::current_exe() 6055 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 6056 let working = 6057 tempfile::tempdir().map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 6058 let environment = private_process_environment(working.path())?; 6059 let output = crate::bounded_process::run( 6060 &crate::bounded_process::ProcessRequest::new(executable.clone()) 6061 .arg("advisory-snapshot-fixture-scanner") 6062 .arg("--mode") 6063 .arg("report") 6064 .current_dir(working.path()) 6065 .environment(environment) 6066 .deadline(Duration::from_secs(2)) 6067 .output_limits(1024, 1024), 6068 ) 6069 .map_err(map_process_error)?; 6070 if !output.status().success() 6071 || output.stdout() != b"radroots-advisory-fixture-report-v1\n" 6072 || !output.stderr().is_empty() 6073 { 6074 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidReport)); 6075 } 6076 let error = crate::bounded_process::run( 6077 &crate::bounded_process::ProcessRequest::new(executable) 6078 .arg("advisory-snapshot-fixture-scanner") 6079 .arg("--mode") 6080 .arg("timeout") 6081 .current_dir(working.path()) 6082 .environment(private_process_environment(working.path())?) 6083 .deadline(Duration::from_millis(50)) 6084 .output_limits(1024, 1024), 6085 ) 6086 .err() 6087 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6088 if error.kind() != crate::bounded_process::ProcessFailureKind::DeadlineExceeded { 6089 return Err(map_process_error(error)); 6090 } 6091 Ok(()) 6092 } 6093 6094 fn private_process_environment( 6095 root: &Path, 6096 ) -> Result<crate::bounded_process::ReplacementEnvironment, AdvisoryError> { 6097 let mut environment = crate::bounded_process::ReplacementEnvironment::default(); 6098 for (name, value) in [ 6099 ("CARGO_HOME", root.as_os_str().to_owned()), 6100 ("GRADLE_USER_HOME", root.as_os_str().to_owned()), 6101 ("HOME", root.as_os_str().to_owned()), 6102 ("LC_ALL", OsString::from("C")), 6103 ("PATH", OsString::from("/usr/bin:/bin")), 6104 ("TMPDIR", root.as_os_str().to_owned()), 6105 ("TZ", OsString::from("UTC")), 6106 ] { 6107 environment.insert(name, value).map_err(map_process_error)?; 6108 } 6109 Ok(environment) 6110 } 6111 6112 fn map_process_error(error: crate::bounded_process::ProcessError) -> AdvisoryError { 6113 match error.kind() { 6114 crate::bounded_process::ProcessFailureKind::DeadlineExceeded => { 6115 AdvisoryError::new(AdvisoryFailureKind::TimedOut) 6116 } 6117 crate::bounded_process::ProcessFailureKind::Spawn => { 6118 AdvisoryError::new(AdvisoryFailureKind::Unavailable) 6119 } 6120 _ => AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot), 6121 } 6122 } 6123 6124 fn self_test_suite() -> Result<(), AdvisoryError> { 6125 let fixture = SyntheticFixture::new()?; 6126 let evidence = admit_snapshot( 6127 fixture.root(), 6128 fixture.materialization_parent.path(), 6129 &fixture.request, 6130 )?; 6131 if evidence.candidate != fixture.request.candidate 6132 || evidence.inventory_sha256 != inventory_digest(&fixture.request.inventory)? 6133 || evidence.finding_count != 0 6134 || evidence.suppression_count != 0 6135 || evidence.rustsec_archive.compressed.byte_length == 0 6136 || evidence.nvd_archive.compressed.byte_length == 0 6137 || evidence.provider_freshness.len() != 2 6138 { 6139 return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)); 6140 } 6141 6142 missing_inventory_vector()?; 6143 unavailable_provider_vector()?; 6144 stale_snapshot_vector()?; 6145 timed_out_operation_vector()?; 6146 known_vulnerable_vector()?; 6147 expired_suppression_vector()?; 6148 Ok(()) 6149 } 6150 6151 fn missing_inventory_vector() -> Result<(), AdvisoryError> { 6152 let mut missing = SyntheticFixture::new()?; 6153 missing.manifest.inventory.pop(); 6154 missing.seal()?; 6155 require_failure(&missing, AdvisoryFailureKind::InventoryMismatch) 6156 } 6157 6158 fn unavailable_provider_vector() -> Result<(), AdvisoryError> { 6159 let mut unavailable = SyntheticFixture::new()?; 6160 unavailable.manifest.tool_state[0].state = OperationState::Unavailable; 6161 unavailable.seal()?; 6162 require_failure(&unavailable, AdvisoryFailureKind::Unavailable) 6163 } 6164 6165 fn stale_snapshot_vector() -> Result<(), AdvisoryError> { 6166 let mut stale = SyntheticFixture::new()?; 6167 stale.manifest.provider_snapshot[0].digest_time_epoch = stale 6168 .request 6169 .evaluation_epoch 6170 .checked_sub(QUALIFICATION_FRESHNESS_SECONDS + 1) 6171 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6172 stale.manifest.provider_snapshot[0].acquired_at_epoch = stale.manifest.provider_snapshot[0] 6173 .digest_time_epoch 6174 .checked_sub(1) 6175 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6176 stale.seal()?; 6177 require_failure(&stale, AdvisoryFailureKind::StaleSnapshot) 6178 } 6179 6180 fn timed_out_operation_vector() -> Result<(), AdvisoryError> { 6181 let mut timeout = SyntheticFixture::new()?; 6182 timeout.manifest.provider_snapshot[1].analysis_state = OperationState::TimedOut; 6183 timeout.seal()?; 6184 require_failure(&timeout, AdvisoryFailureKind::TimedOut) 6185 } 6186 6187 fn known_vulnerable_vector() -> Result<(), AdvisoryError> { 6188 let mut vulnerable = SyntheticFixture::new()?; 6189 vulnerable.set_rustsec_finding(NON_WAIVABLE_RUSTSEC, "lru", "0.16.4")?; 6190 require_failure(&vulnerable, AdvisoryFailureKind::KnownVulnerability) 6191 } 6192 6193 fn expired_suppression_vector() -> Result<(), AdvisoryError> { 6194 let mut expired = SyntheticFixture::new()?; 6195 let expired_artifact = expired 6196 .manifest 6197 .gradle_projection 6198 .first() 6199 .and_then(|projection| projection.artifacts.first()) 6200 .cloned() 6201 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6202 expired.set_owasp_finding( 6203 "CVE-2099-0001", 6204 &expired_artifact.package_namespace, 6205 &expired_artifact.package_name, 6206 &expired_artifact.package_version, 6207 )?; 6208 expired.manifest.suppressions.push(Suppression { 6209 provider: ProviderId::OwaspNvd, 6210 advisory_id: "CVE-2099-0001".to_owned(), 6211 id: "synthetic-expired".to_owned(), 6212 package_ecosystem: "maven".to_owned(), 6213 package_namespace: expired_artifact.package_namespace, 6214 package_name: expired_artifact.package_name, 6215 package_version: expired_artifact.package_version, 6216 workload_id: "app_design_system".to_owned(), 6217 owner: "security@example.invalid".to_owned(), 6218 rationale: "synthetic expiry vector".to_owned(), 6219 created_at_epoch: expired.request.evaluation_epoch - 100, 6220 expires_at_epoch: expired.request.evaluation_epoch, 6221 }); 6222 expired.seal()?; 6223 require_failure(&expired, AdvisoryFailureKind::ExpiredSuppression) 6224 } 6225 6226 fn require_failure( 6227 fixture: &SyntheticFixture, 6228 expected: AdvisoryFailureKind, 6229 ) -> Result<(), AdvisoryError> { 6230 let error = admit_snapshot( 6231 fixture.root(), 6232 fixture.materialization_parent.path(), 6233 &fixture.request, 6234 ) 6235 .err() 6236 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6237 if error.kind() == expected { 6238 Ok(()) 6239 } else { 6240 Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)) 6241 } 6242 } 6243 6244 struct SyntheticFixture { 6245 directory: tempfile::TempDir, 6246 materialization_parent: tempfile::TempDir, 6247 _gradle_fixture_root: tempfile::TempDir, 6248 scanner_fixture_root: tempfile::TempDir, 6249 scanner_generation: u64, 6250 request: AdmissionRequest, 6251 manifest: SnapshotManifest, 6252 process_receipts: Vec<TrustedProcessReceipt>, 6253 } 6254 6255 impl SyntheticFixture { 6256 fn new() -> Result<Self, AdvisoryError> { 6257 use std::fs; 6258 6259 let directory = tempfile::tempdir() 6260 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6261 let materialization_parent = trusted_tempdir(".rshr-advisory-materialization-")?; 6262 let gradle_fixture_root = trusted_tempdir(".rshr-advisory-gradle-input-")?; 6263 let scanner_fixture_root = trusted_tempdir(".rshr-advisory-scanner-output-")?; 6264 let evaluation_epoch = 1_000_000; 6265 let candidate = CandidateIdentity { 6266 generation: 1, 6267 digest: "9".repeat(64), 6268 }; 6269 let sources = synthetic_sources(); 6270 let inventory = synthetic_inventory(); 6271 let producer_request = synthetic_producer_request(&candidate); 6272 let producer_request_bytes = canonical_authority_bytes(&producer_request)?; 6273 let producer_request_sha256 = sha256(&producer_request_bytes); 6274 let rustsec_archive = deterministic_archive(b"synthetic-rustsec")?; 6275 let nvd_archive = deterministic_archive(b"synthetic-nvd")?; 6276 fs::write( 6277 directory.path().join(RUSTSEC_ARCHIVE_NAME), 6278 &rustsec_archive, 6279 ) 6280 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6281 fs::write(directory.path().join(NVD_ARCHIVE_NAME), &nvd_archive) 6282 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6283 let tool_acquisition = expected_tool_acquisitions()?; 6284 let tool_state = synthetic_tool_states(evaluation_epoch); 6285 let tool_observation_sha256 = tool_observation_digest(&tool_acquisition, &tool_state)?; 6286 let (rustsec_tree, rustsec_archive_evidence) = 6287 synthetic_archive_tree_digest(&rustsec_archive, materialization_parent.path())?; 6288 let (nvd_tree, nvd_archive_evidence) = 6289 synthetic_archive_tree_digest(&nvd_archive, materialization_parent.path())?; 6290 let nvd_trace = synthetic_nvd_trace(&candidate, &producer_request_sha256); 6291 let nvd_trace_bytes = canonical_authority_bytes(&nvd_trace)?; 6292 let admitted_gradle_graphs = synthetic_admitted_gradle_graphs( 6293 gradle_fixture_root.path(), 6294 materialization_parent.path(), 6295 )?; 6296 let mut gradle_projection = 6297 synthetic_gradle_projections(&admitted_gradle_graphs, &inventory, &sources)?; 6298 let mut process_receipts = vec![synthetic_process_receipt( 6299 "owasp_nvd_update", 6300 TOOL_PINS[1].executable_sha256, 6301 &strings(&OWASP_UPDATE_ARGUMENTS), 6302 &producer_request_sha256, 6303 &nvd_update_output_digest(&nvd_tree, &sha256(&nvd_trace_bytes))?, 6304 vec![ProcessPathBinding { 6305 argument_index: 2, 6306 logical_role: "nvd_update_data".to_owned(), 6307 identity_sha256: nvd_tree.clone(), 6308 }], 6309 evaluation_epoch - 3_000, 6310 evaluation_epoch - 2_990, 6311 0, 6312 )?]; 6313 attach_gradle_receipts( 6314 &mut gradle_projection, 6315 &mut process_receipts, 6316 evaluation_epoch, 6317 )?; 6318 let rustsec_report = synthetic_report( 6319 ProviderId::Rustsec, 6320 &inventory, 6321 &gradle_projection, 6322 &sha256(&rustsec_archive), 6323 &rustsec_tree, 6324 &tool_observation_sha256, 6325 &mut process_receipts, 6326 evaluation_epoch, 6327 )?; 6328 let owasp_report = synthetic_report( 6329 ProviderId::OwaspNvd, 6330 &inventory, 6331 &gradle_projection, 6332 &sha256(&nvd_archive), 6333 &nvd_tree, 6334 &tool_observation_sha256, 6335 &mut process_receipts, 6336 evaluation_epoch, 6337 )?; 6338 fs::write(directory.path().join(RUSTSEC_REPORT_NAME), &rustsec_report) 6339 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6340 fs::write(directory.path().join(OWASP_REPORT_NAME), &owasp_report) 6341 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6342 6343 let provider_snapshot = vec![ 6344 synthetic_provider( 6345 ProviderId::Rustsec, 6346 &rustsec_archive, 6347 &rustsec_report, 6348 &rustsec_tree, 6349 &rustsec_archive_evidence, 6350 &producer_request_sha256, 6351 &nvd_trace_bytes, 6352 evaluation_epoch, 6353 )?, 6354 synthetic_provider( 6355 ProviderId::OwaspNvd, 6356 &nvd_archive, 6357 &owasp_report, 6358 &nvd_tree, 6359 &nvd_archive_evidence, 6360 &producer_request_sha256, 6361 &nvd_trace_bytes, 6362 evaluation_epoch, 6363 )?, 6364 ]; 6365 let mut manifest = SnapshotManifest { 6366 schema: "radroots.advisory-snapshot.v1".to_owned(), 6367 candidate: candidate.clone(), 6368 sources: sources.clone(), 6369 inventory_sha256: inventory_digest(&inventory)?, 6370 inventory: inventory.clone(), 6371 candidate_advisory_input_sha256: String::new(), 6372 step_297_tool_manifest_sha256: String::new(), 6373 fresh_tool_observation_sha256: String::new(), 6374 tool_acquisition, 6375 tool_state, 6376 gradle_projection, 6377 provider_snapshot, 6378 suppressions: Vec::new(), 6379 }; 6380 let request = AdmissionRequest { 6381 candidate, 6382 sources, 6383 inventory, 6384 evaluation_epoch, 6385 freshness: FreshnessMode::Qualification, 6386 producer_request: producer_request_bytes, 6387 step_297_tool_manifest: Vec::new(), 6388 fresh_tool_observation: Vec::new(), 6389 nvd_network_trace: nvd_trace_bytes, 6390 provider_execution_evidence: Vec::new(), 6391 admitted_gradle_graphs, 6392 admitted_scanner_outputs: Vec::new(), 6393 }; 6394 manifest.candidate_advisory_input_sha256 = candidate_advisory_input_digest(&manifest)?; 6395 let mut fixture = Self { 6396 directory, 6397 materialization_parent, 6398 _gradle_fixture_root: gradle_fixture_root, 6399 scanner_fixture_root, 6400 scanner_generation: 0, 6401 request, 6402 manifest, 6403 process_receipts, 6404 }; 6405 fixture.seal()?; 6406 Ok(fixture) 6407 } 6408 6409 fn root(&self) -> &Path { 6410 self.directory.path() 6411 } 6412 6413 fn seal(&mut self) -> Result<(), AdvisoryError> { 6414 let producer_request = sha256(&self.request.producer_request); 6415 let tool_manifest = TrustedToolManifest { 6416 schema: "radroots.advisory-tool-manifest-projection.v1".to_owned(), 6417 candidate_generation: self.request.candidate.generation, 6418 candidate_digest: self.request.candidate.digest.clone(), 6419 platform: "macos_aarch64".to_owned(), 6420 producer_request_sha256: producer_request.clone(), 6421 tool_acquisition: self.manifest.tool_acquisition.clone(), 6422 }; 6423 self.request.step_297_tool_manifest = canonical_authority_bytes(&tool_manifest)?; 6424 let observation = TrustedToolObservation { 6425 schema: "radroots.advisory-tool-observation.v1".to_owned(), 6426 candidate_generation: self.request.candidate.generation, 6427 candidate_digest: self.request.candidate.digest.clone(), 6428 platform: "macos_aarch64".to_owned(), 6429 producer_request_sha256: producer_request.clone(), 6430 tool_manifest_sha256: sha256(&self.request.step_297_tool_manifest), 6431 row_projection_sha256: tool_observation_digest( 6432 &self.manifest.tool_acquisition, 6433 &self.manifest.tool_state, 6434 )?, 6435 observed_at_epoch: self.request.evaluation_epoch - 4_000, 6436 tool_state: self.manifest.tool_state.clone(), 6437 }; 6438 self.request.fresh_tool_observation = canonical_authority_bytes(&observation)?; 6439 self.manifest.step_297_tool_manifest_sha256 = sha256(&self.request.step_297_tool_manifest); 6440 self.manifest.fresh_tool_observation_sha256 = sha256(&self.request.fresh_tool_observation); 6441 self.refresh_gradle_receipts()?; 6442 self.refresh_admitted_scanner_outputs()?; 6443 self.manifest.candidate_advisory_input_sha256 = 6444 candidate_advisory_input_digest(&self.manifest)?; 6445 let provider_evidence = TrustedProviderEvidence { 6446 schema: "radroots.advisory-provider-execution-evidence.v1".to_owned(), 6447 candidate_generation: self.request.candidate.generation, 6448 candidate_digest: self.request.candidate.digest.clone(), 6449 platform: "macos_aarch64".to_owned(), 6450 producer_request_sha256: producer_request, 6451 candidate_advisory_input_sha256: self.manifest.candidate_advisory_input_sha256.clone(), 6452 gradle_projection: self.manifest.gradle_projection.clone(), 6453 provider_snapshot: self.manifest.provider_snapshot.clone(), 6454 process_receipt: self.process_receipts.clone(), 6455 suppressions: self.manifest.suppressions.clone(), 6456 }; 6457 self.request.provider_execution_evidence = canonical_authority_bytes(&provider_evidence)?; 6458 let value = serde_json::to_value(&self.manifest) 6459 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6460 std::fs::write(self.root().join(MANIFEST_NAME), canonical_json(&value)) 6461 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)) 6462 } 6463 6464 fn refresh_admitted_scanner_outputs(&mut self) -> Result<(), AdvisoryError> { 6465 self.scanner_generation = self 6466 .scanner_generation 6467 .checked_add(1) 6468 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6469 let generation_root = self 6470 .scanner_fixture_root 6471 .path() 6472 .join(format!("generation-{}", self.scanner_generation)); 6473 std::fs::create_dir(&generation_root) 6474 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 6475 set_private_directory_mode(&generation_root)?; 6476 let mut admitted = Vec::with_capacity(WORKLOADS.len()); 6477 for provider in [ProviderId::Rustsec, ProviderId::OwaspNvd] { 6478 let provider_root = generation_root.join(provider.as_str()); 6479 std::fs::create_dir(&provider_root) 6480 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 6481 set_private_directory_mode(&provider_root)?; 6482 let report = self.read_report_value(provider)?; 6483 let rows = report["workload_result"] 6484 .as_array() 6485 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 6486 for row in rows { 6487 let workload_id = required_text(row.get("workload_id"))?; 6488 let raw = row 6489 .get("raw_scanner_output") 6490 .and_then(Value::as_str) 6491 .filter(|value| !value.is_empty()) 6492 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 6493 let workload_root = provider_root.join(workload_id); 6494 std::fs::create_dir(&workload_root) 6495 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 6496 set_private_directory_mode(&workload_root)?; 6497 let output = workload_root.join(RAW_SCANNER_OUTPUT_NAME); 6498 std::fs::write(&output, raw.as_bytes()) 6499 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 6500 set_private_file_mode(&output)?; 6501 admitted.push(admit_raw_scanner_output( 6502 &workload_root, 6503 provider, 6504 workload_id, 6505 )?); 6506 } 6507 } 6508 self.request.admitted_scanner_outputs = admitted; 6509 Ok(()) 6510 } 6511 6512 fn refresh_gradle_receipts(&mut self) -> Result<(), AdvisoryError> { 6513 let source = self 6514 .request 6515 .sources 6516 .iter() 6517 .find(|source| source.repository == "oss/harvestcircle") 6518 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 6519 for projection in &mut self.manifest.gradle_projection { 6520 let inventory = self 6521 .request 6522 .inventory 6523 .iter() 6524 .find(|row| row.id == projection.workload_id) 6525 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 6526 projection.input_sha256 = 6527 gradle_candidate_input_digest(&self.request, inventory, source, projection)?; 6528 let receipt = self 6529 .process_receipts 6530 .iter_mut() 6531 .find(|receipt| receipt.id == format!("gradle:{}", projection.workload_id)) 6532 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 6533 receipt.input_sha256 = gradle_process_input_digest(projection)?; 6534 receipt.output_sha256 = gradle_process_output_digest(projection)?; 6535 receipt.path_binding = vec![ 6536 ProcessPathBinding { 6537 argument_index: 5, 6538 logical_role: "gradle_build_root".to_owned(), 6539 identity_sha256: gradle_build_root_binding_digest(projection)?, 6540 }, 6541 ProcessPathBinding { 6542 argument_index: 7, 6543 logical_role: "gradle_init_script".to_owned(), 6544 identity_sha256: projection.init_script_sha256.clone(), 6545 }, 6546 ProcessPathBinding { 6547 argument_index: 10, 6548 logical_role: "gradle_projection_output".to_owned(), 6549 identity_sha256: projection.raw_graph_sha256.clone(), 6550 }, 6551 ]; 6552 projection.process_receipt_sha256 = process_receipt_digest(receipt)?; 6553 } 6554 Ok(()) 6555 } 6556 6557 fn set_rustsec_finding( 6558 &mut self, 6559 advisory_id: &str, 6560 package: &str, 6561 version: &str, 6562 ) -> Result<(), AdvisoryError> { 6563 let mut report = self.read_report_value(ProviderId::Rustsec)?; 6564 let raw = report["workload_result"][0]["raw_scanner_output"] 6565 .as_str() 6566 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 6567 let mut raw: Value = serde_json::from_str(raw) 6568 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 6569 raw["vulnerabilities"] = serde_json::json!({ 6570 "count": 1, 6571 "found": true, 6572 "list": [{ 6573 "advisory": { 6574 "aliases": [], 6575 "categories": [], 6576 "collection": "crates", 6577 "cvss": null, 6578 "date": "2099-01-01", 6579 "description": "synthetic known-vulnerable RustSec fixture", 6580 "expect-deleted": false, 6581 "id": advisory_id, 6582 "informational": null, 6583 "keywords": [], 6584 "license": "CC0-1.0", 6585 "package": package, 6586 "references": [], 6587 "related": [], 6588 "source": "registry+https://github.com/rust-lang/crates.io-index", 6589 "title": "synthetic known-vulnerable fixture", 6590 "url": null, 6591 "withdrawn": null 6592 }, 6593 "affected": null, 6594 "package": { 6595 "checksum": null, 6596 "name": package, 6597 "replace": null, 6598 "source": "registry+https://github.com/rust-lang/crates.io-index", 6599 "version": version 6600 }, 6601 "versions": {"patched": [], "unaffected": []} 6602 }] 6603 }); 6604 set_raw_output(&mut report["workload_result"][0], &raw, 1)?; 6605 self.replace_report(ProviderId::Rustsec, report) 6606 } 6607 6608 fn set_owasp_finding( 6609 &mut self, 6610 advisory_id: &str, 6611 package_namespace: &str, 6612 package: &str, 6613 version: &str, 6614 ) -> Result<(), AdvisoryError> { 6615 let mut report = self.read_report_value(ProviderId::OwaspNvd)?; 6616 let raw = report["workload_result"][0]["raw_scanner_output"] 6617 .as_str() 6618 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 6619 let mut raw: Value = serde_json::from_str(raw) 6620 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 6621 let dependency = raw["dependencies"] 6622 .as_array_mut() 6623 .and_then(|rows| rows.first_mut()) 6624 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 6625 dependency["packages"] = serde_json::json!([{ 6626 "id": format!("pkg:maven/{package_namespace}/{package}@{version}") 6627 }]); 6628 let matched_cpe = 6629 format!("cpe:2.3:a:{package_namespace}:{package}:{version}:*:*:*:*:*:*:*"); 6630 dependency["vulnerabilityIds"] = serde_json::json!([{ 6631 "id": matched_cpe 6632 }]); 6633 dependency["vulnerabilities"] = serde_json::json!([{ 6634 "description": "synthetic known-vulnerable fixture", 6635 "name": advisory_id, 6636 "notes": "synthetic unsuppressed scanner output", 6637 "references": [], 6638 "source": "NVD", 6639 "vulnerableSoftware": [{"software": {"id": matched_cpe}}] 6640 }]); 6641 set_raw_output(&mut report["workload_result"][0], &raw, 0)?; 6642 self.replace_report(ProviderId::OwaspNvd, report) 6643 } 6644 6645 fn read_report_value(&self, provider: ProviderId) -> Result<Value, AdvisoryError> { 6646 let name = report_name(provider); 6647 let bytes = std::fs::read(self.root().join(name)) 6648 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6649 serde_json::from_slice(&bytes) 6650 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport)) 6651 } 6652 6653 fn replace_report( 6654 &mut self, 6655 provider: ProviderId, 6656 mut report: Value, 6657 ) -> Result<(), AdvisoryError> { 6658 self.refresh_report_receipts(provider, &mut report)?; 6659 let bytes = canonical_json(&report); 6660 let name = report_name(provider); 6661 std::fs::write(self.root().join(name), &bytes) 6662 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6663 let snapshot = self 6664 .manifest 6665 .provider_snapshot 6666 .iter_mut() 6667 .find(|snapshot| snapshot.provider == provider) 6668 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6669 snapshot.report = blob(name, &bytes)?; 6670 self.seal() 6671 } 6672 6673 fn refresh_report_receipts( 6674 &mut self, 6675 provider: ProviderId, 6676 report: &mut Value, 6677 ) -> Result<(), AdvisoryError> { 6678 let rows = report["workload_result"] 6679 .as_array_mut() 6680 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 6681 for row in rows { 6682 let workload_id = required_text(row.get("workload_id"))?; 6683 let prefix = match provider { 6684 ProviderId::Rustsec => "cargo_audit", 6685 ProviderId::OwaspNvd => "owasp_analysis", 6686 }; 6687 let receipt = self 6688 .process_receipts 6689 .iter_mut() 6690 .find(|receipt| receipt.id == format!("{prefix}:{workload_id}")) 6691 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 6692 let raw = row 6693 .get("raw_scanner_output") 6694 .and_then(Value::as_str) 6695 .filter(|raw| !raw.is_empty() && raw.len() <= MAX_REPORT_BYTES as usize) 6696 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 6697 receipt.exit_code = row["exit_code"] 6698 .as_i64() 6699 .and_then(|value| i32::try_from(value).ok()) 6700 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 6701 if provider == ProviderId::Rustsec { 6702 receipt.stdout_byte_length = raw.len() as u64; 6703 receipt.stdout_sha256 = sha256(raw.as_bytes()); 6704 receipt.output_sha256 = receipt.stdout_sha256.clone(); 6705 } else { 6706 receipt.stdout_byte_length = 0; 6707 receipt.stdout_sha256 = sha256(&[]); 6708 receipt.output_sha256 = sha256(raw.as_bytes()); 6709 let report_binding = receipt 6710 .path_binding 6711 .iter_mut() 6712 .find(|binding| binding.logical_role == "raw_report_output") 6713 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6714 report_binding.identity_sha256 = sha256(raw.as_bytes()); 6715 } 6716 row["process_receipt_sha256"] = Value::from(process_receipt_digest(receipt)?); 6717 } 6718 Ok(()) 6719 } 6720 } 6721 6722 fn expected_tool_acquisitions() -> Result<Vec<ToolAcquisition>, AdvisoryError> { 6723 let decision: Value = serde_json::from_slice(DECISION_BYTES) 6724 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))?; 6725 let acquisitions = decision 6726 .get("tool_acquisition") 6727 .and_then(Value::as_object) 6728 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract))?; 6729 TOOL_PINS 6730 .iter() 6731 .map(|pin| { 6732 acquisitions 6733 .get(pin.id) 6734 .cloned() 6735 .map(|projection| ToolAcquisition { 6736 id: pin.id.to_owned(), 6737 projection, 6738 }) 6739 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InvalidContract)) 6740 }) 6741 .collect() 6742 } 6743 6744 fn synthetic_sources() -> Vec<SourceIdentity> { 6745 CANDIDATE_REPOSITORIES 6746 .into_iter() 6747 .map(|repository| SourceIdentity { 6748 repository: repository.to_owned(), 6749 revision: "1".repeat(40), 6750 tree: "2".repeat(40), 6751 }) 6752 .collect() 6753 } 6754 6755 fn synthetic_inventory() -> Vec<WorkloadInventory> { 6756 WORKLOADS 6757 .iter() 6758 .enumerate() 6759 .map(|(index, workload)| WorkloadInventory { 6760 id: workload.id.to_owned(), 6761 repository: workload.repository.to_owned(), 6762 build_root: workload.build_root.to_owned(), 6763 package_manager: workload.package_manager.to_owned(), 6764 language: workload.language.to_owned(), 6765 manifest_path: workload.manifest_path.map(str::to_owned), 6766 lockfile_path: workload.lockfile_path.map(str::to_owned), 6767 project_path: workload.project_path.map(str::to_owned), 6768 configuration: workload.configuration.map(str::to_owned), 6769 dependency_count: 1, 6770 input_sha256: format!("{:064x}", index + 1), 6771 }) 6772 .collect() 6773 } 6774 6775 #[allow(clippy::too_many_arguments)] 6776 fn synthetic_provider( 6777 provider: ProviderId, 6778 archive: &[u8], 6779 report: &[u8], 6780 materialized_tree_sha256: &str, 6781 archive_evidence: &ArchiveEvidence, 6782 producer_request_sha256: &str, 6783 nvd_trace: &[u8], 6784 evaluation_epoch: u64, 6785 ) -> Result<ProviderSnapshot, AdvisoryError> { 6786 let ( 6787 acquisition_kind, 6788 archive_name, 6789 report_name, 6790 acquisition_arguments, 6791 analysis_environment, 6792 analysis_arguments, 6793 ) = match provider { 6794 ProviderId::Rustsec => ( 6795 "externally_admitted_immutable_snapshot", 6796 RUSTSEC_ARCHIVE_NAME, 6797 RUSTSEC_REPORT_NAME, 6798 Vec::new(), 6799 "fresh_private_config_free_workdir_home_and_cargo_home", 6800 strings(&CARGO_AUDIT_ARGUMENTS), 6801 ), 6802 ProviderId::OwaspNvd => ( 6803 "bounded_nvd_update_only", 6804 NVD_ARCHIVE_NAME, 6805 OWASP_REPORT_NAME, 6806 strings(&OWASP_UPDATE_ARGUMENTS), 6807 "replacement_allowlist_private_data_and_output", 6808 strings(&OWASP_OFFLINE_ARGUMENTS), 6809 ), 6810 }; 6811 Ok(ProviderSnapshot { 6812 provider, 6813 acquisition_kind: acquisition_kind.to_owned(), 6814 acquisition_count: 1, 6815 acquisition_state: OperationState::Complete, 6816 acquired_at_epoch: evaluation_epoch - 2_990, 6817 digest_time_epoch: evaluation_epoch - 2_900, 6818 database_identity_sha256: database_identity_from_report( 6819 provider, 6820 report, 6821 materialized_tree_sha256, 6822 )?, 6823 materialized_tree_sha256: materialized_tree_sha256.to_owned(), 6824 bounded_deadline_seconds: ANALYSIS_DEADLINE_SECONDS, 6825 network_mode: match provider { 6826 ProviderId::Rustsec => "external_snapshot_admission".to_owned(), 6827 ProviderId::OwaspNvd => "contracted_fetch_only".to_owned(), 6828 }, 6829 network_trace_sha256: match provider { 6830 ProviderId::Rustsec => "0".repeat(64), 6831 ProviderId::OwaspNvd => sha256(nvd_trace), 6832 }, 6833 producer_request_sha256: producer_request_sha256.to_owned(), 6834 acquisition_arguments, 6835 archive_format: "deterministic_tar_gzip_exact_bytes_normalized_headers_sorted_members" 6836 .to_owned(), 6837 archive: blob(archive_name, archive)?, 6838 archive_expanded_bytes: archive_evidence.expanded_bytes, 6839 archive_member_count: archive_evidence.member_count, 6840 archive_payload_bytes: archive_evidence.payload_bytes, 6841 analysis_state: OperationState::Complete, 6842 analyzed_at_epoch: evaluation_epoch 6843 - match provider { 6844 ProviderId::Rustsec => 2_505, 6845 ProviderId::OwaspNvd => 2_245, 6846 }, 6847 analysis_environment: analysis_environment.to_owned(), 6848 analysis_arguments, 6849 report: blob(report_name, report)?, 6850 }) 6851 } 6852 6853 fn blob(path: &str, bytes: &[u8]) -> Result<BlobBinding, AdvisoryError> { 6854 let (media_type, logical_role) = match path { 6855 RUSTSEC_ARCHIVE_NAME => ("application/gzip", "rustsec_database_snapshot"), 6856 NVD_ARCHIVE_NAME => ("application/gzip", "owasp_nvd_database_snapshot"), 6857 RUSTSEC_REPORT_NAME => ("application/json", "rustsec_unsuppressed_report"), 6858 OWASP_REPORT_NAME => ("application/json", "owasp_unsuppressed_report"), 6859 _ => return Err(AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)), 6860 }; 6861 let digest = sha256(bytes); 6862 Ok(BlobBinding { 6863 path: path.to_owned(), 6864 byte_length: bytes.len() as u64, 6865 logical_uri: format!("extbuild-cas://sha256/{digest}"), 6866 media_type: media_type.to_owned(), 6867 logical_role: logical_role.to_owned(), 6868 sha256: digest, 6869 }) 6870 } 6871 6872 fn strings<const N: usize>(values: &[&str; N]) -> Vec<String> { 6873 values.iter().map(|value| (*value).to_owned()).collect() 6874 } 6875 6876 fn report_name(provider: ProviderId) -> &'static str { 6877 match provider { 6878 ProviderId::Rustsec => RUSTSEC_REPORT_NAME, 6879 ProviderId::OwaspNvd => OWASP_REPORT_NAME, 6880 } 6881 } 6882 6883 fn canonical_authority_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, AdvisoryError> { 6884 let value = serde_json::to_value(value) 6885 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 6886 Ok(canonical_json(&value)) 6887 } 6888 6889 fn synthetic_tool_states(evaluation_epoch: u64) -> Vec<ToolState> { 6890 TOOL_PINS 6891 .iter() 6892 .map(|pin| ToolState { 6893 id: pin.id.to_owned(), 6894 state: OperationState::Complete, 6895 reviewed_at_epoch: evaluation_epoch - 4_100, 6896 normalized_version: pin.version.to_owned(), 6897 executable_sha256: pin.executable_sha256.to_owned(), 6898 source_sha256: pin.source_sha256.to_owned(), 6899 package_receipt_sha256: pin.receipt_sha256.to_owned(), 6900 }) 6901 .collect() 6902 } 6903 6904 fn synthetic_nvd_trace( 6905 candidate: &CandidateIdentity, 6906 producer_request_sha256: &str, 6907 ) -> NvdNetworkTrace { 6908 let enforcement_program_sha256 = "7".repeat(64); 6909 let enforcement_configuration_sha256 = "8".repeat(64); 6910 NvdNetworkTrace { 6911 schema: "radroots.advisory-nvd-network-trace.v1".to_owned(), 6912 candidate_generation: candidate.generation, 6913 candidate_digest: candidate.digest.clone(), 6914 producer_request_sha256: producer_request_sha256.to_owned(), 6915 enforcement: "deny_by_default_nvd_api_get_only_proxy".to_owned(), 6916 enforcement_program_sha256, 6917 enforcement_configuration_sha256, 6918 request: vec![NvdRequestTrace { 6919 sequence: 1, 6920 method: "GET".to_owned(), 6921 scheme: "https".to_owned(), 6922 authority: "services.nvd.nist.gov".to_owned(), 6923 path: "/rest/json/cves/2.0".to_owned(), 6924 started_at_epoch: 997_001, 6925 completed_at_epoch: 997_009, 6926 query: [ 6927 ("lastModStartDate", "1970-01-11T10:00:00Z"), 6928 ("lastModEndDate", "1970-01-11T10:01:40Z"), 6929 ("startIndex", "0"), 6930 ("resultsPerPage", "2000"), 6931 ] 6932 .into_iter() 6933 .map(|(name, value)| NvdQueryTrace { 6934 name: name.to_owned(), 6935 value: value.to_owned(), 6936 }) 6937 .collect(), 6938 response_status: 200, 6939 response_byte_length: 128, 6940 response_sha256: "5".repeat(64), 6941 response_start_index: 0, 6942 response_results_per_page: 2_000, 6943 response_total_results: 1, 6944 }], 6945 } 6946 } 6947 6948 fn synthetic_producer_request(candidate: &CandidateIdentity) -> TrustedProducerRequest { 6949 TrustedProducerRequest { 6950 schema: "radroots.advisory-producer-request.v1".to_owned(), 6951 candidate_generation: candidate.generation, 6952 candidate_digest: candidate.digest.clone(), 6953 platform: "macos_aarch64".to_owned(), 6954 process_contract_sha256: BOUNDED_PROCESS_DECISION_SHA256.to_owned(), 6955 process_runner_source_sha256: BOUNDED_PROCESS_SOURCE_SHA256.to_owned(), 6956 nvd_endpoint: "/rest/json/cves/2.0".to_owned(), 6957 nvd_enforcement_program_sha256: "7".repeat(64), 6958 nvd_enforcement_configuration_sha256: "8".repeat(64), 6959 nvd_query_keys: [ 6960 "lastModStartDate", 6961 "lastModEndDate", 6962 "startIndex", 6963 "resultsPerPage", 6964 ] 6965 .into_iter() 6966 .map(str::to_owned) 6967 .collect(), 6968 workload_ids: WORKLOADS 6969 .iter() 6970 .map(|workload| workload.id.to_owned()) 6971 .collect(), 6972 } 6973 } 6974 6975 #[allow(clippy::too_many_arguments)] 6976 fn synthetic_process_receipt( 6977 id: &str, 6978 program_sha256: &str, 6979 arguments: &[String], 6980 input_sha256: &str, 6981 output_sha256: &str, 6982 path_binding: Vec<ProcessPathBinding>, 6983 started_at_epoch: u64, 6984 completed_at_epoch: u64, 6985 exit_code: i32, 6986 ) -> Result<TrustedProcessReceipt, AdvisoryError> { 6987 let environment = expected_process_environment(id)?; 6988 let working_directory = expected_process_working_directory(id)?; 6989 Ok(TrustedProcessReceipt { 6990 id: id.to_owned(), 6991 state: OperationState::Complete, 6992 program_sha256: program_sha256.to_owned(), 6993 runtime_sha256: expected_runtime_sha256(id), 6994 arguments_sha256: domain_json_digest( 6995 b"radroots-advisory-process-arguments-v1\0", 6996 arguments, 6997 )?, 6998 environment_sha256: process_environment_digest(&environment)?, 6999 environment, 7000 working_directory_sha256: process_working_directory_digest(&working_directory)?, 7001 working_directory, 7002 path_binding, 7003 stdin_closed: true, 7004 deadline_seconds: ANALYSIS_DEADLINE_SECONDS, 7005 started_at_epoch, 7006 completed_at_epoch, 7007 exit_code, 7008 stdout_byte_length: 0, 7009 stdout_sha256: sha256(&[]), 7010 stderr_byte_length: 0, 7011 stderr_sha256: sha256(&[]), 7012 input_sha256: input_sha256.to_owned(), 7013 output_sha256: output_sha256.to_owned(), 7014 }) 7015 } 7016 7017 fn attach_gradle_receipts( 7018 projections: &mut [GradleProjection], 7019 receipts: &mut Vec<TrustedProcessReceipt>, 7020 evaluation_epoch: u64, 7021 ) -> Result<(), AdvisoryError> { 7022 for (index, projection) in projections.iter_mut().enumerate() { 7023 let receipt = synthetic_process_receipt( 7024 &format!("gradle:{}", projection.workload_id), 7025 TOOL_PINS[3].executable_sha256, 7026 &projection.wrapper_arguments, 7027 &gradle_process_input_digest(projection)?, 7028 &gradle_process_output_digest(projection)?, 7029 vec![ 7030 ProcessPathBinding { 7031 argument_index: 5, 7032 logical_role: "gradle_build_root".to_owned(), 7033 identity_sha256: gradle_build_root_binding_digest(projection)?, 7034 }, 7035 ProcessPathBinding { 7036 argument_index: 7, 7037 logical_role: "gradle_init_script".to_owned(), 7038 identity_sha256: projection.init_script_sha256.clone(), 7039 }, 7040 ProcessPathBinding { 7041 argument_index: 10, 7042 logical_role: "gradle_projection_output".to_owned(), 7043 identity_sha256: projection.raw_graph_sha256.clone(), 7044 }, 7045 ], 7046 evaluation_epoch - 2_800 + index as u64 * 10, 7047 evaluation_epoch - 2_795 + index as u64 * 10, 7048 0, 7049 )?; 7050 projection.environment_sha256 = receipt.environment_sha256.clone(); 7051 projection.process_receipt_sha256 = process_receipt_digest(&receipt)?; 7052 receipts.push(receipt); 7053 } 7054 Ok(()) 7055 } 7056 7057 fn synthetic_gradle_projections( 7058 admitted_graphs: &[AdmittedGradleGraph], 7059 inventory: &[WorkloadInventory], 7060 sources: &[SourceIdentity], 7061 ) -> Result<Vec<GradleProjection>, AdvisoryError> { 7062 let source = sources 7063 .iter() 7064 .find(|source| source.repository == "oss/harvestcircle") 7065 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 7066 WORKLOADS 7067 .iter() 7068 .enumerate() 7069 .filter(|(_, workload)| workload.package_manager == "gradle") 7070 .zip(admitted_graphs) 7071 .map(|((index, workload), admitted)| { 7072 let inventory = inventory 7073 .iter() 7074 .find(|row| row.id == workload.id) 7075 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 7076 admitted.revalidate()?; 7077 Ok(GradleProjection { 7078 workload_id: workload.id.to_owned(), 7079 state: OperationState::Complete, 7080 raw_graph_byte_length: admitted.raw.byte_length, 7081 raw_graph_sha256: admitted.raw.sha256.clone(), 7082 init_script_sha256: GRADLE_INIT_SCRIPT_SHA256.to_owned(), 7083 wrapper_arguments: expected_gradle_arguments(workload)?, 7084 environment_keys: [ 7085 "GRADLE_USER_HOME", 7086 "HOME", 7087 "JAVA_HOME", 7088 "LC_ALL", 7089 "PATH", 7090 "TMPDIR", 7091 "TZ", 7092 ] 7093 .into_iter() 7094 .map(str::to_owned) 7095 .collect(), 7096 environment_sha256: format!("{:064x}", index + 4_000), 7097 exit_code: 0, 7098 source_revision: source.revision.clone(), 7099 source_tree: source.tree.clone(), 7100 input_sha256: inventory.input_sha256.clone(), 7101 dependency_count: inventory.dependency_count, 7102 component_count: admitted.components.len() as u64, 7103 edge_count: admitted.edges.len() as u64, 7104 artifact_count: admitted.artifacts.len() as u64, 7105 components: admitted.components.clone(), 7106 edges: admitted.edges.clone(), 7107 artifacts: admitted.artifacts.clone(), 7108 canonical_graph_sha256: admitted.canonical_graph_sha256.clone(), 7109 materialized_tree_sha256: admitted.materialized_tree_sha256.clone(), 7110 normalization_receipt_sha256: admitted.normalization_receipt_sha256.clone(), 7111 artifact_source_roots_sha256: admitted.artifact_source_roots_sha256.clone(), 7112 seed_cache_inventory_sha256: format!("{:064x}", index + 6_000), 7113 wrapper_distribution_sha256: 7114 "553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746".to_owned(), 7115 process_receipt_sha256: format!("{:064x}", index + 7_000), 7116 }) 7117 }) 7118 .collect() 7119 } 7120 7121 #[allow(clippy::too_many_arguments)] 7122 fn synthetic_report( 7123 provider: ProviderId, 7124 inventory: &[WorkloadInventory], 7125 gradle_projections: &[GradleProjection], 7126 archive_sha256: &str, 7127 materialized_tree_sha256: &str, 7128 tool_observation_sha256: &str, 7129 receipts: &mut Vec<TrustedProcessReceipt>, 7130 evaluation_epoch: u64, 7131 ) -> Result<Vec<u8>, AdvisoryError> { 7132 let mut workload_result = Vec::new(); 7133 for workload in WORKLOADS.iter().filter(|workload| match provider { 7134 ProviderId::Rustsec => workload.package_manager == "cargo", 7135 ProviderId::OwaspNvd => workload.package_manager == "gradle", 7136 }) { 7137 let inventory = inventory 7138 .iter() 7139 .find(|row| row.id == workload.id) 7140 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 7141 let raw = match provider { 7142 ProviderId::Rustsec => serde_json::json!({ 7143 "database": { 7144 "advisory-count": 1, 7145 "last-commit": "6".repeat(40), 7146 "last-updated": format_report_epoch(evaluation_epoch - 2_900)? 7147 }, 7148 "lockfile": {"dependency-count": inventory.dependency_count}, 7149 "settings": { 7150 "ignore": [], 7151 "informational_warnings": ["unmaintained", "unsound", "notice"], 7152 "severity": null, 7153 "target_arch": [], 7154 "target_os": [] 7155 }, 7156 "vulnerabilities": {"count": 0, "found": false, "list": []}, 7157 "warnings": {} 7158 }), 7159 ProviderId::OwaspNvd => { 7160 let projection = gradle_projections 7161 .iter() 7162 .find(|projection| projection.workload_id == workload.id) 7163 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 7164 let dependencies = projection 7165 .artifacts 7166 .iter() 7167 .map(|artifact| { 7168 serde_json::json!({ 7169 "evidenceCollected": { 7170 "productEvidence": [], 7171 "vendorEvidence": [], 7172 "versionEvidence": [] 7173 }, 7174 "fileName": artifact.materialized_name, 7175 "filePath": format!( 7176 "/private/advisory/scan/{}/{}", 7177 workload.id, 7178 artifact.materialized_name, 7179 ), 7180 "isVirtual": false, 7181 "md5": "0".repeat(32), 7182 "packages": [{ 7183 "id": format!( 7184 "pkg:maven/{}/{}@{}", 7185 artifact.package_namespace, 7186 artifact.package_name, 7187 artifact.package_version, 7188 ) 7189 }], 7190 "sha1": "0".repeat(40), 7191 "sha256": artifact.artifact_sha256 7192 }) 7193 }) 7194 .collect::<Vec<_>>(); 7195 serde_json::json!({ 7196 "dependencies": dependencies, 7197 "projectInfo": { 7198 "credits": { 7199 "CISA": "This report may contain data retrieved from the CISA Known Exploited Vulnerability Catalog: https://www.cisa.gov/known-exploited-vulnerabilities-catalog", 7200 "NPM": "This report may contain data retrieved from the Github Advisory Database (via NPM Audit API): https://github.com/advisories/", 7201 "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", 7202 "OSSINDEX": "This report may contain data retrieved from the Sonatype Guide OSS Index API: https://www.sonatype.com/products/sonatype-guide/oss-index-users", 7203 "RETIREJS": "This report may contain data retrieved from the RetireJS community: https://retirejs.github.io/retire.js/" 7204 }, 7205 "name": workload.id, 7206 "reportDate": format_report_epoch( 7207 evaluation_epoch - 2_300 + workload_result.len() as u64 * 10, 7208 )? 7209 }, 7210 "reportSchema": "1.1", 7211 "scanInfo": { 7212 "dataSource": [{ 7213 "name": "NVD CVE Checked", 7214 "timestamp": format_report_epoch(evaluation_epoch - 2_900)? 7215 }], 7216 "engineVersion": "12.2.2" 7217 } 7218 }) 7219 } 7220 }; 7221 let raw_scanner_output = serde_json::to_string(&raw) 7222 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 7223 let arguments = synthetic_scanner_arguments(provider, workload.id); 7224 let prefix = match provider { 7225 ProviderId::Rustsec => "cargo_audit", 7226 ProviderId::OwaspNvd => "owasp_analysis", 7227 }; 7228 let program_sha256 = match provider { 7229 ProviderId::Rustsec => TOOL_PINS[0].executable_sha256, 7230 ProviderId::OwaspNvd => TOOL_PINS[1].executable_sha256, 7231 }; 7232 let exit_code = 0; 7233 let database_copy = (provider == ProviderId::OwaspNvd).then(|| ScannerDatabaseCopy { 7234 root_identity_sha256: format!("{:064x}", workload_result.len() + 30_000), 7235 source_tree_sha256: materialized_tree_sha256.to_owned(), 7236 pre_scan_tree_sha256: materialized_tree_sha256.to_owned(), 7237 post_scan_tree_sha256: materialized_tree_sha256.to_owned(), 7238 }); 7239 let path_binding = match provider { 7240 ProviderId::Rustsec => vec![ 7241 ProcessPathBinding { 7242 argument_index: 2, 7243 logical_role: "rustsec_database".to_owned(), 7244 identity_sha256: materialized_tree_sha256.to_owned(), 7245 }, 7246 ProcessPathBinding { 7247 argument_index: 6, 7248 logical_role: "cargo_lock".to_owned(), 7249 identity_sha256: inventory.input_sha256.clone(), 7250 }, 7251 ], 7252 ProviderId::OwaspNvd => { 7253 let projection = gradle_projections 7254 .iter() 7255 .find(|projection| projection.workload_id == workload.id) 7256 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::InventoryMismatch))?; 7257 vec![ 7258 ProcessPathBinding { 7259 argument_index: 2, 7260 logical_role: "nvd_database".to_owned(), 7261 identity_sha256: scanner_database_copy_digest( 7262 database_copy.as_ref().ok_or_else(|| { 7263 AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot) 7264 })?, 7265 )?, 7266 }, 7267 ProcessPathBinding { 7268 argument_index: 4, 7269 logical_role: "gradle_scan_projection".to_owned(), 7270 identity_sha256: projection.materialized_tree_sha256.clone(), 7271 }, 7272 ProcessPathBinding { 7273 argument_index: 10, 7274 logical_role: "raw_report_output".to_owned(), 7275 identity_sha256: sha256(raw_scanner_output.as_bytes()), 7276 }, 7277 ] 7278 } 7279 }; 7280 let timing_offset = match provider { 7281 ProviderId::Rustsec => 2_600, 7282 ProviderId::OwaspNvd => 2_300, 7283 }; 7284 let started_at_epoch = evaluation_epoch - timing_offset + workload_result.len() as u64 * 10; 7285 let mut receipt = synthetic_process_receipt( 7286 &format!("{prefix}:{}", workload.id), 7287 program_sha256, 7288 &arguments, 7289 &scanner_process_input_digest_fields( 7290 provider, 7291 workload.id, 7292 &inventory.input_sha256, 7293 inventory.dependency_count, 7294 archive_sha256, 7295 materialized_tree_sha256, 7296 &database_copy 7297 .as_ref() 7298 .map(scanner_database_copy_digest) 7299 .transpose()? 7300 .unwrap_or_else(|| "none".to_owned()), 7301 tool_observation_sha256, 7302 )?, 7303 &sha256(raw_scanner_output.as_bytes()), 7304 path_binding, 7305 started_at_epoch, 7306 started_at_epoch + 5, 7307 exit_code, 7308 )?; 7309 if provider == ProviderId::Rustsec { 7310 receipt.stdout_byte_length = raw_scanner_output.len() as u64; 7311 receipt.stdout_sha256 = sha256(raw_scanner_output.as_bytes()); 7312 } 7313 let process_receipt_sha256 = process_receipt_digest(&receipt)?; 7314 let environment_sha256 = receipt.environment_sha256.clone(); 7315 receipts.push(receipt); 7316 workload_result.push(WorkloadResultFixture { 7317 workload_id: workload.id.to_owned(), 7318 input_sha256: inventory.input_sha256.clone(), 7319 dependency_count: inventory.dependency_count, 7320 provider_archive_sha256: archive_sha256.to_owned(), 7321 materialized_tree_sha256: materialized_tree_sha256.to_owned(), 7322 tool_observation_sha256: tool_observation_sha256.to_owned(), 7323 arguments, 7324 environment_sha256, 7325 process_receipt_sha256, 7326 database_copy, 7327 exit_code, 7328 raw_output_byte_length: raw_scanner_output.len() as u64, 7329 raw_output_sha256: sha256(raw_scanner_output.as_bytes()), 7330 raw_scanner_output, 7331 }); 7332 } 7333 let value = serde_json::json!({ 7334 "provider": provider, 7335 "schema": "radroots.advisory-unsuppressed-report.v1", 7336 "workload_result": workload_result 7337 }); 7338 serde_json::from_value::<ReportEnvelope>(value.clone()) 7339 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 7340 Ok(canonical_json(&value)) 7341 } 7342 7343 #[derive(Serialize)] 7344 struct WorkloadResultFixture { 7345 workload_id: String, 7346 input_sha256: String, 7347 dependency_count: u64, 7348 provider_archive_sha256: String, 7349 materialized_tree_sha256: String, 7350 tool_observation_sha256: String, 7351 arguments: Vec<String>, 7352 environment_sha256: String, 7353 process_receipt_sha256: String, 7354 database_copy: Option<ScannerDatabaseCopy>, 7355 exit_code: i32, 7356 raw_output_byte_length: u64, 7357 raw_output_sha256: String, 7358 raw_scanner_output: String, 7359 } 7360 7361 fn synthetic_scanner_arguments(provider: ProviderId, workload_id: &str) -> Vec<String> { 7362 match provider { 7363 ProviderId::Rustsec => [ 7364 "audit".to_owned(), 7365 "--db".to_owned(), 7366 "/private/advisory/rustsec-db".to_owned(), 7367 "--no-fetch".to_owned(), 7368 "--json".to_owned(), 7369 "--file".to_owned(), 7370 format!("/private/advisory/locks/{workload_id}/Cargo.lock"), 7371 ] 7372 .into_iter() 7373 .collect(), 7374 ProviderId::OwaspNvd => { 7375 let mut arguments = strings(&OWASP_OFFLINE_ARGUMENTS); 7376 arguments[2] = "/private/advisory/nvd-data".to_owned(); 7377 arguments[4] = format!("/private/advisory/scan/{workload_id}"); 7378 arguments[6] = workload_id.to_owned(); 7379 arguments[10] = format!("/private/advisory/report/{workload_id}"); 7380 arguments 7381 } 7382 } 7383 } 7384 7385 fn set_raw_output(result: &mut Value, raw: &Value, exit_code: i32) -> Result<(), AdvisoryError> { 7386 let raw = serde_json::to_string(raw) 7387 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidReport))?; 7388 result["exit_code"] = Value::from(exit_code); 7389 result["raw_output_byte_length"] = Value::from(raw.len() as u64); 7390 result["raw_output_sha256"] = Value::from(sha256(raw.as_bytes())); 7391 result["raw_scanner_output"] = Value::from(raw); 7392 Ok(()) 7393 } 7394 7395 fn synthetic_archive_tree_digest( 7396 archive: &[u8], 7397 materialization_parent: &Path, 7398 ) -> Result<(String, ArchiveEvidence), AdvisoryError> { 7399 let source = tempfile::tempdir() 7400 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 7401 std::fs::write(source.path().join("snapshot.tar.gz"), archive) 7402 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 7403 let snapshot = safe_artifact_io::traverse_regular_files( 7404 source.path(), 7405 TraversalLimits { 7406 max_entries: 1, 7407 max_files: 1, 7408 max_total_bytes: archive.len() as u64, 7409 max_file_bytes: archive.len() as u64, 7410 max_depth: 1, 7411 max_path_bytes: 64, 7412 }, 7413 &[], 7414 ) 7415 .map_err(map_archive_error)?; 7416 let file = snapshot 7417 .files() 7418 .first() 7419 .ok_or_else(|| AdvisoryError::new(AdvisoryFailureKind::ArchiveRejected))?; 7420 let materialized = snapshot 7421 .materialize_deterministic_tar_gzip(file, materialization_parent, archive_limits()) 7422 .map_err(map_archive_error)?; 7423 let digest = materialized_tree_digest(materialized.snapshot())?; 7424 let evidence = materialized.evidence().clone(); 7425 materialized.revalidate().map_err(map_binding_error)?; 7426 snapshot.revalidate().map_err(map_binding_error)?; 7427 Ok((digest, evidence)) 7428 } 7429 7430 fn deterministic_archive(payload: &[u8]) -> Result<Vec<u8>, AdvisoryError> { 7431 use flate2::{Compression, GzBuilder}; 7432 use tar::{Builder, EntryType, Header}; 7433 7434 let encoder = GzBuilder::new() 7435 .mtime(0) 7436 .operating_system(255) 7437 .write(Vec::new(), Compression::new(6)); 7438 let mut builder = Builder::new(encoder); 7439 let mut directory = Header::new_gnu(); 7440 directory 7441 .set_path("snapshot/") 7442 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 7443 directory.set_entry_type(EntryType::Directory); 7444 directory.set_mode(0o755); 7445 directory.set_uid(0); 7446 directory.set_gid(0); 7447 directory.set_mtime(0); 7448 directory.set_size(0); 7449 directory.set_cksum(); 7450 builder 7451 .append(&directory, &[][..]) 7452 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 7453 let mut header = Header::new_gnu(); 7454 header 7455 .set_path("snapshot/data.json") 7456 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 7457 header.set_entry_type(EntryType::Regular); 7458 header.set_mode(0o644); 7459 header.set_uid(0); 7460 header.set_gid(0); 7461 header.set_mtime(0); 7462 header.set_size(payload.len() as u64); 7463 header.set_cksum(); 7464 builder 7465 .append(&header, payload) 7466 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 7467 let encoder = builder 7468 .into_inner() 7469 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot))?; 7470 encoder 7471 .finish() 7472 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::InvalidSnapshot)) 7473 } 7474 7475 fn trusted_tempdir(prefix: &str) -> Result<tempfile::TempDir, AdvisoryError> { 7476 let current = std::env::current_dir() 7477 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable))?; 7478 tempfile::Builder::new() 7479 .prefix(prefix) 7480 .tempdir_in(current) 7481 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable)) 7482 } 7483 7484 fn set_private_directory_mode(path: &Path) -> Result<(), AdvisoryError> { 7485 #[cfg(unix)] 7486 { 7487 use std::os::unix::fs::PermissionsExt as _; 7488 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) 7489 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable)) 7490 } 7491 #[cfg(not(unix))] 7492 { 7493 let _ = path; 7494 Err(AdvisoryError::new(AdvisoryFailureKind::Unavailable)) 7495 } 7496 } 7497 7498 fn set_private_file_mode(path: &Path) -> Result<(), AdvisoryError> { 7499 #[cfg(unix)] 7500 { 7501 use std::os::unix::fs::PermissionsExt as _; 7502 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) 7503 .map_err(|_| AdvisoryError::new(AdvisoryFailureKind::Unavailable)) 7504 } 7505 #[cfg(not(unix))] 7506 { 7507 let _ = path; 7508 Err(AdvisoryError::new(AdvisoryFailureKind::Unavailable)) 7509 } 7510 } 7511 7512 #[cfg(test)] 7513 mod tests { 7514 use super::*; 7515 7516 #[test] 7517 fn known_vulnerable_fixture() { 7518 let fixture = SyntheticFixture::new().expect("construct baseline advisory fixture"); 7519 let provider: TrustedProviderEvidence = 7520 parse_canonical_authority(&fixture.request.provider_execution_evidence) 7521 .expect("parse provider evidence"); 7522 validate_process_receipts(&provider.process_receipt, fixture.request.evaluation_epoch) 7523 .expect("validate process receipts"); 7524 validate_trusted_authority(&fixture.request).expect("validate trusted authority"); 7525 validate_request(&fixture.request).expect("validate baseline request"); 7526 validate_manifest(&fixture.manifest, &fixture.request).expect("validate baseline manifest"); 7527 admit_snapshot( 7528 fixture.root(), 7529 fixture.materialization_parent.path(), 7530 &fixture.request, 7531 ) 7532 .expect("admit baseline advisory fixture"); 7533 7534 let mut fixture = SyntheticFixture::new().expect("construct vulnerable fixture"); 7535 fixture 7536 .set_rustsec_finding(NON_WAIVABLE_RUSTSEC, "lru", "0.16.4") 7537 .expect("install known vulnerability"); 7538 let error = admit_snapshot( 7539 fixture.root(), 7540 fixture.materialization_parent.path(), 7541 &fixture.request, 7542 ) 7543 .expect_err("known vulnerability must fail closed"); 7544 assert_eq!(error.kind(), AdvisoryFailureKind::KnownVulnerability); 7545 } 7546 7547 #[test] 7548 fn missing_inventory_is_rejected() { 7549 missing_inventory_vector().expect("missing inventory must fail closed"); 7550 } 7551 7552 #[test] 7553 fn unavailable_provider_is_nonpass() { 7554 unavailable_provider_vector().expect("unavailable provider must be non-pass"); 7555 } 7556 7557 #[test] 7558 fn stale_snapshot_is_rejected() { 7559 stale_snapshot_vector().expect("stale snapshot must fail closed"); 7560 } 7561 7562 #[test] 7563 fn timed_out_operation_is_nonpass() { 7564 timed_out_operation_vector().expect("timed-out operation must be non-pass"); 7565 } 7566 7567 #[test] 7568 fn expired_suppression_is_rejected() { 7569 expired_suppression_vector().expect("expired suppression must fail closed"); 7570 } 7571 } 7572 7573 #[cfg(test)] 7574 mod step_296_tests { 7575 use super::*; 7576 7577 #[test] 7578 fn checked_in_semantically_ordered_decision_is_accepted() { 7579 assert!(DECISION_BYTES.starts_with(b"{\n \"schema\"")); 7580 let root = Path::new(env!("CARGO_MANIFEST_DIR")) 7581 .parent() 7582 .and_then(Path::parent) 7583 .expect("xtask manifest must be beneath the workspace root"); 7584 validate_decision(root).expect("checked-in advisory decision must be accepted"); 7585 } 7586 } 7587 7588 #[cfg(test)] 7589 mod admission_boundary_tests { 7590 use super::*; 7591 7592 #[test] 7593 fn snapshot_inventory_rejects_missing_nonfile_and_unrecognized_members() { 7594 for case in ["missing", "directory", "unknown"] { 7595 let directory = trusted_tempdir("radroots-snapshot-inventory-").unwrap(); 7596 for name in SNAPSHOT_FILE_NAMES { 7597 std::fs::write(directory.path().join(name), b"{}").unwrap(); 7598 } 7599 let limits = TraversalLimits { 7600 max_entries: 16, 7601 max_files: 16, 7602 max_total_bytes: 1024, 7603 max_file_bytes: 1024, 7604 max_depth: 2, 7605 max_path_bytes: 128, 7606 }; 7607 let original = 7608 safe_artifact_io::traverse_regular_files(directory.path(), limits, &[]).unwrap(); 7609 exact_snapshot_files(&original).unwrap(); 7610 let member = directory.path().join(MANIFEST_NAME); 7611 match case { 7612 "missing" => std::fs::remove_file(member).unwrap(), 7613 "directory" => { 7614 std::fs::remove_file(&member).unwrap(); 7615 std::fs::create_dir(member).unwrap(); 7616 } 7617 "unknown" => { 7618 std::fs::rename(member, directory.path().join("unknown.json")).unwrap() 7619 } 7620 _ => unreachable!(), 7621 } 7622 let changed = 7623 safe_artifact_io::traverse_regular_files(directory.path(), limits, &[]).unwrap(); 7624 assert!(exact_snapshot_files(&changed).is_err(), "{case}"); 7625 } 7626 let mut binding = blob(RUSTSEC_REPORT_NAME, b"{}").unwrap(); 7627 assert!(valid_blob_binding(&binding)); 7628 binding.path = "other.json".into(); 7629 assert!(!valid_blob_binding(&binding)); 7630 } 7631 7632 #[test] 7633 fn reports_bind_one_database_across_all_workloads_and_the_exact_network_trace() { 7634 let fixture = SyntheticFixture::new().unwrap(); 7635 for provider in &fixture.manifest.provider_snapshot { 7636 let original = fixture.read_report_value(provider.provider).unwrap(); 7637 let check = |value: &Value| { 7638 database_identity_from_report( 7639 provider.provider, 7640 &canonical_json(value), 7641 &provider.materialized_tree_sha256, 7642 ) 7643 }; 7644 assert_eq!(check(&original).unwrap(), provider.database_identity_sha256); 7645 let mut empty = original.clone(); 7646 empty["workload_result"] = json!([]); 7647 assert!(check(&empty).is_err()); 7648 let mut wrong_provider = original.clone(); 7649 wrong_provider["provider"] = json!(if provider.provider == ProviderId::Rustsec { 7650 "owasp_nvd" 7651 } else { 7652 "rustsec" 7653 }); 7654 assert!(check(&wrong_provider).is_err()); 7655 let mut changed = original.clone(); 7656 let mut second = original["workload_result"][0].clone(); 7657 let mut raw: Value = 7658 serde_json::from_str(second["raw_scanner_output"].as_str().unwrap()).unwrap(); 7659 match provider.provider { 7660 ProviderId::Rustsec => raw["database"]["last-commit"] = json!("f".repeat(40)), 7661 ProviderId::OwaspNvd => { 7662 raw["scanInfo"]["dataSource"][0]["name"] = json!("NVD CVE substituted") 7663 } 7664 } 7665 second["raw_scanner_output"] = json!(String::from_utf8(canonical_json(&raw)).unwrap()); 7666 changed["workload_result"] 7667 .as_array_mut() 7668 .unwrap() 7669 .push(second); 7670 assert_eq!( 7671 check(&changed).unwrap_err().kind(), 7672 AdvisoryFailureKind::BindingChanged 7673 ); 7674 } 7675 let provider = &fixture.manifest.provider_snapshot[1]; 7676 let trace: NvdNetworkTrace = 7677 parse_canonical_authority(&fixture.request.nvd_network_trace).unwrap(); 7678 validate_provider( 7679 provider, 7680 &fixture.request, 7681 &trace, 7682 &fixture.process_receipts, 7683 ) 7684 .unwrap(); 7685 let mut changed = provider.clone(); 7686 changed.network_trace_sha256 = "f".repeat(64); 7687 assert_eq!( 7688 validate_provider( 7689 &changed, 7690 &fixture.request, 7691 &trace, 7692 &fixture.process_receipts 7693 ) 7694 .unwrap_err() 7695 .kind(), 7696 AdvisoryFailureKind::BindingChanged 7697 ); 7698 let mut changed_trace = trace; 7699 changed_trace.producer_request_sha256 = "f".repeat(64); 7700 assert_eq!( 7701 validate_provider( 7702 provider, 7703 &fixture.request, 7704 &changed_trace, 7705 &fixture.process_receipts 7706 ) 7707 .unwrap_err() 7708 .kind(), 7709 AdvisoryFailureKind::BindingChanged 7710 ); 7711 } 7712 7713 #[test] 7714 fn related_artifact_digests_and_exact_package_tokens_are_bound() { 7715 let fixture = SyntheticFixture::new().unwrap(); 7716 let artifact = &fixture.manifest.gradle_projection[0].artifacts[0]; 7717 let expected = BTreeMap::from([(artifact.materialized_name.as_str(), vec![artifact])]); 7718 let original = json!([{"fileName":artifact.materialized_name,"filePath":format!("/fixture/{}",artifact.materialized_name),"isVirtual":false, 7719 "md5":"1".repeat(32),"sha1":"2".repeat(40),"sha256":artifact.artifact_sha256}]); 7720 parse_owasp_related(Some(&original), &expected).unwrap(); 7721 let mut changed = original.clone(); 7722 changed[0]["sha256"] = json!("f".repeat(64)); 7723 assert!(parse_owasp_related(Some(&changed), &expected).is_err()); 7724 let id = format!( 7725 "pkg:maven/{}/{}@{}", 7726 artifact.package_namespace, artifact.package_name, artifact.package_version 7727 ); 7728 assert!(package_identifier_compatible(&id, artifact)); 7729 let mut changed = artifact.clone(); 7730 changed.package_ecosystem = "cargo".into(); 7731 assert!(!package_identifier_compatible(&id, &changed)); 7732 changed = artifact.clone(); 7733 changed.package_namespace = "has space".into(); 7734 assert!(!package_identifier_compatible(&id, &changed)); 7735 let package = json!({"name":"example","version":"1.0.0","checksum":null,"replace":null,"source":null}); 7736 validate_rustsec_package(&package).unwrap(); 7737 for field in ["name", "version"] { 7738 let mut changed = package.clone(); 7739 changed[field] = json!("has space"); 7740 assert!(validate_rustsec_package(&changed).is_err()); 7741 } 7742 assert!(!valid_rustsec_dependency( 7743 &json!({"name":"example","version":"1.0.0","source":null,"unexpected":true}) 7744 )); 7745 assert!(!valid_text_allow_empty("too long", 3)); 7746 } 7747 7748 #[test] 7749 fn artifact_source_roots_and_project_versions_have_closed_identity() { 7750 let digest = "1".repeat(64); 7751 let make = |role| GradleArtifactSourceRoot { 7752 path: Path::new("/fixture"), 7753 logical_role: role, 7754 identity_sha256: &digest, 7755 }; 7756 artifact_source_roots_digest(&[ 7757 make("candidate_build_output"), 7758 make("governed_seed_cache"), 7759 ]) 7760 .unwrap(); 7761 for roots in [ 7762 vec![], 7763 vec![ 7764 make("candidate_build_output"), 7765 make("governed_seed_cache"), 7766 make("candidate_build_output"), 7767 ], 7768 vec![make("unknown")], 7769 vec![ 7770 make("candidate_build_output"), 7771 make("candidate_build_output"), 7772 ], 7773 vec![GradleArtifactSourceRoot { 7774 path: Path::new("relative"), 7775 ..make("candidate_build_output") 7776 }], 7777 vec![GradleArtifactSourceRoot { 7778 identity_sha256: "invalid", 7779 ..make("candidate_build_output") 7780 }], 7781 ] { 7782 assert!(artifact_source_roots_digest(&roots).is_err()); 7783 } 7784 let version = RawGradleModuleVersion { 7785 group: "harvestcircle.app".into(), 7786 name: "shared".into(), 7787 version: "unspecified".into(), 7788 }; 7789 assert!(valid_project_module_version( 7790 ".", 7791 ":app:shared", 7792 &version, 7793 &"1".repeat(40) 7794 )); 7795 assert!(!valid_project_module_version( 7796 ".", 7797 ":app:shared", 7798 &version, 7799 "invalid" 7800 )); 7801 assert!(!valid_project_module_version( 7802 ".", 7803 ":app:other", 7804 &version, 7805 &"1".repeat(40) 7806 )); 7807 assert!(!valid_gradle_project_path(":app:")); 7808 for value in ["CVE-2026", "CVE-26-1234", "CVE-2026-123"] { 7809 assert!(!valid_advisory_id(ProviderId::OwaspNvd, value)); 7810 } 7811 for value in ["a\0b", "a\nb"] { 7812 assert!(!valid_text_allow_empty(value, 128)); 7813 } 7814 assert!(parse_report_epoch("2026-04-01T00:00:00Z").is_ok()); 7815 assert_eq!( 7816 format_report_epoch(parse_report_epoch("2026-01-01T00:00:00Z").unwrap()).unwrap(), 7817 "2026-01-01T00:00:00Z" 7818 ); 7819 assert!(parse_exact_package_url("pkg:maven/example/bad name@1.0").is_err()); 7820 assert!(!valid_owasp_software_identifier("")); 7821 } 7822 7823 #[test] 7824 fn process_environment_is_private_per_attempt_and_shared_tools_are_consistent() { 7825 let id = "gradle:harvestcircle.android.app.debugRuntimeClasspath"; 7826 let fixture = SyntheticFixture::new().unwrap(); 7827 let original = &fixture 7828 .process_receipts 7829 .iter() 7830 .find(|row| row.id.starts_with("gradle:")) 7831 .unwrap() 7832 .environment; 7833 let validate = |environment: &[ProcessEnvironmentRow]| { 7834 validate_process_environment( 7835 id, 7836 environment, 7837 &mut BTreeSet::new(), 7838 &mut BTreeMap::new(), 7839 ) 7840 }; 7841 validate(original).unwrap(); 7842 assert!(validate(&original[..original.len() - 1]).is_err()); 7843 for name in ["LC_ALL", "TZ"] { 7844 let mut changed = original.clone(); 7845 changed 7846 .iter_mut() 7847 .find(|row| row.name == name) 7848 .unwrap() 7849 .value_sha256 = "f".repeat(64); 7850 assert!(validate(&changed).is_err(), "{name}"); 7851 } 7852 let mut changed = original.clone(); 7853 let home = changed 7854 .iter() 7855 .find(|row| row.name == "HOME") 7856 .unwrap() 7857 .value_sha256 7858 .clone(); 7859 changed 7860 .iter_mut() 7861 .find(|row| row.name == "TMPDIR") 7862 .unwrap() 7863 .value_sha256 = home; 7864 assert!(validate(&changed).is_err()); 7865 let mut shared = BTreeMap::from([("PATH".into(), "f".repeat(64))]); 7866 assert!( 7867 validate_process_environment(id, original, &mut BTreeSet::new(), &mut shared).is_err() 7868 ); 7869 let mut receipts = fixture.process_receipts.clone(); 7870 receipts[1].working_directory.identity_sha256 = 7871 receipts[0].working_directory.identity_sha256.clone(); 7872 receipts[1].working_directory_sha256 = 7873 process_working_directory_digest(&receipts[1].working_directory).unwrap(); 7874 assert!(validate_process_receipts(&receipts, fixture.request.evaluation_epoch).is_err()); 7875 let mut receipts = fixture.process_receipts.clone(); 7876 receipts[0].working_directory.identity_sha256 = receipts[0] 7877 .environment 7878 .iter() 7879 .find(|row| row.name == "HOME") 7880 .unwrap() 7881 .value_sha256 7882 .clone(); 7883 receipts[0].working_directory_sha256 = 7884 process_working_directory_digest(&receipts[0].working_directory).unwrap(); 7885 assert!(validate_process_receipts(&receipts, fixture.request.evaluation_epoch).is_err()); 7886 } 7887 7888 #[test] 7889 fn gradle_projection_bindings_reject_independent_metadata_and_receipt_substitution() { 7890 let fixture = SyntheticFixture::new().unwrap(); 7891 let original = &fixture.manifest.gradle_projection[0]; 7892 let authority = WORKLOADS 7893 .iter() 7894 .find(|row| row.id == original.workload_id) 7895 .unwrap(); 7896 let inventory = fixture 7897 .request 7898 .inventory 7899 .iter() 7900 .find(|row| row.id == original.workload_id) 7901 .unwrap(); 7902 let source = fixture 7903 .request 7904 .sources 7905 .iter() 7906 .find(|row| row.repository == "oss/harvestcircle") 7907 .unwrap(); 7908 let receipt = process_receipt( 7909 &fixture.process_receipts, 7910 &format!("gradle:{}", authority.id), 7911 ) 7912 .unwrap(); 7913 let validate = |projection: &GradleProjection, receipt: &TrustedProcessReceipt| { 7914 validate_gradle_projection_bindings( 7915 projection, 7916 authority, 7917 inventory, 7918 source, 7919 &fixture.request, 7920 receipt, 7921 ) 7922 }; 7923 validate(original, receipt).unwrap(); 7924 for (pointer, replacement) in [ 7925 ("/workload_id", json!("unknown")), 7926 ("/raw_graph_byte_length", json!(0)), 7927 ("/raw_graph_byte_length", json!(MAX_GRADLE_GRAPH_BYTES + 1)), 7928 ("/raw_graph_sha256", json!("invalid")), 7929 ("/init_script_sha256", json!("f".repeat(64))), 7930 ("/wrapper_arguments", json!([])), 7931 ("/environment_keys", json!([])), 7932 ("/environment_sha256", json!("invalid")), 7933 ("/exit_code", json!(1)), 7934 ("/source_revision", json!("f".repeat(40))), 7935 ("/source_tree", json!("f".repeat(40))), 7936 ("/input_sha256", json!("f".repeat(64))), 7937 ("/dependency_count", json!(0)), 7938 ("/component_count", json!(0)), 7939 ("/edge_count", json!(0)), 7940 ("/artifact_count", json!(0)), 7941 ("/materialized_tree_sha256", json!("invalid")), 7942 ("/artifact_source_roots_sha256", json!("invalid")), 7943 ("/seed_cache_inventory_sha256", json!("invalid")), 7944 ("/wrapper_distribution_sha256", json!("f".repeat(64))), 7945 ("/process_receipt_sha256", json!("f".repeat(64))), 7946 ("/canonical_graph_sha256", json!("f".repeat(64))), 7947 ("/normalization_receipt_sha256", json!("f".repeat(64))), 7948 ] { 7949 let mut value = serde_json::to_value(original).unwrap(); 7950 assert_ne!(value.pointer(pointer).unwrap(), &replacement); 7951 *value.pointer_mut(pointer).unwrap() = replacement; 7952 let mut projection: GradleProjection = serde_json::from_value(value).unwrap(); 7953 if [ 7954 "/artifact_source_roots_sha256", 7955 "/seed_cache_inventory_sha256", 7956 "/wrapper_distribution_sha256", 7957 ] 7958 .contains(&pointer) 7959 { 7960 projection.input_sha256 = 7961 gradle_candidate_input_digest(&fixture.request, inventory, source, &projection) 7962 .unwrap(); 7963 } 7964 assert!(validate(&projection, receipt).is_err(), "{pointer}"); 7965 } 7966 let mut empty = original.clone(); 7967 empty.components.clear(); 7968 empty.component_count = 0; 7969 assert!(validate(&empty, receipt).is_err()); 7970 for (field, value) in [ 7971 ("program_sha256", json!("f".repeat(64))), 7972 ("arguments_sha256", json!("f".repeat(64))), 7973 ("environment_sha256", json!("f".repeat(64))), 7974 ("input_sha256", json!("f".repeat(64))), 7975 ("output_sha256", json!("f".repeat(64))), 7976 ("path_binding", json!([])), 7977 ("exit_code", json!(1)), 7978 ] { 7979 let mut changed = serde_json::to_value(receipt).unwrap(); 7980 changed[field] = value; 7981 let changed: TrustedProcessReceipt = serde_json::from_value(changed).unwrap(); 7982 let mut projection = original.clone(); 7983 projection.process_receipt_sha256 = process_receipt_digest(&changed).unwrap(); 7984 assert!(validate(&projection, &changed).is_err(), "{field}"); 7985 } 7986 } 7987 7988 #[test] 7989 fn raw_scanner_files_and_archive_bindings_reject_changed_bytes_and_counters() { 7990 let directory = trusted_tempdir("radroots-scanner-boundary-").unwrap(); 7991 let path = directory.path().join(RAW_SCANNER_OUTPUT_NAME); 7992 for bytes in [b"".as_slice(), b"invalid"] { 7993 std::fs::write(&path, bytes).unwrap(); 7994 assert!( 7995 admit_raw_scanner_output(directory.path(), ProviderId::Rustsec, "fixture").is_err() 7996 ); 7997 } 7998 std::fs::write(&path, b"{}").unwrap(); 7999 let admitted = 8000 admit_raw_scanner_output(directory.path(), ProviderId::Rustsec, "fixture").unwrap(); 8001 admitted.revalidate().unwrap(); 8002 std::fs::write(&path, b"{\"changed\":true}").unwrap(); 8003 assert!(admitted.revalidate().is_err()); 8004 let binding = blob(RUSTSEC_REPORT_NAME, b"{}").unwrap(); 8005 validate_blob( 8006 &binding, 8007 &FileEvidence { 8008 byte_length: 2, 8009 sha256: sha256(b"{}"), 8010 }, 8011 ) 8012 .unwrap(); 8013 for evidence in [ 8014 FileEvidence { 8015 byte_length: 3, 8016 sha256: sha256(b"{}"), 8017 }, 8018 FileEvidence { 8019 byte_length: 2, 8020 sha256: sha256(b"[]"), 8021 }, 8022 ] { 8023 assert!(validate_blob(&binding, &evidence).is_err()); 8024 } 8025 for field in [ 8026 "archive_expanded_bytes", 8027 "archive_member_count", 8028 "archive_payload_bytes", 8029 "materialized_tree_sha256", 8030 ] { 8031 let mut fixture = SyntheticFixture::new().unwrap(); 8032 let mut value = serde_json::to_value(&fixture.manifest.provider_snapshot[0]).unwrap(); 8033 value[field] = if field == "materialized_tree_sha256" { 8034 json!("f".repeat(64)) 8035 } else { 8036 json!(value[field].as_u64().unwrap() + 1) 8037 }; 8038 fixture.manifest.provider_snapshot[0] = serde_json::from_value(value).unwrap(); 8039 fixture.seal().unwrap(); 8040 assert_eq!( 8041 admit_snapshot( 8042 fixture.root(), 8043 fixture.materialization_parent.path(), 8044 &fixture.request 8045 ) 8046 .unwrap_err() 8047 .kind(), 8048 AdvisoryFailureKind::BindingChanged, 8049 "{field}" 8050 ); 8051 } 8052 let fixture = SyntheticFixture::new().unwrap(); 8053 let path = fixture.root().join(MANIFEST_NAME); 8054 let mut bytes = std::fs::read(&path).unwrap(); 8055 bytes.push(b' '); 8056 std::fs::write(path, bytes).unwrap(); 8057 assert_eq!( 8058 admit_snapshot( 8059 fixture.root(), 8060 fixture.materialization_parent.path(), 8061 &fixture.request 8062 ) 8063 .unwrap_err() 8064 .kind(), 8065 AdvisoryFailureKind::InvalidSnapshot 8066 ); 8067 } 8068 8069 #[test] 8070 fn normalized_graph_structure_rejects_each_independent_link_and_artifact_fault() { 8071 let fixture = SyntheticFixture::new().unwrap(); 8072 let original = &fixture.manifest.gradle_projection[0]; 8073 let authority = WORKLOADS 8074 .iter() 8075 .find(|row| row.id == original.workload_id) 8076 .unwrap(); 8077 let revision = "1".repeat(40); 8078 let validate = |projection: &GradleProjection| { 8079 validate_gradle_graph_structure(projection, authority, &revision).is_ok() 8080 }; 8081 rejects_typed_changes( 8082 original, 8083 &[ 8084 ("/components/0/identity_sha256", json!("invalid")), 8085 ("/components/0/variant_sha256", json!("invalid")), 8086 ("/edges/0/from_identity_sha256", json!("invalid")), 8087 ("/edges/0/to_identity_sha256", json!("invalid")), 8088 ("/edges/0/requested_sha256", json!("invalid")), 8089 ("/edges/0/selected_variant_sha256", json!("invalid")), 8090 ("/edges", json!([])), 8091 ("/artifacts/0/component_identity_sha256", json!("invalid")), 8092 ("/artifacts/0/component", json!("")), 8093 ("/artifacts/0/package_ecosystem", json!("cargo")), 8094 ("/artifacts/0/package_namespace", json!("")), 8095 ("/artifacts/0/package_name", json!("")), 8096 ("/artifacts/0/package_version", json!("")), 8097 ("/artifacts/0/artifact_sha256", json!("invalid")), 8098 ("/artifacts/0/variant_sha256", json!("invalid")), 8099 ("/artifacts/0/byte_length", json!(0)), 8100 ("/artifacts/0/artifact_name", json!("")), 8101 ("/artifacts/0/logical_name", json!("")), 8102 ("/artifacts/0/artifact_type", json!("")), 8103 ("/artifacts/0/classifier", json!("")), 8104 ("/artifacts/0/extension", json!("unknown")), 8105 ("/artifacts/0/materialized_name", json!("invalid")), 8106 ("/artifacts/0/package_name", json!("wrong-binding")), 8107 ], 8108 validate, 8109 ); 8110 for collection in ["components", "edges", "artifacts"] { 8111 let mut changed = serde_json::to_value(original).unwrap(); 8112 let duplicate = changed[collection][0].clone(); 8113 changed[collection].as_array_mut().unwrap().push(duplicate); 8114 assert!( 8115 !validate(&serde_json::from_value(changed).unwrap()), 8116 "duplicate {collection}" 8117 ); 8118 } 8119 let mut changed = original.clone(); 8120 changed.edges[0].to_identity_sha256 = changed.edges[0].from_identity_sha256.clone(); 8121 assert!(!validate(&changed)); 8122 let mut changed = original.clone(); 8123 // Reversing the sole edge leaves the non-root component unreachable. 8124 changed.edges[0].from_identity_sha256 = original.edges[0].to_identity_sha256.clone(); 8125 changed.edges[0].to_identity_sha256 = original.edges[0].from_identity_sha256.clone(); 8126 assert!(!validate(&changed)); 8127 for case in 0..6 { 8128 let mut changed = original.clone(); 8129 match case { 8130 0 => { 8131 changed.components[0].variant = json!({"selected": []}); 8132 changed.components[0].variant_sha256 = 8133 gradle_variant_digest(&changed.components[0].variant).unwrap(); 8134 changed.components[0].identity_sha256 = 8135 gradle_component_digest(&changed.components[0]).unwrap(); 8136 changed 8137 .components 8138 .sort_by(|a, b| a.identity_sha256.cmp(&b.identity_sha256)); 8139 } 8140 1 => { 8141 changed.edges[0].requested = json!({}); 8142 changed.edges[0].requested_sha256 = 8143 gradle_request_digest(&changed.edges[0].requested).unwrap(); 8144 } 8145 2 => { 8146 changed.edges[0].selected_variant = json!({}); 8147 changed.edges[0].selected_variant_sha256 = 8148 gradle_variant_digest(&changed.edges[0].selected_variant).unwrap(); 8149 } 8150 3 => { 8151 changed.artifacts[0].variant = json!({}); 8152 changed.artifacts[0].variant_sha256 = 8153 gradle_variant_digest(&changed.artifacts[0].variant).unwrap(); 8154 } 8155 4 => { 8156 changed.artifacts[0].variant["attributes"][0]["value"] = 8157 json!("different-selection"); 8158 changed.artifacts[0].variant_sha256 = 8159 gradle_variant_digest(&changed.artifacts[0].variant).unwrap(); 8160 } 8161 _ => { 8162 for component in &mut changed.components { 8163 component.root = false; 8164 component.identity_sha256 = gradle_component_digest(component).unwrap(); 8165 } 8166 changed 8167 .components 8168 .sort_by(|a, b| a.identity_sha256.cmp(&b.identity_sha256)); 8169 } 8170 } 8171 assert!(!validate(&changed), "graph structure case {case}"); 8172 } 8173 let mut duplicates = original.clone(); 8174 let mut second = duplicates.artifacts[0].clone(); 8175 second.classifier = Some("sources".to_owned()); 8176 duplicates.artifacts.push(second); 8177 duplicates 8178 .artifacts 8179 .sort_by_key(|row| canonical_row_key(row).unwrap()); 8180 assert!( 8181 validate(&duplicates), 8182 "same materialized bytes can support distinct artifacts" 8183 ); 8184 duplicates.artifacts[1].byte_length += 1; 8185 duplicates 8186 .artifacts 8187 .sort_by_key(|row| canonical_row_key(row).unwrap()); 8188 assert!( 8189 !validate(&duplicates), 8190 "one content digest cannot bind different lengths" 8191 ); 8192 } 8193 8194 #[test] 8195 fn process_directories_bind_identity_kind_and_before_after_state() { 8196 let fixture = SyntheticFixture::new().unwrap(); 8197 for prefix in [ 8198 "cargo_audit:", 8199 "gradle:", 8200 "owasp_analysis:", 8201 "owasp_nvd_update", 8202 ] { 8203 let receipt = fixture 8204 .process_receipts 8205 .iter() 8206 .find(|row| row.id.starts_with(prefix)) 8207 .unwrap(); 8208 let directory = &receipt.working_directory; 8209 let mut changes = vec![ 8210 ("/logical_uri", json!("unknown")), 8211 ("/kind", json!("unknown")), 8212 ("/identity_sha256", json!("invalid")), 8213 ("/pre_execution_tree_sha256", json!("invalid")), 8214 ("/post_execution_tree_sha256", json!("invalid")), 8215 ( 8216 "/pre_execution_entry_count", 8217 json!(if directory.pre_execution_entry_count == 0 { 8218 1 8219 } else { 8220 0 8221 }), 8222 ), 8223 ( 8224 "/post_execution_entry_count", 8225 json!(if directory.post_execution_entry_count == 0 { 8226 1 8227 } else { 8228 0 8229 }), 8230 ), 8231 ("/pre_execution_tree_sha256", json!("f".repeat(64))), 8232 ]; 8233 if prefix != "owasp_nvd_update" { 8234 changes.push(("/post_execution_tree_sha256", json!("f".repeat(64)))); 8235 } 8236 rejects_typed_changes(directory, &changes, |changed| { 8237 validate_process_working_directory(&receipt.id, changed).is_ok() 8238 }); 8239 } 8240 assert!(expected_process_working_directory("unknown").is_err()); 8241 assert!(expected_environment_logical_value("UNKNOWN").is_err()); 8242 } 8243 8244 #[test] 8245 fn report_timestamps_and_json_bounds_reject_malformed_or_oversized_values() { 8246 assert!(parse_report_epoch("1970-01-01T00:00:00Z").is_err()); 8247 assert_eq!(parse_report_epoch("1970-01-01T00:00:01.125Z").unwrap(), 1); 8248 for year in [2000, 2024] { 8249 let timestamp = format!("{year}-02-29T12:30:45Z"); 8250 let epoch = parse_report_epoch(×tamp).unwrap(); 8251 assert_eq!(format_report_epoch(epoch).unwrap(), timestamp); 8252 } 8253 for value in [ 8254 "1970-01-00T00:00:00Z", 8255 "1969-01-01T00:00:00Z", 8256 "2100-02-29T00:00:00Z", 8257 "2023-02-29T00:00:00Z", 8258 "2026-04-31T00:00:00Z", 8259 "2026-13-01T00:00:00Z", 8260 "2026-01-01T24:00:00Z", 8261 "2026-01-01T00:60:00Z", 8262 "2026-01-01T00:00:60Z", 8263 "2026-01-01T00:00:00.xZ", 8264 "2026-01-01T00:00:00,1Z", 8265 ] { 8266 assert!(parse_report_epoch(value).is_err(), "{value}"); 8267 } 8268 let original = b"2026-03-01T00:00:00Z"; 8269 for index in [4, 7, 10, 13, 16, 19, 0, 5, 8, 11, 14, 17] { 8270 let mut bytes = original.to_vec(); 8271 bytes[index] = b'x'; 8272 assert!(parse_report_epoch(std::str::from_utf8(&bytes).unwrap()).is_err()); 8273 } 8274 validate_json_bounds(&json!({"rows": [null, true, 1, "safe"]})).unwrap(); 8275 validate_json_bounds(&json!([])).unwrap(); 8276 for value in [json!("x".repeat(1_048_577)), json!("a\nb")] { 8277 assert!(validate_json_bounds(&value).is_err()); 8278 } 8279 let mut deep = Value::Null; 8280 for _ in 0..33 { 8281 deep = Value::Array(vec![deep]); 8282 } 8283 assert!(validate_json_bounds(&deep).is_err()); 8284 assert!(validate_json_bounds(&Value::Array(vec![Value::Null; 65_537])).is_err()); 8285 let object = (0..1_025) 8286 .map(|index| (format!("key{index}"), Value::Null)) 8287 .collect(); 8288 assert!(validate_json_bounds(&Value::Object(object)).is_err()); 8289 assert!(validate_json_bounds(&json!({"bad\nkey": null})).is_err()); 8290 let million = Value::Array(vec![Value::Array(vec![Value::Null; 65_536]); 16]); 8291 assert!(validate_json_bounds(&million).is_err()); 8292 for id in ["CVE-2026", "CVE-26-0001", "CVE-2026-1"] { 8293 assert!(!valid_advisory_id(ProviderId::OwaspNvd, id)); 8294 } 8295 } 8296 8297 #[test] 8298 fn workload_reports_bind_raw_bytes_counts_database_and_process_receipts() { 8299 let fixture = SyntheticFixture::new().unwrap(); 8300 for provider in &fixture.manifest.provider_snapshot { 8301 let original = fixture.read_report_value(provider.provider).unwrap(); 8302 let envelope: ReportEnvelope = serde_json::from_value(original.clone()).unwrap(); 8303 let result = &envelope.workload_result[0]; 8304 let inventory = fixture 8305 .request 8306 .inventory 8307 .iter() 8308 .find(|row| row.id == result.workload_id) 8309 .unwrap(); 8310 let mut changes = vec![ 8311 ("/raw_scanner_output", json!("")), 8312 ( 8313 "/raw_scanner_output", 8314 json!("x".repeat(MAX_RAW_WORKLOAD_REPORT_BYTES as usize + 1)), 8315 ), 8316 ("/input_sha256", json!("f".repeat(64))), 8317 ("/dependency_count", json!(0)), 8318 ("/provider_archive_sha256", json!("f".repeat(64))), 8319 ("/materialized_tree_sha256", json!("f".repeat(64))), 8320 ("/tool_observation_sha256", json!("f".repeat(64))), 8321 ("/raw_output_byte_length", json!(0)), 8322 ("/raw_output_sha256", json!("f".repeat(64))), 8323 ("/environment_sha256", json!("invalid")), 8324 ("/environment_sha256", json!("f".repeat(64))), 8325 ("/process_receipt_sha256", json!("f".repeat(64))), 8326 ("/arguments", json!([])), 8327 ("/exit_code", json!(2)), 8328 ]; 8329 if provider.provider == ProviderId::OwaspNvd { 8330 changes.extend([ 8331 ("/database_copy", json!(null)), 8332 ("/database_copy/root_identity_sha256", json!("invalid")), 8333 ("/database_copy/source_tree_sha256", json!("f".repeat(64))), 8334 ("/database_copy/pre_scan_tree_sha256", json!("f".repeat(64))), 8335 ( 8336 "/database_copy/post_scan_tree_sha256", 8337 json!("f".repeat(64)), 8338 ), 8339 ]); 8340 } 8341 validate_workload_execution(provider, result, inventory, &fixture.request).unwrap(); 8342 for (pointer, replacement) in changes { 8343 let mut changed = original["workload_result"][0].clone(); 8344 *changed.pointer_mut(pointer).unwrap() = replacement; 8345 let changed: WorkloadResult = serde_json::from_value(changed).unwrap(); 8346 assert!( 8347 validate_workload_execution(provider, &changed, inventory, &fixture.request) 8348 .is_err(), 8349 "{pointer}" 8350 ); 8351 } 8352 let bytes = canonical_json(&original); 8353 assert!( 8354 parse_unsuppressed_report( 8355 provider, 8356 &bytes, 8357 &fixture.request, 8358 &fixture.manifest.gradle_projection 8359 ) 8360 .is_ok() 8361 ); 8362 for (pointer, replacement) in [ 8363 ("/schema", json!("unknown")), 8364 ( 8365 "/provider", 8366 json!(if provider.provider == ProviderId::Rustsec { 8367 "owasp_nvd" 8368 } else { 8369 "rustsec" 8370 }), 8371 ), 8372 ("/workload_result", json!([])), 8373 ("/workload_result/0/workload_id", json!("unknown")), 8374 ] { 8375 let mut changed = original.clone(); 8376 *changed.pointer_mut(pointer).unwrap() = replacement; 8377 assert!( 8378 parse_unsuppressed_report( 8379 provider, 8380 &canonical_json(&changed), 8381 &fixture.request, 8382 &fixture.manifest.gradle_projection 8383 ) 8384 .is_err() 8385 ); 8386 } 8387 let mut noncanonical = bytes; 8388 noncanonical.push(b' '); 8389 assert!( 8390 parse_unsuppressed_report( 8391 provider, 8392 &noncanonical, 8393 &fixture.request, 8394 &fixture.manifest.gradle_projection 8395 ) 8396 .is_err() 8397 ); 8398 } 8399 } 8400 8401 #[test] 8402 fn raw_gradle_graph_rejects_identity_variant_artifact_and_source_drift() { 8403 let fixture = SyntheticFixture::new().unwrap(); 8404 let authority = WORKLOADS 8405 .iter() 8406 .find(|row| row.package_manager == "gradle") 8407 .unwrap(); 8408 let workload_root = fixture._gradle_fixture_root.path().join(authority.id); 8409 let raw: RawGradleGraph = serde_json::from_slice( 8410 &std::fs::read(workload_root.join("raw").join(GRADLE_RAW_GRAPH_NAME)).unwrap(), 8411 ) 8412 .unwrap(); 8413 let source = workload_root.join("source"); 8414 let source_identity = "a".repeat(64); 8415 let roots = [GradleArtifactSourceRoot { 8416 path: &source, 8417 logical_role: "candidate_build_output", 8418 identity_sha256: &source_identity, 8419 }]; 8420 let revision = "1".repeat(40); 8421 let normalize = 8422 |raw: &RawGradleGraph| normalize_raw_gradle_graph(raw, authority, &revision, 1, &roots); 8423 assert_eq!(normalize(&raw).unwrap().artifacts.len(), 1); 8424 rejects_typed_changes( 8425 &raw, 8426 &[ 8427 ("/schema", json!("unknown")), 8428 ("/workload_id", json!("unknown")), 8429 ("/build_root", json!("unknown")), 8430 ("/project_path", json!(":other")), 8431 ("/configuration", json!("unknown")), 8432 ("/components", json!([])), 8433 ("/edges", json!([])), 8434 ( 8435 "/edges/0/selected_variant/attributes/0/value", 8436 json!("unselected"), 8437 ), 8438 ( 8439 "/artifacts/0/variant/attributes/0/value", 8440 json!("unselected"), 8441 ), 8442 ("/artifacts/0/group", json!("other")), 8443 ("/artifacts/0/name", json!("other")), 8444 ("/artifacts/0/version", json!("2.0")), 8445 ("/artifacts/0/module_version/group", json!("")), 8446 ("/artifacts/0/module_version/name", json!("")), 8447 ("/artifacts/0/module_version/version", json!("")), 8448 ("/artifacts/0/module_version/group", json!("other")), 8449 ("/artifacts/0/module_version/name", json!("other")), 8450 ("/artifacts/0/module_version/version", json!("other")), 8451 ("/artifacts/0/observed_byte_length", json!(0)), 8452 ( 8453 "/artifacts/0/observed_byte_length", 8454 json!(MAX_GRADLE_ARTIFACT_BYTES + 1), 8455 ), 8456 ("/artifacts/0/observed_byte_length", json!(1)), 8457 ("/artifacts/0/observed_sha256", json!("invalid")), 8458 ("/artifacts/0/observed_sha256", json!("0".repeat(64))), 8459 ("/artifacts/0/artifact_name", json!("")), 8460 ("/artifacts/0/artifact_name", json!("nested/unsafe.jar")), 8461 ("/artifacts/0/artifact_name", json!("wrong.extension")), 8462 ("/artifacts/0/logical_name", json!("")), 8463 ("/artifacts/0/artifact_type", json!("")), 8464 ("/artifacts/0/extension", json!("unknown")), 8465 ("/artifacts/0/classifier", json!("")), 8466 ("/artifacts/0/source_path", json!("")), 8467 ("/artifacts/0/source_path", json!("relative/path")), 8468 ("/artifacts/0/source_path", json!("/outside/root")), 8469 ( 8470 "/edges/0/from", 8471 serde_json::to_value(&raw.edges[0].to).unwrap(), 8472 ), 8473 ], 8474 |changed| normalize(changed).is_ok(), 8475 ); 8476 assert!(normalize_raw_gradle_graph(&raw, authority, &revision, 2, &roots).is_err()); 8477 assert!(normalize_raw_gradle_graph(&raw, &WORKLOADS[0], &revision, 1, &roots).is_err()); 8478 assert!(normalize_raw_gradle_graph(&raw, authority, &revision, 1, &[]).is_err()); 8479 for slot in ["components", "edges", "artifacts"] { 8480 let mut changed = serde_json::to_value(&raw).unwrap(); 8481 let repeated = changed[slot][0].clone(); 8482 changed[slot].as_array_mut().unwrap().push(repeated); 8483 assert!(normalize(&serde_json::from_value(changed).unwrap()).is_err()); 8484 } 8485 let mut no_root = raw.clone(); 8486 for component in &mut no_root.components { 8487 component.root = false; 8488 } 8489 assert!(normalize(&no_root).is_err()); 8490 let mut cycle = raw.clone(); 8491 cycle.edges[0].from = raw.edges[0].to.clone(); 8492 cycle.edges[0].to = raw.edges[0].from.clone(); 8493 assert!(normalize(&cycle).is_err()); 8494 } 8495 8496 #[test] 8497 fn normalized_gradle_components_and_artifacts_keep_their_kind_specific_identity() { 8498 let fixture = SyntheticFixture::new().unwrap(); 8499 let projection = &fixture.manifest.gradle_projection[0]; 8500 let artifact = &projection.artifacts[0]; 8501 let module = projection 8502 .components 8503 .iter() 8504 .find(|row| row.kind == "module") 8505 .unwrap(); 8506 let project = projection 8507 .components 8508 .iter() 8509 .find(|row| row.kind == "project") 8510 .unwrap(); 8511 rejects_typed_changes( 8512 module, 8513 &[ 8514 ("/group", json!(null)), 8515 ("/name", json!(null)), 8516 ("/version", json!(null)), 8517 ("/build_root", json!(".")), 8518 ("/project_path", json!(":app")), 8519 ("/root", json!(true)), 8520 ], 8521 valid_gradle_component, 8522 ); 8523 rejects_typed_changes( 8524 project, 8525 &[ 8526 ("/group", json!("extra")), 8527 ("/name", json!("extra")), 8528 ("/version", json!("extra")), 8529 ("/build_root", json!(null)), 8530 ("/project_path", json!(null)), 8531 ], 8532 valid_gradle_component, 8533 ); 8534 rejects_typed_changes( 8535 artifact, 8536 &[ 8537 ("/component", json!("unknown")), 8538 ("/package_ecosystem", json!("cargo")), 8539 ("/package_namespace", json!("other")), 8540 ("/package_name", json!("other")), 8541 ("/package_version", json!("other")), 8542 ], 8543 |changed| artifact_matches_component(changed, module, &"1".repeat(40)), 8544 ); 8545 for missing in ["group", "name", "version"] { 8546 let mut changed = serde_json::to_value(module).unwrap(); 8547 changed[missing] = Value::Null; 8548 assert!(!artifact_matches_component( 8549 artifact, 8550 &serde_json::from_value(changed).unwrap(), 8551 &"1".repeat(40) 8552 )); 8553 } 8554 let mut project_artifact = artifact.clone(); 8555 project_artifact.component = format!( 8556 "{}:{}", 8557 project.build_root.as_deref().unwrap(), 8558 project.project_path.as_deref().unwrap() 8559 ); 8560 project_artifact.package_namespace = "harvestcircle.app".to_owned(); 8561 project_artifact.package_name = "design_system".to_owned(); 8562 project_artifact.package_version = "unspecified".to_owned(); 8563 project_artifact.classifier = None; 8564 assert!(artifact_matches_component( 8565 &project_artifact, 8566 project, 8567 &"1".repeat(40) 8568 )); 8569 rejects_typed_changes( 8570 &project_artifact, 8571 &[ 8572 ("/component", json!("unknown")), 8573 ("/package_ecosystem", json!("cargo")), 8574 ("/package_namespace", json!("other")), 8575 ("/classifier", json!("classified")), 8576 ], 8577 |changed| artifact_matches_component(changed, project, &"1".repeat(40)), 8578 ); 8579 for missing in ["build_root", "project_path"] { 8580 let mut changed = serde_json::to_value(project).unwrap(); 8581 changed[missing] = Value::Null; 8582 assert!(!artifact_matches_component( 8583 &project_artifact, 8584 &serde_json::from_value(changed).unwrap(), 8585 &"1".repeat(40) 8586 )); 8587 } 8588 } 8589 8590 #[test] 8591 fn request_requires_complete_ordered_sources_graphs_and_scanner_outputs() { 8592 for case in 0..13 { 8593 let mut fixture = SyntheticFixture::new().unwrap(); 8594 match case { 8595 0 => fixture.request.candidate.generation = 0, 8596 1 => fixture.request.candidate.digest = "invalid".to_owned(), 8597 2 => fixture.request.evaluation_epoch = 0, 8598 3 => { 8599 fixture.request.inventory.pop(); 8600 } 8601 4 => { 8602 fixture.request.admitted_gradle_graphs.pop(); 8603 } 8604 5 => { 8605 fixture.request.admitted_scanner_outputs.pop(); 8606 } 8607 6 => { 8608 fixture.request.sources.pop(); 8609 } 8610 7 => fixture.request.sources.swap(0, 1), 8611 8 => fixture.request.sources[0].revision = "invalid".to_owned(), 8612 9 => fixture.request.sources[0].tree = "invalid".to_owned(), 8613 10 => fixture.request.admitted_gradle_graphs.swap(0, 1), 8614 11 => fixture.request.admitted_scanner_outputs.swap(0, 1), 8615 _ => fixture.request.sources.clear(), 8616 } 8617 assert!( 8618 validate_request(&fixture.request).is_err(), 8619 "request case {case}" 8620 ); 8621 } 8622 for (slot, maximum) in [ 8623 MAX_PRODUCER_REQUEST_BYTES, 8624 MAX_TOOL_MANIFEST_BYTES, 8625 MAX_TOOL_OBSERVATION_BYTES, 8626 MAX_NVD_TRACE_BYTES, 8627 MAX_PROVIDER_EVIDENCE_BYTES, 8628 ] 8629 .into_iter() 8630 .enumerate() 8631 { 8632 let mut fixture = SyntheticFixture::new().unwrap(); 8633 let field = match slot { 8634 0 => &mut fixture.request.producer_request, 8635 1 => &mut fixture.request.step_297_tool_manifest, 8636 2 => &mut fixture.request.fresh_tool_observation, 8637 3 => &mut fixture.request.nvd_network_trace, 8638 _ => &mut fixture.request.provider_execution_evidence, 8639 }; 8640 field.resize(maximum + 1, b' '); 8641 assert_eq!( 8642 validate_trusted_authority(&fixture.request) 8643 .unwrap_err() 8644 .kind(), 8645 AdvisoryFailureKind::InvalidSnapshot 8646 ); 8647 } 8648 } 8649 8650 #[test] 8651 fn pinned_tools_and_scanner_arguments_reject_stale_or_substituted_inputs() { 8652 let fixture = SyntheticFixture::new().unwrap(); 8653 let states = &fixture.manifest.tool_state; 8654 rejects_typed_changes( 8655 states, 8656 &[ 8657 ("/0/id", json!("unknown")), 8658 ("/0/normalized_version", json!("0")), 8659 ("/0/executable_sha256", json!("f".repeat(64))), 8660 ("/0/source_sha256", json!("f".repeat(64))), 8661 ("/0/package_receipt_sha256", json!("f".repeat(64))), 8662 ("/0/reviewed_at_epoch", json!(0)), 8663 ( 8664 "/0/reviewed_at_epoch", 8665 json!(fixture.request.evaluation_epoch + 1), 8666 ), 8667 ( 8668 "/0/reviewed_at_epoch", 8669 json!(fixture.request.evaluation_epoch - TOOL_REVIEW_SECONDS - 1), 8670 ), 8671 ("/0/state", json!("unavailable")), 8672 ], 8673 |changed| validate_tool_states(changed, fixture.request.evaluation_epoch).is_ok(), 8674 ); 8675 assert!(validate_tool_states(&[], fixture.request.evaluation_epoch).is_err()); 8676 let mut boundary = states.clone(); 8677 boundary[0].reviewed_at_epoch = fixture.request.evaluation_epoch - TOOL_REVIEW_SECONDS; 8678 validate_tool_states(&boundary, fixture.request.evaluation_epoch).unwrap(); 8679 rejects_typed_changes( 8680 &fixture.manifest.tool_acquisition, 8681 &[("/0/id", json!("unknown")), ("/0/projection", json!({}))], 8682 |changed| validate_tool_acquisitions(changed).is_ok(), 8683 ); 8684 assert!(validate_tool_acquisitions(&[]).is_err()); 8685 for (provider, workload) in [ 8686 (ProviderId::Rustsec, "lib"), 8687 (ProviderId::OwaspNvd, "app_design_system"), 8688 ] { 8689 let original = synthetic_scanner_arguments(provider, workload); 8690 assert!(valid_scanner_arguments(provider, &original)); 8691 assert!(!valid_scanner_arguments(provider, &[])); 8692 for index in 0..original.len() { 8693 let mut changed = original.clone(); 8694 changed[index] = "untrusted".to_owned(); 8695 assert!( 8696 !valid_scanner_arguments(provider, &changed), 8697 "{provider:?} argument {index}" 8698 ); 8699 } 8700 } 8701 for value in ["", "relative", "/contains\0nul"] { 8702 assert!(!absolute_path(value)); 8703 } 8704 assert!(absolute_path("/valid/path")); 8705 } 8706 8707 #[test] 8708 fn suppression_requires_unique_exact_finding_and_unexpired_owner_rationale() { 8709 let suppression = Suppression { 8710 id: "review-1".to_owned(), 8711 provider: ProviderId::Rustsec, 8712 advisory_id: "RUSTSEC-2099-0001".to_owned(), 8713 workload_id: WORKLOADS[0].id.to_owned(), 8714 package_ecosystem: "cargo".to_owned(), 8715 package_namespace: String::new(), 8716 package_name: "example".to_owned(), 8717 package_version: "1.0.0".to_owned(), 8718 owner: "security@example.invalid".to_owned(), 8719 rationale: "Reviewed fixture".to_owned(), 8720 created_at_epoch: 100, 8721 expires_at_epoch: 200, 8722 }; 8723 let finding = Finding { 8724 provider: suppression.provider, 8725 advisory_id: suppression.advisory_id.clone(), 8726 package_ecosystem: suppression.package_ecosystem.clone(), 8727 package_namespace: suppression.package_namespace.clone(), 8728 package_name: suppression.package_name.clone(), 8729 package_version: suppression.package_version.clone(), 8730 workload_id: suppression.workload_id.clone(), 8731 }; 8732 let mut findings = vec![finding.clone()]; 8733 apply_suppressions(&mut findings, std::slice::from_ref(&suppression), 150).unwrap(); 8734 assert!(findings.is_empty()); 8735 for mut findings in [vec![], vec![finding.clone(), finding.clone()]] { 8736 assert!( 8737 apply_suppressions(&mut findings, std::slice::from_ref(&suppression), 150).is_err() 8738 ); 8739 } 8740 rejects_typed_changes( 8741 &suppression, 8742 &[ 8743 ("/advisory_id", json!(NON_WAIVABLE_RUSTSEC)), 8744 ("/advisory_id", json!("unknown")), 8745 ("/id", json!("..")), 8746 ("/package_ecosystem", json!("npm")), 8747 ("/package_namespace", json!("unexpected")), 8748 ("/package_name", json!("")), 8749 ("/package_version", json!("")), 8750 ("/workload_id", json!("unknown")), 8751 ("/owner", json!("")), 8752 ("/rationale", json!("")), 8753 ("/created_at_epoch", json!(0)), 8754 ("/created_at_epoch", json!(200)), 8755 ("/created_at_epoch", json!(151)), 8756 ("/expires_at_epoch", json!(150)), 8757 ], 8758 |changed| validate_suppression_inventory(std::slice::from_ref(changed), 150).is_ok(), 8759 ); 8760 for field in [ 8761 "provider", 8762 "advisory_id", 8763 "package_ecosystem", 8764 "package_namespace", 8765 "package_name", 8766 "package_version", 8767 "workload_id", 8768 ] { 8769 let mut changed = serde_json::to_value(&suppression).unwrap(); 8770 changed[field] = if field == "provider" { 8771 json!("owasp_nvd") 8772 } else { 8773 json!("mismatch") 8774 }; 8775 let changed: Suppression = serde_json::from_value(changed).unwrap(); 8776 assert!(!suppression_matches(&changed, &finding), "{field}"); 8777 } 8778 assert!( 8779 validate_suppression_inventory(&[suppression.clone(), suppression.clone()], 150) 8780 .is_err() 8781 ); 8782 let mut second = suppression.clone(); 8783 second.id = "review-2".to_owned(); 8784 validate_suppression_inventory(&[suppression.clone(), second.clone()], 150).unwrap(); 8785 assert!(validate_suppression_inventory(&[second, suppression.clone()], 150).is_err()); 8786 let mut maven = suppression; 8787 maven.provider = ProviderId::OwaspNvd; 8788 maven.advisory_id = "CVE-2099-0001".to_owned(); 8789 maven.package_ecosystem = "maven".to_owned(); 8790 assert!(validate_suppression_inventory(&[maven.clone()], 150).is_err()); 8791 maven.package_namespace = "com.example".to_owned(); 8792 validate_suppression_inventory(&[maven], 150).unwrap(); 8793 } 8794 8795 #[test] 8796 fn temporal_evidence_orders_review_acquisition_normalization_and_scans() { 8797 let fixture = SyntheticFixture::new().unwrap(); 8798 let observation: TrustedToolObservation = 8799 parse_canonical_authority(&fixture.request.fresh_tool_observation).unwrap(); 8800 let providers = &fixture.manifest.provider_snapshot; 8801 let receipts = &fixture.process_receipts; 8802 validate_temporal_order(&observation, providers, receipts).unwrap(); 8803 rejects_typed_changes( 8804 &observation, 8805 &[ 8806 ("/observed_at_epoch", json!(0)), 8807 ( 8808 "/tool_state/0/reviewed_at_epoch", 8809 json!(observation.observed_at_epoch + 1), 8810 ), 8811 ], 8812 |changed| validate_temporal_order(changed, providers, receipts).is_ok(), 8813 ); 8814 rejects_typed_changes( 8815 providers, 8816 &[ 8817 ( 8818 "/0/acquired_at_epoch", 8819 json!(observation.observed_at_epoch - 1), 8820 ), 8821 ("/0/digest_time_epoch", json!(u64::MAX)), 8822 ("/0/analyzed_at_epoch", json!(u64::MAX)), 8823 ( 8824 "/1/acquired_at_epoch", 8825 json!(providers[1].acquired_at_epoch + 1), 8826 ), 8827 ], 8828 |changed| validate_temporal_order(&observation, changed, receipts).is_ok(), 8829 ); 8830 assert!(validate_temporal_order(&observation, providers, &[]).is_err()); 8831 let update = receipts 8832 .iter() 8833 .position(|row| row.id == "owasp_nvd_update") 8834 .unwrap(); 8835 let gradle = receipts 8836 .iter() 8837 .position(|row| row.id.starts_with("gradle:")) 8838 .unwrap(); 8839 for (index, started) in [(update, true), (gradle, false)] { 8840 let mut changed = receipts.clone(); 8841 if started { 8842 changed[index].started_at_epoch = observation.observed_at_epoch - 1; 8843 } else { 8844 changed[index].completed_at_epoch = u64::MAX; 8845 } 8846 assert!(validate_temporal_order(&observation, providers, &changed).is_err()); 8847 } 8848 } 8849 use serde_json::json; 8850 8851 #[test] 8852 fn complete_rustsec_reports_reject_nested_type_count_and_scanner_policy_drift() { 8853 let mut fixture = SyntheticFixture::new().unwrap(); 8854 fixture 8855 .set_rustsec_finding("RUSTSEC-2099-0001", "example", "1.0.0") 8856 .unwrap(); 8857 let report = fixture.read_report_value(ProviderId::Rustsec).unwrap(); 8858 let result: WorkloadResult = 8859 serde_json::from_value(report["workload_result"][0].clone()).unwrap(); 8860 let original: Value = serde_json::from_str(&result.raw_scanner_output).unwrap(); 8861 let provider = &fixture.manifest.provider_snapshot[0]; 8862 let parse = |value: &Value| { 8863 parse_rustsec_output( 8864 &result.workload_id, 8865 value, 8866 result.dependency_count, 8867 &provider.database_identity_sha256, 8868 &provider.materialized_tree_sha256, 8869 provider.digest_time_epoch, 8870 &mut Vec::new(), 8871 ) 8872 }; 8873 rejects_wrong_node_types(&original, |value| parse(value).is_ok()); 8874 rejects_unknown_object_fields( 8875 &original, 8876 &[ 8877 "", 8878 "/database", 8879 "/lockfile", 8880 "/settings", 8881 "/vulnerabilities", 8882 "/vulnerabilities/list/0", 8883 "/vulnerabilities/list/0/advisory", 8884 "/vulnerabilities/list/0/package", 8885 "/vulnerabilities/list/0/versions", 8886 ], 8887 |value| parse(value).is_ok(), 8888 ); 8889 let mut substituted_database = original.clone(); 8890 substituted_database["database"]["last-commit"] = json!("f".repeat(40)); 8891 assert!(parse(&substituted_database).is_err()); 8892 for (pointer, replacement) in [ 8893 ("/database/advisory-count", json!(0)), 8894 ("/database/last-commit", json!("invalid")), 8895 ("/database/last-updated", json!("invalid")), 8896 ("/lockfile/dependency-count", json!(0)), 8897 ("/settings/ignore", json!(["RUSTSEC-2099-0001"])), 8898 ("/settings/target_arch", json!(["x86_64"])), 8899 ("/settings/target_os", json!(["linux"])), 8900 ("/settings/severity", json!("high")), 8901 ("/settings/informational_warnings", json!([])), 8902 ("/vulnerabilities/found", json!(false)), 8903 ("/vulnerabilities/count", json!(0)), 8904 ] { 8905 let mut changed = original.clone(); 8906 *changed.pointer_mut(pointer).unwrap() = replacement; 8907 assert!(parse(&changed).is_err(), "{pointer}"); 8908 } 8909 for key in original.as_object().unwrap().keys() { 8910 let mut changed = original.clone(); 8911 changed.as_object_mut().unwrap().remove(key); 8912 assert!(parse(&changed).is_err(), "missing {key}"); 8913 } 8914 for kind in ["notice", "unmaintained", "unsound"] { 8915 let mut changed = original.clone(); 8916 let mut warning = original["vulnerabilities"]["list"][0].clone(); 8917 warning["kind"] = json!(kind); 8918 changed["warnings"][kind] = json!([warning]); 8919 rejects_wrong_node_types(&changed, |value| parse(value).is_ok()); 8920 rejects_unknown_object_fields(&changed, &[&format!("/warnings/{kind}/0")], |value| { 8921 parse(value).is_ok() 8922 }); 8923 changed["warnings"][kind][0]["kind"] = json!("other"); 8924 assert!(parse(&changed).is_err()); 8925 } 8926 for (kind, rows) in [("unknown", json!([])), ("yanked", json!([{}]))] { 8927 let mut changed = original.clone(); 8928 changed["warnings"][kind] = rows; 8929 assert!(parse(&changed).is_err()); 8930 } 8931 let mut empty_yanked = original.clone(); 8932 empty_yanked["warnings"]["yanked"] = json!([]); 8933 assert!(parse(&empty_yanked).is_ok()); 8934 } 8935 8936 #[test] 8937 fn complete_owasp_reports_bind_database_project_file_and_package_identity() { 8938 let mut fixture = SyntheticFixture::new().unwrap(); 8939 let artifact = fixture.manifest.gradle_projection[0].artifacts[0].clone(); 8940 fixture 8941 .set_owasp_finding( 8942 "CVE-2099-0001", 8943 &artifact.package_namespace, 8944 &artifact.package_name, 8945 &artifact.package_version, 8946 ) 8947 .unwrap(); 8948 let report = fixture.read_report_value(ProviderId::OwaspNvd).unwrap(); 8949 let result: WorkloadResult = 8950 serde_json::from_value(report["workload_result"][0].clone()).unwrap(); 8951 let mut original: Value = serde_json::from_str(&result.raw_scanner_output).unwrap(); 8952 for key in ["artifactID", "groupID", "version"] { 8953 original["projectInfo"][key] = json!("fixture"); 8954 } 8955 original["dependencies"][0]["description"] = json!("fixture"); 8956 original["dependencies"][0]["license"] = json!("Apache-2.0"); 8957 original["dependencies"][0]["projectReferences"] = json!(["fixture"]); 8958 let provider = &fixture.manifest.provider_snapshot[1]; 8959 let projection = &fixture.manifest.gradle_projection[0]; 8960 let receipt = process_receipt( 8961 &fixture.process_receipts, 8962 &format!("owasp_analysis:{}", result.workload_id), 8963 ) 8964 .unwrap(); 8965 let parse = |value: &Value| { 8966 parse_owasp_output( 8967 &result.workload_id, 8968 value, 8969 projection, 8970 &provider.database_identity_sha256, 8971 &provider.materialized_tree_sha256, 8972 &result.arguments[4], 8973 receipt.started_at_epoch, 8974 receipt.completed_at_epoch, 8975 provider.digest_time_epoch, 8976 &mut Vec::new(), 8977 ) 8978 }; 8979 rejects_wrong_node_types(&original, |value| parse(value).is_ok()); 8980 rejects_unknown_object_fields( 8981 &original, 8982 &[ 8983 "", 8984 "/scanInfo", 8985 "/scanInfo/dataSource/0", 8986 "/projectInfo", 8987 "/projectInfo/credits", 8988 "/dependencies/0", 8989 "/dependencies/0/evidenceCollected", 8990 "/dependencies/0/packages/0", 8991 "/dependencies/0/vulnerabilities/0", 8992 ], 8993 |value| parse(value).is_ok(), 8994 ); 8995 for timestamp in [ 8996 provider.digest_time_epoch - 1, 8997 provider.digest_time_epoch + 1, 8998 ] { 8999 let mut changed = original.clone(); 9000 changed["scanInfo"]["dataSource"][0]["timestamp"] = 9001 json!(format_report_epoch(timestamp).unwrap()); 9002 assert!(parse(&changed).is_err()); 9003 } 9004 for timestamp in [receipt.started_at_epoch - 1, receipt.completed_at_epoch + 1] { 9005 let mut changed = original.clone(); 9006 changed["projectInfo"]["reportDate"] = json!(format_report_epoch(timestamp).unwrap()); 9007 assert!(parse(&changed).is_err()); 9008 } 9009 for count in [2, 65] { 9010 let mut changed = original.clone(); 9011 changed["scanInfo"]["dataSource"] = 9012 json!(vec![original["scanInfo"]["dataSource"][0].clone(); count]); 9013 assert!(parse(&changed).is_err()); 9014 } 9015 let mut database_name = original.clone(); 9016 database_name["scanInfo"]["dataSource"][0]["name"] = json!("NVD CVE substituted"); 9017 assert!(parse(&database_name).is_err()); 9018 let mut unsorted_references = original.clone(); 9019 unsorted_references["dependencies"][0]["projectReferences"] = json!(["z", "a"]); 9020 assert!(parse(&unsorted_references).is_err()); 9021 for (pointer, replacement) in [ 9022 ("/reportSchema", json!("2.0")), 9023 ("/scanInfo/engineVersion", json!("unknown")), 9024 ("/scanInfo/dataSource", json!([])), 9025 ("/scanInfo/dataSource/0/name", json!("other")), 9026 ( 9027 "/scanInfo/dataSource/0/timestamp", 9028 json!("2099-01-01T00:00:00Z"), 9029 ), 9030 ("/projectInfo/name", json!("another-workload")), 9031 ("/projectInfo/reportDate", json!("1970-01-01T00:00:00Z")), 9032 ("/dependencies", json!([])), 9033 ("/dependencies/0/fileName", json!("other.jar")), 9034 ("/dependencies/0/filePath", json!("/other/path")), 9035 ("/dependencies/0/md5", json!("invalid")), 9036 ("/dependencies/0/sha1", json!("invalid")), 9037 ("/dependencies/0/sha256", json!("invalid")), 9038 ("/dependencies/0/sha256", json!("f".repeat(64))), 9039 ( 9040 "/dependencies/0/packages/0/id", 9041 json!("pkg:maven/other/package@1.0"), 9042 ), 9043 ("/dependencies/0/vulnerabilityIds/0/id", json!("invalid")), 9044 ( 9045 "/dependencies/0/vulnerabilityIds/0/id", 9046 json!("cpe:/a:other:package:1.0"), 9047 ), 9048 ] { 9049 let mut changed = original.clone(); 9050 *changed.pointer_mut(pointer).unwrap() = replacement; 9051 assert!(parse(&changed).is_err(), "{pointer}"); 9052 } 9053 for key in [ 9054 "suppressedVulnerabilities", 9055 "suppressedVulnerabilityIds", 9056 "unknown", 9057 ] { 9058 let mut changed = original.clone(); 9059 changed["dependencies"][0][key] = json!([]); 9060 assert!(parse(&changed).is_err()); 9061 } 9062 let mut missing_findings = original.clone(); 9063 missing_findings["dependencies"][0] 9064 .as_object_mut() 9065 .unwrap() 9066 .remove("vulnerabilities"); 9067 assert!(parse(&missing_findings).is_err()); 9068 let mut duplicated = original.clone(); 9069 duplicated["dependencies"] 9070 .as_array_mut() 9071 .unwrap() 9072 .push(original["dependencies"][0].clone()); 9073 assert!(parse(&duplicated).is_err()); 9074 } 9075 9076 fn rejects_unknown_object_fields( 9077 original: &Value, 9078 pointers: &[&str], 9079 validate: impl Fn(&Value) -> bool, 9080 ) { 9081 assert!(validate(original), "valid closed-schema fixture"); 9082 for pointer in pointers { 9083 let mut changed = original.clone(); 9084 let object = changed 9085 .pointer_mut(pointer) 9086 .expect(pointer) 9087 .as_object_mut() 9088 .expect(pointer); 9089 assert!( 9090 object 9091 .insert("unexpected_field".into(), json!(true)) 9092 .is_none() 9093 ); 9094 assert!(!validate(&changed), "unknown field at {pointer}"); 9095 } 9096 } 9097 9098 #[test] 9099 fn virtual_owasp_dependencies_require_rooted_identity_and_emit_exact_findings() { 9100 let mut fixture = SyntheticFixture::new().unwrap(); 9101 let artifact = fixture.manifest.gradle_projection[0].artifacts[0].clone(); 9102 fixture 9103 .set_owasp_finding( 9104 "CVE-2099-0001", 9105 &artifact.package_namespace, 9106 &artifact.package_name, 9107 &artifact.package_version, 9108 ) 9109 .unwrap(); 9110 let report = fixture.read_report_value(ProviderId::OwaspNvd).unwrap(); 9111 let result: WorkloadResult = 9112 serde_json::from_value(report["workload_result"][0].clone()).unwrap(); 9113 let mut original: Value = serde_json::from_str(&result.raw_scanner_output).unwrap(); 9114 let provider = &fixture.manifest.provider_snapshot[1]; 9115 let projection = &fixture.manifest.gradle_projection[0]; 9116 let receipt = process_receipt( 9117 &fixture.process_receipts, 9118 &format!("owasp_analysis:{}", result.workload_id), 9119 ) 9120 .unwrap(); 9121 let parse = |value: &Value, scan_root: &str| { 9122 let mut findings = Vec::new(); 9123 parse_owasp_output( 9124 &result.workload_id, 9125 value, 9126 projection, 9127 &provider.database_identity_sha256, 9128 &provider.materialized_tree_sha256, 9129 scan_root, 9130 receipt.started_at_epoch, 9131 receipt.completed_at_epoch, 9132 provider.digest_time_epoch, 9133 &mut findings, 9134 )?; 9135 Ok::<_, AdvisoryError>(findings) 9136 }; 9137 let mut virtual_row = original["dependencies"][0].clone(); 9138 for key in ["md5", "sha1", "sha256"] { 9139 virtual_row.as_object_mut().unwrap().remove(key); 9140 } 9141 virtual_row["isVirtual"] = json!(true); 9142 virtual_row["fileName"] = json!("embedded-module"); 9143 virtual_row["filePath"] = json!(format!("{}/embedded-module", result.arguments[4])); 9144 virtual_row["packages"] = json!([{"id":"pkg:maven/embedded/library@2.0"}]); 9145 virtual_row["includedBy"] = 9146 json!([{"reference":format!("project:{}", result.workload_id)}]); 9147 original["dependencies"] 9148 .as_array_mut() 9149 .unwrap() 9150 .push(virtual_row); 9151 let findings = parse(&original, &result.arguments[4]).unwrap(); 9152 assert_eq!(findings.len(), 2); 9153 assert_eq!(findings[1].package_namespace, "embedded"); 9154 assert_eq!(findings[1].package_name, "library"); 9155 assert_eq!(findings[1].package_version, "2.0"); 9156 assert_eq!(findings[1].advisory_id, "CVE-2099-0001"); 9157 for (key, value) in [ 9158 ("md5", json!("1".repeat(32))), 9159 ("sha1", json!("1".repeat(40))), 9160 ("sha256", json!("1".repeat(64))), 9161 ("filePath", json!("/outside/module")), 9162 ("fileName", json!(artifact.materialized_name)), 9163 ("packages", json!([])), 9164 ("includedBy", json!([])), 9165 ] { 9166 let mut changed = original.clone(); 9167 changed["dependencies"][1][key] = value; 9168 assert!(parse(&changed, &result.arguments[4]).is_err(), "{key}"); 9169 } 9170 for key in ["packages", "includedBy"] { 9171 let mut changed = original.clone(); 9172 changed["dependencies"][1] 9173 .as_object_mut() 9174 .unwrap() 9175 .remove(key); 9176 assert!(parse(&changed, &result.arguments[4]).is_err()); 9177 } 9178 let mut missing_physical = original.clone(); 9179 missing_physical["dependencies"] 9180 .as_array_mut() 9181 .unwrap() 9182 .remove(0); 9183 assert!(parse(&missing_physical, &result.arguments[4]).is_err()); 9184 for root in ["relative", "/trailing/"] { 9185 assert!(parse(&original, root).is_err()); 9186 } 9187 } 9188 9189 #[test] 9190 fn scanner_execution_rejects_independently_substituted_trusted_receipts() { 9191 let mut fixture = SyntheticFixture::new().unwrap(); 9192 for provider in &fixture.manifest.provider_snapshot { 9193 let original = 9194 fixture.read_report_value(provider.provider).unwrap()["workload_result"][0].clone(); 9195 let original_result: WorkloadResult = serde_json::from_value(original.clone()).unwrap(); 9196 let inventory = fixture 9197 .request 9198 .inventory 9199 .iter() 9200 .find(|row| row.id == original_result.workload_id) 9201 .unwrap() 9202 .clone(); 9203 let id = format!( 9204 "{}:{}", 9205 if provider.provider == ProviderId::Rustsec { 9206 "cargo_audit" 9207 } else { 9208 "owasp_analysis" 9209 }, 9210 original_result.workload_id 9211 ); 9212 let authority: TrustedProviderEvidence = 9213 parse_canonical_authority(&fixture.request.provider_execution_evidence).unwrap(); 9214 validate_workload_execution(provider, &original_result, &inventory, &fixture.request) 9215 .unwrap(); 9216 let original_bytes = fixture.request.provider_execution_evidence.clone(); 9217 let authority_value = serde_json::to_value(&authority).unwrap(); 9218 let index = authority 9219 .process_receipt 9220 .iter() 9221 .position(|row| row.id == id) 9222 .unwrap(); 9223 for (field, replacement) in [ 9224 ("program_sha256", json!("f".repeat(64))), 9225 ("arguments_sha256", json!("f".repeat(64))), 9226 ("input_sha256", json!("f".repeat(64))), 9227 ("output_sha256", json!("f".repeat(64))), 9228 ("stdout_byte_length", json!(1)), 9229 ("stdout_sha256", json!("f".repeat(64))), 9230 ("exit_code", json!(2)), 9231 ("path_binding", json!([])), 9232 ] { 9233 let mut changed = authority_value.clone(); 9234 assert_ne!( 9235 changed["process_receipt"][index][field], replacement, 9236 "mutation {field}" 9237 ); 9238 changed["process_receipt"][index][field] = replacement; 9239 let changed: TrustedProviderEvidence = serde_json::from_value(changed).unwrap(); 9240 fixture.request.provider_execution_evidence = 9241 canonical_authority_bytes(&changed).unwrap(); 9242 validate_trusted_authority(&fixture.request) 9243 .expect("receipt remains structurally trusted"); 9244 let mut result = original.clone(); 9245 result["process_receipt_sha256"] = 9246 json!(process_receipt_digest(&changed.process_receipt[index]).unwrap()); 9247 let result: WorkloadResult = serde_json::from_value(result).unwrap(); 9248 assert!( 9249 validate_workload_execution(provider, &result, &inventory, &fixture.request) 9250 .is_err(), 9251 "{field}" 9252 ); 9253 } 9254 fixture.request.provider_execution_evidence = original_bytes; 9255 } 9256 } 9257 9258 fn rejects_typed_changes<T: for<'de> Deserialize<'de> + Serialize>( 9259 original: &T, 9260 changes: &[(&str, Value)], 9261 validate: impl Fn(&T) -> bool, 9262 ) { 9263 assert!(validate(original), "accepted typed fixture"); 9264 let value = serde_json::to_value(original).unwrap(); 9265 for (pointer, replacement) in changes { 9266 let mut changed = value.clone(); 9267 *changed.pointer_mut(pointer).expect(pointer) = replacement.clone(); 9268 assert_ne!(changed, value, "mutation must change {pointer}"); 9269 let changed: T = 9270 serde_json::from_value(changed).expect("mutation preserves wire types"); 9271 assert!( 9272 !validate(&changed), 9273 "invalid typed field accepted: {pointer}" 9274 ); 9275 } 9276 } 9277 9278 #[test] 9279 fn provider_admission_binds_artifacts_times_and_execution_receipts() { 9280 let fixture = SyntheticFixture::new().unwrap(); 9281 let trace: NvdNetworkTrace = 9282 parse_canonical_authority(&fixture.request.nvd_network_trace).unwrap(); 9283 for provider in &fixture.manifest.provider_snapshot { 9284 let mut changes = vec![ 9285 ("/acquisition_state", json!("failed")), 9286 ("/analysis_state", json!("unavailable")), 9287 ("/acquisition_count", json!(0)), 9288 ("/bounded_deadline_seconds", json!(0)), 9289 ("/network_trace_sha256", json!("invalid")), 9290 ("/producer_request_sha256", json!("invalid")), 9291 ("/producer_request_sha256", json!("f".repeat(64))), 9292 ("/database_identity_sha256", json!("invalid")), 9293 ("/archive_format", json!("zip")), 9294 ("/archive_expanded_bytes", json!(0)), 9295 ("/archive_member_count", json!(0)), 9296 ("/archive_payload_bytes", json!(0)), 9297 ("/acquired_at_epoch", json!(0)), 9298 ("/digest_time_epoch", json!(0)), 9299 ("/analyzed_at_epoch", json!(0)), 9300 ("/acquired_at_epoch", json!(u64::MAX)), 9301 ("/digest_time_epoch", json!(u64::MAX)), 9302 ("/analyzed_at_epoch", json!(u64::MAX)), 9303 ("/acquisition_kind", json!("untrusted")), 9304 ("/network_mode", json!("unrestricted")), 9305 ("/archive/path", json!("other.gz")), 9306 ("/report/path", json!("other.json")), 9307 ("/analysis_environment", json!("ambient")), 9308 ("/analysis_arguments", json!([])), 9309 ("/acquisition_arguments", json!(["untrusted"])), 9310 ("/archive/byte_length", json!(0)), 9311 ("/report/byte_length", json!(0)), 9312 ("/archive/byte_length", json!(2_147_483_649u64)), 9313 ("/report/byte_length", json!(MAX_REPORT_BYTES + 1)), 9314 ("/archive/sha256", json!("invalid")), 9315 ("/report/sha256", json!("invalid")), 9316 ("/archive/logical_uri", json!("file:///untrusted")), 9317 ("/report/logical_uri", json!("file:///untrusted")), 9318 ("/archive/media_type", json!("application/zip")), 9319 ("/report/media_type", json!("text/plain")), 9320 ("/archive/logical_role", json!("unknown")), 9321 ("/report/logical_role", json!("unknown")), 9322 ]; 9323 if provider.provider == ProviderId::Rustsec { 9324 changes.push(("/network_trace_sha256", json!("a".repeat(64)))); 9325 } 9326 rejects_typed_changes(provider, &changes, |changed| { 9327 validate_provider(changed, &fixture.request, &trace, &fixture.process_receipts) 9328 .is_ok() 9329 }); 9330 } 9331 let provider = &fixture.manifest.provider_snapshot[1]; 9332 let receipts = &fixture.process_receipts; 9333 rejects_typed_changes( 9334 receipts, 9335 &[ 9336 ("/0/program_sha256", json!("a".repeat(64))), 9337 ("/0/arguments_sha256", json!("a".repeat(64))), 9338 ("/0/output_sha256", json!("a".repeat(64))), 9339 ("/0/input_sha256", json!("a".repeat(64))), 9340 ("/0/path_binding", json!([])), 9341 ("/0/completed_at_epoch", json!(0)), 9342 ("/0/exit_code", json!(1)), 9343 ], 9344 |changed| validate_provider(provider, &fixture.request, &trace, changed).is_ok(), 9345 ); 9346 } 9347 9348 #[test] 9349 fn request_inventory_and_projection_fields_are_independently_bound() { 9350 let mut fixture = SyntheticFixture::new().unwrap(); 9351 let inventory = fixture.request.inventory.clone(); 9352 let original = serde_json::to_value(&inventory[0]).unwrap(); 9353 for (key, value) in original.as_object().unwrap() { 9354 let mut changed = original.clone(); 9355 changed[key] = match value { 9356 Value::Number(_) => json!(0), 9357 Value::String(_) | Value::Null => json!("untrusted"), 9358 _ => panic!("unexpected inventory field {key}"), 9359 }; 9360 fixture.request.inventory[0] = serde_json::from_value(changed).unwrap(); 9361 assert!(validate_request(&fixture.request).is_err(), "{key}"); 9362 } 9363 fixture.request.inventory = inventory; 9364 validate_request(&fixture.request).unwrap(); 9365 let projections = &fixture.manifest.gradle_projection; 9366 let original = serde_json::to_value(&projections[0]).unwrap(); 9367 for (key, value) in original.as_object().unwrap() { 9368 let mut changed = original.clone(); 9369 changed[key] = match value { 9370 Value::Number(_) => { 9371 if key == "exit_code" { 9372 json!(1) 9373 } else { 9374 json!(0) 9375 } 9376 } 9377 Value::String(_) => { 9378 if key == "state" { 9379 json!("failed") 9380 } else { 9381 json!("untrusted") 9382 } 9383 } 9384 Value::Array(_) => json!([]), 9385 _ => panic!("unexpected projection field {key}"), 9386 }; 9387 let mut candidate = projections.clone(); 9388 candidate[0] = serde_json::from_value(changed).unwrap(); 9389 assert!( 9390 validate_gradle_projections( 9391 &candidate, 9392 &fixture.request, 9393 &fixture.process_receipts 9394 ) 9395 .is_err(), 9396 "{key}" 9397 ); 9398 } 9399 assert!( 9400 validate_gradle_projections(&[], &fixture.request, &fixture.process_receipts).is_err() 9401 ); 9402 fixture.request.sources.clear(); 9403 assert!(validate_request(&fixture.request).is_err()); 9404 assert!( 9405 validate_gradle_projections(projections, &fixture.request, &fixture.process_receipts) 9406 .is_err() 9407 ); 9408 } 9409 9410 #[test] 9411 fn raw_gradle_variants_bound_attributes_capabilities_and_recursion() { 9412 let variant: RawGradleVariant = serde_json::from_value(json!({ 9413 "attributes":[{"name":"usage", "value":"runtime"}], 9414 "capabilities":[{"group":"example", "name":"library", "version":"1.0"}], 9415 "external_variant":null, 9416 })) 9417 .unwrap(); 9418 rejects_typed_changes( 9419 &variant, 9420 &[ 9421 ("/attributes/0/name", json!("")), 9422 ("/attributes/0/value", json!("\0")), 9423 ("/capabilities/0/group", json!("\0")), 9424 ("/capabilities/0/name", json!("")), 9425 ("/capabilities/0/version", json!("\0")), 9426 ], 9427 |changed| validate_raw_variant(changed, 0).is_ok(), 9428 ); 9429 let mut duplicate = variant.clone(); 9430 duplicate.attributes.push(duplicate.attributes[0].clone()); 9431 assert!(validate_raw_variant(&duplicate, 0).is_err()); 9432 duplicate.attributes[1].value = "z".into(); 9433 assert!(validate_raw_variant(&duplicate, 0).is_err()); 9434 let mut oversized = variant.clone(); 9435 oversized.attributes = vec![variant.attributes[0].clone(); MAX_GRADLE_ATTRIBUTES + 1]; 9436 assert!(validate_raw_variant(&oversized, 0).is_err()); 9437 oversized = variant.clone(); 9438 oversized.capabilities = vec![variant.capabilities[0].clone(); MAX_GRADLE_CAPABILITIES + 1]; 9439 assert!(validate_raw_variant(&oversized, 0).is_err()); 9440 let mut nested = variant.clone(); 9441 for _ in 0..MAX_GRADLE_EXTERNAL_VARIANT_DEPTH { 9442 let mut parent = variant.clone(); 9443 parent.external_variant = Some(Box::new(nested)); 9444 nested = parent; 9445 } 9446 validate_raw_variant(&nested, 0).unwrap(); 9447 let mut too_deep = variant.clone(); 9448 too_deep.external_variant = Some(Box::new(nested)); 9449 assert!(validate_raw_variant(&too_deep, 0).is_err()); 9450 assert!( 9451 validate_raw_variant_envelope(&RawGradleVariantEnvelope { selected: vec![] }).is_err() 9452 ); 9453 assert!( 9454 validate_raw_variant_envelope(&RawGradleVariantEnvelope { 9455 selected: vec![variant.clone(); MAX_GRADLE_VARIANTS + 1] 9456 }) 9457 .is_err() 9458 ); 9459 assert!( 9460 validate_raw_variant_envelope(&RawGradleVariantEnvelope { 9461 selected: vec![variant.clone(), variant] 9462 }) 9463 .is_err() 9464 ); 9465 } 9466 9467 #[test] 9468 fn raw_gradle_selectors_reject_mixed_identity_and_invalid_constraints() { 9469 let module: RawGradleSelector = serde_json::from_value(json!({ 9470 "kind":"module", "group":"example", "name":"library", "version":"1.0", 9471 "build_root":null, "project_path":null, "attributes":[], "capabilities":[], 9472 "version_constraint":{"branch":null, "preferred":"", "required":"1.0", "strict":"", "rejected":["0.9"]} 9473 })).unwrap(); 9474 rejects_typed_changes( 9475 &module, 9476 &[ 9477 ("/kind", json!("unknown")), 9478 ("/group", json!(null)), 9479 ("/name", json!(null)), 9480 ("/version", json!(null)), 9481 ("/group", json!("")), 9482 ("/name", json!("")), 9483 ("/version", json!("\0")), 9484 ("/build_root", json!(".")), 9485 ("/project_path", json!(":app")), 9486 ("/version_constraint", json!(null)), 9487 ("/version_constraint/branch", json!("\0")), 9488 ("/version_constraint/preferred", json!("\0")), 9489 ("/version_constraint/required", json!("\0")), 9490 ("/version_constraint/strict", json!("\0")), 9491 ("/version_constraint/rejected", json!([""])), 9492 ("/version_constraint/rejected", json!(["b", "a"])), 9493 ("/version_constraint/rejected", json!(["a", "a"])), 9494 ( 9495 "/version_constraint/rejected", 9496 json!(vec!["x"; MAX_GRADLE_REJECTED_VERSIONS + 1]), 9497 ), 9498 ], 9499 |changed| validate_raw_selector(changed).is_ok(), 9500 ); 9501 let project: RawGradleSelector = serde_json::from_value(json!({ 9502 "kind":"project", "group":null, "name":null, "version":null, "version_constraint":null, 9503 "build_root":".", "project_path":":app:shared", "attributes":[], "capabilities":[] 9504 })) 9505 .unwrap(); 9506 rejects_typed_changes( 9507 &project, 9508 &[ 9509 ("/group", json!("example")), 9510 ("/name", json!("library")), 9511 ("/version", json!("1.0")), 9512 ( 9513 "/version_constraint", 9514 serde_json::to_value(&module.version_constraint).unwrap(), 9515 ), 9516 ("/build_root", json!(null)), 9517 ("/project_path", json!(null)), 9518 ("/build_root", json!("../other")), 9519 ("/project_path", json!("app")), 9520 ("/project_path", json!("::app")), 9521 ], 9522 |changed| validate_raw_selector(changed).is_ok(), 9523 ); 9524 for selector in [&module, &project] { 9525 let core = RawGradleComponentCore { 9526 kind: selector.kind.clone(), 9527 group: selector.group.clone(), 9528 name: selector.name.clone(), 9529 version: selector.version.clone(), 9530 build_root: selector.build_root.clone(), 9531 project_path: selector.project_path.clone(), 9532 }; 9533 validate_raw_component_core(&core).unwrap(); 9534 let original = serde_json::to_value(&core).unwrap(); 9535 for (key, value) in original.as_object().unwrap() { 9536 let mut changed = original.clone(); 9537 changed[key] = if value.is_null() { 9538 json!("untrusted") 9539 } else { 9540 json!("") 9541 }; 9542 assert!( 9543 validate_raw_component_core(&serde_json::from_value(changed).unwrap()).is_err(), 9544 "{key}" 9545 ); 9546 } 9547 } 9548 } 9549 9550 #[test] 9551 fn owasp_virtual_lineage_rejects_orphans_cycles_and_alias_collisions() { 9552 fn dependency(alias: &str, parents: &[&str], is_virtual: bool) -> ParsedOwaspDependency { 9553 ParsedOwaspDependency { 9554 aliases: vec![alias.into()], 9555 included_by: parents.iter().map(|value| (*value).into()).collect(), 9556 is_virtual, 9557 file_name: alias.into(), 9558 package_ids: vec![], 9559 vulnerabilities: vec![], 9560 } 9561 } 9562 assert!( 9563 validate_owasp_lineage( 9564 "root", 9565 &[ 9566 dependency("physical", &[], false), 9567 dependency("parent", &["project:root"], true), 9568 dependency("child", &["parent", "physical"], true), 9569 dependency("sibling", &["parent"], true), 9570 ] 9571 ) 9572 .is_ok() 9573 ); 9574 for rows in [ 9575 vec![dependency("orphan", &[], true)], 9576 vec![dependency("child", &["absent"], true)], 9577 vec![dependency("a", &["b"], true), dependency("b", &["a"], true)], 9578 vec![ 9579 dependency("duplicate", &[], false), 9580 dependency("duplicate", &[], false), 9581 ], 9582 vec![dependency("root", &[], false)], 9583 ] { 9584 assert!(validate_owasp_lineage("root", &rows).is_err()); 9585 } 9586 } 9587 9588 #[test] 9589 fn rustsec_affected_missing_required_fields_returns_a_typed_error() { 9590 for field in ["arch", "functions", "os"] { 9591 let mut value = json!({"arch":[], "functions":{}, "os":[]}); 9592 value.as_object_mut().unwrap().remove(field); 9593 let error = validate_rustsec_affected(&value).expect_err(field); 9594 assert_eq!(error.kind(), AdvisoryFailureKind::InvalidReport); 9595 } 9596 } 9597 9598 // Exercise a complete accepted wire object, then corrupt each node's JSON 9599 // type independently. A malformed nested row must never be ignored merely 9600 // because another part of the report remains valid. 9601 fn rejects_wrong_node_types(value: &Value, validate: impl Fn(&Value) -> bool) { 9602 fn paths(value: &Value, prefix: String, out: &mut Vec<String>) { 9603 out.push(prefix.clone()); 9604 match value { 9605 Value::Object(object) => { 9606 for (key, child) in object { 9607 let key = key.replace('~', "~0").replace('/', "~1"); 9608 paths(child, format!("{prefix}/{key}"), out); 9609 } 9610 } 9611 Value::Array(rows) => { 9612 for (index, child) in rows.iter().enumerate() { 9613 paths(child, format!("{prefix}/{index}"), out); 9614 } 9615 } 9616 _ => {} 9617 } 9618 } 9619 assert!(validate(value), "positive wire fixture must be accepted"); 9620 let mut pointers = Vec::new(); 9621 paths(value, String::new(), &mut pointers); 9622 for pointer in pointers { 9623 let mut changed = value.clone(); 9624 let node = changed.pointer_mut(&pointer).expect("fixture pointer"); 9625 *node = match node { 9626 Value::Object(_) | Value::Null => json!([]), 9627 Value::Array(_) => json!({}), 9628 Value::String(_) => json!(false), 9629 Value::Number(_) => json!("invalid-number"), 9630 Value::Bool(_) => json!(0), 9631 }; 9632 assert!(!validate(&changed), "wrong type accepted at {pointer}"); 9633 } 9634 } 9635 9636 #[test] 9637 fn rustsec_nested_package_affected_and_version_rows_fail_closed() { 9638 let dependency = 9639 json!({"name":"dep", "source":"registry+https://example.test", "version":"1.2.3"}); 9640 rejects_wrong_node_types(&dependency, valid_rustsec_dependency); 9641 let package = json!({ 9642 "name":"example", "version":"1.0.0", "source":"registry+https://example.test", 9643 "checksum":"a".repeat(64), "replace":dependency, "dependencies":[dependency] 9644 }); 9645 rejects_wrong_node_types(&package, |value| validate_rustsec_package(value).is_ok()); 9646 for (pointer, replacement) in [ 9647 ("/name", json!("")), 9648 ("/version", json!("..")), 9649 ("/checksum", json!("g".repeat(64))), 9650 ("/dependencies", json!([])), 9651 ] { 9652 let mut invalid = package.clone(); 9653 *invalid.pointer_mut(pointer).unwrap() = replacement; 9654 assert!(validate_rustsec_package(&invalid).is_err(), "{pointer}"); 9655 } 9656 assert!( 9657 validate_rustsec_package(&json!({ 9658 "name":"example", "version":"1.0.0", "source":null, "checksum":null, "replace":null 9659 })) 9660 .is_ok() 9661 ); 9662 let affected = json!({"arch":["aarch64"], "os":["linux"], "functions":{"example::Thing<T,U>::_method":["<1.0.0"]}}); 9663 rejects_wrong_node_types(&affected, |value| validate_rustsec_affected(value).is_ok()); 9664 assert!(validate_rustsec_affected(&Value::Null).is_ok()); 9665 for path in [ 9666 "", 9667 "example", 9668 "::method", 9669 "example::", 9670 "0crate::method", 9671 "crate::bad-name", 9672 "crate::méthod", 9673 ] { 9674 assert!(!valid_rust_function_path(path), "{path}"); 9675 } 9676 rejects_wrong_node_types( 9677 &json!({"patched":[">=1.0.0"], "unaffected":["<0.5.0"]}), 9678 |value| validate_rustsec_versions(value).is_ok(), 9679 ); 9680 } 9681 9682 #[test] 9683 fn rustsec_advisory_optional_metadata_is_validated_when_present() { 9684 let mut fixture = SyntheticFixture::new().unwrap(); 9685 fixture 9686 .set_rustsec_finding("RUSTSEC-2099-0001", "example", "1.0.0") 9687 .unwrap(); 9688 let report = fixture.read_report_value(ProviderId::Rustsec).unwrap(); 9689 let raw: Value = serde_json::from_str( 9690 report["workload_result"][0]["raw_scanner_output"] 9691 .as_str() 9692 .unwrap(), 9693 ) 9694 .unwrap(); 9695 let mut advisory = raw["vulnerabilities"]["list"][0]["advisory"].clone(); 9696 for key in ["aliases", "categories", "keywords", "references", "related"] { 9697 advisory[key] = json!(["example"]); 9698 } 9699 for key in ["cvss", "informational", "url"] { 9700 advisory[key] = json!("example"); 9701 } 9702 advisory["withdrawn"] = json!("2099-02-28"); 9703 rejects_wrong_node_types(&advisory, |value| { 9704 validate_rustsec_advisory(value, "example").is_ok() 9705 }); 9706 for (key, invalid) in [ 9707 ("package", "another"), 9708 ("id", "CVE-2099-0001"), 9709 ("license", ""), 9710 ("date", "2099-02-29"), 9711 ("withdrawn", "2099/01/01"), 9712 ] { 9713 let mut changed = advisory.clone(); 9714 changed[key] = json!(invalid); 9715 assert!( 9716 validate_rustsec_advisory(&changed, "example").is_err(), 9717 "{key}" 9718 ); 9719 } 9720 for date in [ 9721 "", 9722 "2099-01001", 9723 "2099/01-01", 9724 "2099-01/01", 9725 "209x-01-01", 9726 "2099-13-01", 9727 "2099-01-32", 9728 ] { 9729 assert!(!valid_rustsec_date(&json!(date)), "{date}"); 9730 } 9731 assert!(valid_rustsec_date(&json!("2000-02-29"))); 9732 } 9733 9734 #[test] 9735 fn manifest_rejects_each_independently_changed_binding() { 9736 let fixture = SyntheticFixture::new().unwrap(); 9737 validate_manifest(&fixture.manifest, &fixture.request).unwrap(); 9738 let original = serde_json::to_value(&fixture.manifest).unwrap(); 9739 for (key, value) in original.as_object().unwrap() { 9740 let mut changed = original.clone(); 9741 changed[key] = match value { 9742 Value::String(_) => json!("wrong-binding"), 9743 Value::Array(_) => json!([]), 9744 Value::Object(_) => { 9745 let mut candidate = value.clone(); 9746 candidate["digest"] = json!("f".repeat(64)); 9747 candidate 9748 } 9749 _ => panic!("unexpected manifest field {key}"), 9750 }; 9751 if changed == original { 9752 continue; 9753 } 9754 let manifest: SnapshotManifest = serde_json::from_value(changed).unwrap(); 9755 assert!( 9756 validate_manifest(&manifest, &fixture.request).is_err(), 9757 "unbound manifest field {key}" 9758 ); 9759 } 9760 } 9761 9762 #[test] 9763 fn trusted_authority_rejects_each_changed_envelope_field() { 9764 let mut fixture = SyntheticFixture::new().unwrap(); 9765 for slot in 0..5 { 9766 let original = match slot { 9767 0 => &fixture.request.producer_request, 9768 1 => &fixture.request.step_297_tool_manifest, 9769 2 => &fixture.request.fresh_tool_observation, 9770 3 => &fixture.request.nvd_network_trace, 9771 _ => &fixture.request.provider_execution_evidence, 9772 } 9773 .clone(); 9774 let value: Value = serde_json::from_slice(&original).unwrap(); 9775 for (key, field) in value.as_object().unwrap() { 9776 // These two values are bound by manifest admission after the 9777 // trusted envelope has been admitted; that boundary is above. 9778 if matches!( 9779 key.as_str(), 9780 "candidate_advisory_input_sha256" | "suppressions" 9781 ) { 9782 continue; 9783 } 9784 let mut changed = value.clone(); 9785 changed[key] = match field { 9786 Value::String(_) => json!("wrong-binding"), 9787 Value::Number(_) => json!(u64::MAX), 9788 Value::Array(_) => json!([]), 9789 _ => panic!("unexpected authority field {key}"), 9790 }; 9791 let bytes = canonical_authority_bytes(&changed).unwrap(); 9792 match slot { 9793 0 => fixture.request.producer_request = bytes, 9794 1 => fixture.request.step_297_tool_manifest = bytes, 9795 2 => fixture.request.fresh_tool_observation = bytes, 9796 3 => fixture.request.nvd_network_trace = bytes, 9797 _ => fixture.request.provider_execution_evidence = bytes, 9798 } 9799 assert!( 9800 validate_trusted_authority(&fixture.request).is_err(), 9801 "slot {slot} field {key}" 9802 ); 9803 match slot { 9804 0 => fixture.request.producer_request = original.clone(), 9805 1 => fixture.request.step_297_tool_manifest = original.clone(), 9806 2 => fixture.request.fresh_tool_observation = original.clone(), 9807 3 => fixture.request.nvd_network_trace = original.clone(), 9808 _ => fixture.request.provider_execution_evidence = original.clone(), 9809 } 9810 } 9811 } 9812 fixture.request.producer_request.push(b' '); 9813 assert!(validate_trusted_authority(&fixture.request).is_err()); 9814 } 9815 9816 #[test] 9817 fn process_receipts_reject_identity_environment_bounds_and_order_faults() { 9818 let fixture = SyntheticFixture::new().unwrap(); 9819 let original = serde_json::to_value(&fixture.process_receipts).unwrap(); 9820 validate_process_receipts(&fixture.process_receipts, fixture.request.evaluation_epoch) 9821 .unwrap(); 9822 for (pointer, replacement) in [ 9823 ("/0/id", json!("unknown")), 9824 ("/0/state", json!("failed")), 9825 ("/0/program_sha256", json!("invalid")), 9826 ("/0/runtime_sha256", json!(["invalid"])), 9827 ("/0/arguments_sha256", json!("invalid")), 9828 ("/0/environment_sha256", json!("invalid")), 9829 ("/0/working_directory_sha256", json!("invalid")), 9830 ("/0/working_directory/logical_uri", json!("untrusted")), 9831 ("/0/path_binding/0/logical_role", json!("..")), 9832 ("/0/path_binding/0/identity_sha256", json!("invalid")), 9833 ("/0/stdin_closed", json!(false)), 9834 ("/0/deadline_seconds", json!(0)), 9835 ("/0/started_at_epoch", json!(0)), 9836 ("/0/completed_at_epoch", json!(0)), 9837 ("/0/completed_at_epoch", json!(u64::MAX)), 9838 ("/1/started_at_epoch", json!(1)), 9839 ("/0/stdout_byte_length", json!(67_108_865u64)), 9840 ("/0/stderr_byte_length", json!(67_108_865u64)), 9841 ("/0/stdout_sha256", json!("invalid")), 9842 ("/0/stderr_sha256", json!("invalid")), 9843 ("/0/input_sha256", json!("invalid")), 9844 ("/0/output_sha256", json!("invalid")), 9845 ("/0/environment/0/name", json!("UNTRUSTED")), 9846 ("/0/environment/0/logical_value", json!("untrusted")), 9847 ("/0/environment/0/value_sha256", json!("invalid")), 9848 ] { 9849 let mut changed = original.clone(); 9850 *changed.pointer_mut(pointer).expect(pointer) = replacement; 9851 let receipts: Vec<TrustedProcessReceipt> = serde_json::from_value(changed).unwrap(); 9852 assert!( 9853 validate_process_receipts(&receipts, fixture.request.evaluation_epoch).is_err(), 9854 "{pointer}" 9855 ); 9856 } 9857 let mut duplicate_binding = fixture.process_receipts.clone(); 9858 let repeated = duplicate_binding[0].path_binding[0].clone(); 9859 duplicate_binding[0].path_binding.push(repeated); 9860 assert!( 9861 validate_process_receipts(&duplicate_binding, fixture.request.evaluation_epoch) 9862 .is_err() 9863 ); 9864 for state in [ 9865 OperationState::Failed, 9866 OperationState::TimedOut, 9867 OperationState::Unavailable, 9868 ] { 9869 assert!(require_complete(state).is_err()); 9870 } 9871 } 9872 9873 #[test] 9874 fn bounded_tokens_dates_and_canonical_counters_accept_only_their_grammar() { 9875 for value in [ 9876 "", 9877 "01", 9878 "-1", 9879 "+1", 9880 "1.0", 9881 "1e2", 9882 " 1", 9883 "١", 9884 "18446744073709551616", 9885 ] { 9886 assert_eq!(parse_canonical_u64(value), None, "{value}"); 9887 } 9888 assert_eq!(parse_canonical_u64("0"), Some(0)); 9889 assert_eq!(parse_canonical_u64("18446744073709551615"), Some(u64::MAX)); 9890 for value in [ 9891 "", 9892 "x", 9893 "RUSTSEC-2026", 9894 "RUSTSEC-26-0001", 9895 "RUSTSEC-202x-0001", 9896 "RUSTSEC-2026-abcd", 9897 "CVE-2026-0001", 9898 ] { 9899 assert!(!valid_advisory_id(ProviderId::Rustsec, value), "{value}"); 9900 } 9901 assert!(valid_advisory_id(ProviderId::OwaspNvd, "CVE-2026-12345")); 9902 assert!(valid_hex("0123456789abcdef", 16)); 9903 for value in ["G", "g", "é", "\0"] { 9904 assert!(!valid_hex(value, value.len())); 9905 } 9906 assert!(valid_identifier("relative/normal")); 9907 for value in [ 9908 "", 9909 "/absolute", 9910 "../parent", 9911 "a/../b", 9912 "a\\b", 9913 "a\0b", 9914 "a\nb", 9915 ] { 9916 assert!(!valid_identifier(value), "{value:?}"); 9917 } 9918 assert!(valid_exact_identity_token("a-Z_1./:@+", 32)); 9919 for value in ["..", "with space", "é", "\n", "long"] { 9920 assert!(!valid_exact_identity_token(value, 3)); 9921 } 9922 assert!(valid_ascii_text("", 0, true)); 9923 assert!(valid_ascii_text("abc", 3, false)); 9924 for value in ["", "abcd", "é", "a\nb"] { 9925 assert!(!valid_ascii_text(value, 3, false)); 9926 } 9927 assert!(valid_exact_string_array(&json!(["abc"]), 1, 3)); 9928 for value in [ 9929 json!(["a", "b"]), 9930 json!(["abcd"]), 9931 json!([""]), 9932 json!([null]), 9933 json!({}), 9934 ] { 9935 assert!(!valid_exact_string_array(&value, 1, 3)); 9936 } 9937 assert!(require_strict_canonical_order(&[1, 2]).is_ok()); 9938 assert!(require_strict_canonical_order(&[2, 1]).is_err()); 9939 assert!(require_strict_canonical_order(&[1, 1]).is_err()); 9940 } 9941 9942 #[test] 9943 fn owasp_optional_rows_are_validated_and_duplicates_are_rejected() { 9944 let identifiers = json!([{"id":"cpe:/a:example:library:1.0", "notes":"note", "url":"https://example.test", "confidence":"HIGH"}]); 9945 rejects_wrong_node_types(&identifiers, |value| { 9946 parse_owasp_identifiers(Some(value), true).is_ok() 9947 }); 9948 assert!(parse_owasp_identifiers(Some(&identifiers), false).is_err()); 9949 assert!(parse_owasp_identifiers(None, false).unwrap().is_empty()); 9950 let included = json!([{"reference":"project:root", "type":"DIRECT"}]); 9951 rejects_wrong_node_types(&included, |value| { 9952 parse_owasp_included_by(Some(value)).is_ok() 9953 }); 9954 rejects_unknown_object_fields(&included, &["/0"], |value| { 9955 parse_owasp_included_by(Some(value)).is_ok() 9956 }); 9957 assert!(parse_owasp_included_by(None).unwrap().is_empty()); 9958 for value in [ 9959 json!([]), 9960 json!([{"id":"b"},{"id":"a"}]), 9961 json!([{"id":"a"},{"id":"a"}]), 9962 ] { 9963 assert!(parse_owasp_identifiers(Some(&value), false).is_err()); 9964 } 9965 for value in [ 9966 json!([]), 9967 json!([{"reference":"b"},{"reference":"a"}]), 9968 json!([{"reference":"a"},{"reference":"a"}]), 9969 ] { 9970 assert!(parse_owasp_included_by(Some(&value)).is_err()); 9971 } 9972 let mut evidence = json!({}); 9973 for (key, kind) in [ 9974 ("productEvidence", "product"), 9975 ("vendorEvidence", "vendor"), 9976 ("versionEvidence", "version"), 9977 ] { 9978 evidence[key] = json!([{"confidence":"HIGH", "name":"name", "source":"manifest", "type":kind, "value":"value"}]); 9979 } 9980 rejects_wrong_node_types(&evidence, valid_owasp_evidence); 9981 rejects_unknown_object_fields( 9982 &evidence, 9983 &[ 9984 "", 9985 "/productEvidence/0", 9986 "/vendorEvidence/0", 9987 "/versionEvidence/0", 9988 ], 9989 valid_owasp_evidence, 9990 ); 9991 evidence["productEvidence"][0]["type"] = json!("vendor"); 9992 assert!(!valid_owasp_evidence(&evidence)); 9993 let references = json!([{"source":"NVD", "name":"advisory", "url":"https://example.test"}]); 9994 rejects_wrong_node_types(&references, valid_owasp_references); 9995 rejects_unknown_object_fields(&references, &["/0"], valid_owasp_references); 9996 assert!(valid_owasp_references(&json!([]))); 9997 let software = json!([{"software":{ 9998 "id":"cpe:/a:example:library:1.0", "versionEndExcluding":"2.0", "versionEndIncluding":"1.9", 9999 "versionStartExcluding":"0.1", "versionStartIncluding":"0.2", "vulnerabilityIdMatched":"true", "vulnerable":"false" 10000 }}]); 10001 rejects_wrong_node_types(&software, |value| { 10002 parse_owasp_vulnerable_software(value).is_ok() 10003 }); 10004 rejects_unknown_object_fields(&software, &["/0", "/0/software"], |value| { 10005 parse_owasp_vulnerable_software(value).is_ok() 10006 }); 10007 let mut duplicated = software.clone(); 10008 duplicated.as_array_mut().unwrap().push(software[0].clone()); 10009 assert!(parse_owasp_vulnerable_software(&duplicated).is_err()); 10010 for (key, value) in [ 10011 ("vulnerabilityIdMatched", "false"), 10012 ("vulnerable", "true"), 10013 ("id", "invalid"), 10014 ] { 10015 let mut changed = software.clone(); 10016 changed[0]["software"][key] = json!(value); 10017 assert!(parse_owasp_vulnerable_software(&changed).is_err()); 10018 } 10019 let vulnerabilities = json!([{ 10020 "name":"CVE-2099-0001", "description":"description", "notes":"notes", "references":references, 10021 "source":"NVD", "severity":"HIGH", "unscored":"true", "cwes":["CWE-20"], "vulnerableSoftware":software, 10022 "cvssv2": {"accessComplexity":"LOW", "accessVector":"NETWORK", "authenticationr":"NONE", "availabilityImpact":"PARTIAL", "confidentialityImpact":"PARTIAL", "integrityImpact":"PARTIAL", "score":7.5, "severity":"HIGH"}, 10023 "cvssv3": {"attackComplexity":"LOW", "attackVector":"NETWORK", "availabilityImpact":"HIGH", "baseScore":9.8, "baseSeverity":"CRITICAL", "confidentialityImpact":"HIGH", "integrityImpact":"HIGH", "privilegesRequired":"NONE", "scope":"UNCHANGED", "userInteraction":"NONE"}, 10024 "cvssv4": {"baseScore":9.3, "environmentalScore":9.0, "threatScore":8.0, "version":"4.0"} 10025 }]); 10026 rejects_wrong_node_types(&vulnerabilities, |value| { 10027 parse_owasp_vulnerabilities(Some(value)).is_ok() 10028 }); 10029 rejects_unknown_object_fields( 10030 &vulnerabilities, 10031 &["/0", "/0/cvssv2", "/0/cvssv3", "/0/cvssv4"], 10032 |value| parse_owasp_vulnerabilities(Some(value)).is_ok(), 10033 ); 10034 let mut unsorted_cwes = vulnerabilities.clone(); 10035 unsorted_cwes[0]["cwes"] = json!(["CWE-99", "CWE-20"]); 10036 assert!(parse_owasp_vulnerabilities(Some(&unsorted_cwes)).is_err()); 10037 assert!(parse_owasp_vulnerabilities(None).unwrap().is_empty()); 10038 for (key, value) in [ 10039 ("name", "RUSTSEC-2099-0001"), 10040 ("source", "OTHER"), 10041 ("unscored", "false"), 10042 ] { 10043 let mut changed = vulnerabilities.clone(); 10044 changed[0][key] = json!(value); 10045 assert!(parse_owasp_vulnerabilities(Some(&changed)).is_err()); 10046 } 10047 let mut duplicated = vulnerabilities.clone(); 10048 duplicated 10049 .as_array_mut() 10050 .unwrap() 10051 .push(vulnerabilities[0].clone()); 10052 assert!(parse_owasp_vulnerabilities(Some(&duplicated)).is_err()); 10053 } 10054 10055 #[test] 10056 fn owasp_related_artifacts_bind_file_digests_and_virtual_lineage() { 10057 let physical = json!([{"fileName":"example.jar", "filePath":"/fixture/example.jar", "isVirtual":false, 10058 "md5":"1".repeat(32), "sha1":"2".repeat(40), "sha256":"3".repeat(64), 10059 "packageIds":[{"id":"pkg:maven/example/library@1.0"}]}]); 10060 let expected = BTreeMap::new(); 10061 rejects_wrong_node_types(&physical, |value| { 10062 parse_owasp_related(Some(value), &expected).is_ok() 10063 }); 10064 rejects_unknown_object_fields(&physical, &["/0"], |value| { 10065 parse_owasp_related(Some(value), &expected).is_ok() 10066 }); 10067 let virtual_row = 10068 json!([{"fileName":"project", "filePath":"project:root", "isVirtual":true}]); 10069 rejects_wrong_node_types(&virtual_row, |value| { 10070 parse_owasp_related(Some(value), &expected).is_ok() 10071 }); 10072 for key in ["md5", "sha1", "sha256"] { 10073 let mut changed = physical.clone(); 10074 changed[0][key] = json!("invalid"); 10075 assert!(parse_owasp_related(Some(&changed), &expected).is_err()); 10076 let mut changed = virtual_row.clone(); 10077 changed[0][key] = json!("invalid"); 10078 assert!(parse_owasp_related(Some(&changed), &expected).is_err()); 10079 } 10080 let mut repeated = physical.clone(); 10081 repeated.as_array_mut().unwrap().push(physical[0].clone()); 10082 assert!(parse_owasp_related(Some(&repeated), &expected).is_err()); 10083 assert!( 10084 parse_owasp_related(None, &expected) 10085 .unwrap() 10086 .aliases 10087 .is_empty() 10088 ); 10089 for value in [ 10090 "", 10091 "pkg:cargo/example@1.0", 10092 "pkg:maven/example", 10093 "pkg:maven/example@1.0", 10094 "pkg:maven//example@1.0", 10095 "pkg:maven/example/library@", 10096 "pkg:maven/example/library@1.0?x", 10097 "pkg:maven/example/library%20@1.0", 10098 ] { 10099 assert!(parse_exact_package_url(value).is_err(), "{value}"); 10100 } 10101 assert_eq!( 10102 parse_exact_package_url("pkg:maven/example/library@1.0").unwrap(), 10103 ( 10104 "maven".into(), 10105 "example".into(), 10106 "library".into(), 10107 "1.0".into() 10108 ) 10109 ); 10110 } 10111 10112 #[test] 10113 fn nvd_pagination_requires_complete_contiguous_pages_and_time_windows() { 10114 let fixture = SyntheticFixture::new().unwrap(); 10115 let producer: TrustedProducerRequest = 10116 parse_canonical_authority(&fixture.request.producer_request).unwrap(); 10117 let baseline: NvdNetworkTrace = 10118 parse_canonical_authority(&fixture.request.nvd_network_trace).unwrap(); 10119 let receipts = &fixture.process_receipts; 10120 validate_nvd_trace(&producer, &baseline, receipts).unwrap(); 10121 let original = serde_json::to_value(&baseline).unwrap(); 10122 for (pointer, replacement) in [ 10123 ("/request/0/sequence", json!(0)), 10124 ("/request/0/method", json!("POST")), 10125 ("/request/0/scheme", json!("http")), 10126 ("/request/0/authority", json!("example.test")), 10127 ("/request/0/path", json!("/other")), 10128 ("/request/0/started_at_epoch", json!(0)), 10129 ("/request/0/completed_at_epoch", json!(0)), 10130 ("/request/0/completed_at_epoch", json!(u64::MAX)), 10131 ("/request/0/response_status", json!(500)), 10132 ("/request/0/response_byte_length", json!(0)), 10133 ( 10134 "/request/0/response_byte_length", 10135 json!(MAX_NVD_AGGREGATE_BYTES + 1), 10136 ), 10137 ( 10138 "/request/0/response_byte_length", 10139 json!(MAX_NVD_RESPONSE_BYTES + 1), 10140 ), 10141 ("/request/0/response_sha256", json!("invalid")), 10142 ("/request/0/response_start_index", json!(1)), 10143 ("/request/0/response_results_per_page", json!(0)), 10144 ("/request/0/response_total_results", json!(0)), 10145 ] { 10146 let mut changed = original.clone(); 10147 *changed.pointer_mut(pointer).unwrap() = replacement; 10148 let trace: NvdNetworkTrace = serde_json::from_value(changed).unwrap(); 10149 assert!( 10150 validate_nvd_trace(&producer, &trace, receipts).is_err(), 10151 "{pointer}" 10152 ); 10153 } 10154 for key in [ 10155 "lastModStartDate", 10156 "lastModEndDate", 10157 "startIndex", 10158 "resultsPerPage", 10159 ] { 10160 let mut changed = baseline.clone(); 10161 changed.request[0] 10162 .query 10163 .iter_mut() 10164 .find(|row| row.name == key) 10165 .unwrap() 10166 .value = "invalid".into(); 10167 assert!( 10168 validate_nvd_trace(&producer, &changed, receipts).is_err(), 10169 "{key}" 10170 ); 10171 } 10172 let set_query = |row: &mut NvdRequestTrace, name: &str, value: String| { 10173 row.query 10174 .iter_mut() 10175 .find(|query| query.name == name) 10176 .unwrap() 10177 .value = value; 10178 }; 10179 let mut pages = baseline.clone(); 10180 assert_eq!(pages.request.len(), 1); 10181 let page_size = pages.request[0].response_results_per_page; 10182 pages.request[0].response_total_results = page_size + 1; 10183 let mut second = pages.request[0].clone(); 10184 second.sequence = 2; 10185 second.started_at_epoch = second.completed_at_epoch; 10186 second.response_start_index = page_size; 10187 set_query(&mut second, "startIndex", page_size.to_string()); 10188 pages.request.push(second); 10189 validate_nvd_trace(&producer, &pages, receipts).unwrap(); 10190 let mut total_drift = pages.clone(); 10191 total_drift.request[1].response_total_results += 1; 10192 assert!(validate_nvd_trace(&producer, &total_drift, receipts).is_err()); 10193 let mut backwards_time = pages.clone(); 10194 backwards_time.request[1].started_at_epoch = backwards_time.request[0].started_at_epoch; 10195 assert!(validate_nvd_trace(&producer, &backwards_time, receipts).is_err()); 10196 let mut initial_offset = baseline.clone(); 10197 initial_offset.request[0].response_start_index = 1; 10198 set_query(&mut initial_offset.request[0], "startIndex", "1".into()); 10199 assert!(validate_nvd_trace(&producer, &initial_offset, receipts).is_err()); 10200 let mut extra_query = baseline.clone(); 10201 let repeated_query = extra_query.request[0].query[0].clone(); 10202 extra_query.request[0].query.push(repeated_query); 10203 assert!(validate_nvd_trace(&producer, &extra_query, receipts).is_err()); 10204 let mut reordered = baseline.clone(); 10205 reordered.request[0].query.swap(0, 1); 10206 assert!(validate_nvd_trace(&producer, &reordered, receipts).is_err()); 10207 let mut truncated = pages.clone(); 10208 truncated.request.pop(); 10209 assert!(validate_nvd_trace(&producer, &truncated, receipts).is_err()); 10210 for gap in [0, page_size + 1] { 10211 let mut changed = pages.clone(); 10212 changed.request[1].response_start_index = gap; 10213 set_query(&mut changed.request[1], "startIndex", gap.to_string()); 10214 assert!(validate_nvd_trace(&producer, &changed, receipts).is_err()); 10215 } 10216 let mut windows = baseline.clone(); 10217 let end = windows.request[0] 10218 .query 10219 .iter() 10220 .find(|row| row.name == "lastModEndDate") 10221 .unwrap() 10222 .value 10223 .clone(); 10224 let next_start = parse_report_epoch(&end).unwrap() + 1; 10225 let mut second = windows.request[0].clone(); 10226 second.sequence = 2; 10227 second.started_at_epoch = second.completed_at_epoch; 10228 set_query( 10229 &mut second, 10230 "lastModStartDate", 10231 format_report_epoch(next_start).unwrap(), 10232 ); 10233 set_query( 10234 &mut second, 10235 "lastModEndDate", 10236 format_report_epoch(next_start + 1).unwrap(), 10237 ); 10238 windows.request.push(second); 10239 validate_nvd_trace(&producer, &windows, receipts).unwrap(); 10240 let mut offset_window = windows.clone(); 10241 offset_window.request[1].response_start_index = 1; 10242 set_query(&mut offset_window.request[1], "startIndex", "1".into()); 10243 assert!(validate_nvd_trace(&producer, &offset_window, receipts).is_err()); 10244 let mut reversed_window = windows.clone(); 10245 set_query( 10246 &mut reversed_window.request[1], 10247 "lastModStartDate", 10248 format_report_epoch(next_start + 2).unwrap(), 10249 ); 10250 assert!(validate_nvd_trace(&producer, &reversed_window, receipts).is_err()); 10251 set_query( 10252 &mut windows.request[1], 10253 "lastModStartDate", 10254 format_report_epoch(next_start + 1).unwrap(), 10255 ); 10256 assert!(validate_nvd_trace(&producer, &windows, receipts).is_err()); 10257 } 10258 }