Skip to main content

bitwarden_access_connector/integrations/scripting/
command.rs

1//! Script resolution and the process each script type runs as.
2
3use std::{
4    ffi::OsString,
5    path::{Path, PathBuf},
6};
7
8use super::{Operation, PowershellConfig, misconfigured};
9use crate::{
10    integrations::IntegrationError,
11    resolver::ResolvedCredentials,
12    sys::{FileSystem, Platform},
13};
14
15const HOST_CANDIDATES: &[&str] = &["pwsh", "pwsh.exe", "powershell.exe"];
16const ENV_ALLOWLIST: &[&str] = &[
17    // Windows
18    "SystemRoot",
19    "windir",
20    "PATHEXT",
21    "COMSPEC",
22    "PSModulePath",
23    "PROGRAMFILES",
24    "PROGRAMFILES(X86)",
25    "PROGRAMDATA",
26    "APPDATA",
27    "LOCALAPPDATA",
28    "USERPROFILE",
29    "HOMEDRIVE",
30    "HOMEPATH",
31    // Cross-platform.
32    "PATH",
33    "TEMP",
34    "TMP",
35    "HOME",
36    "TMPDIR",
37    "LANG",
38];
39
40/// How the connector launches an operator-supplied script.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub(crate) enum ScriptType {
44    /// Execute the file itself, relying on a shebang or the executable bit.
45    Direct,
46    /// Launch the file through a PowerShell host. The default for a `.ps1`.
47    Powershell,
48}
49
50impl ScriptType {
51    /// Parses an operator-supplied `SCRIPT_TYPE` value, or `None` if it names no known type.
52    pub(crate) fn parse(value: &str) -> Option<Self> {
53        match value.trim().to_ascii_lowercase().as_str() {
54            "direct" => Some(Self::Direct),
55            "powershell" => Some(Self::Powershell),
56            _ => None,
57        }
58    }
59
60    /// The config spelling of this variant.
61    pub(crate) fn as_str(self) -> &'static str {
62        match self {
63            Self::Direct => "direct",
64            Self::Powershell => "powershell",
65        }
66    }
67
68    /// The launcher for `script`: `explicit` when set, otherwise PowerShell for a `.ps1`.
69    pub(crate) fn detect(script: &Path, explicit: Option<ScriptType>) -> Self {
70        if let Some(explicit) = explicit {
71            return explicit;
72        }
73        match script.extension() {
74            Some(ext) if ext.eq_ignore_ascii_case("ps1") => Self::Powershell,
75            _ => Self::Direct,
76        }
77    }
78}
79
80/// A process to run, as a plain value: no OS handles and no ambient state.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub(crate) struct CommandSpec {
83    pub(crate) program: PathBuf,
84    /// Arguments, excluding the program name.
85    pub(crate) args: Vec<OsString>,
86    /// The child's complete environment; nothing is inherited.
87    pub(crate) env: Vec<(OsString, OsString)>,
88}
89
90/// Resolves the `SCRIPT` credential to a canonical path, confined to `script_root` when set.
91pub(crate) fn resolve_script_path(
92    creds: &ResolvedCredentials,
93    script_root: Option<&Path>,
94    fs: &dyn FileSystem,
95) -> Result<PathBuf, IntegrationError> {
96    use bitwarden_sensitive_value::ExposeSensitive as _;
97
98    let script_val = creds
99        .get("SCRIPT")
100        .ok_or_else(|| misconfigured("MissingScript"))?;
101
102    let script_str = script_val.expose();
103    let raw_path = PathBuf::from(script_str.as_ref() as &str);
104
105    let canonical = fs
106        .canonicalize(&raw_path)
107        .map_err(|e| misconfigured("ScriptNotFound").with_source(e))?;
108
109    if let Some(root) = script_root {
110        let canonical_root = fs
111            .canonicalize(root)
112            .map_err(|e| misconfigured("ScriptRootInvalid").with_source(e))?;
113
114        if !canonical.starts_with(&canonical_root) {
115            return Err(misconfigured("ScriptOutsideRoot"));
116        }
117    }
118
119    Ok(canonical)
120}
121
122/// The launcher for `script`: the `SCRIPT_TYPE` credential when set, otherwise by extension.
123pub(crate) fn resolve_script_type(
124    creds: &ResolvedCredentials,
125    script: &Path,
126) -> Result<ScriptType, IntegrationError> {
127    use bitwarden_sensitive_value::ExposeSensitive as _;
128
129    let explicit = match creds.get("SCRIPT_TYPE") {
130        Some(value) => {
131            let exposed = value.expose();
132            Some(
133                ScriptType::parse(exposed.as_ref() as &str)
134                    .ok_or_else(|| misconfigured("UnknownScriptType"))?,
135            )
136        }
137        None => None,
138    };
139
140    Ok(ScriptType::detect(script, explicit))
141}
142
143/// The process that runs `script` for `operation`: the script itself, or the PowerShell host
144/// running it.
145pub(crate) fn build_command(
146    script: &Path,
147    script_type: ScriptType,
148    operation: Operation,
149    powershell: &PowershellConfig,
150    platform: &Platform,
151) -> Result<CommandSpec, IntegrationError> {
152    match script_type {
153        ScriptType::Direct => Ok(CommandSpec {
154            program: script.to_path_buf(),
155            args: vec![operation.as_str().into()],
156            env: Vec::new(),
157        }),
158        ScriptType::Powershell => {
159            let host = resolve_host(powershell.host.as_deref(), platform)
160                .ok_or_else(|| misconfigured("PowerShellHostNotFound"))?;
161
162            Ok(CommandSpec {
163                program: host,
164                args: vec![
165                    "-NoProfile".into(),
166                    "-NonInteractive".into(),
167                    "-ExecutionPolicy".into(),
168                    powershell.execution_policy.as_str().into(),
169                    "-File".into(),
170                    strip_verbatim_path_prefix(script).into_os_string(),
171                    operation.as_str().into(),
172                ],
173                env: allowlisted_env(platform),
174            })
175        }
176    }
177}
178
179fn allowlisted_env(platform: &Platform) -> Vec<(OsString, OsString)> {
180    ENV_ALLOWLIST
181        .iter()
182        .filter_map(|name| {
183            platform
184                .env
185                .var(name)
186                .map(|value| (OsString::from(name), value))
187        })
188        .collect()
189}
190
191/// The configured PowerShell host, else the first candidate found on `PATH`.
192fn resolve_host(configured: Option<&Path>, platform: &Platform) -> Option<PathBuf> {
193    if let Some(configured) = configured {
194        return Some(configured.to_path_buf());
195    }
196
197    let path_var = platform.env.var("PATH")?;
198    for candidate in HOST_CANDIDATES {
199        for dir in std::env::split_paths(&path_var) {
200            let full = dir.join(candidate);
201            if platform.fs.is_file(&full) {
202                return Some(full);
203            }
204        }
205    }
206    None
207}
208
209/// Rewrites a `\\?\C:\...` verbatim disk path to `C:\...`; other paths are returned unchanged.
210fn strip_verbatim_path_prefix(path: &Path) -> PathBuf {
211    let Some(rest) = path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) else {
212        return path.to_path_buf();
213    };
214
215    // Starts with a drive letter, a colon, then a separator?
216    let is_verbatim_disk = {
217        let mut chars = rest.chars();
218        matches!(chars.next(), Some(c) if c.is_ascii_alphabetic())
219            && matches!(chars.next(), Some(':'))
220            && matches!(chars.next(), Some('\\'))
221    };
222
223    if is_verbatim_disk {
224        PathBuf::from(rest)
225    } else {
226        path.to_path_buf()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use bitwarden_pam::RotationFailureCode;
233
234    use super::*;
235    use crate::{
236        error::Retry,
237        integrations::TargetEffect,
238        sys::{FakeEnv, FakeFs},
239    };
240
241    const SH_SCRIPT: &str = "/opt/bwac/rotate.sh";
242    const PS_SCRIPT: &str = "/opt/bwac/rotate.ps1";
243    const HOST: &str = "/usr/local/bin/pwsh";
244
245    fn creds_with_script(path: &str) -> ResolvedCredentials {
246        let mut creds = ResolvedCredentials::new();
247        creds.insert("SCRIPT".to_string(), path.to_string());
248        creds
249    }
250
251    fn detail_of(err: IntegrationError) -> String {
252        err.detail.as_str().to_string()
253    }
254
255    #[test]
256    fn resolve_script_path_requires_the_script_credential() {
257        let err =
258            resolve_script_path(&ResolvedCredentials::new(), None, &FakeFs::empty()).unwrap_err();
259        assert_eq!(detail_of(err), "error kind: MissingScript");
260    }
261
262    #[test]
263    fn resolve_script_path_rejects_a_path_that_does_not_exist() {
264        let err = resolve_script_path(
265            &creds_with_script("/opt/missing.sh"),
266            None,
267            &FakeFs::empty(),
268        )
269        .unwrap_err();
270        assert_eq!(detail_of(err), "error kind: ScriptNotFound");
271    }
272
273    #[test]
274    fn resolve_script_path_accepts_a_script_under_the_root() {
275        let fs = FakeFs::empty()
276            .with_dir("/opt/bwac")
277            .with_file("/opt/bwac/rotate.sh");
278        let resolved = resolve_script_path(
279            &creds_with_script("/opt/bwac/rotate.sh"),
280            Some(Path::new("/opt/bwac")),
281            &fs,
282        )
283        .unwrap();
284        assert_eq!(resolved, PathBuf::from("/opt/bwac/rotate.sh"));
285    }
286
287    #[test]
288    fn resolve_script_path_rejects_a_script_outside_the_root() {
289        let fs = FakeFs::empty()
290            .with_dir("/opt/bwac")
291            .with_file("/etc/passwd");
292        let err = resolve_script_path(
293            &creds_with_script("/etc/passwd"),
294            Some(Path::new("/opt/bwac")),
295            &fs,
296        )
297        .unwrap_err();
298        assert_eq!(detail_of(err), "error kind: ScriptOutsideRoot");
299    }
300
301    /// Containment is checked after resolution, so an in-root link pointing out is rejected. The
302    /// filesystem is faked; real symlink canonicalisation is not covered by unit tests.
303    #[test]
304    fn resolve_script_path_rejects_a_link_escaping_the_root() {
305        let fs = FakeFs::empty()
306            .with_dir("/opt/bwac")
307            .with_link("/opt/bwac/escape.sh", "/bin/sh");
308        let err = resolve_script_path(
309            &creds_with_script("/opt/bwac/escape.sh"),
310            Some(Path::new("/opt/bwac")),
311            &fs,
312        )
313        .unwrap_err();
314        assert_eq!(detail_of(err), "error kind: ScriptOutsideRoot");
315    }
316
317    #[test]
318    fn resolve_script_path_rejects_an_unresolvable_root() {
319        let fs = FakeFs::empty().with_file("/opt/bwac/rotate.sh");
320        let err = resolve_script_path(
321            &creds_with_script("/opt/bwac/rotate.sh"),
322            Some(Path::new("/nonexistent")),
323            &fs,
324        )
325        .unwrap_err();
326        assert_eq!(detail_of(err), "error kind: ScriptRootInvalid");
327    }
328
329    fn creds_with_script_type(value: &str) -> ResolvedCredentials {
330        let mut creds = ResolvedCredentials::new();
331        creds.insert("SCRIPT_TYPE".to_string(), value.to_string());
332        creds
333    }
334
335    #[test]
336    fn parse_accepts_known_types_case_insensitively() {
337        assert_eq!(ScriptType::parse("direct"), Some(ScriptType::Direct));
338        assert_eq!(
339            ScriptType::parse("powershell"),
340            Some(ScriptType::Powershell)
341        );
342        assert_eq!(
343            ScriptType::parse("PowerShell"),
344            Some(ScriptType::Powershell)
345        );
346        assert_eq!(
347            ScriptType::parse("  POWERSHELL  "),
348            Some(ScriptType::Powershell)
349        );
350    }
351
352    #[test]
353    fn parse_rejects_unknown_type() {
354        assert_eq!(ScriptType::parse("bash"), None);
355        assert_eq!(ScriptType::parse("pwsh"), None);
356        assert_eq!(ScriptType::parse(""), None);
357    }
358
359    #[test]
360    fn as_str_round_trips_through_parse() {
361        for t in [ScriptType::Direct, ScriptType::Powershell] {
362            assert_eq!(ScriptType::parse(t.as_str()), Some(t));
363        }
364    }
365
366    #[test]
367    fn detect_selects_powershell_for_ps1() {
368        let t = ScriptType::detect(Path::new("/opt/bwac/rotate.ps1"), None);
369        assert_eq!(t, ScriptType::Powershell);
370    }
371
372    #[test]
373    fn detect_is_case_insensitive_on_the_extension() {
374        // Windows paths routinely differ in case.
375        for name in ["rotate.PS1", "rotate.Ps1", "rotate.pS1"] {
376            assert_eq!(
377                ScriptType::detect(Path::new(name), None),
378                ScriptType::Powershell,
379                "{name} should select PowerShell"
380            );
381        }
382    }
383
384    #[test]
385    fn detect_selects_direct_for_everything_else() {
386        for name in ["rotate.sh", "rotate.exe", "rotate", "rotate.ps1.bak"] {
387            assert_eq!(
388                ScriptType::detect(Path::new(name), None),
389                ScriptType::Direct,
390                "{name} should execute directly"
391            );
392        }
393    }
394
395    #[test]
396    fn explicit_type_overrides_the_extension_both_ways() {
397        assert_eq!(
398            ScriptType::detect(Path::new("rotate.ps1"), Some(ScriptType::Direct)),
399            ScriptType::Direct
400        );
401        assert_eq!(
402            ScriptType::detect(Path::new("rotate"), Some(ScriptType::Powershell)),
403            ScriptType::Powershell
404        );
405    }
406
407    #[test]
408    fn resolve_script_type_falls_back_to_the_extension() {
409        let creds = ResolvedCredentials::new();
410        assert_eq!(
411            resolve_script_type(&creds, Path::new("/opt/rotate.ps1")).unwrap(),
412            ScriptType::Powershell
413        );
414        assert_eq!(
415            resolve_script_type(&creds, Path::new("/opt/rotate.sh")).unwrap(),
416            ScriptType::Direct
417        );
418    }
419
420    #[test]
421    fn resolve_script_type_lets_the_credential_override_the_extension() {
422        assert_eq!(
423            resolve_script_type(
424                &creds_with_script_type("direct"),
425                Path::new("/opt/rotate.ps1")
426            )
427            .unwrap(),
428            ScriptType::Direct
429        );
430        assert_eq!(
431            resolve_script_type(
432                &creds_with_script_type("powershell"),
433                Path::new("/opt/rotate")
434            )
435            .unwrap(),
436            ScriptType::Powershell
437        );
438    }
439
440    #[test]
441    fn resolve_script_type_rejects_an_unknown_value() {
442        let err = resolve_script_type(
443            &creds_with_script_type("pwsh"),
444            Path::new("/opt/rotate.ps1"),
445        )
446        .unwrap_err();
447        assert_eq!(err.retry, Retry::Permanent);
448        assert_eq!(err.effect, TargetEffect::NotApplied);
449        assert_eq!(err.code, RotationFailureCode::CredentialsUnresolved);
450        assert_eq!(err.detail.as_str(), "error kind: UnknownScriptType");
451    }
452
453    fn platform_with_path(dirs: &[&str], fs: FakeFs) -> Platform {
454        let joined = std::env::join_paths(dirs).unwrap();
455        Platform::fake(FakeEnv::from([("PATH", joined.to_str().unwrap())]), fs)
456    }
457
458    #[test]
459    fn resolve_host_returns_none_when_path_is_empty_or_absent() {
460        assert_eq!(resolve_host(None, &Platform::blank()), None);
461        assert_eq!(
462            resolve_host(None, &platform_with_path(&[], FakeFs::empty())),
463            None
464        );
465    }
466
467    #[test]
468    fn resolve_host_prefers_pwsh_over_powershell_across_the_whole_path() {
469        let fs = FakeFs::empty()
470            .with_file("/first/powershell.exe")
471            .with_file("/second/pwsh");
472        let platform = platform_with_path(&["/first", "/second"], fs);
473
474        assert_eq!(
475            resolve_host(None, &platform),
476            Some(PathBuf::from("/second/pwsh"))
477        );
478    }
479
480    #[test]
481    fn resolve_host_falls_back_to_windows_powershell() {
482        let fs = FakeFs::empty().with_file("/hostdir/powershell.exe");
483        let platform = platform_with_path(&["/hostdir"], fs);
484
485        assert_eq!(
486            resolve_host(None, &platform),
487            Some(PathBuf::from("/hostdir/powershell.exe"))
488        );
489    }
490
491    fn args_of(spec: &CommandSpec) -> Vec<String> {
492        spec.args
493            .iter()
494            .map(|a| a.to_string_lossy().into_owned())
495            .collect()
496    }
497
498    fn pinned_host() -> PowershellConfig {
499        PowershellConfig {
500            host: Some(PathBuf::from(HOST)),
501            execution_policy: "Bypass".to_string(),
502        }
503    }
504
505    #[test]
506    fn direct_launcher_executes_the_script_itself_with_no_environment() {
507        let spec = build_command(
508            Path::new(SH_SCRIPT),
509            ScriptType::Direct,
510            Operation::Rotate,
511            &pinned_host(),
512            &Platform::blank(),
513        )
514        .unwrap();
515
516        assert_eq!(spec.program, PathBuf::from(SH_SCRIPT));
517        assert_eq!(args_of(&spec), vec!["rotate"]);
518        assert!(
519            spec.env.is_empty(),
520            "a directly executed script inherits nothing: {:?}",
521            spec.env
522        );
523    }
524
525    #[test]
526    fn powershell_launcher_runs_the_host_with_file() {
527        let spec = build_command(
528            Path::new(PS_SCRIPT),
529            ScriptType::Powershell,
530            Operation::Rotate,
531            &pinned_host(),
532            &Platform::blank(),
533        )
534        .unwrap();
535
536        assert_eq!(spec.program, PathBuf::from(HOST));
537        assert_eq!(
538            args_of(&spec),
539            vec![
540                "-NoProfile",
541                "-NonInteractive",
542                "-ExecutionPolicy",
543                "Bypass",
544                "-File",
545                PS_SCRIPT,
546                "rotate",
547            ]
548        );
549    }
550
551    #[test]
552    fn powershell_launcher_honours_a_custom_execution_policy() {
553        let config = PowershellConfig {
554            execution_policy: "AllSigned".to_string(),
555            ..pinned_host()
556        };
557        let spec = build_command(
558            Path::new(PS_SCRIPT),
559            ScriptType::Powershell,
560            Operation::Verify,
561            &config,
562            &Platform::blank(),
563        )
564        .unwrap();
565        assert!(args_of(&spec).contains(&"AllSigned".to_string()));
566    }
567
568    #[test]
569    fn powershell_launcher_forwards_the_allowlist_and_nothing_else() {
570        // The credential and token exist in the connector's environment; only allowlisted names
571        // reach the child.
572        let env = FakeEnv::from([
573            ("PATH", "/usr/bin"),
574            ("BWAC_TOKEN", "SENTINEL_TOKEN"),
575            (
576                "A1B2C3D4_0000_0000_0000_000000000001_CLIENT_SECRET",
577                "SENTINEL_SECRET",
578            ),
579        ]);
580        let spec = build_command(
581            Path::new(PS_SCRIPT),
582            ScriptType::Powershell,
583            Operation::Rotate,
584            &pinned_host(),
585            &Platform::fake(env, FakeFs::empty()),
586        )
587        .unwrap();
588
589        let names: Vec<String> = spec
590            .env
591            .iter()
592            .map(|(k, _)| k.to_string_lossy().into_owned())
593            .collect();
594        assert_eq!(names, vec!["PATH"], "only PATH is allowlisted here");
595
596        let flattened = format!("{:?}", spec.env);
597        assert!(!flattened.contains("SENTINEL_TOKEN"), "{flattened}");
598        assert!(!flattened.contains("SENTINEL_SECRET"), "{flattened}");
599    }
600
601    #[test]
602    fn powershell_launcher_uses_a_configured_host_verbatim() {
603        let configured = PathBuf::from("/nonexistent/pwsh");
604        let config = PowershellConfig {
605            host: Some(configured.clone()),
606            execution_policy: "Bypass".to_string(),
607        };
608        let spec = build_command(
609            Path::new(PS_SCRIPT),
610            ScriptType::Powershell,
611            Operation::Rotate,
612            &config,
613            &Platform::blank(),
614        )
615        .unwrap();
616        assert_eq!(spec.program, configured);
617    }
618
619    #[test]
620    fn powershell_launcher_without_any_host_is_fatal() {
621        // No configured host and an environment with no PATH to search.
622        let config = PowershellConfig {
623            host: None,
624            execution_policy: "Bypass".to_string(),
625        };
626        let err = build_command(
627            Path::new(PS_SCRIPT),
628            ScriptType::Powershell,
629            Operation::Rotate,
630            &config,
631            &Platform::blank(),
632        )
633        .unwrap_err();
634        assert_eq!(err.retry, Retry::Permanent);
635        assert_eq!(err.effect, TargetEffect::NotApplied);
636        assert_eq!(err.code, RotationFailureCode::CredentialsUnresolved);
637        assert_eq!(err.detail.as_str(), "error kind: PowerShellHostNotFound");
638    }
639
640    #[test]
641    fn allowlisted_env_forwards_only_listed_names() {
642        let env = FakeEnv::from([
643            ("PATH", "/usr/bin"),
644            ("SystemRoot", r"C:\Windows"),
645            ("BWAC_TOKEN", "SENTINEL_TOKEN"),
646            (
647                "A1B2C3D4_0000_0000_0000_000000000001_CLIENT_SECRET",
648                "SENTINEL_SECRET",
649            ),
650            ("SOME_OTHER_VAR", "nope"),
651        ]);
652
653        let forwarded = allowlisted_env(&Platform::fake(env, FakeFs::empty()));
654        let mut names: Vec<String> = forwarded
655            .iter()
656            .map(|(k, _)| k.to_string_lossy().into_owned())
657            .collect();
658        names.sort();
659        assert_eq!(names, vec!["PATH", "SystemRoot"]);
660
661        let flattened = format!("{forwarded:?}");
662        assert!(!flattened.contains("SENTINEL_TOKEN"), "{flattened}");
663        assert!(!flattened.contains("SENTINEL_SECRET"), "{flattened}");
664    }
665
666    #[test]
667    fn allowlisted_env_skips_names_that_are_unset() {
668        let forwarded = allowlisted_env(&Platform::fake(
669            FakeEnv::from([("PATH", "/usr/bin")]),
670            FakeFs::empty(),
671        ));
672        assert_eq!(forwarded.len(), 1, "{forwarded:?}");
673    }
674
675    #[test]
676    fn strip_verbatim_prefix_unwraps_a_verbatim_disk_path() {
677        assert_eq!(
678            strip_verbatim_path_prefix(Path::new(r"\\?\C:\bwac\rotate.ps1")),
679            PathBuf::from(r"C:\bwac\rotate.ps1")
680        );
681    }
682
683    #[test]
684    fn strip_verbatim_prefix_leaves_unc_and_plain_paths_alone() {
685        for untouched in [
686            r"\\?\UNC\server\share\rotate.ps1",
687            r"C:\bwac\rotate.ps1",
688            "/opt/bwac/rotate.ps1",
689        ] {
690            assert_eq!(
691                strip_verbatim_path_prefix(Path::new(untouched)),
692                PathBuf::from(untouched),
693                "{untouched} must be left as-is"
694            );
695        }
696    }
697
698    #[test]
699    fn env_allowlist_contains_no_credential_or_connector_names() {
700        for name in ENV_ALLOWLIST {
701            assert!(
702                !name.starts_with("BWAC"),
703                "connector variable {name} must never be forwarded"
704            );
705            for suffix in [
706                "SCRIPT",
707                "CLIENT_SECRET",
708                "CLIENT_ID",
709                "TENANT_ID",
710                "SECRET",
711            ] {
712                assert!(
713                    !name.contains(suffix),
714                    "credential-shaped name {name} must not be in the allowlist"
715                );
716            }
717        }
718    }
719
720    #[test]
721    fn env_allowlist_includes_the_windows_host_essentials() {
722        for required in ["SystemRoot", "PSModulePath", "PATH", "PATHEXT"] {
723            assert!(
724                ENV_ALLOWLIST.contains(&required),
725                "{required} is required for a PowerShell host"
726            );
727        }
728    }
729}