artifact_admission.rs (43712B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 fmt, fs, 4 io::{Seek as _, SeekFrom}, 5 path::{Component, Path}, 6 time::Duration, 7 }; 8 9 use goblin::{ 10 Object, 11 elf::{header::EM_X86_64, program_header::PF_X}, 12 mach::{Mach, constants::cputype::CPU_TYPE_ARM64, header::MH_EXECUTE}, 13 }; 14 use serde_json::{Map, Value, json}; 15 use sha2::{Digest as _, Sha256}; 16 17 use crate::{bounded_process, safe_artifact_io}; 18 use safe_artifact_io::TarGzipLimits; 19 20 const CONTRACT_RELATIVE: &str = 21 "contracts/architecture/decisions/services_hardening_artifact_admission.v1.json"; 22 const MAX_CONTRACT_BYTES: u64 = 65_536; 23 const MAX_BINARY_PARSE_BYTES: u64 = 67_108_864; 24 const MAX_OCI_BYTES: u64 = 2_147_483_648; 25 const MAX_ARCHIVE_EXPANDED_BYTES: u64 = 17_179_869_184; 26 const MAX_ARCHIVE_MEMBERS: u64 = 65_536; 27 const MAX_JSON_BYTES: u64 = 1_048_576; 28 const MAX_PATH_BYTES: usize = 4_096; 29 const MAX_DEPTH: usize = 64; 30 const MAX_LAYERS: usize = 2; 31 const MAX_RUNTIME_STREAM_BYTES: usize = 65_536; 32 const RUNTIME_DEADLINE: Duration = Duration::from_secs(10); 33 const AGPL_LICENSE: &str = "AGPL-3.0-or-later"; 34 const LINUX_TARGET: &str = "x86_64-unknown-linux-gnu"; 35 const MACOS_TARGET: &str = "aarch64-apple-darwin"; 36 37 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 38 enum AdmissionError { 39 InvalidContract, 40 InvalidBinary, 41 BinarySmokeFailure, 42 InvalidOci, 43 } 44 45 impl fmt::Display for AdmissionError { 46 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 47 formatter.write_str(match self { 48 Self::InvalidContract => "artifact admission contract is invalid", 49 Self::InvalidBinary => "service binary admission failed", 50 Self::BinarySmokeFailure => "service binary smoke admission failed", 51 Self::InvalidOci => "service OCI admission failed", 52 }) 53 } 54 } 55 56 impl std::error::Error for AdmissionError {} 57 58 #[derive(Clone, Copy)] 59 pub(crate) struct ContractVersions { 60 pub(crate) admin: u32, 61 pub(crate) config: u32, 62 pub(crate) provider: u32, 63 pub(crate) state: u32, 64 pub(crate) status: u32, 65 } 66 67 pub(crate) struct OciExpectation<'a> { 68 pub(crate) service: &'a str, 69 pub(crate) binary_name: &'a str, 70 pub(crate) version: &'a str, 71 pub(crate) service_revision: &'a str, 72 pub(crate) lib_revision: &'a str, 73 pub(crate) license: &'a str, 74 pub(crate) contract_versions: ContractVersions, 75 } 76 77 pub(crate) fn validate_contract(workspace_root: &Path) -> Result<(), String> { 78 let bytes = safe_artifact_io::read_regular_path( 79 &workspace_root.join(CONTRACT_RELATIVE), 80 MAX_CONTRACT_BYTES, 81 ) 82 .map_err(|_| AdmissionError::InvalidContract.to_string())?; 83 let observed = serde_json::from_slice::<Value>(&bytes) 84 .map_err(|_| AdmissionError::InvalidContract.to_string())?; 85 if observed == expected_contract() { 86 Ok(()) 87 } else { 88 Err(AdmissionError::InvalidContract.to_string()) 89 } 90 } 91 92 fn expected_contract() -> Value { 93 json!({ 94 "schema": "radroots.services-hardening.artifact-admission-decisions.v1", 95 "contract_version": 1, 96 "decision_state": "active", 97 "owner_step": 304, 98 "command_owner": "tools/xtask", 99 "supported_binary_targets": [MACOS_TARGET, LINUX_TARGET], 100 "binary": { 101 "formats": { 102 MACOS_TARGET: "thin_macho64_arm64_execute", 103 LINUX_TARGET: "elf64_little_endian_x86_64_execute_or_pie" 104 }, 105 "maximum_parse_bytes": MAX_BINARY_PARSE_BYTES, 106 "architecture": "exact_target_match", 107 "linkage": "parse_declared_dynamic_libraries_and_forbid_external_sqlite", 108 "sqlite": "one_bundled_native_linkage_under_the_separate_sqlx_only_source_contract", 109 "structural_smoke": "executable_type_nonzero_entrypoint_and_executable_segment", 110 "runtime_smoke": "bounded_help_execution_on_the_matching_native_host", 111 "fat_or_multi_arch": "forbidden" 112 }, 113 "oci": { 114 "format": "single_image_docker_archive_tar_gzip", 115 "platform": "linux_amd64", 116 "maximum_layers": MAX_LAYERS, 117 "outer_archive": "descriptor_bound_bounded_safe_materialization_with_exact_inventory", 118 "manifest": "one_entry_exact_config_repo_tag_and_layer_references", 119 "config": "content_addressed_json_with_exact_rootless_runtime_and_build_labels", 120 "license": "Cargo.toml package license derived AGPL-3.0-or-later", 121 "layers": "parse_only_bounded_tar_validation_with_content_digest_reconciliation", 122 "entrypoint": "one_regular_executable_payload_at_the_configured_store_path", 123 "unpacking": "forbidden" 124 }, 125 "maximums": { 126 "outer_compressed_bytes": MAX_OCI_BYTES, 127 "outer_expanded_bytes": MAX_ARCHIVE_EXPANDED_BYTES, 128 "outer_members": MAX_ARCHIVE_MEMBERS, 129 "outer_member_bytes": MAX_ARCHIVE_EXPANDED_BYTES, 130 "json_bytes": MAX_JSON_BYTES, 131 "layer_members": MAX_ARCHIVE_MEMBERS, 132 "layer_payload_bytes": MAX_ARCHIVE_EXPANDED_BYTES, 133 "path_bytes": MAX_PATH_BYTES, 134 "depth": MAX_DEPTH, 135 "runtime_seconds": RUNTIME_DEADLINE.as_secs(), 136 "runtime_stream_bytes": MAX_RUNTIME_STREAM_BYTES 137 }, 138 "required_negative_vectors": [ 139 "arbitrary_binary_bytes", "wrong_binary_architecture", "fat_macho", 140 "missing_binary_entrypoint", "external_sqlite_linkage", "runtime_smoke_failure", 141 "unsafe_outer_tar_member", "wrong_oci_license", "multiple_manifest_entries", 142 "config_digest_mismatch", "wrong_oci_platform", "wrong_rootless_config", 143 "unexpected_label", "missing_layer", "layer_digest_mismatch", 144 "unsafe_layer_path", "missing_entrypoint_payload" 145 ], 146 "nonclaims": [ 147 "Linux artifact build on a macOS host without a Linux builder", 148 "signature notarization publication deployment or production activation" 149 ] 150 }) 151 } 152 153 pub(crate) fn admit_binary(path: &Path, target: &str, runtime_smoke: bool) -> Result<(), String> { 154 admit_binary_inner(path, target, runtime_smoke).map_err(|error| error.to_string()) 155 } 156 157 fn admit_binary_inner( 158 path: &Path, 159 target: &str, 160 runtime_smoke: bool, 161 ) -> Result<(), AdmissionError> { 162 if ![MACOS_TARGET, LINUX_TARGET].contains(&target) { 163 return Err(AdmissionError::InvalidBinary); 164 } 165 let bytes = safe_artifact_io::read_regular_path(path, MAX_BINARY_PARSE_BYTES) 166 .map_err(|_| AdmissionError::InvalidBinary)?; 167 admit_binary_bytes(&bytes, target)?; 168 if runtime_smoke && target_matches_host(target) { 169 runtime_help_smoke(path)?; 170 } 171 Ok(()) 172 } 173 174 fn admit_binary_bytes(bytes: &[u8], target: &str) -> Result<(), AdmissionError> { 175 let libraries = match ( 176 target, 177 Object::parse(bytes).map_err(|_| AdmissionError::InvalidBinary)?, 178 ) { 179 (LINUX_TARGET, Object::Elf(binary)) => { 180 if !binary.is_64 181 || !binary.little_endian 182 || binary.header.e_machine != EM_X86_64 183 || !matches!( 184 binary.header.e_type, 185 goblin::elf::header::ET_EXEC | goblin::elf::header::ET_DYN 186 ) 187 || binary.entry == 0 188 || !binary.program_headers.iter().any(|header| { 189 header.p_flags & PF_X != 0 190 && binary.entry >= header.p_vaddr 191 && binary.entry < header.p_vaddr.saturating_add(header.p_memsz) 192 }) 193 { 194 return Err(AdmissionError::InvalidBinary); 195 } 196 binary 197 .libraries 198 .iter() 199 .map(|value| (*value).to_owned()) 200 .collect::<Vec<_>>() 201 } 202 (MACOS_TARGET, Object::Mach(Mach::Binary(binary))) => { 203 if binary.header.cputype != CPU_TYPE_ARM64 204 || binary.header.filetype != MH_EXECUTE 205 || binary.entry == 0 206 || !binary.segments.iter().any(|segment| { 207 segment.initprot & 0x4 != 0 208 && binary.entry >= segment.vmaddr 209 && binary.entry < segment.vmaddr.saturating_add(segment.vmsize) 210 }) 211 { 212 return Err(AdmissionError::InvalidBinary); 213 } 214 binary 215 .libs 216 .iter() 217 .map(|value| (*value).to_owned()) 218 .collect::<Vec<_>>() 219 } 220 _ => return Err(AdmissionError::InvalidBinary), 221 }; 222 validate_dynamic_libraries(&libraries) 223 } 224 225 fn validate_dynamic_libraries(libraries: &[String]) -> Result<(), AdmissionError> { 226 if libraries.iter().any(|library| { 227 library.is_empty() 228 || library.len() > 1_024 229 || library.contains(['\n', '\r', '\0']) 230 || library.to_ascii_lowercase().contains("sqlite") 231 }) { 232 Err(AdmissionError::InvalidBinary) 233 } else { 234 Ok(()) 235 } 236 } 237 238 fn target_matches_host(target: &str) -> bool { 239 matches!( 240 (target, std::env::consts::OS, std::env::consts::ARCH), 241 (MACOS_TARGET, "macos", "aarch64") | (LINUX_TARGET, "linux", "x86_64") 242 ) 243 } 244 245 fn runtime_help_smoke(path: &Path) -> Result<(), AdmissionError> { 246 #[cfg(unix)] 247 { 248 use std::os::unix::fs::PermissionsExt as _; 249 fs::set_permissions(path, fs::Permissions::from_mode(0o500)) 250 .map_err(|_| AdmissionError::BinarySmokeFailure)?; 251 } 252 let parent = path.parent().ok_or(AdmissionError::BinarySmokeFailure)?; 253 let output = bounded_process::run( 254 &bounded_process::ProcessRequest::new(path.as_os_str()) 255 .arg("--help") 256 .current_dir(parent) 257 .deadline(RUNTIME_DEADLINE) 258 .output_limits(MAX_RUNTIME_STREAM_BYTES, MAX_RUNTIME_STREAM_BYTES), 259 ) 260 .map_err(|_| AdmissionError::BinarySmokeFailure)?; 261 if output.status().success() { 262 Ok(()) 263 } else { 264 Err(AdmissionError::BinarySmokeFailure) 265 } 266 } 267 268 pub(crate) fn admit_oci( 269 path: &Path, 270 trusted_parent: &Path, 271 expected: &OciExpectation<'_>, 272 ) -> Result<(), String> { 273 admit_oci_inner(path, trusted_parent, expected).map_err(|error| error.to_string()) 274 } 275 276 fn admit_oci_inner( 277 path: &Path, 278 trusted_parent: &Path, 279 expected: &OciExpectation<'_>, 280 ) -> Result<(), AdmissionError> { 281 if expected.license != AGPL_LICENSE 282 || !valid_identifier(expected.service) 283 || !valid_binary_name(expected.binary_name) 284 || !valid_hex(expected.service_revision, 40) 285 || !valid_hex(expected.lib_revision, 40) 286 { 287 return Err(AdmissionError::InvalidOci); 288 } 289 let limits = TarGzipLimits { 290 max_compressed_bytes: MAX_OCI_BYTES, 291 max_expanded_bytes: MAX_ARCHIVE_EXPANDED_BYTES, 292 max_members: MAX_ARCHIVE_MEMBERS, 293 max_member_bytes: MAX_ARCHIVE_EXPANDED_BYTES, 294 max_payload_bytes: MAX_ARCHIVE_EXPANDED_BYTES, 295 max_depth: MAX_DEPTH, 296 max_path_bytes: MAX_PATH_BYTES, 297 }; 298 let materialized = safe_artifact_io::materialize_tar_gzip_path(path, trusted_parent, limits) 299 .map_err(|_| AdmissionError::InvalidOci)?; 300 let snapshot = materialized.snapshot(); 301 let manifest = read_json_member(snapshot, "manifest.json")?; 302 let manifest = manifest.as_array().ok_or(AdmissionError::InvalidOci)?; 303 if manifest.len() != 1 { 304 return Err(AdmissionError::InvalidOci); 305 } 306 let record = manifest[0].as_object().ok_or(AdmissionError::InvalidOci)?; 307 require_exact_keys(record, &["Config", "Layers", "RepoTags"])?; 308 let config_name = json_string(record, "Config")?; 309 if !config_name.ends_with(".json") || !valid_hex(config_name.trim_end_matches(".json"), 64) { 310 return Err(AdmissionError::InvalidOci); 311 } 312 let image_name = expected.service.replace('_', "-"); 313 if json_string_array(record, "RepoTags")? != [format!("{image_name}:{}", expected.version)] { 314 return Err(AdmissionError::InvalidOci); 315 } 316 let layers = json_string_array(record, "Layers")?; 317 if layers.is_empty() || layers.len() > MAX_LAYERS || !all_unique(&layers) { 318 return Err(AdmissionError::InvalidOci); 319 } 320 for layer in &layers { 321 validate_layer_name(layer)?; 322 } 323 let config_bytes = read_member(snapshot, config_name, MAX_JSON_BYTES)?; 324 if sha256(&config_bytes) != config_name.trim_end_matches(".json") { 325 return Err(AdmissionError::InvalidOci); 326 } 327 let config = 328 serde_json::from_slice::<Value>(&config_bytes).map_err(|_| AdmissionError::InvalidOci)?; 329 let entrypoint = validate_config(&config, expected)?; 330 validate_repositories(snapshot, &image_name, expected.version, &layers)?; 331 validate_outer_inventory(snapshot, config_name, &layers)?; 332 let rootfs = config 333 .get("rootfs") 334 .and_then(Value::as_object) 335 .ok_or(AdmissionError::InvalidOci)?; 336 require_exact_keys(rootfs, &["diff_ids", "type"])?; 337 if json_string(rootfs, "type")? != "layers" { 338 return Err(AdmissionError::InvalidOci); 339 } 340 let diff_ids = json_string_array(rootfs, "diff_ids")?; 341 if diff_ids.len() != layers.len() { 342 return Err(AdmissionError::InvalidOci); 343 } 344 let mut entrypoint_count = 0_u32; 345 for (layer, diff_id) in layers.iter().zip(diff_ids) { 346 let evidence = snapshot 347 .hash( 348 snapshot_member(snapshot, layer)?, 349 MAX_ARCHIVE_EXPANDED_BYTES, 350 ) 351 .map_err(|_| AdmissionError::InvalidOci)?; 352 if diff_id != format!("sha256:{}", evidence.sha256) { 353 return Err(AdmissionError::InvalidOci); 354 } 355 entrypoint_count = entrypoint_count 356 .checked_add(validate_layer_tar(materialized.root(), layer, &entrypoint)?) 357 .ok_or(AdmissionError::InvalidOci)?; 358 } 359 materialized 360 .revalidate() 361 .map_err(|_| AdmissionError::InvalidOci)?; 362 if entrypoint_count == 1 { 363 Ok(()) 364 } else { 365 Err(AdmissionError::InvalidOci) 366 } 367 } 368 369 fn validate_config( 370 config: &Value, 371 expected: &OciExpectation<'_>, 372 ) -> Result<String, AdmissionError> { 373 let object = config.as_object().ok_or(AdmissionError::InvalidOci)?; 374 if json_string(object, "architecture")? != "amd64" 375 || json_string(object, "os")? != "linux" 376 || json_string(object, "created")? != "1970-01-01T00:00:01+00:00" 377 { 378 return Err(AdmissionError::InvalidOci); 379 } 380 let runtime = object 381 .get("config") 382 .and_then(Value::as_object) 383 .ok_or(AdmissionError::InvalidOci)?; 384 require_exact_keys( 385 runtime, 386 &[ 387 "Entrypoint", 388 "Env", 389 "Labels", 390 "StopSignal", 391 "User", 392 "WorkingDir", 393 ], 394 )?; 395 if json_string(runtime, "User")? != "65532:65532" 396 || json_string(runtime, "WorkingDir")? != "/" 397 || json_string(runtime, "StopSignal")? != "SIGTERM" 398 || json_string_array(runtime, "Env")? 399 != ["SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"] 400 { 401 return Err(AdmissionError::InvalidOci); 402 } 403 let entrypoints = json_string_array(runtime, "Entrypoint")?; 404 if entrypoints.len() != 1 { 405 return Err(AdmissionError::InvalidOci); 406 } 407 let entrypoint = entrypoints[0].clone(); 408 if !entrypoint.starts_with("/nix/store/") 409 || !entrypoint.ends_with(&format!("/bin/{}", expected.binary_name)) 410 || !valid_absolute_path(&entrypoint) 411 { 412 return Err(AdmissionError::InvalidOci); 413 } 414 let labels = runtime 415 .get("Labels") 416 .and_then(Value::as_object) 417 .ok_or(AdmissionError::InvalidOci)?; 418 let expected_labels = expected_labels(expected); 419 if labels.len() != expected_labels.len() 420 || expected_labels 421 .iter() 422 .any(|(key, value)| labels.get(key).and_then(Value::as_str) != Some(value)) 423 { 424 return Err(AdmissionError::InvalidOci); 425 } 426 Ok(entrypoint) 427 } 428 429 fn expected_labels(expected: &OciExpectation<'_>) -> BTreeMap<String, String> { 430 let mut labels = BTreeMap::new(); 431 for (key, value) in [ 432 ( 433 "dev.radroots.build.feature-profile", 434 "service-host".to_owned(), 435 ), 436 ( 437 "dev.radroots.build.lib-revision", 438 expected.lib_revision.to_owned(), 439 ), 440 ("dev.radroots.build.rust-version", "1.97.1".to_owned()), 441 ("dev.radroots.build.target", LINUX_TARGET.to_owned()), 442 ( 443 "dev.radroots.contract.admin-version", 444 expected.contract_versions.admin.to_string(), 445 ), 446 ( 447 "dev.radroots.contract.config-version", 448 expected.contract_versions.config.to_string(), 449 ), 450 ( 451 "dev.radroots.contract.provider-version", 452 expected.contract_versions.provider.to_string(), 453 ), 454 ( 455 "dev.radroots.contract.state-version", 456 expected.contract_versions.state.to_string(), 457 ), 458 ( 459 "dev.radroots.contract.status-version", 460 expected.contract_versions.status.to_string(), 461 ), 462 ( 463 "dev.radroots.mount.config", 464 format!("/etc/radroots/services/{}", expected.service), 465 ), 466 ("dev.radroots.mount.config.mode", "read-only".to_owned()), 467 ( 468 "dev.radroots.mount.credentials", 469 format!("/etc/radroots/secrets/services/{}", expected.service), 470 ), 471 ( 472 "dev.radroots.mount.credentials.mode", 473 "read-only".to_owned(), 474 ), 475 ( 476 "dev.radroots.mount.runtime", 477 format!("/run/radroots/services/{}", expected.service), 478 ), 479 ("dev.radroots.mount.runtime.mode", "read-write".to_owned()), 480 ( 481 "dev.radroots.mount.state", 482 format!("/var/lib/radroots/services/{}", expected.service), 483 ), 484 ("dev.radroots.mount.state.mode", "read-write".to_owned()), 485 ("dev.radroots.rootfs", "read-only-compatible".to_owned()), 486 ( 487 "org.opencontainers.image.description", 488 format!("Hardened {} service image", expected.service), 489 ), 490 ( 491 "org.opencontainers.image.licenses", 492 expected.license.to_owned(), 493 ), 494 ( 495 "org.opencontainers.image.revision", 496 expected.service_revision.to_owned(), 497 ), 498 ( 499 "org.opencontainers.image.title", 500 expected.service.to_owned(), 501 ), 502 ( 503 "org.opencontainers.image.version", 504 expected.version.to_owned(), 505 ), 506 ] { 507 labels.insert(key.to_owned(), value); 508 } 509 labels 510 } 511 512 #[cfg(test)] 513 pub(crate) fn fixture_labels(expected: &OciExpectation<'_>) -> BTreeMap<String, String> { 514 expected_labels(expected) 515 } 516 517 fn validate_repositories( 518 snapshot: &safe_artifact_io::TraversalSnapshot, 519 image_name: &str, 520 version: &str, 521 layers: &[String], 522 ) -> Result<(), AdmissionError> { 523 let repositories = read_json_member(snapshot, "repositories")?; 524 let last_layer = layers 525 .last() 526 .and_then(|path| path.split('/').next()) 527 .ok_or(AdmissionError::InvalidOci)?; 528 if repositories == json!({ image_name: { version: last_layer } }) { 529 Ok(()) 530 } else { 531 Err(AdmissionError::InvalidOci) 532 } 533 } 534 535 fn validate_outer_inventory( 536 snapshot: &safe_artifact_io::TraversalSnapshot, 537 config_name: &str, 538 layers: &[String], 539 ) -> Result<(), AdmissionError> { 540 let mut expected_files = BTreeSet::from([ 541 "manifest.json".to_owned(), 542 "repositories".to_owned(), 543 config_name.to_owned(), 544 ]); 545 let mut expected_directories = BTreeSet::new(); 546 for layer in layers { 547 let directory = layer.split('/').next().ok_or(AdmissionError::InvalidOci)?; 548 expected_directories.insert(directory.to_owned()); 549 expected_files.insert(format!("{directory}/VERSION")); 550 expected_files.insert(format!("{directory}/json")); 551 expected_files.insert(layer.clone()); 552 } 553 let observed_files = snapshot 554 .files() 555 .iter() 556 .map(|file| { 557 file.relative_path() 558 .to_str() 559 .map(str::to_owned) 560 .ok_or(AdmissionError::InvalidOci) 561 }) 562 .collect::<Result<BTreeSet<_>, _>>()?; 563 let observed_directories = snapshot 564 .directories() 565 .filter_map(|(path, _)| (!path.as_os_str().is_empty()).then_some(path)) 566 .map(|path| { 567 path.to_str() 568 .map(str::to_owned) 569 .ok_or(AdmissionError::InvalidOci) 570 }) 571 .collect::<Result<BTreeSet<_>, _>>()?; 572 if observed_files != expected_files || observed_directories != expected_directories { 573 return Err(AdmissionError::InvalidOci); 574 } 575 for layer in layers { 576 let directory = layer.split('/').next().ok_or(AdmissionError::InvalidOci)?; 577 if read_member(snapshot, &format!("{directory}/VERSION"), 16)? != b"1.0" { 578 return Err(AdmissionError::InvalidOci); 579 } 580 serde_json::from_slice::<Value>(&read_member( 581 snapshot, 582 &format!("{directory}/json"), 583 MAX_JSON_BYTES, 584 )?) 585 .map_err(|_| AdmissionError::InvalidOci)?; 586 } 587 Ok(()) 588 } 589 590 fn validate_layer_tar( 591 materialized_root: &Path, 592 relative: &str, 593 entrypoint: &str, 594 ) -> Result<u32, AdmissionError> { 595 let path = materialized_root.join(relative); 596 let mut file = fs::File::open(&path).map_err(|_| AdmissionError::InvalidOci)?; 597 let length = file 598 .metadata() 599 .map_err(|_| AdmissionError::InvalidOci)? 600 .len(); 601 if length == 0 || length > MAX_ARCHIVE_EXPANDED_BYTES { 602 return Err(AdmissionError::InvalidOci); 603 } 604 file.seek(SeekFrom::Start(0)) 605 .map_err(|_| AdmissionError::InvalidOci)?; 606 let mut archive = tar::Archive::new(file); 607 let mut count = 0_u64; 608 let mut payload = 0_u64; 609 let mut paths = BTreeSet::new(); 610 let mut entrypoint_count = 0_u32; 611 let expected_entrypoint = entrypoint.trim_start_matches('/').as_bytes(); 612 for entry in archive.entries().map_err(|_| AdmissionError::InvalidOci)? { 613 let mut entry = entry.map_err(|_| AdmissionError::InvalidOci)?; 614 count = count.checked_add(1).ok_or(AdmissionError::InvalidOci)?; 615 if count > MAX_ARCHIVE_MEMBERS { 616 return Err(AdmissionError::InvalidOci); 617 } 618 let path = entry.path_bytes(); 619 validate_relative_path(&path)?; 620 let normalized = path.strip_suffix(b"/").unwrap_or(&path).to_vec(); 621 if !paths.insert(normalized.clone()) { 622 return Err(AdmissionError::InvalidOci); 623 } 624 let kind = entry.header().entry_type(); 625 if kind.is_file() { 626 payload = payload 627 .checked_add(entry.size()) 628 .ok_or(AdmissionError::InvalidOci)?; 629 if payload > MAX_ARCHIVE_EXPANDED_BYTES { 630 return Err(AdmissionError::InvalidOci); 631 } 632 if normalized == expected_entrypoint { 633 if entry 634 .header() 635 .mode() 636 .map_err(|_| AdmissionError::InvalidOci)? 637 & 0o111 638 == 0 639 { 640 return Err(AdmissionError::InvalidOci); 641 } 642 entrypoint_count = entrypoint_count 643 .checked_add(1) 644 .ok_or(AdmissionError::InvalidOci)?; 645 } 646 std::io::copy(&mut entry, &mut std::io::sink()) 647 .map_err(|_| AdmissionError::InvalidOci)?; 648 } else if kind.is_dir() { 649 if !path.ends_with(b"/") || entry.size() != 0 { 650 return Err(AdmissionError::InvalidOci); 651 } 652 } else if kind.is_symlink() || kind.is_hard_link() { 653 if entry.size() != 0 { 654 return Err(AdmissionError::InvalidOci); 655 } 656 let target = entry.link_name_bytes().ok_or(AdmissionError::InvalidOci)?; 657 validate_link_target(&normalized, &target)?; 658 } else { 659 return Err(AdmissionError::InvalidOci); 660 } 661 } 662 if count == 0 { 663 Err(AdmissionError::InvalidOci) 664 } else { 665 Ok(entrypoint_count) 666 } 667 } 668 669 fn validate_layer_name(path: &str) -> Result<(), AdmissionError> { 670 let Some((directory, leaf)) = path.split_once('/') else { 671 return Err(AdmissionError::InvalidOci); 672 }; 673 if leaf == "layer.tar" && valid_hex(directory, 64) { 674 Ok(()) 675 } else { 676 Err(AdmissionError::InvalidOci) 677 } 678 } 679 680 fn validate_relative_path(path: &[u8]) -> Result<(), AdmissionError> { 681 if path.is_empty() 682 || path.len() > MAX_PATH_BYTES 683 || path.starts_with(b"/") 684 || path.contains(&0) 685 || path.contains(&b'\\') 686 || std::str::from_utf8(path).is_err() 687 { 688 return Err(AdmissionError::InvalidOci); 689 } 690 let path = path.strip_suffix(b"/").unwrap_or(path); 691 let mut depth = 0_usize; 692 for component in path.split(|byte| *byte == b'/') { 693 depth = depth.checked_add(1).ok_or(AdmissionError::InvalidOci)?; 694 if component.is_empty() || matches!(component, b"." | b"..") || depth > MAX_DEPTH { 695 return Err(AdmissionError::InvalidOci); 696 } 697 } 698 Ok(()) 699 } 700 701 fn validate_link_target(path: &[u8], target: &[u8]) -> Result<(), AdmissionError> { 702 if target.is_empty() 703 || target.len() > MAX_PATH_BYTES 704 || target.starts_with(b"/") 705 || target.contains(&0) 706 || target.contains(&b'\\') 707 || std::str::from_utf8(target).is_err() 708 { 709 return Err(AdmissionError::InvalidOci); 710 } 711 let mut depth = path.split(|byte| *byte == b'/').count().saturating_sub(1); 712 for component in target.split(|byte| *byte == b'/') { 713 match component { 714 b"" | b"." => {} 715 b".." => depth = depth.checked_sub(1).ok_or(AdmissionError::InvalidOci)?, 716 _ => { 717 depth = depth.checked_add(1).ok_or(AdmissionError::InvalidOci)?; 718 if depth > MAX_DEPTH { 719 return Err(AdmissionError::InvalidOci); 720 } 721 } 722 } 723 } 724 Ok(()) 725 } 726 727 fn valid_absolute_path(value: &str) -> bool { 728 let path = Path::new(value); 729 path.is_absolute() 730 && value.len() <= MAX_PATH_BYTES 731 && path 732 .components() 733 .all(|component| matches!(component, Component::RootDir | Component::Normal(_))) 734 } 735 fn read_json_member( 736 snapshot: &safe_artifact_io::TraversalSnapshot, 737 name: &str, 738 ) -> Result<Value, AdmissionError> { 739 serde_json::from_slice(&read_member(snapshot, name, MAX_JSON_BYTES)?) 740 .map_err(|_| AdmissionError::InvalidOci) 741 } 742 fn read_member( 743 snapshot: &safe_artifact_io::TraversalSnapshot, 744 name: &str, 745 maximum: u64, 746 ) -> Result<Vec<u8>, AdmissionError> { 747 snapshot 748 .read(snapshot_member(snapshot, name)?, maximum) 749 .map_err(|_| AdmissionError::InvalidOci) 750 } 751 fn snapshot_member<'a>( 752 snapshot: &'a safe_artifact_io::TraversalSnapshot, 753 name: &str, 754 ) -> Result<&'a safe_artifact_io::TraversedFile, AdmissionError> { 755 snapshot 756 .files() 757 .iter() 758 .find(|file| file.relative_path() == Path::new(name)) 759 .ok_or(AdmissionError::InvalidOci) 760 } 761 fn require_exact_keys( 762 object: &Map<String, Value>, 763 expected: &[&str], 764 ) -> Result<(), AdmissionError> { 765 let observed = object.keys().map(String::as_str).collect::<BTreeSet<_>>(); 766 if observed == expected.iter().copied().collect() { 767 Ok(()) 768 } else { 769 Err(AdmissionError::InvalidOci) 770 } 771 } 772 fn json_string<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a str, AdmissionError> { 773 object 774 .get(key) 775 .and_then(Value::as_str) 776 .ok_or(AdmissionError::InvalidOci) 777 } 778 fn json_string_array( 779 object: &Map<String, Value>, 780 key: &str, 781 ) -> Result<Vec<String>, AdmissionError> { 782 object 783 .get(key) 784 .and_then(Value::as_array) 785 .ok_or(AdmissionError::InvalidOci)? 786 .iter() 787 .map(|value| { 788 value 789 .as_str() 790 .map(str::to_owned) 791 .ok_or(AdmissionError::InvalidOci) 792 }) 793 .collect() 794 } 795 fn all_unique(values: &[String]) -> bool { 796 values.iter().collect::<BTreeSet<_>>().len() == values.len() 797 } 798 fn valid_hex(value: &str, length: usize) -> bool { 799 value.len() == length 800 && value 801 .bytes() 802 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 803 } 804 fn valid_identifier(value: &str) -> bool { 805 value.len() <= 128 806 && value 807 .bytes() 808 .next() 809 .is_some_and(|byte| byte.is_ascii_lowercase()) 810 && value 811 .bytes() 812 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') 813 } 814 fn valid_binary_name(value: &str) -> bool { 815 value.len() <= 128 816 && value 817 .bytes() 818 .next() 819 .is_some_and(|byte| byte.is_ascii_lowercase()) 820 && value.bytes().all(|byte| { 821 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') 822 }) 823 } 824 fn sha256(bytes: &[u8]) -> String { 825 hex::encode(Sha256::digest(bytes)) 826 } 827 828 #[cfg(test)] 829 mod tests { 830 use super::*; 831 832 // Synthetic ELF headers with one executable PT_LOAD segment. These bytes 833 // exercise format admission only; no fixture is executed or released. 834 fn elf_fixture(is_64: bool, little_endian: bool) -> Vec<u8> { 835 fn put(bytes: &mut [u8], offset: usize, width: usize, value: u64, little: bool) { 836 let encoded = if little { 837 value.to_le_bytes() 838 } else { 839 value.to_be_bytes() 840 }; 841 let source = if little { 842 &encoded[..width] 843 } else { 844 &encoded[8 - width..] 845 }; 846 bytes[offset..offset + width].copy_from_slice(source); 847 } 848 let header = if is_64 { 64 } else { 52 }; 849 let program = if is_64 { 56 } else { 32 }; 850 let mut bytes = vec![0; header + program + 8]; 851 bytes[..4].copy_from_slice(goblin::elf::header::ELFMAG); 852 bytes[4] = if is_64 { 2 } else { 1 }; 853 bytes[5] = if little_endian { 1 } else { 2 }; 854 bytes[6] = 1; 855 let length = bytes.len() as u64; 856 let word = if is_64 { 8 } else { 4 }; 857 for (offset, width, value) in [ 858 (16, 2, 2), 859 (18, 2, u64::from(EM_X86_64)), 860 (20, 4, 1), 861 (24, word, 0x400000 + (header + program) as u64), 862 (24 + word, word, header as u64), 863 (if is_64 { 52 } else { 40 }, 2, header as u64), 864 (if is_64 { 54 } else { 42 }, 2, program as u64), 865 (if is_64 { 56 } else { 44 }, 2, 1), 866 (header, 4, 1), 867 (header + if is_64 { 4 } else { 24 }, 4, 5), 868 (header + if is_64 { 16 } else { 8 }, word, 0x400000), 869 (header + if is_64 { 32 } else { 16 }, word, length), 870 (header + if is_64 { 40 } else { 20 }, word, length), 871 ] { 872 put(&mut bytes, offset, width, value, little_endian); 873 } 874 bytes 875 } 876 877 #[test] 878 fn linux_format_requires_both_64_bit_class_and_little_endian_encoding() { 879 let expected = expected_contract(); 880 assert_eq!( 881 expected["binary"]["formats"][LINUX_TARGET], 882 "elf64_little_endian_x86_64_execute_or_pie" 883 ); 884 assert!(admit_binary_bytes(&elf_fixture(true, true), LINUX_TARGET).is_ok()); 885 let results = [(false, true), (true, false)].map(|(class, endian)| { 886 let bytes = elf_fixture(class, endian); 887 let Object::Elf(parsed) = Object::parse(&bytes).unwrap() else { 888 panic!("ELF fixture"); 889 }; 890 assert_eq!(parsed.is_64, class); 891 assert_eq!(parsed.little_endian, endian); 892 admit_binary_bytes(&bytes, LINUX_TARGET) 893 }); 894 assert_eq!(results, [Err(AdmissionError::InvalidBinary); 2]); 895 } 896 897 #[test] 898 fn mach_executable_admission_binds_machine_type_entry_and_executable_segment() { 899 let mut original = vec![0u8; 136]; 900 for (offset, value) in [ 901 (0, 0xfeedfacfu32), 902 (4, CPU_TYPE_ARM64), 903 (12, MH_EXECUTE), 904 (16, 2), 905 (20, 96), 906 (32, 0x19), 907 (36, 72), 908 (88, 5), 909 (92, 5), 910 (104, 0x80000028), 911 (108, 24), 912 ] { 913 original[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); 914 } 915 original[40..46].copy_from_slice(b"__TEXT"); 916 for (offset, value) in [(56, 0x100000000u64), (64, 136), (80, 136), (112, 128)] { 917 original[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); 918 } 919 assert!(admit_binary_bytes(&original, MACOS_TARGET).is_ok()); 920 for (offset, bytes) in [ 921 (4, 7u32.to_le_bytes().to_vec()), 922 (12, 6u32.to_le_bytes().to_vec()), 923 (92, 1u32.to_le_bytes().to_vec()), 924 (112, 136u64.to_le_bytes().to_vec()), 925 ] { 926 let mut changed = original.clone(); 927 changed[offset..offset + bytes.len()].copy_from_slice(&bytes); 928 assert_eq!( 929 admit_binary_bytes(&changed, MACOS_TARGET), 930 Err(AdmissionError::InvalidBinary), 931 "offset {offset}" 932 ); 933 } 934 let mut no_entry = original; 935 no_entry[56..64].fill(0); 936 no_entry[112..120].fill(0); 937 assert_eq!( 938 admit_binary_bytes(&no_entry, MACOS_TARGET), 939 Err(AdmissionError::InvalidBinary) 940 ); 941 } 942 943 #[test] 944 fn linux_entrypoint_must_belong_to_an_executable_segment_of_the_exact_machine() { 945 let original = elf_fixture(true, true); 946 for (offset, replacement) in [ 947 (16, 1u16.to_le_bytes().to_vec()), 948 (18, 183u16.to_le_bytes().to_vec()), 949 (24, 0u64.to_le_bytes().to_vec()), 950 (24, 0x3fffffu64.to_le_bytes().to_vec()), 951 ( 952 24, 953 (0x400000u64 + original.len() as u64).to_le_bytes().to_vec(), 954 ), 955 (68, 4u32.to_le_bytes().to_vec()), 956 ] { 957 let mut invalid = original.clone(); 958 invalid[offset..offset + replacement.len()].copy_from_slice(&replacement); 959 assert_eq!( 960 admit_binary_bytes(&invalid, LINUX_TARGET), 961 Err(AdmissionError::InvalidBinary), 962 "header offset {offset}" 963 ); 964 } 965 let mut pie = original; 966 pie[16..18].copy_from_slice(&goblin::elf::header::ET_DYN.to_le_bytes()); 967 assert!(admit_binary_bytes(&pie, LINUX_TARGET).is_ok()); 968 assert!(admit_binary_bytes(&pie, MACOS_TARGET).is_err()); 969 assert!(admit_binary_inner(Path::new("/absent"), "unknown", false).is_err()); 970 for libraries in [ 971 vec![String::new()], 972 vec!["x".repeat(1025)], 973 vec!["lib\nname".into()], 974 vec!["SQLite.DLL".into()], 975 ] { 976 assert_eq!( 977 validate_dynamic_libraries(&libraries), 978 Err(AdmissionError::InvalidBinary) 979 ); 980 } 981 assert!(validate_dynamic_libraries(&["libc.so.6".into()]).is_ok()); 982 } 983 984 #[test] 985 fn archive_paths_and_links_enforce_byte_depth_and_traversal_bounds() { 986 let exact_depth = vec!["a"; MAX_DEPTH].join("/"); 987 let excessive_depth = vec!["a"; MAX_DEPTH + 1].join("/"); 988 assert!(validate_relative_path(exact_depth.as_bytes()).is_ok()); 989 assert!(validate_relative_path(&vec![b'a'; MAX_PATH_BYTES]).is_ok()); 990 for path in [ 991 b"".to_vec(), 992 b"/absolute".to_vec(), 993 b"a\0b".to_vec(), 994 b"a\\b".to_vec(), 995 vec![0xff], 996 b"a//b".to_vec(), 997 b"a/./b".to_vec(), 998 b"a/../b".to_vec(), 999 vec![b'a'; MAX_PATH_BYTES + 1], 1000 excessive_depth.into_bytes(), 1001 ] { 1002 assert!(validate_relative_path(&path).is_err(), "{path:?}"); 1003 } 1004 assert!(validate_relative_path(b"directory/").is_ok()); 1005 assert!(validate_link_target(b"a/link", b"./../target").is_ok()); 1006 assert!(validate_link_target(b"a/link", b"target//leaf").is_ok()); 1007 for target in [ 1008 b"".to_vec(), 1009 b"/absolute".to_vec(), 1010 b"a\0b".to_vec(), 1011 b"a\\b".to_vec(), 1012 vec![0xff], 1013 vec![b'a'; MAX_PATH_BYTES + 1], 1014 vec!["a"; MAX_DEPTH + 1].join("/").into_bytes(), 1015 ] { 1016 assert!( 1017 validate_link_target(b"a/link", &target).is_err(), 1018 "{target:?}" 1019 ); 1020 } 1021 for path in [ 1022 "layer.tar".to_owned(), 1023 "bad/layer.tar".to_owned(), 1024 format!("{}/other.tar", "a".repeat(64)), 1025 format!("{}/nested/layer.tar", "a".repeat(64)), 1026 ] { 1027 assert!(validate_layer_name(&path).is_err()); 1028 } 1029 assert!(validate_layer_name(&format!("{}/layer.tar", "a".repeat(64))).is_ok()); 1030 } 1031 1032 #[test] 1033 fn oci_runtime_config_binds_rootless_identity_entrypoint_and_every_label() { 1034 let expected = OciExpectation { 1035 service: "fixture_service", 1036 binary_name: "fixture-service", 1037 version: "0.1.0-alpha", 1038 service_revision: "1111111111111111111111111111111111111111", 1039 lib_revision: "2222222222222222222222222222222222222222", 1040 license: AGPL_LICENSE, 1041 contract_versions: ContractVersions { 1042 admin: 3, 1043 config: 1, 1044 provider: 5, 1045 state: 2, 1046 status: 4, 1047 }, 1048 }; 1049 let entrypoint = "/nix/store/fixture/bin/fixture-service"; 1050 let original = json!({"architecture":"amd64", "os":"linux", "created":"1970-01-01T00:00:01+00:00", 1051 "config":{"User":"65532:65532", "WorkingDir":"/", "StopSignal":"SIGTERM", 1052 "Env":["SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"], "Entrypoint":[entrypoint], "Labels":fixture_labels(&expected)}}); 1053 assert_eq!(validate_config(&original, &expected).unwrap(), entrypoint); 1054 for (pointer, replacement) in [ 1055 ("", json!(null)), 1056 ("/architecture", json!("arm64")), 1057 ("/os", json!("macos")), 1058 ("/created", json!("now")), 1059 ("/config", json!(null)), 1060 ("/config/User", json!("root")), 1061 ("/config/WorkingDir", json!("/tmp")), 1062 ("/config/StopSignal", json!("SIGKILL")), 1063 ("/config/Env", json!([])), 1064 ("/config/Env", json!([null])), 1065 ("/config/Entrypoint", json!([])), 1066 ("/config/Entrypoint", json!([entrypoint, entrypoint])), 1067 ("/config/Entrypoint", json!(["/bin/fixture-service"])), 1068 ( 1069 "/config/Entrypoint", 1070 json!(["/nix/store/fixture/bin/other"]), 1071 ), 1072 ( 1073 "/config/Entrypoint", 1074 json!(["/nix/store/../bin/fixture-service"]), 1075 ), 1076 ("/config/Labels", json!(null)), 1077 ("/config/Labels", json!({})), 1078 ] { 1079 let mut changed = original.clone(); 1080 *changed.pointer_mut(pointer).unwrap() = replacement; 1081 assert!(validate_config(&changed, &expected).is_err(), "{pointer}"); 1082 } 1083 for key in original["config"]["Labels"].as_object().unwrap().keys() { 1084 let mut changed = original.clone(); 1085 changed["config"]["Labels"][key] = json!("unbound"); 1086 assert!(validate_config(&changed, &expected).is_err(), "label {key}"); 1087 } 1088 for value in ["", "A", "0a", "a-b", "a/b"] { 1089 assert!(!valid_identifier(value)); 1090 } 1091 assert!(valid_identifier("service_1")); 1092 assert!(valid_binary_name("service-1")); 1093 for value in ["", "A", "0a", "a/b"] { 1094 assert!(!valid_binary_name(value)); 1095 } 1096 assert!(!valid_identifier(&"a".repeat(129))); 1097 assert!(!valid_binary_name(&"a".repeat(129))); 1098 assert!(!valid_absolute_path("relative")); 1099 assert!(!valid_absolute_path(&format!( 1100 "/{}", 1101 "a".repeat(MAX_PATH_BYTES) 1102 ))); 1103 } 1104 1105 #[test] 1106 fn contract_is_exact() { 1107 let root = Path::new(env!("CARGO_MANIFEST_DIR")) 1108 .parent() 1109 .and_then(Path::parent) 1110 .expect("root"); 1111 validate_contract(root).expect("artifact admission contract"); 1112 } 1113 1114 #[test] 1115 fn native_binary_and_negative_formats_are_bounded() { 1116 let target = if cfg!(all(target_os = "macos", target_arch = "aarch64")) { 1117 MACOS_TARGET 1118 } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { 1119 LINUX_TARGET 1120 } else { 1121 return; 1122 }; 1123 let binary = std::env::current_exe().expect("test binary"); 1124 let bytes = fs::read(&binary).expect("test binary bytes"); 1125 admit_binary_bytes(&bytes, target).expect("native binary structure"); 1126 if bytes.len() <= MAX_BINARY_PARSE_BYTES as usize { 1127 admit_binary_inner(&binary, target, false).expect("bounded native admission"); 1128 } else { 1129 assert_eq!( 1130 admit_binary_inner(&binary, target, false), 1131 Err(AdmissionError::InvalidBinary) 1132 ); 1133 } 1134 let wrong = if target == MACOS_TARGET { 1135 LINUX_TARGET 1136 } else { 1137 MACOS_TARGET 1138 }; 1139 assert_eq!( 1140 admit_binary_inner(&binary, wrong, false), 1141 Err(AdmissionError::InvalidBinary) 1142 ); 1143 let root = tempfile::tempdir().expect("tempdir"); 1144 let arbitrary = root.path().join("binary"); 1145 fs::write(&arbitrary, b"arbitrary bytes").expect("fixture"); 1146 assert_eq!( 1147 admit_binary_inner(&arbitrary, target, false), 1148 Err(AdmissionError::InvalidBinary) 1149 ); 1150 assert_eq!( 1151 validate_dynamic_libraries(&["libsqlite3.so.0".to_owned()]), 1152 Err(AdmissionError::InvalidBinary) 1153 ); 1154 } 1155 1156 #[test] 1157 fn archive_paths_and_agpl_labels_fail_closed() { 1158 assert!(validate_relative_path(b"nix/store/hash/bin/service").is_ok()); 1159 assert!(validate_relative_path(b"../escape").is_err()); 1160 assert!(validate_link_target(b"nix/store/hash/lib/link", b"../target").is_ok()); 1161 assert!(validate_link_target(b"link", b"../escape").is_err()); 1162 let expected = OciExpectation { 1163 service: "fixture_service", 1164 binary_name: "fixture-service", 1165 version: "0.1.0-alpha", 1166 service_revision: "1111111111111111111111111111111111111111", 1167 lib_revision: "2222222222222222222222222222222222222222", 1168 license: AGPL_LICENSE, 1169 contract_versions: ContractVersions { 1170 admin: 3, 1171 config: 1, 1172 provider: 5, 1173 state: 2, 1174 status: 4, 1175 }, 1176 }; 1177 let labels = expected_labels(&expected); 1178 assert_eq!(labels["org.opencontainers.image.licenses"], AGPL_LICENSE); 1179 assert_eq!(labels.len(), 23); 1180 } 1181 }