@@ -768,15 +768,19 @@ impl SandboxBuilder {
768768 . chain ( self . fs_writable . iter ( ) )
769769 . chain ( self . fs_mount . iter ( ) . map ( |( _, host) | host) )
770770 . chain ( self . chroot . as_ref ( ) ) ;
771- if let Some ( grant) =
772- exposing_grant ( std:: path:: Path :: new ( path) , grants, & self . fs_denied )
773- {
771+ if let Some ( exposure) = exposing_grant (
772+ std:: path:: Path :: new ( path) ,
773+ grants,
774+ & self . fs_denied ,
775+ self . chroot . as_deref ( ) ,
776+ ) {
774777 eprintln ! (
775778 "sandlock: warning: credential file {} is inside the sandbox grant {}; \
776779 the sandboxed child can read the secret directly; keep it outside every \
777- fs grant (or add an fs-deny for it)",
780+ fs grant (or add `-- fs-deny {}` to close it)",
778781 path,
779- grant. display( )
782+ exposure. grant. display( ) ,
783+ exposure. deny_target. display( ) ,
780784 ) ;
781785 }
782786 }
@@ -905,24 +909,75 @@ impl SandboxBuilder {
905909 }
906910}
907911
912+ /// An fs grant that exposes a credential file to the sandboxed child, with the
913+ /// path an operator should hand `--fs-deny` to actually close the hole.
914+ struct Exposure {
915+ /// The first fs grant that reaches the secret (named in the diagnostic).
916+ grant : std:: path:: PathBuf ,
917+ /// What `--fs-deny` must target to suppress this: the host path normally,
918+ /// but the in-jail (virtual) path under chroot, where the notif layer
919+ /// matches `--fs-deny` values as virtual prefixes (`ChrootCtx::is_denied`).
920+ deny_target : std:: path:: PathBuf ,
921+ }
922+
908923/// The first fs grant that exposes `secret` to the sandboxed child, or `None`
909924/// when no grant reaches it or an fs-deny covers it (the deny closes the hole,
910925/// so no warning is due). Best-effort: canonicalize where possible.
926+ ///
927+ /// `chroot` is the jail root when running chrooted. In that mode `--fs-deny`
928+ /// values are matched as in-jail (virtual) prefixes, not host paths, so the
929+ /// deny suppression and the advised deny path are computed in the virtual
930+ /// namespace: a host-path deny like `/jail/etc/token` would silence the warning
931+ /// here yet never fire at runtime (the child opens `/etc/token`).
911932fn exposing_grant < ' a > (
912933 secret : & std:: path:: Path ,
913934 grants : impl Iterator < Item = & ' a std:: path:: PathBuf > ,
914935 denies : & [ std:: path:: PathBuf ] ,
915- ) -> Option < std:: path:: PathBuf > {
936+ chroot : Option < & std:: path:: Path > ,
937+ ) -> Option < Exposure > {
916938 let secret_abs = secret. canonicalize ( ) ;
917939 let secret_ref = secret_abs. as_deref ( ) . unwrap_or ( secret) ;
918- let covered_by = |p : & std:: path:: PathBuf | {
919- let abs = p. canonicalize ( ) ;
920- secret_ref. starts_with ( abs. as_deref ( ) . unwrap_or ( p. as_path ( ) ) )
940+
941+ // Whether the child's view of the secret is host-native or in-jail, and the
942+ // path a matching `--fs-deny` targets. Under chroot the secret at host
943+ // `<root>/etc/token` appears to the child at `/etc/token`; a deny is matched
944+ // against that virtual path, so translate before testing.
945+ let ( deny_target, deny_is_virtual) = match chroot {
946+ Some ( root) => {
947+ let root_abs = root. canonicalize ( ) ;
948+ let root_ref = root_abs. as_deref ( ) . unwrap_or ( root) ;
949+ match secret_ref. strip_prefix ( root_ref) {
950+ Ok ( sub) => ( std:: path:: Path :: new ( "/" ) . join ( sub) , true ) ,
951+ // Secret lives outside the jail: chroot can't expose it, but a
952+ // host grant still might, so keep host-path deny semantics.
953+ Err ( _) => ( secret_ref. to_path_buf ( ) , false ) ,
954+ }
955+ }
956+ None => ( secret_ref. to_path_buf ( ) , false ) ,
921957 } ;
922- if denies. iter ( ) . any ( & covered_by) {
958+
959+ let covered_by_deny = |d : & std:: path:: PathBuf | {
960+ if deny_is_virtual {
961+ // Virtual prefix match mirroring `ChrootCtx::is_denied`. No
962+ // canonicalize: an in-jail path need not exist on the host.
963+ deny_target. starts_with ( d)
964+ } else {
965+ let abs = d. canonicalize ( ) ;
966+ deny_target. starts_with ( abs. as_deref ( ) . unwrap_or ( d. as_path ( ) ) )
967+ }
968+ } ;
969+ if denies. iter ( ) . any ( covered_by_deny) {
923970 return None ;
924971 }
925- grants. into_iter ( ) . find ( |g| covered_by ( g) ) . cloned ( )
972+
973+ // Grant overlap stays host-native: the file physically sits inside the
974+ // grant (chroot root, bind-mount host dir, or read/write grant).
975+ let covered_by_grant = |g : & std:: path:: PathBuf | {
976+ let abs = g. canonicalize ( ) ;
977+ secret_ref. starts_with ( abs. as_deref ( ) . unwrap_or ( g. as_path ( ) ) )
978+ } ;
979+ let grant = grants. into_iter ( ) . find ( |g| covered_by_grant ( g) ) . cloned ( ) ?;
980+ Some ( Exposure { grant, deny_target } )
926981}
927982
928983#[ cfg( test) ]
@@ -937,23 +992,61 @@ mod tests {
937992 let secret = dir. join ( "key.txt" ) ;
938993 std:: fs:: write ( & secret, "s" ) . unwrap ( ) ;
939994 let grants = vec ! [ dir. clone( ) ] ;
995+ let found = |denies : & [ PathBuf ] | {
996+ exposing_grant ( & secret, grants. iter ( ) , denies, None ) . map ( |e| e. grant )
997+ } ;
940998
941999 // Inside a read grant: the exposing grant is reported.
942- assert_eq ! ( exposing_grant ( & secret , grants . iter ( ) , & [ ] ) , Some ( dir. clone( ) ) ) ;
1000+ assert_eq ! ( found ( & [ ] ) , Some ( dir. clone( ) ) ) ;
9431001 // An fs-deny on the file itself, or on a covering directory, closes the
9441002 // hole: following the warning's own advice must silence it.
945- assert_eq ! ( exposing_grant ( & secret , grants . iter ( ) , std:: slice:: from_ref( & secret) ) , None ) ;
946- assert_eq ! ( exposing_grant ( & secret , grants . iter ( ) , std:: slice:: from_ref( & dir) ) , None ) ;
1003+ assert_eq ! ( found ( std:: slice:: from_ref( & secret) ) , None ) ;
1004+ assert_eq ! ( found ( std:: slice:: from_ref( & dir) ) , None ) ;
9471005 // A deny elsewhere does not suppress the warning.
948- assert_eq ! (
949- exposing_grant( & secret, grants. iter( ) , & [ PathBuf :: from( "/nonexistent-deny" ) ] ) ,
950- Some ( dir. clone( ) )
951- ) ;
1006+ assert_eq ! ( found( & [ PathBuf :: from( "/nonexistent-deny" ) ] ) , Some ( dir. clone( ) ) ) ;
9521007 // Outside every grant: nothing to report.
9531008 let other = vec ! [ PathBuf :: from( "/nonexistent-grant" ) ] ;
954- assert_eq ! ( exposing_grant( & secret, other. iter( ) , & [ ] ) , None ) ;
1009+ assert_eq ! ( exposing_grant( & secret, other. iter( ) , & [ ] , None ) . map ( |e| e . grant ) , None ) ;
9551010
9561011 let _ = std:: fs:: remove_file ( & secret) ;
9571012 let _ = std:: fs:: remove_dir ( & dir) ;
9581013 }
1014+
1015+ #[ test]
1016+ fn exposing_grant_chroot_deny_uses_virtual_namespace ( ) {
1017+ // Jail root with the secret at <root>/etc/token; the child sees it at
1018+ // the in-jail path /etc/token, which is also what --fs-deny matches.
1019+ let root = std:: env:: temp_dir ( ) . join ( format ! ( "sandlock-jail-{}" , std:: process:: id( ) ) ) ;
1020+ let etc = root. join ( "etc" ) ;
1021+ std:: fs:: create_dir_all ( & etc) . unwrap ( ) ;
1022+ let secret = etc. join ( "token" ) ;
1023+ std:: fs:: write ( & secret, "s" ) . unwrap ( ) ;
1024+ let grants = vec ! [ root. clone( ) ] ;
1025+ let chroot = Some ( root. as_path ( ) ) ;
1026+ let virtual_path = PathBuf :: from ( "/etc/token" ) ;
1027+
1028+ // The whole jail is visible: the chroot root grant is reported, and the
1029+ // advised deny path is the in-jail path, not the host path.
1030+ let exposure = exposing_grant ( & secret, grants. iter ( ) , & [ ] , chroot) . unwrap ( ) ;
1031+ assert_eq ! ( exposure. grant, root) ;
1032+ assert_eq ! ( exposure. deny_target, virtual_path) ;
1033+
1034+ // Denying the in-jail path (what the notif layer actually matches, and
1035+ // what the warning now advises) suppresses it.
1036+ assert ! ( exposing_grant( & secret, grants. iter( ) , & [ virtual_path. clone( ) ] , chroot) . is_none( ) ) ;
1037+ // Denying a covering in-jail directory also suppresses.
1038+ assert ! (
1039+ exposing_grant( & secret, grants. iter( ) , & [ PathBuf :: from( "/etc" ) ] , chroot) . is_none( )
1040+ ) ;
1041+ // The old advice (the host path) does NOT suppress: at runtime the child
1042+ // opens /etc/token, so a /jail/etc/token deny never fires. This is the
1043+ // regression the fix closes.
1044+ assert ! (
1045+ exposing_grant( & secret, grants. iter( ) , std:: slice:: from_ref( & secret) , chroot) . is_some( )
1046+ ) ;
1047+
1048+ let _ = std:: fs:: remove_file ( & secret) ;
1049+ let _ = std:: fs:: remove_dir ( & etc) ;
1050+ let _ = std:: fs:: remove_dir ( & root) ;
1051+ }
9591052}
0 commit comments