1use 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 "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 "PATH",
33 "TEMP",
34 "TMP",
35 "HOME",
36 "TMPDIR",
37 "LANG",
38];
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub(crate) enum ScriptType {
44 Direct,
46 Powershell,
48}
49
50impl ScriptType {
51 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 pub(crate) fn as_str(self) -> &'static str {
62 match self {
63 Self::Direct => "direct",
64 Self::Powershell => "powershell",
65 }
66 }
67
68 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#[derive(Debug, Clone, PartialEq, Eq)]
82pub(crate) struct CommandSpec {
83 pub(crate) program: PathBuf,
84 pub(crate) args: Vec<OsString>,
86 pub(crate) env: Vec<(OsString, OsString)>,
88}
89
90pub(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
122pub(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
143pub(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
191fn 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
209fn 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 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 #[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 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 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 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}