Lab 05 — Build Invoke-VigilTool: an Injection-Safe External-Process Wrapper¶
Setup¶
This is a reference lab — it ships a one-command environment in the companion
plaintext-labs repo at
plaintext-labs/powershell-for-security/05-driving-tools-safely/: a PowerShell 7 container
(mcr.microsoft.com/powershell:7.4-ubuntu-22.04) with PSScriptAnalyzer 1.22.0 and Pester 5.6.1
pinned, a mock external tool (data/vigil-scan.sh) that echoes the exact argument array it received
so you can see whether your data leaked into a command line, and sample inputs including a hostile
filename and a hostile indicator.
git clone https://github.com/plaintext-security/plaintext-labs
cd plaintext-labs/powershell-for-security/05-driving-tools-safely
make up # build the pwsh 7 + PSScriptAnalyzer + Pester container
make shell # drop into pwsh with Vigil and the mock tool
make demo # gate: AST scan (no Invoke-Expression) + PSScriptAnalyzer + Pester (injection proofs)
make down # stop when done
The lab builds a custom minimal target — a mock external tool that prints its argv — because the
lesson is how the argument reaches the process, and a black-box binary would hide exactly the thing you
need to see: whether ; rm -rf arrived as one inert argument or got re-parsed into a command. It is
reproducible at zero cost and runs entirely in the Linux container.
Scenario¶
Vigil now needs to drive external tools: hash a suspicious file, scan it, resolve a domain. You're
building Invoke-VigilTool, the wrapper every future call goes through. The catch is that the arguments
come from attacker-controlled telemetry — filenames and indicators lifted from the events you're
triaging — so the wrapper must pass them as data to the external process, never as a command line it
built from strings. The copilot's first draft will almost certainly use Invoke-Expression or string
interpolation; your job is to build it the safe way, prove a malicious argument can't execute, and wire a
review gate that makes the unsafe idiom un-mergeable.
Authorization. This lab is offensive-capable: you are deliberately feeding command-injection payloads (
; rm -rf,$(...)) to a process wrapper to prove they don't execute. Only run injection payloads against systems you own or have explicit written permission to test. Everything here runs locally in the lab container against a bundled mock tool and sample data — never point these payloads at a real system or a shared host.
Do¶
- [ ] Reproduce the unsafe baseline — and watch it fire. Write the wrong version first, in a
throwaway file: run the mock tool with
Invoke-Expression "$tool $path"where$pathis the hostile filename fromdata/. Observe that the injected command runs (the payload writes a marker file / prints a tell). This is your ground truth for what "unsafe" looks like — you're proving the risk is real before you close it. (Hint: the hostile input is a filename engineered with a shell metacharacter; seedata/.) - [ ] Build
Invoke-VigilToolwith an argument array. Add aPublic/Invoke-VigilTool.ps1advanced function:[CmdletBinding()],[OutputType([pscustomobject])], a validated-FilePath(the tool) and a-ArgumentList [string[]]. Invoke via the native call operator with splatting —& $FilePath @ArgumentList— capturing stderr to a temp file (2>...) and reading$LASTEXITCODE. Pass the hostile argument through it and confirm the payload is received as one inert token, not executed. (Hint: never build"$FilePath $ArgumentList"— that string is the injection. And prefer& @argsoverStart-Process -ArgumentListhere: on .NET,Start-Processre-joins the list into a single string it re-splits on whitespace, which mangles an argument that legitimately contains a space; the call operator preserves the argv array faithfully. Verify this yourself — feed a filename with a space through both.) - [ ] Parse the output into a typed object. Don't return the raw stdout. Capture stdout, stderr, and
the process exit code, and emit a
[pscustomobject]with named fields (e.g.Tool,ExitCode,Success,StdOut,StdErr). A failed tool and an empty result must be distinguishable — that's whatExitCode/Successis for. (Hint:Start-Process -PassThru -Waitgives you.ExitCode.) - [ ] Add the AST review gate. Write (or have the copilot write, then review) a check that parses
every
.ps1underVigil/with[System.Management.Automation.Language.Parser]::ParseFile(...)and usesAst.FindAll(...)to find anyCommandAstwhose command name isInvoke-Expressionoriex. It must exit non-zero if it finds one. Confirm it walks the tree, not the raw text — a comment mentioningiexmust NOT trip it. (Hint: match onCommandAst.GetCommandName().) - [ ] Prove the gate catches the copilot's default. Temporarily paste an
Invoke-Expressionline into aVigil/function; run the AST gate; confirm it fails the build. Remove it; confirm green. You have now demonstrated the gate both ways. - [ ] Write the injection
Pestertests. Add tests that (a) feedInvoke-VigilToola hostile argument (; rm -rf/$(touch pwned)) and assert the payload's side effect did not happen (no marker file, the arg is echoed back verbatim as data), (b) assert the returned object carries a realExitCode, and (c) assert the AST gate finds a plantedInvoke-Expressionand finds none in the clean module. - [ ] Run the full gate.
make demomust be green: AST scan clean,PSScriptAnalyzerclean (PSAvoidUsingInvokeExpressionespecially),Pestergreen — including the injection proofs. - [ ] Automate & own it. Commit
Invoke-VigilTool, the AST gate (gate.ps1's scan step or a standaloneTest-VigilNoInvokeExpression), and the injection tests. In the commit/PR, note what the copilot generated for the wrapper, whether it reached forInvoke-Expression/string interpolation, and the exact change you made to pass data as an array instead.
Success criteria — you're done when¶
- [ ]
Invoke-VigilToolruns the mock tool via an argument array (the native call operator& @args), never a string-built command line — verified by reading the source. - [ ] Feeding a hostile argument (
; rm -rf,$(...)) throughInvoke-VigilTooltreats it as data — the injected command does not execute (no side effect; the mock tool echoes it back verbatim), and aPestertest proves it. - [ ] The wrapper returns a typed object with
ExitCode/Success, distinguishing a failed tool from an empty result. - [ ] The AST gate fails the build on a planted
Invoke-Expression/iexand passes on the clean module — and is not fooled by the word in a comment (you demonstrated both). - [ ]
PSScriptAnalyzeris clean (PSAvoidUsingInvokeExpressionincluded) andPesteris green;make demopasses offline.
Deliverables¶
Invoke-VigilTool (injection-safe wrapper) added to the cumulative Vigil module, the AST review check,
and the Pester tests that prove a malicious argument is treated as data — committed alongside the prior
Vigil cmdlets. Do not commit any marker files, temp stdout/stderr captures, or real samples produced
by the wrapper — those are lab artifacts, not source.
AI acceleration¶
Have the copilot draft Invoke-VigilTool from a one-line spec ("run an external tool with a
caller-supplied argument, return a typed result"), then review the draft for its default: does it build a
command string (Invoke-Expression, & "$exe $arg", cmd /c "...") or pass an argument array? Reject
the string form and specify the array. Then have it draft the AST gate and review whether it truly parses
the tree (FindAll over CommandAst) or just greps text a comment would fool. The whole module is a
Type 14 review in build clothing: the AI writes both the wrapper and the gate, and you own the proof
that the gate holds — the injection test is that proof.
Connects forward¶
The argument-array discipline is Vigil's permanent posture for anything it shells out to. Module 06
(secrets, remoting, least privilege) passes credentials into remote sessions the same way — as structured
objects, never strings. Module 08 (PowerShell as the weapon) detonates the exact IEX ... DownloadString
cradle T1059.001 documents and builds the detection for it — the attack you're learning to never write
here. The AST-scan muscle returns in Module 09's supply-chain/eval gate.
Marketable proof¶
"I build injection-safe PowerShell wrappers around external tools — arguments passed as arrays, never string-built command lines — and gate the codebase against
Invoke-Expressionwith an AST scan andPestertests that prove a malicious argument is treated as data, not executed."
Stretch (optional)¶
- Extend the AST gate to also flag
[scriptblock]::Create(...)andAdd-Typeon non-literal input — the other two paths to "execute a string" the copilot reaches for when it can't useiex. - Add a timeout to
Invoke-VigilTool(kill the process after N seconds) and aPestertest that a hung tool returns a non-zeroExitCoderather than blocking the pipeline forever.
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).