|
10 | 10 | //! these types, ensuring round-trip fidelity. |
11 | 11 |
|
12 | 12 | use std::collections::{BTreeMap, HashMap}; |
| 13 | +use std::fmt; |
| 14 | +use std::path::Path; |
13 | 15 |
|
14 | 16 | use miette::{IntoDiagnostic, Result, WrapErr}; |
15 | 17 | use navigator_core::proto::{ |
@@ -359,12 +361,12 @@ pub fn serialize_sandbox_policy(policy: &SandboxPolicy) -> Result<String> { |
359 | 361 | /// default. |
360 | 362 | pub fn load_sandbox_policy(cli_path: Option<&str>) -> Result<Option<SandboxPolicy>> { |
361 | 363 | let contents = if let Some(p) = cli_path { |
362 | | - let path = std::path::Path::new(p); |
| 364 | + let path = Path::new(p); |
363 | 365 | std::fs::read_to_string(path) |
364 | 366 | .into_diagnostic() |
365 | 367 | .wrap_err_with(|| format!("failed to read sandbox policy from {}", path.display()))? |
366 | 368 | } else if let Ok(policy_path) = std::env::var("NEMOCLAW_SANDBOX_POLICY") { |
367 | | - let path = std::path::Path::new(&policy_path); |
| 369 | + let path = Path::new(&policy_path); |
368 | 370 | std::fs::read_to_string(path) |
369 | 371 | .into_diagnostic() |
370 | 372 | .wrap_err_with(|| format!("failed to read sandbox policy from {}", path.display()))? |
@@ -425,6 +427,195 @@ pub fn clear_process_identity(policy: &mut SandboxPolicy) { |
425 | 427 | } |
426 | 428 | } |
427 | 429 |
|
| 430 | +// --------------------------------------------------------------------------- |
| 431 | +// Policy safety validation |
| 432 | +// --------------------------------------------------------------------------- |
| 433 | + |
| 434 | +/// Maximum number of filesystem paths (`read_only` + `read_write` combined). |
| 435 | +const MAX_FILESYSTEM_PATHS: usize = 256; |
| 436 | + |
| 437 | +/// Maximum length of any single filesystem path string. |
| 438 | +const MAX_PATH_LENGTH: usize = 4096; |
| 439 | + |
| 440 | +/// A safety violation found in a sandbox policy. |
| 441 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 442 | +pub enum PolicyViolation { |
| 443 | + /// `run_as_user` or `run_as_group` is "root" or "0". |
| 444 | + RootProcessIdentity { field: &'static str, value: String }, |
| 445 | + /// A filesystem path contains `..` components. |
| 446 | + PathTraversal { path: String }, |
| 447 | + /// A filesystem path is not absolute (does not start with `/`). |
| 448 | + RelativePath { path: String }, |
| 449 | + /// A read-write filesystem path is overly broad (e.g. `/`). |
| 450 | + OverlyBroadPath { path: String }, |
| 451 | + /// A filesystem path exceeds the maximum allowed length. |
| 452 | + FieldTooLong { path: String, length: usize }, |
| 453 | + /// Too many filesystem paths in the policy. |
| 454 | + TooManyPaths { count: usize }, |
| 455 | +} |
| 456 | + |
| 457 | +impl fmt::Display for PolicyViolation { |
| 458 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 459 | + match self { |
| 460 | + Self::RootProcessIdentity { field, value } => { |
| 461 | + write!(f, "{field} cannot be '{value}' (root is not allowed)") |
| 462 | + } |
| 463 | + Self::PathTraversal { path } => { |
| 464 | + write!(f, "path contains '..' traversal component: {path}") |
| 465 | + } |
| 466 | + Self::RelativePath { path } => { |
| 467 | + write!(f, "path must be absolute (start with '/'): {path}") |
| 468 | + } |
| 469 | + Self::OverlyBroadPath { path } => { |
| 470 | + write!(f, "read-write path is overly broad: {path}") |
| 471 | + } |
| 472 | + Self::FieldTooLong { path, length } => { |
| 473 | + write!( |
| 474 | + f, |
| 475 | + "path exceeds maximum length ({length} > {MAX_PATH_LENGTH}): {path}" |
| 476 | + ) |
| 477 | + } |
| 478 | + Self::TooManyPaths { count } => { |
| 479 | + write!( |
| 480 | + f, |
| 481 | + "too many filesystem paths ({count} > {MAX_FILESYSTEM_PATHS})" |
| 482 | + ) |
| 483 | + } |
| 484 | + } |
| 485 | + } |
| 486 | +} |
| 487 | + |
| 488 | +/// Validate that a sandbox policy does not contain unsafe content. |
| 489 | +/// |
| 490 | +/// Returns `Ok(())` if the policy is safe, or `Err(violations)` listing all |
| 491 | +/// safety violations found. Callers decide how to handle violations (hard |
| 492 | +/// error vs. logged warning). |
| 493 | +/// |
| 494 | +/// Checks performed: |
| 495 | +/// - `run_as_user` / `run_as_group` must not be "root" or "0" |
| 496 | +/// - Filesystem paths must be absolute (start with `/`) |
| 497 | +/// - Filesystem paths must not contain `..` components |
| 498 | +/// - Read-write paths must not be overly broad (just `/`) |
| 499 | +/// - Individual path lengths must not exceed [`MAX_PATH_LENGTH`] |
| 500 | +/// - Total path count must not exceed [`MAX_FILESYSTEM_PATHS`] |
| 501 | +pub fn validate_sandbox_policy( |
| 502 | + policy: &SandboxPolicy, |
| 503 | +) -> std::result::Result<(), Vec<PolicyViolation>> { |
| 504 | + let mut violations = Vec::new(); |
| 505 | + |
| 506 | + // Check process identity |
| 507 | + if let Some(ref process) = policy.process { |
| 508 | + if is_root_identity(&process.run_as_user) { |
| 509 | + violations.push(PolicyViolation::RootProcessIdentity { |
| 510 | + field: "run_as_user", |
| 511 | + value: process.run_as_user.clone(), |
| 512 | + }); |
| 513 | + } |
| 514 | + if is_root_identity(&process.run_as_group) { |
| 515 | + violations.push(PolicyViolation::RootProcessIdentity { |
| 516 | + field: "run_as_group", |
| 517 | + value: process.run_as_group.clone(), |
| 518 | + }); |
| 519 | + } |
| 520 | + } |
| 521 | + |
| 522 | + // Check filesystem paths |
| 523 | + if let Some(ref fs) = policy.filesystem { |
| 524 | + let total_paths = fs.read_only.len() + fs.read_write.len(); |
| 525 | + if total_paths > MAX_FILESYSTEM_PATHS { |
| 526 | + violations.push(PolicyViolation::TooManyPaths { count: total_paths }); |
| 527 | + } |
| 528 | + |
| 529 | + for path_str in fs.read_only.iter().chain(fs.read_write.iter()) { |
| 530 | + if path_str.len() > MAX_PATH_LENGTH { |
| 531 | + violations.push(PolicyViolation::FieldTooLong { |
| 532 | + path: truncate_for_display(path_str), |
| 533 | + length: path_str.len(), |
| 534 | + }); |
| 535 | + continue; |
| 536 | + } |
| 537 | + |
| 538 | + let path = Path::new(path_str); |
| 539 | + |
| 540 | + if !path.has_root() { |
| 541 | + violations.push(PolicyViolation::RelativePath { |
| 542 | + path: path_str.clone(), |
| 543 | + }); |
| 544 | + } |
| 545 | + |
| 546 | + if path |
| 547 | + .components() |
| 548 | + .any(|c| matches!(c, std::path::Component::ParentDir)) |
| 549 | + { |
| 550 | + violations.push(PolicyViolation::PathTraversal { |
| 551 | + path: path_str.clone(), |
| 552 | + }); |
| 553 | + } |
| 554 | + } |
| 555 | + |
| 556 | + // Only reject "/" as read-write (overly broad) |
| 557 | + for path_str in &fs.read_write { |
| 558 | + let normalized = path_str.trim_end_matches('/'); |
| 559 | + if normalized.is_empty() { |
| 560 | + // Path is "/" or "///" etc. |
| 561 | + violations.push(PolicyViolation::OverlyBroadPath { |
| 562 | + path: path_str.clone(), |
| 563 | + }); |
| 564 | + } |
| 565 | + } |
| 566 | + } |
| 567 | + |
| 568 | + if violations.is_empty() { |
| 569 | + Ok(()) |
| 570 | + } else { |
| 571 | + Err(violations) |
| 572 | + } |
| 573 | +} |
| 574 | + |
| 575 | +/// Check if a user/group identity string refers to root. |
| 576 | +fn is_root_identity(value: &str) -> bool { |
| 577 | + if value.is_empty() { |
| 578 | + return false; |
| 579 | + } |
| 580 | + let trimmed = value.trim(); |
| 581 | + trimmed == "root" || trimmed == "0" |
| 582 | +} |
| 583 | + |
| 584 | +/// Truncate a string for safe inclusion in error messages. |
| 585 | +fn truncate_for_display(s: &str) -> String { |
| 586 | + if s.len() <= 80 { |
| 587 | + s.to_string() |
| 588 | + } else { |
| 589 | + format!("{}...", &s[..77]) |
| 590 | + } |
| 591 | +} |
| 592 | + |
| 593 | +/// Normalize a filesystem path by collapsing redundant separators |
| 594 | +/// and removing trailing slashes, without requiring the path to exist on disk. |
| 595 | +/// |
| 596 | +/// This is a lexical normalization only — it does NOT resolve symlinks or |
| 597 | +/// check the filesystem. |
| 598 | +pub fn normalize_path(path: &str) -> String { |
| 599 | + use std::path::Component; |
| 600 | + |
| 601 | + let p = Path::new(path); |
| 602 | + let mut normalized = std::path::PathBuf::new(); |
| 603 | + for component in p.components() { |
| 604 | + match component { |
| 605 | + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), |
| 606 | + #[allow(clippy::path_buf_push_overwrite)] |
| 607 | + Component::RootDir => normalized.push("/"), |
| 608 | + Component::CurDir => {} // skip "." |
| 609 | + Component::ParentDir => { |
| 610 | + // Keep ".." — validation will catch it separately |
| 611 | + normalized.push(".."); |
| 612 | + } |
| 613 | + Component::Normal(c) => normalized.push(c), |
| 614 | + } |
| 615 | + } |
| 616 | + normalized.to_string_lossy().to_string() |
| 617 | +} |
| 618 | + |
428 | 619 | // --------------------------------------------------------------------------- |
429 | 620 | // Tests |
430 | 621 | // --------------------------------------------------------------------------- |
@@ -635,4 +826,169 @@ network_policies: |
635 | 826 | fn container_policy_path_is_expected() { |
636 | 827 | assert_eq!(CONTAINER_POLICY_PATH, "/etc/navigator/policy.yaml"); |
637 | 828 | } |
| 829 | + |
| 830 | + // ---- Policy validation tests ---- |
| 831 | + |
| 832 | + #[test] |
| 833 | + fn validate_rejects_root_run_as_user() { |
| 834 | + let mut policy = restrictive_default_policy(); |
| 835 | + policy.process = Some(ProcessPolicy { |
| 836 | + run_as_user: "root".into(), |
| 837 | + run_as_group: "sandbox".into(), |
| 838 | + }); |
| 839 | + let violations = validate_sandbox_policy(&policy).unwrap_err(); |
| 840 | + assert!(violations.iter().any(|v| matches!( |
| 841 | + v, |
| 842 | + PolicyViolation::RootProcessIdentity { |
| 843 | + field: "run_as_user", |
| 844 | + .. |
| 845 | + } |
| 846 | + ))); |
| 847 | + } |
| 848 | + |
| 849 | + #[test] |
| 850 | + fn validate_rejects_uid_zero() { |
| 851 | + let mut policy = restrictive_default_policy(); |
| 852 | + policy.process = Some(ProcessPolicy { |
| 853 | + run_as_user: "0".into(), |
| 854 | + run_as_group: "0".into(), |
| 855 | + }); |
| 856 | + let violations = validate_sandbox_policy(&policy).unwrap_err(); |
| 857 | + assert_eq!(violations.len(), 2); |
| 858 | + } |
| 859 | + |
| 860 | + #[test] |
| 861 | + fn validate_rejects_path_traversal() { |
| 862 | + let mut policy = restrictive_default_policy(); |
| 863 | + policy.filesystem = Some(FilesystemPolicy { |
| 864 | + include_workdir: true, |
| 865 | + read_only: vec!["/usr/../etc/shadow".into()], |
| 866 | + read_write: vec!["/tmp".into()], |
| 867 | + }); |
| 868 | + let violations = validate_sandbox_policy(&policy).unwrap_err(); |
| 869 | + assert!( |
| 870 | + violations |
| 871 | + .iter() |
| 872 | + .any(|v| matches!(v, PolicyViolation::PathTraversal { .. })) |
| 873 | + ); |
| 874 | + } |
| 875 | + |
| 876 | + #[test] |
| 877 | + fn validate_rejects_relative_paths() { |
| 878 | + let mut policy = restrictive_default_policy(); |
| 879 | + policy.filesystem = Some(FilesystemPolicy { |
| 880 | + include_workdir: true, |
| 881 | + read_only: vec!["usr/lib".into()], |
| 882 | + read_write: vec!["/tmp".into()], |
| 883 | + }); |
| 884 | + let violations = validate_sandbox_policy(&policy).unwrap_err(); |
| 885 | + assert!( |
| 886 | + violations |
| 887 | + .iter() |
| 888 | + .any(|v| matches!(v, PolicyViolation::RelativePath { .. })) |
| 889 | + ); |
| 890 | + } |
| 891 | + |
| 892 | + #[test] |
| 893 | + fn validate_rejects_overly_broad_read_write_path() { |
| 894 | + let mut policy = restrictive_default_policy(); |
| 895 | + policy.filesystem = Some(FilesystemPolicy { |
| 896 | + include_workdir: true, |
| 897 | + read_only: vec!["/usr".into()], |
| 898 | + read_write: vec!["/".into()], |
| 899 | + }); |
| 900 | + let violations = validate_sandbox_policy(&policy).unwrap_err(); |
| 901 | + assert!( |
| 902 | + violations |
| 903 | + .iter() |
| 904 | + .any(|v| matches!(v, PolicyViolation::OverlyBroadPath { .. })) |
| 905 | + ); |
| 906 | + } |
| 907 | + |
| 908 | + #[test] |
| 909 | + fn validate_accepts_valid_policy() { |
| 910 | + let policy = restrictive_default_policy(); |
| 911 | + assert!(validate_sandbox_policy(&policy).is_ok()); |
| 912 | + } |
| 913 | + |
| 914 | + #[test] |
| 915 | + fn validate_accepts_empty_process() { |
| 916 | + let policy = SandboxPolicy { |
| 917 | + version: 1, |
| 918 | + process: None, |
| 919 | + filesystem: None, |
| 920 | + landlock: None, |
| 921 | + network_policies: HashMap::new(), |
| 922 | + inference: None, |
| 923 | + }; |
| 924 | + assert!(validate_sandbox_policy(&policy).is_ok()); |
| 925 | + } |
| 926 | + |
| 927 | + #[test] |
| 928 | + fn validate_accepts_empty_run_as_user() { |
| 929 | + let mut policy = restrictive_default_policy(); |
| 930 | + policy.process = Some(ProcessPolicy { |
| 931 | + run_as_user: String::new(), |
| 932 | + run_as_group: String::new(), |
| 933 | + }); |
| 934 | + assert!(validate_sandbox_policy(&policy).is_ok()); |
| 935 | + } |
| 936 | + |
| 937 | + #[test] |
| 938 | + fn validate_rejects_too_many_paths() { |
| 939 | + let mut policy = restrictive_default_policy(); |
| 940 | + let many_paths: Vec<String> = (0..300).map(|i| format!("/path/{i}")).collect(); |
| 941 | + policy.filesystem = Some(FilesystemPolicy { |
| 942 | + include_workdir: true, |
| 943 | + read_only: many_paths, |
| 944 | + read_write: vec!["/tmp".into()], |
| 945 | + }); |
| 946 | + let violations = validate_sandbox_policy(&policy).unwrap_err(); |
| 947 | + assert!( |
| 948 | + violations |
| 949 | + .iter() |
| 950 | + .any(|v| matches!(v, PolicyViolation::TooManyPaths { .. })) |
| 951 | + ); |
| 952 | + } |
| 953 | + |
| 954 | + #[test] |
| 955 | + fn validate_rejects_path_too_long() { |
| 956 | + let mut policy = restrictive_default_policy(); |
| 957 | + let long_path = format!("/{}", "a".repeat(5000)); |
| 958 | + policy.filesystem = Some(FilesystemPolicy { |
| 959 | + include_workdir: true, |
| 960 | + read_only: vec![long_path], |
| 961 | + read_write: vec!["/tmp".into()], |
| 962 | + }); |
| 963 | + let violations = validate_sandbox_policy(&policy).unwrap_err(); |
| 964 | + assert!( |
| 965 | + violations |
| 966 | + .iter() |
| 967 | + .any(|v| matches!(v, PolicyViolation::FieldTooLong { .. })) |
| 968 | + ); |
| 969 | + } |
| 970 | + |
| 971 | + #[test] |
| 972 | + fn normalize_path_collapses_separators() { |
| 973 | + assert_eq!(normalize_path("/usr//lib"), "/usr/lib"); |
| 974 | + assert_eq!(normalize_path("/usr/./lib"), "/usr/lib"); |
| 975 | + assert_eq!(normalize_path("/tmp/"), "/tmp"); |
| 976 | + } |
| 977 | + |
| 978 | + #[test] |
| 979 | + fn normalize_path_preserves_parent_dir() { |
| 980 | + // normalize_path preserves ".." — validation catches it separately |
| 981 | + assert_eq!(normalize_path("/usr/../etc"), "/usr/../etc"); |
| 982 | + } |
| 983 | + |
| 984 | + #[test] |
| 985 | + fn policy_violation_display() { |
| 986 | + let v = PolicyViolation::RootProcessIdentity { |
| 987 | + field: "run_as_user", |
| 988 | + value: "root".into(), |
| 989 | + }; |
| 990 | + let s = format!("{v}"); |
| 991 | + assert!(s.contains("root")); |
| 992 | + assert!(s.contains("run_as_user")); |
| 993 | + } |
638 | 994 | } |
0 commit comments