@@ -48,20 +48,11 @@ def scan(re; flags): matches(re; "g" + flags)[] | .[0].string;
4848def scan(re): scan(re; "");
4949"# ;
5050
51- /// RAII guard that restores process env vars when dropped.
52- /// Ensures cleanup even on early-return error paths.
53- struct EnvRestoreGuard ( Vec < ( String , Option < String > ) > ) ;
54-
55- impl Drop for EnvRestoreGuard {
56- fn drop ( & mut self ) {
57- for ( k, old) in & self . 0 {
58- match old {
59- Some ( v) => std:: env:: set_var ( k, v) ,
60- None => std:: env:: remove_var ( k) ,
61- }
62- }
63- }
64- }
51+ /// Internal global variable name used to pass shell env to jq's `env` filter.
52+ /// SECURITY: Replaces std::env::set_var() which was thread-unsafe and leaked
53+ /// host process env vars. Shell variables are now passed as a jaq global
54+ /// variable, and `def env:` is overridden to read from it.
55+ const ENV_VAR_NAME : & str = "$__bashkit_env__" ;
6556
6657/// jq command - JSON processor
6758pub struct Jq ;
@@ -327,9 +318,26 @@ impl Builtin for Jq {
327318 let loader = load:: Loader :: new ( jaq_std:: defs ( ) . chain ( jaq_json:: defs ( ) ) ) ;
328319 let arena = load:: Arena :: default ( ) ;
329320
321+ // Build shell env as a JSON object for the custom `env` filter.
322+ // SECURITY: This avoids calling std::env::set_var() which is
323+ // thread-unsafe and leaks host process env vars (TM-INF-013).
324+ // ctx.env takes precedence over ctx.variables (prefix assignments
325+ // like FOO=bar jq ... shadow exported variables).
326+ let env_obj = {
327+ let mut map = serde_json:: Map :: new ( ) ;
328+ // variables first, then env overrides (last write wins)
329+ for ( k, v) in ctx. variables . iter ( ) . chain ( ctx. env . iter ( ) ) {
330+ map. insert ( k. clone ( ) , serde_json:: Value :: String ( v. clone ( ) ) ) ;
331+ }
332+ serde_json:: Value :: Object ( map)
333+ } ;
334+
330335 // Prepend compatibility definitions (setpath, leaf_paths, match, scan)
331336 // to override jaq's defaults with jq-compatible behavior.
332- let compat_filter = format ! ( "{}{}" , JQ_COMPAT_DEFS , filter) ;
337+ // Also override `env` to read from our injected variable instead of
338+ // the process environment.
339+ let env_def = format ! ( "def env: {};" , ENV_VAR_NAME ) ;
340+ let compat_filter = format ! ( "{}\n {}\n {}" , JQ_COMPAT_DEFS , env_def, filter) ;
333341 let filter = compat_filter. as_str ( ) ;
334342
335343 // Parse the filter
@@ -353,13 +361,17 @@ impl Builtin for Jq {
353361 } ;
354362
355363 // Compile the filter, registering any --arg/--argjson variable names
356- let var_names: Vec < & str > = var_bindings. iter ( ) . map ( |( n, _) | n. as_str ( ) ) . collect ( ) ;
357- let compiler = Compiler :: default ( ) . with_funs ( jaq_std:: funs ( ) . chain ( jaq_json:: funs ( ) ) ) ;
358- let compiler = if var_names. is_empty ( ) {
359- compiler
360- } else {
361- compiler. with_global_vars ( var_names. iter ( ) . copied ( ) )
362- } ;
364+ // plus the internal $__bashkit_env__ variable.
365+ // Filter out jaq-std's native `env` filter since we override it with
366+ // a def that reads from our injected global variable.
367+ let mut var_names: Vec < & str > = var_bindings. iter ( ) . map ( |( n, _) | n. as_str ( ) ) . collect ( ) ;
368+ var_names. push ( ENV_VAR_NAME ) ;
369+ let native_funs = jaq_std:: funs ( )
370+ . filter ( |( name, _, _) | * name != "env" )
371+ . chain ( jaq_json:: funs ( ) ) ;
372+ let compiler = Compiler :: default ( )
373+ . with_funs ( native_funs)
374+ . with_global_vars ( var_names. iter ( ) . copied ( ) ) ;
363375 let filter = match compiler. compile ( modules) {
364376 Ok ( f) => f,
365377 Err ( errs) => {
@@ -404,22 +416,6 @@ impl Builtin for Jq {
404416 }
405417 } ;
406418
407- // Expose bashkit's shell env/variables to the process environment so
408- // jaq's built-in `env` function (which reads std::env::vars()) works.
409- // Include both ctx.env (prefix assignments like FOO=bar jq ...)
410- // and ctx.variables (set via export builtin).
411- // Uses a drop guard to ensure cleanup on all return paths.
412- let mut seen = std:: collections:: HashSet :: new ( ) ;
413- let mut env_backup: Vec < ( String , Option < String > ) > = Vec :: new ( ) ;
414- for ( k, v) in ctx. env . iter ( ) . chain ( ctx. variables . iter ( ) ) {
415- if seen. insert ( k. clone ( ) ) {
416- let old = std:: env:: var ( k) . ok ( ) ;
417- std:: env:: set_var ( k, v) ;
418- env_backup. push ( ( k. clone ( ) , old) ) ;
419- }
420- }
421- let _env_guard = EnvRestoreGuard ( env_backup) ;
422-
423419 // Track for -e exit status
424420 let mut has_output = false ;
425421 let mut all_null_or_false = true ;
@@ -428,6 +424,9 @@ impl Builtin for Jq {
428424 // and jaq's input/inputs functions consume from the same source.
429425 let shared_inputs = RcIter :: new ( inputs_to_process. into_iter ( ) . map ( Ok :: < Val , String > ) ) ;
430426
427+ // Pre-convert env object to jaq Val once (reused for each input)
428+ let env_val = Val :: from ( env_obj) ;
429+
431430 for jaq_input in & shared_inputs {
432431 let jaq_input: Val = match jaq_input {
433432 Ok ( v) => v,
@@ -436,11 +435,13 @@ impl Builtin for Jq {
436435 }
437436 } ;
438437
439- // Run the filter, passing any --arg/--argjson variable values
440- let var_vals: Vec < Val > = var_bindings
438+ // Run the filter, passing --arg/--argjson variable values
439+ // plus the env object as the last global variable.
440+ let mut var_vals: Vec < Val > = var_bindings
441441 . iter ( )
442442 . map ( |( _, v) | Val :: from ( v. clone ( ) ) )
443443 . collect ( ) ;
444+ var_vals. push ( env_val. clone ( ) ) ;
444445 let ctx = Ctx :: new ( var_vals, & shared_inputs) ;
445446 for result in filter. run ( ( ctx, jaq_input) ) {
446447 match result {
@@ -1233,4 +1234,94 @@ mod tests {
12331234 assert ! ( result. contains( "a,b,c" ) ) ;
12341235 assert ! ( result. contains( "1,2,3" ) ) ;
12351236 }
1237+
1238+ // --- Process env pollution tests (issue #410) ---
1239+
1240+ #[ tokio:: test]
1241+ async fn test_jq_env_no_process_pollution ( ) {
1242+ // Shell variables passed via ctx.env must NOT leak into the process
1243+ // environment. This is a security issue: std::env::set_var() is
1244+ // thread-unsafe and exposes host env vars to jaq's `env` function.
1245+ let unique_key = "BASHKIT_TEST_ENV_POLLUTION_410" ;
1246+
1247+ // Ensure the var is not already in the process env
1248+ assert ! (
1249+ std:: env:: var( unique_key) . is_err( ) ,
1250+ "precondition: {} must not exist in process env" ,
1251+ unique_key
1252+ ) ;
1253+
1254+ let jq = Jq ;
1255+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) ;
1256+ let mut vars = HashMap :: new ( ) ;
1257+ let mut cwd = PathBuf :: from ( "/" ) ;
1258+ let mut env = HashMap :: new ( ) ;
1259+ env. insert ( unique_key. to_string ( ) , "leaked" . to_string ( ) ) ;
1260+ let args = vec ! [ "-n" . to_string( ) , format!( "env.{}" , unique_key) ] ;
1261+
1262+ let ctx = Context {
1263+ args : & args,
1264+ env : & env,
1265+ variables : & mut vars,
1266+ cwd : & mut cwd,
1267+ fs,
1268+ stdin : None ,
1269+ #[ cfg( feature = "http_client" ) ]
1270+ http_client : None ,
1271+ #[ cfg( feature = "git" ) ]
1272+ git_client : None ,
1273+ } ;
1274+
1275+ let result = jq. execute ( ctx) . await . unwrap ( ) ;
1276+ // The jq filter should still see the variable via our custom env
1277+ assert_eq ! ( result. stdout. trim( ) , "\" leaked\" " ) ;
1278+
1279+ // CRITICAL: The process environment must NOT have been modified
1280+ assert ! (
1281+ std:: env:: var( unique_key) . is_err( ) ,
1282+ "process env was polluted with shell variable {}" ,
1283+ unique_key
1284+ ) ;
1285+ }
1286+
1287+ #[ tokio:: test]
1288+ async fn test_jq_env_no_host_leak ( ) {
1289+ // Host process env vars should NOT be visible to jq's env filter.
1290+ // Only shell variables from ctx.env/ctx.variables should be exposed.
1291+ let unique_key = "BASHKIT_TEST_HOST_LEAK_410" ;
1292+
1293+ // Set a host process env var that should NOT be visible to jq
1294+ std:: env:: set_var ( unique_key, "host_secret" ) ;
1295+
1296+ let jq = Jq ;
1297+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) ;
1298+ let mut vars = HashMap :: new ( ) ;
1299+ let mut cwd = PathBuf :: from ( "/" ) ;
1300+ let args = vec ! [ "-n" . to_string( ) , format!( "env.{}" , unique_key) ] ;
1301+
1302+ let ctx = Context {
1303+ args : & args,
1304+ env : & HashMap :: new ( ) ,
1305+ variables : & mut vars,
1306+ cwd : & mut cwd,
1307+ fs,
1308+ stdin : None ,
1309+ #[ cfg( feature = "http_client" ) ]
1310+ http_client : None ,
1311+ #[ cfg( feature = "git" ) ]
1312+ git_client : None ,
1313+ } ;
1314+
1315+ let result = jq. execute ( ctx) . await . unwrap ( ) ;
1316+ // Host env var should NOT be visible - should return null
1317+ assert_eq ! (
1318+ result. stdout. trim( ) ,
1319+ "null" ,
1320+ "host env var {} leaked into jq env" ,
1321+ unique_key
1322+ ) ;
1323+
1324+ // Cleanup
1325+ std:: env:: remove_var ( unique_key) ;
1326+ }
12361327}
0 commit comments