mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
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.
165 lines
7.4 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
})
|