@@ -621,58 +621,103 @@ fn normalize_endpoint_ports(data: &mut serde_json::Value) {
621621/// - Path is not a symlink
622622/// - Resolution fails (binary doesn't exist in container)
623623/// - Resolved path equals the original
624+ /// Normalize a path by resolving `.` and `..` components without touching
625+ /// the filesystem. Only works correctly for absolute paths.
626+ fn normalize_path ( path : & std:: path:: Path ) -> std:: path:: PathBuf {
627+ let mut result = std:: path:: PathBuf :: new ( ) ;
628+ for component in path. components ( ) {
629+ match component {
630+ std:: path:: Component :: ParentDir => {
631+ result. pop ( ) ;
632+ }
633+ std:: path:: Component :: CurDir => { }
634+ other => result. push ( other) ,
635+ }
636+ }
637+ result
638+ }
639+
624640#[ cfg( target_os = "linux" ) ]
625641fn resolve_binary_in_container ( policy_path : & str , entrypoint_pid : u32 ) -> Option < String > {
626642 if policy_path. contains ( '*' ) || entrypoint_pid == 0 {
627643 return None ;
628644 }
629645
630- let container_path = format ! ( "/proc/{entrypoint_pid}/root{policy_path}" ) ;
631-
632- // Check if we can access the container filesystem at all.
633- // Failure here means /proc/<pid>/root/ is inaccessible (missing
634- // CAP_SYS_PTRACE, restricted ptrace scope, rootless container, etc.).
635- let meta = match std:: fs:: symlink_metadata ( & container_path) {
636- Ok ( m) => m,
637- Err ( e) => {
638- tracing:: warn!(
639- path = %policy_path,
640- container_path = %container_path,
641- pid = entrypoint_pid,
642- error = %e,
643- "Cannot access container filesystem for symlink resolution; \
644- binary paths in policy will be matched literally. If a policy \
645- binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \
646- use the canonical path instead, or run with CAP_SYS_PTRACE"
647- ) ;
648- return None ;
646+ // Walk the symlink chain inside the container filesystem using
647+ // read_link rather than canonicalize. canonicalize resolves
648+ // /proc/<pid>/root itself (a kernel pseudo-symlink to /) which
649+ // strips the prefix we need. read_link only reads the target of
650+ // the specified symlink, keeping us in the container's namespace.
651+ let mut resolved = std:: path:: PathBuf :: from ( policy_path) ;
652+
653+ // Linux SYMLOOP_MAX is 40; stop before infinite loops
654+ for _ in 0 ..40 {
655+ let container_path = format ! ( "/proc/{entrypoint_pid}/root{}" , resolved. display( ) ) ;
656+
657+ let meta = match std:: fs:: symlink_metadata ( & container_path) {
658+ Ok ( m) => m,
659+ Err ( e) => {
660+ // Only warn on the first iteration (the original policy path).
661+ // On subsequent iterations, the intermediate target may
662+ // legitimately not exist (broken symlink chain).
663+ if resolved. as_os_str ( ) == policy_path {
664+ tracing:: warn!(
665+ path = %policy_path,
666+ container_path = %container_path,
667+ pid = entrypoint_pid,
668+ error = %e,
669+ "Cannot access container filesystem for symlink resolution; \
670+ binary paths in policy will be matched literally. If a policy \
671+ binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \
672+ use the canonical path instead, or run with CAP_SYS_PTRACE"
673+ ) ;
674+ } else {
675+ tracing:: warn!(
676+ original = %policy_path,
677+ current = %resolved. display( ) ,
678+ pid = entrypoint_pid,
679+ error = %e,
680+ "Symlink chain broken during resolution; \
681+ binary will be matched by original path only"
682+ ) ;
683+ }
684+ return None ;
685+ }
686+ } ;
687+
688+ if !meta. file_type ( ) . is_symlink ( ) {
689+ // Reached a non-symlink — this is the final resolved target
690+ break ;
649691 }
650- } ;
651692
652- // Not a symlink — no expansion needed (this is the common, expected case)
653- if !meta. file_type ( ) . is_symlink ( ) {
654- return None ;
655- }
693+ let target = match std:: fs:: read_link ( & container_path) {
694+ Ok ( t) => t,
695+ Err ( e) => {
696+ tracing:: warn!(
697+ path = %policy_path,
698+ current = %resolved. display( ) ,
699+ pid = entrypoint_pid,
700+ error = %e,
701+ "Symlink detected but read_link failed; \
702+ binary will be matched by original path only"
703+ ) ;
704+ return None ;
705+ }
706+ } ;
656707
657- // Resolve through the container's filesystem (handles multi-level symlinks)
658- let canonical = match std:: fs:: canonicalize ( & container_path) {
659- Ok ( c) => c,
660- Err ( e) => {
661- tracing:: warn!(
662- path = %policy_path,
663- pid = entrypoint_pid,
664- error = %e,
665- "Symlink detected but canonicalize failed; \
666- binary will be matched by original path only"
667- ) ;
668- return None ;
708+ if target. is_absolute ( ) {
709+ resolved = target;
710+ } else {
711+ // Relative symlink: resolve against the containing directory
712+ // e.g., /usr/bin/python3 -> python3.11 becomes /usr/bin/python3.11
713+ if let Some ( parent) = resolved. parent ( ) {
714+ resolved = normalize_path ( & parent. join ( & target) ) ;
715+ } else {
716+ break ;
717+ }
669718 }
670- } ;
719+ }
671720
672- // Strip the /proc/<pid>/root prefix to get the in-container absolute path
673- let prefix = format ! ( "/proc/{entrypoint_pid}/root" ) ;
674- let in_container = canonical. strip_prefix ( & prefix) . ok ( ) ?;
675- let resolved = std:: path:: PathBuf :: from ( "/" ) . join ( in_container) ;
676721 let resolved_str = resolved. to_string_lossy ( ) . into_owned ( ) ;
677722
678723 if resolved_str == policy_path {
@@ -2946,6 +2991,27 @@ process:
29462991 // Symlink resolution tests (issue #770)
29472992 // ========================================================================
29482993
2994+ #[ test]
2995+ fn normalize_path_resolves_parent_and_current ( ) {
2996+ use std:: path:: { Path , PathBuf } ;
2997+ assert_eq ! (
2998+ normalize_path( Path :: new( "/usr/bin/../lib/python3" ) ) ,
2999+ PathBuf :: from( "/usr/lib/python3" )
3000+ ) ;
3001+ assert_eq ! (
3002+ normalize_path( Path :: new( "/usr/bin/./python3" ) ) ,
3003+ PathBuf :: from( "/usr/bin/python3" )
3004+ ) ;
3005+ assert_eq ! (
3006+ normalize_path( Path :: new( "/a/b/c/../../d" ) ) ,
3007+ PathBuf :: from( "/a/d" )
3008+ ) ;
3009+ assert_eq ! (
3010+ normalize_path( Path :: new( "/usr/bin/python3" ) ) ,
3011+ PathBuf :: from( "/usr/bin/python3" )
3012+ ) ;
3013+ }
3014+
29493015 #[ test]
29503016 fn resolve_binary_skips_glob_paths ( ) {
29513017 // Glob patterns should never be resolved — they're matched differently
@@ -3290,15 +3356,30 @@ network_policies:
32903356 ) ;
32913357 }
32923358
3293- /// Check if `/proc/<pid>/root/` is accessible for the current process.
3294- /// In CI containers or restricted environments, this path may not be
3295- /// readable even for the process's own PID. Tests that depend on
3296- /// procfs root access should skip gracefully when this returns false.
3359+ /// Check if symlink resolution through `/proc/<pid>/root/` actually works.
3360+ /// Creates a real symlink in a tempdir and attempts to resolve it via
3361+ /// the procfs root path. This catches environments where the probe path
3362+ /// is readable but canonicalization/read_link fails (e.g., containers
3363+ /// with restricted ptrace scope, rootless containers).
32973364 #[ cfg( target_os = "linux" ) ]
32983365 fn procfs_root_accessible ( ) -> bool {
3366+ use std:: os:: unix:: fs:: symlink;
3367+ let dir = match tempfile:: tempdir ( ) {
3368+ Ok ( d) => d,
3369+ Err ( _) => return false ,
3370+ } ;
3371+ let target = dir. path ( ) . join ( "probe_target" ) ;
3372+ let link = dir. path ( ) . join ( "probe_link" ) ;
3373+ if std:: fs:: write ( & target, b"probe" ) . is_err ( ) {
3374+ return false ;
3375+ }
3376+ if symlink ( & target, & link) . is_err ( ) {
3377+ return false ;
3378+ }
32993379 let pid = std:: process:: id ( ) ;
3300- let probe = format ! ( "/proc/{pid}/root/tmp" ) ;
3301- std:: fs:: symlink_metadata ( & probe) . is_ok ( )
3380+ let link_path = link. to_string_lossy ( ) . to_string ( ) ;
3381+ // Actually attempt the same resolution our production code uses
3382+ resolve_binary_in_container ( & link_path, pid) . is_some ( )
33023383 }
33033384
33043385 #[ cfg( target_os = "linux" ) ]
0 commit comments