Files
orca/src/main/ssh/ssh-remote-powershell.test.ts
T
Orca Worker 3a50fcb795 test(ssh): scan remote payloads with the shared source walk
The ratchet had its own tree walk and comment stripper. The walk skipped
neither node_modules/dist/.git nor dot-directories and excluded tests by
`.test.ts` alone, so its importer count -- the guard's own goalpost -- could
be wrong about what it scanned. The stripper was anchored to line start to
dodge a `/*` inside a glob string, which silently skipped trailing comments;
`stripComments` tracks quote state and handles both.

Importer set re-derived against the shared walk: 15, floor unchanged at 10.
2026-09-05 18:09:31 -07:00

165 lines
7.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import { scanSourceTree, stripComments } from '../../shared/source-scan/source-tree-scan'
import { powerShellCommand } from './ssh-remote-powershell'
function decodePayload(command: string): string {
const encoded = command.match(/ -EncodedCommand (\S+)$/)?.[1]
if (!encoded) {
throw new Error(`no -EncodedCommand payload in: ${command}`)
}
return Buffer.from(encoded, 'base64').toString('utf16le')
}
/**
* This one helper builds the command line for every remote-Windows SSH call site
* (relay deploy, install locks, upload staging, GC claim, browse, CLI launch), so
* its switches are worth pinning.
*/
describe('powerShellCommand', () => {
it('spells no -ExecutionPolicy switch', () => {
const command = powerShellCommand('exit 0')
const switches = command.replace(/ -EncodedCommand \S+$/, '')
// Why: `-EncodedCommand` is not execution-policy gated — only `-File` is — so the switch
// was a no-op, and `-ExecutionPolicy Bypass` beside base64 is among the most heavily
// EDR-flagged PowerShell command lines there is.
expect(switches).not.toMatch(/-ExecutionPolicy/i)
expect(switches).not.toMatch(/Bypass/i)
expect(switches).toBe('powershell.exe -NoProfile -NonInteractive')
})
it('keeps the base64 payload the remote shell cannot rewrite', () => {
// Why: this string is re-parsed by the remote host's sshd DefaultShell, which is
// cmd.exe on a stock Windows OpenSSH install. Base64 is load-bearing here.
const command = powerShellCommand("Write-Output 'a & b' | Out-String")
expect(command).toMatch(
/^powershell\.exe -NoProfile -NonInteractive -EncodedCommand [A-Za-z0-9+/=]+$/
)
expect(decodePayload(command)).toBe("Write-Output 'a & b' | Out-String")
})
})
const MAIN_DIR = join(import.meta.dirname, '..')
// Why: execution policy gates loading script FILES and nothing else, so dropping
// `-ExecutionPolicy Bypass` is a no-op exactly while no remote payload loads one. That
// invariant is what makes the switch safe to omit, and it was previously guarded by nothing:
// a future payload that dot-sourced or used `-File` would fail only on a remote host whose
// LocalMachine policy is Restricted/AllSigned. See the invariant note on `powerShellCommand`.
// Every pattern is case-insensitive: PowerShell switches and cmdlet names are, and Windows
// paths are, so `-file`, `import-module` and `DEPLOY.PS1` are all legitimate spellings that a
// case-sensitive pattern would wave through. Verified to add no false positive across the real
// importers. Each entry carries the fixtures it must catch AND the near-misses it must not, so
// a future tightening cannot quietly trade one for the other.
//
// Limit: this is a source-text scan, so a script file reached only through a variable
// (`& $scriptPath`) never appears in source and no pattern here can catch it — this narrows
// the hole rather than sealing it. The invariant note on `powerShellCommand` covers the rest.
//
// Matched against `stripComments`, the shared quote-tracking stripper, so a construct named in
// prose is not counted as code. A line-anchored regex pair cannot do this job: it either eats
// live code by pairing a `/*` inside a glob string with a later comment close, or — anchoring
// to avoid that — skips every trailing comment. Quote state is the only fix.
const POLICY_GATED_CONSTRUCTS = [
{
label: 'a PowerShell script file (.ps1/.psm1)',
pattern: /\.psm?1\b/i,
catches: [
`powerShellCommand("$script = 'C:\\tools\\deploy.ps1'")`,
`powerShellCommand("Import-Module '$dir\\orca.psm1'")`,
`powerShellCommand("& '$root\\DEPLOY.PS1'")`
],
ignores: [`const build = 'artifact.ps10'`]
},
{
label: 'Import-Module',
pattern: /\bImport-Module\b/i,
catches: [
`powerShellCommand("Import-Module 'NetSecurity'")`,
`powerShellCommand("import-module $modulePath")`
],
ignores: [`const name = 'Import-ModuleList'`]
},
{
// Anchored to a token boundary: a bare /-File\b/i also matches `--credential-file`,
// `--log-file` and `--body-file`, which are real arguments in three of these importers.
label: 'the -File switch',
pattern: /(^|[\s'"`([{,])-File\b/i,
catches: [
`runRemote("powershell.exe -NoProfile -File 'C:\\x.ps1'")`,
`runRemote("powershell.exe -file $scriptVar")`,
`runRemote(["-NoProfile", "-File", scriptVar])`
],
ignores: [`fetchWith("--credential-file", path)`, `run("--log-file $p --body-file $b")`]
},
{
// The quote/backtick prefixes matter: a dot-source in a generated payload usually sits at
// the very start of a TS string literal — `powerShellCommand(". '$x'")` — not after a `;`.
label: 'dot-sourcing',
pattern: /(^|[;{'"`]|\n)[ \t]*\.[ \t]+['"$]/,
catches: [
`powerShellCommand(". '$profileScript'")`,
`powerShellCommand("$ErrorActionPreference = 'Stop'; . '$profile'")`,
`powerShellCommand(". $profileScript")`
],
ignores: [
`cp -a $sourcePath/. $destinationPath/`,
`Host key verification failed for $displayHost. $detail`
]
}
] as const
describe('remote PowerShell payload invariant', () => {
// `scanSourceTree` is the shared walk: it skips node_modules/dist/out/build/.git,
// dot-directories and `__fixtures__`, and excludes tests by the shared `isTestFile` (which
// also covers `.spec.ts`, `__tests__/` and `-test-harness.ts`). A hand-rolled walk that got
// any of those wrong would move the floor below, which is this guard's own goalpost.
const importers = scanSourceTree(MAIN_DIR).filter((file) =>
file.source.includes('ssh-remote-powershell')
)
it('finds the modules that build remote payloads', () => {
// Guards the scan itself: a resolution change that emptied this list would make every
// assertion below vacuously pass. 15 importers today, re-derived against the shared walk.
expect(importers.length).toBeGreaterThan(10)
})
it.each(POLICY_GATED_CONSTRUCTS)('loads no remote payload through $label', ({
label,
pattern
}) => {
const offenders = importers
.filter((file) => pattern.test(stripComments(file.source)))
.map((file) => file.relativePath)
expect(
offenders,
`${offenders.join(', ')} uses ${label}, which IS execution-policy gated on the remote ` +
'host. Do not restore `-ExecutionPolicy Bypass` to the command line (a GPO scope ' +
'beats it). Set the policy in-payload at process scope instead — see the note on ' +
'powerShellCommand.'
).toEqual([])
})
// Why: these patterns only earn trust if they fire on a real violation spelled the way a
// generated payload spells it — inside a TS string literal — and stay quiet on the near
// misses. Both halves are load-bearing: an earlier dot-source pattern passed a `;`-prefixed
// sample but missed `powerShellCommand(". '$x'")`, and the obvious case-insensitive fix for
// `-File` matches `--credential-file` in three real importers. A fixture written from the
// pattern confirms the pattern; these are written from the requirement.
it.each(POLICY_GATED_CONSTRUCTS)('detects $label wherever it is spelled', ({
pattern,
catches,
ignores
}) => {
for (const sample of catches) {
expect(pattern.test(sample), `should catch: ${sample}`).toBe(true)
}
for (const sample of ignores) {
expect(pattern.test(sample), `should ignore: ${sample}`).toBe(false)
}
})
})