( raw: RawPipelineElement, )
| 828 | /** Transform a raw CommandAst pipeline element into ParsedCommandElement */ |
| 829 | // exported for testing |
| 830 | export function transformCommandAst( |
| 831 | raw: RawPipelineElement, |
| 832 | ): ParsedCommandElement { |
| 833 | const cmdElements = ensureArray(raw.commandElements) |
| 834 | let name = '' |
| 835 | const args: string[] = [] |
| 836 | const elementTypes: CommandElementType[] = [] |
| 837 | const children: (CommandElementChild[] | undefined)[] = [] |
| 838 | let hasChildren = false |
| 839 | |
| 840 | // SECURITY: nameType MUST be computed from the raw name (before |
| 841 | // stripModulePrefix). classifyCommandName('scripts\\Get-Process') returns |
| 842 | // 'application' (contains \\) — the correct answer, since PowerShell resolves |
| 843 | // this as a file path. After stripping it becomes 'Get-Process' which |
| 844 | // classifies as 'cmdlet' — wrong, and allowlist checks would trust it. |
| 845 | // Auto-allow paths gate on nameType !== 'application' to catch this. |
| 846 | // name (stripped) is still used for deny-rule matching symmetry, which is |
| 847 | // fail-safe: deny rules over-match (Module\\Remove-Item still hits a |
| 848 | // Remove-Item deny), allow rules are separately gated by nameType. |
| 849 | let nameType: 'cmdlet' | 'application' | 'unknown' = 'unknown' |
| 850 | if (cmdElements.length > 0) { |
| 851 | const first = cmdElements[0]! |
| 852 | // SECURITY: only trust .value for string-literal element types with a |
| 853 | // string-typed value. Numeric ConstantExpressionAst (e.g. `& 1`) emits an |
| 854 | // integer .value that crashes stripModulePrefix() → parser falls through |
| 855 | // to passthrough. For non-string-literal or non-string .value, use .text. |
| 856 | const isFirstStringLiteral = |
| 857 | first.type === 'StringConstantExpressionAst' || |
| 858 | first.type === 'ExpandableStringExpressionAst' |
| 859 | const rawNameUnstripped = |
| 860 | isFirstStringLiteral && typeof first.value === 'string' |
| 861 | ? first.value |
| 862 | : first.text |
| 863 | // SECURITY: strip surrounding quotes from the command name. When .value is |
| 864 | // unavailable (no StaticType on the raw node), .text preserves quotes — |
| 865 | // `& 'Invoke-Expression' 'x'` yields "'Invoke-Expression'". Stripping here |
| 866 | // at the source means every downstream reader of element.name (deny-rule |
| 867 | // matching, GIT_SAFETY_WRITE_CMDLETS lookup, resolveToCanonical, etc.) |
| 868 | // sees the bare cmdlet name. No-op when .value already stripped. |
| 869 | const rawName = rawNameUnstripped.replace(/^['"]|['"]$/g, '') |
| 870 | // SECURITY: PowerShell built-in cmdlet names are ASCII-only. Non-ASCII |
| 871 | // characters in cmdlet position are inherently suspicious — .NET |
| 872 | // OrdinalIgnoreCase folds U+017F (ſ) → S and U+0131 (ı) → I per |
| 873 | // UnicodeData.txt SimpleUppercaseMapping, so PowerShell resolves |
| 874 | // `ſtart-proceſſ` → Start-Process at runtime. JS .toLowerCase() does NOT |
| 875 | // fold these (ſ is already lowercase), so every downstream name |
| 876 | // comparison (NEVER_SUGGEST, deny-rule strEquals, resolveToCanonical, |
| 877 | // security validators) misses. Force 'application' to gate auto-allow |
| 878 | // (blocks at the nameType !== 'application' checks). Finding #31. |
| 879 | // Verified on Windows (pwsh 7.x, 2026-03): ſtart-proceſſ does NOT resolve. |
| 880 | // Retained as defense-in-depth against future .NET/PS behavior changes |
| 881 | // or module-provided command resolution hooks. |
| 882 | if (/[\u0080-\uFFFF]/.test(rawName)) { |
| 883 | nameType = 'application' |
| 884 | } else { |
| 885 | nameType = classifyCommandName(rawName) |
| 886 | } |
| 887 | name = stripModulePrefix(rawName) |
no test coverage detected