@@ -637,58 +637,103 @@ fn normalize_endpoint_ports(data: &mut serde_json::Value) {
637637/// - Path is not a symlink
638638/// - Resolution fails (binary doesn't exist in container)
639639/// - Resolved path equals the original
640+ /// Normalize a path by resolving `.` and `..` components without touching
641+ /// the filesystem. Only works correctly for absolute paths.
642+ fn normalize_path ( path : & std:: path:: Path ) -> std:: path:: PathBuf {
643+ let mut result = std:: path:: PathBuf :: new ( ) ;
644+ for component in path. components ( ) {
645+ match component {
646+ std:: path:: Component :: ParentDir => {
647+ result. pop ( ) ;
648+ }
649+ std:: path:: Component :: CurDir => { }
650+ other => result. push ( other) ,
651+ }
652+ }
653+ result
654+ }
655+
640656#[ cfg( target_os = "linux" ) ]
641657fn resolve_binary_in_container ( policy_path : & str , entrypoint_pid : u32 ) -> Option < String > {
642658 if policy_path. contains ( '*' ) || entrypoint_pid == 0 {
643659 return None ;
644660 }
645661
646- let container_path = format ! ( "/proc/{entrypoint_pid}/root{policy_path}" ) ;
647-
648- // Check if we can access the container filesystem at all.
649- // Failure here means /proc/<pid>/root/ is inaccessible (missing
650- // CAP_SYS_PTRACE, restricted ptrace scope, rootless container, etc.).
651- let meta = match std:: fs:: symlink_metadata ( & container_path) {
652- Ok ( m) => m,
653- Err ( e) => {
654- tracing:: warn!(
655- path = %policy_path,
656- container_path = %container_path,
657- pid = entrypoint_pid,
658- error = %e,
659- "Cannot access container filesystem for symlink resolution; \
660- binary paths in policy will be matched literally. If a policy \
661- binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \
662- use the canonical path instead, or run with CAP_SYS_PTRACE"
663- ) ;
664- return None ;
662+ // Walk the symlink chain inside the container filesystem using
663+ // read_link rather than canonicalize. canonicalize resolves
664+ // /proc/<pid>/root itself (a kernel pseudo-symlink to /) which
665+ // strips the prefix we need. read_link only reads the target of
666+ // the specified symlink, keeping us in the container's namespace.
667+ let mut resolved = std:: path:: PathBuf :: from ( policy_path) ;
668+
669+ // Linux SYMLOOP_MAX is 40; stop before infinite loops
670+ for _ in 0 ..40 {
671+ let container_path = format ! ( "/proc/{entrypoint_pid}/root{}" , resolved. display( ) ) ;
672+
673+ let meta = match std:: fs:: symlink_metadata ( & container_path) {
674+ Ok ( m) => m,
675+ Err ( e) => {
676+ // Only warn on the first iteration (the original policy path).
677+ // On subsequent iterations, the intermediate target may
678+ // legitimately not exist (broken symlink chain).
679+ if resolved. as_os_str ( ) == policy_path {
680+ tracing:: warn!(
681+ path = %policy_path,
682+ container_path = %container_path,
683+ pid = entrypoint_pid,
684+ error = %e,
685+ "Cannot access container filesystem for symlink resolution; \
686+ binary paths in policy will be matched literally. If a policy \
687+ binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \
688+ use the canonical path instead, or run with CAP_SYS_PTRACE"
689+ ) ;
690+ } else {
691+ tracing:: warn!(
692+ original = %policy_path,
693+ current = %resolved. display( ) ,
694+ pid = entrypoint_pid,
695+ error = %e,
696+ "Symlink chain broken during resolution; \
697+ binary will be matched by original path only"
698+ ) ;
699+ }
700+ return None ;
701+ }
702+ } ;
703+
704+ if !meta. file_type ( ) . is_symlink ( ) {
705+ // Reached a non-symlink — this is the final resolved target
706+ break ;
665707 }
666- } ;
667708
668- // Not a symlink — no expansion needed (this is the common, expected case)
669- if !meta. file_type ( ) . is_symlink ( ) {
670- return None ;
671- }
709+ let target = match std:: fs:: read_link ( & container_path) {
710+ Ok ( t) => t,
711+ Err ( e) => {
712+ tracing:: warn!(
713+ path = %policy_path,
714+ current = %resolved. display( ) ,
715+ pid = entrypoint_pid,
716+ error = %e,
717+ "Symlink detected but read_link failed; \
718+ binary will be matched by original path only"
719+ ) ;
720+ return None ;
721+ }
722+ } ;
672723
673- // Resolve through the container's filesystem (handles multi-level symlinks)
674- let canonical = match std:: fs:: canonicalize ( & container_path) {
675- Ok ( c) => c,
676- Err ( e) => {
677- tracing:: warn!(
678- path = %policy_path,
679- pid = entrypoint_pid,
680- error = %e,
681- "Symlink detected but canonicalize failed; \
682- binary will be matched by original path only"
683- ) ;
684- return None ;
724+ if target. is_absolute ( ) {
725+ resolved = target;
726+ } else {
727+ // Relative symlink: resolve against the containing directory
728+ // e.g., /usr/bin/python3 -> python3.11 becomes /usr/bin/python3.11
729+ if let Some ( parent) = resolved. parent ( ) {
730+ resolved = normalize_path ( & parent. join ( & target) ) ;
731+ } else {
732+ break ;
733+ }
685734 }
686- } ;
735+ }
687736
688- // Strip the /proc/<pid>/root prefix to get the in-container absolute path
689- let prefix = format ! ( "/proc/{entrypoint_pid}/root" ) ;
690- let in_container = canonical. strip_prefix ( & prefix) . ok ( ) ?;
691- let resolved = std:: path:: PathBuf :: from ( "/" ) . join ( in_container) ;
692737 let resolved_str = resolved. to_string_lossy ( ) . into_owned ( ) ;
693738
694739 if resolved_str == policy_path {
@@ -2962,6 +3007,27 @@ process:
29623007 // Symlink resolution tests (issue #770)
29633008 // ========================================================================
29643009
3010+ #[ test]
3011+ fn normalize_path_resolves_parent_and_current ( ) {
3012+ use std:: path:: { Path , PathBuf } ;
3013+ assert_eq ! (
3014+ normalize_path( Path :: new( "/usr/bin/../lib/python3" ) ) ,
3015+ PathBuf :: from( "/usr/lib/python3" )
3016+ ) ;
3017+ assert_eq ! (
3018+ normalize_path( Path :: new( "/usr/bin/./python3" ) ) ,
3019+ PathBuf :: from( "/usr/bin/python3" )
3020+ ) ;
3021+ assert_eq ! (
3022+ normalize_path( Path :: new( "/a/b/c/../../d" ) ) ,
3023+ PathBuf :: from( "/a/d" )
3024+ ) ;
3025+ assert_eq ! (
3026+ normalize_path( Path :: new( "/usr/bin/python3" ) ) ,
3027+ PathBuf :: from( "/usr/bin/python3" )
3028+ ) ;
3029+ }
3030+
29653031 #[ test]
29663032 fn resolve_binary_skips_glob_paths ( ) {
29673033 // Glob patterns should never be resolved — they're matched differently
@@ -3306,15 +3372,30 @@ network_policies:
33063372 ) ;
33073373 }
33083374
3309- /// Check if `/proc/<pid>/root/` is accessible for the current process.
3310- /// In CI containers or restricted environments, this path may not be
3311- /// readable even for the process's own PID. Tests that depend on
3312- /// procfs root access should skip gracefully when this returns false.
3375+ /// Check if symlink resolution through `/proc/<pid>/root/` actually works.
3376+ /// Creates a real symlink in a tempdir and attempts to resolve it via
3377+ /// the procfs root path. This catches environments where the probe path
3378+ /// is readable but canonicalization/read_link fails (e.g., containers
3379+ /// with restricted ptrace scope, rootless containers).
33133380 #[ cfg( target_os = "linux" ) ]
33143381 fn procfs_root_accessible ( ) -> bool {
3382+ use std:: os:: unix:: fs:: symlink;
3383+ let dir = match tempfile:: tempdir ( ) {
3384+ Ok ( d) => d,
3385+ Err ( _) => return false ,
3386+ } ;
3387+ let target = dir. path ( ) . join ( "probe_target" ) ;
3388+ let link = dir. path ( ) . join ( "probe_link" ) ;
3389+ if std:: fs:: write ( & target, b"probe" ) . is_err ( ) {
3390+ return false ;
3391+ }
3392+ if symlink ( & target, & link) . is_err ( ) {
3393+ return false ;
3394+ }
33153395 let pid = std:: process:: id ( ) ;
3316- let probe = format ! ( "/proc/{pid}/root/tmp" ) ;
3317- std:: fs:: symlink_metadata ( & probe) . is_ok ( )
3396+ let link_path = link. to_string_lossy ( ) . to_string ( ) ;
3397+ // Actually attempt the same resolution our production code uses
3398+ resolve_binary_in_container ( & link_path, pid) . is_some ( )
33183399 }
33193400
33203401 #[ cfg( target_os = "linux" ) ]
0 commit comments