Lab 01 — Migrate a Loose Script into a Gated Vigil Module¶
Setup¶
This is a reference lab — it ships a one-command environment in the companion
plaintext-labs repo at
plaintext-labs/powershell-for-security/01-modern-toolchain/: a PowerShell 7 container
(mcr.microsoft.com/powershell) with PSScriptAnalyzer and Pester preinstalled, plus the loose
hunt.ps1 you'll migrate and a small bundled event sample.
git clone https://github.com/plaintext-security/plaintext-labs
cd plaintext-labs/powershell-for-security/01-modern-toolchain
make up # build the pwsh + PSScriptAnalyzer + Pester container
make shell # drop into pwsh with the loose script
make demo # runs the module's gate: PSScriptAnalyzer + Pester over the migrated module
make down # stop when done
The lab builds one custom minimal target — a loose script — because the lesson is the migration mechanism and you edit the source yourself; a black-box module can't teach that. It is reproducible at zero cost and runs entirely in the Linux container.
Scenario¶
You've inherited hunt.ps1: a ~50-line script a teammate wrote that reads a JSON export of Windows events
and prints a summary of suspicious ones. It works, it's used, and it has no manifest, no tests, no
PSScriptAnalyzer pass, and a Write-Host-and-Invoke-Expression habit. You're going to migrate it into
the module the rest of this track builds in — without breaking it at any step — and turn it into the
seed of Vigil, the telemetry triage-and-hunt module you grow across all nine modules.
Only test systems you own or have explicit written permission to test. Everything here runs locally in the lab container against bundled sample data.
Do¶
- [ ] Reproduce the loose baseline. Run
hunt.ps1against the bundled sample and capture its output. This is your ground truth — the migration is correct only if this output never changes. - [ ] Write the spec first. Using the spec-driven workflow (openspec, or spec-kit), write a short
change spec for the migration: "wrap the existing script unchanged in a
Vigilmodule with a manifest and explicitFunctionsToExport; addPSScriptAnalyzerand aPestersmoke test; gate both in CI; behavior identical." The spec is the contract you'll review the copilot's work against. - [ ] Stand up the module around the script (strangler-fig). Scaffold
Vigil.psd1+Vigil.psm1, drophunt.ps1's logic in behind one exported function (Get-VigilEvent) unchanged, and getImport-Module ./Vigil+ a call to reproduce step 1's output byte-for-byte. Do not refactor yet — wrap first. - [ ] Add the gates. Configure a
PSScriptAnalyzerSettings.psd1, get the module analyzer-clean (or suppress one finding with a justification comment), and write one realPestertest that asserts the wrapped function reproduces the baseline. Let the copilot generate the config, then check it against your spec — isFunctionsToExportexplicit (not'*')? is the analyzer actually failing on findings? - [ ] Wire CI. Add a workflow that fails on any
PSScriptAnalyzerfinding orPesterfailure. Prove it fails by introducing anInvoke-Expression(an analyzer rule will catch it), then remove it. - [ ] Migrate one slice behind the green gate. Now refactor one piece (e.g. replace a
Write-Hostwith a returned object, or split the read from the filter) — and prove step 1's output still matches and the gate stays green. Keep a rollback (the prior commit) at every step. - [ ] Write the ADR. Record the decision: why a module +
PSScriptAnalyzer+Pesterover a loose.ps1, why you targetpwsh7 not Windows PowerShell 5.1, and which spec-driven tool you chose and its honest downside. - [ ] Automate & own it. Commit the migrated module —
Vigilv0 — with the CI gate, the spec, and the ADR. In the commit/PR, note what the copilot generated, what you corrected, and the one thing it defaulted to that you had to fix (the loose script, the missing manifest, theInvoke-Expression, or a dropped behavior).
Success criteria — you're done when¶
- [ ]
Import-Module ./Vigil; Get-VigilEvent …reproduces the loose script's output byte-for-byte — the migration broke nothing. - [ ]
PSScriptAnalyzeris clean (or every suppression carries a written justification), and at least one realPestertest passes. - [ ] CI fails on a planted
Invoke-Expression/analyzer finding and passes when it's fixed (you demonstrated both). - [ ] A spec for the migration exists, and you can point to the implementation that satisfies it.
- [ ] An ADR records the module + toolchain + spec-workflow decision with an honest "Consequences" section.
Deliverables¶
The migrated Vigil v0 module: Vigil.psd1 + Vigil.psm1 wrapping the loose script,
PSScriptAnalyzerSettings.psd1, at least one Pester test, the CI workflow, the migration spec, and
ADR-001-toolchain.md. Commit all of it. Do not commit any real event exports beyond the small
bundled sample, or any .env/secrets — later modules add secrets handling.
AI acceleration¶
Have the copilot generate the entire skeleton — the manifest, the .psm1, the analyzer settings, the CI
YAML — from your spec, then review the diff against the spec rather than reading it cold. The high-value
catch is the copilot's defaults: it will tend to emit a single .ps1, set FunctionsToExport = '*',
reach for Write-Host, and skip tests. Fixing those is the whole point — you're reviewing the AI's setup,
which is where this class of risk actually lives.
Connects forward¶
This module skeleton is the home for every later module: Module 02 adds typed advanced functions inside
it, Module 03 makes it read real .evtx at scale, Module 09 hardens the very supply-chain/signing gate you
started here. The strangler-fig migration muscle returns in Track 06 (brownfield AD tiering) and Track 10
(click-ops → IaC), where a big-bang cutover means a real outage.
Marketable proof¶
"I migrate loose PowerShell into a proper, versioned module with a
PSScriptAnalyzer+Pestergate in CI, using a spec-driven workflow to direct and review AI-generated code — without breaking the running script."
Stretch (optional)¶
- Sign your module with a self-signed certificate and set the CI gate to verify the signature — a preview of Module 09's supply-chain work, and the direct answer to the Gallery's missing mandatory signing.
- Add
Invoke-ScriptAnalyzeras a pre-commit hook so the gate runs before code ever reaches CI.
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).