Reading width
Wide uses the full column for everything, text, diagrams, code, and exercises. Narrow keeps the standard reading width.
Text size
Scales the body text. Headings and code blocks keep their size.
In this section
0.8 Building Your Analyst Workstation
Introduction
Every lesson in this course runs in PowerShell 7.6, and most of the analysis can run on any machine: Windows, macOS or Linux. Collection from Windows hosts and the Windows-only commands need a Windows machine you are allowed to examine.
A Windows virtual machine of your own is enough for every exercise that needs one. This lesson sets up the analyst side, a workstation where you read evidence, write scripts and build tools, in a way you could repeat from scratch on a new machine.
Repeatability is the point. A workstation set up by hand over months, with tools installed from memory and settings changed as needs arose, cannot be recreated when the laptop is replaced or a colleague joins, and nobody can say exactly what it contains. A workstation built from a short list of commands, and checked by a test, can be rebuilt in an afternoon and compared with anyone else's.
An analyst workstation deserves more care than a personal laptop. It holds evidence, credentials for security APIs and scripts that run with your rights, and it is where you open files that came from compromised machines. So the setup favors three things: software installed deliberately from known sources, defaults that prevent quiet data loss, and a folder layout that keeps evidence exactly as collected.
Nothing here is specific to this course. The same workstation serves real investigations afterwards: the case folder, the profile and the test carry over unchanged, and the modules are the ones a working analyst on a Microsoft estate uses. Building it properly once saves rebuilding it in a hurry during an incident.
By the end of the lesson you can install PowerShell 7.6 and VS Code with the PowerShell extension on Windows, macOS or Linux, install modules from a trusted repository for your own account, write a small profile that sets safe defaults, lay out a case folder that separates evidence from working files, and test the whole setup with a function that reports what is missing.
Installation commands are shown, not run, because they change the machine they run on and would need administrator rights or network access the build does not have. Everything that checks the result, the version, the modules, the defaults, the folder and the test, runs live in this course's PowerShell 7.6.
PowerShell 7.6 and VS Code
installing the toolsOn Windows, winget, the Windows package manager, installs both tools in two lines, and VS Code's command-line tool adds the PowerShell extension. On macOS, Homebrew installs PowerShell; on Ubuntu, Microsoft's package repository does, once registered as Microsoft's installation page describes:
# Windows: PowerShell 7 and VS Code with winget, then the PowerShell extension
winget install --id Microsoft.PowerShell --source winget
winget install --id Microsoft.VisualStudioCode --source winget
code --install-extension ms-vscode.powershell
# macOS (Homebrew) and Ubuntu (Microsoft's package repository) install the same PowerShell
brew install --cask powershell
sudo apt-get install -y powershell
Once PowerShell 7 is installed, the version is the first thing to check, here in this course's own PowerShell:
PS C:\Evidence> $PSVersionTable.PSVersion.ToString()
7.6.6
PS C:\Evidence> $PSVersionTable.PSVersion -ge [version]'7.4'
True
PowerShell 7.6.6, at least 7.4 as the comparison confirms. Comparing as a [version] rather than as text, as Lesson 0.2 showed, keeps 7.10 correctly above 7.4 when that day comes. Any 7.4 or later runs this course's lessons; 7.6 is the version they were written and tested in.
The PowerShell extension for VS Code finds PowerShell 7 automatically and uses it for its integrated console; if several versions are installed, the extension's session menu chooses between them, including Windows PowerShell 5.1 when you need to test a script there.
One editor then covers every PowerShell you will meet. Installing it does not touch or replace Windows PowerShell 5.1, which stays exactly as Lesson 0.2 explained. The two coexist, each with its own modules folder and profiles, and the commands in this course start pwsh explicitly whenever the version matters.
Microsoft also publishes PowerShell as an MSI package and as a ZIP for machines where package managers are not allowed, and through the Microsoft Store. The MSI suits managed estates where software is deployed centrally; the ZIP runs without installation at all, which is useful on a machine you are not allowed to change.
Whatever the method, keep it updated: PowerShell's security fixes arrive in new versions, and an analyst workstation is a valuable target. winget upgrade --all on Windows, or the platform's own package manager elsewhere, updates PowerShell and VS Code together; checking for updates weekly is a reasonable habit for a machine that handles evidence.
VS Code needs little else. The PowerShell extension brings the integrated console, PSScriptAnalyzer's live checks and the debugger described in Lesson 0.4. Other extensions are mostly a matter of taste, but each is code running inside the editor that opens your evidence and scripts, so install few, from publishers you recognize.
Modules from a Trusted Source
install for yourself, deliberatelyThe course uses only a few modules that do not ship with PowerShell itself: PSScriptAnalyzer for checking scripts, the Microsoft Graph SDK in Module 10, and the SecretManagement modules for API keys. Installed for your own account with PSResourceGet, from the PowerShell Gallery:
# Run once in pwsh: the modules this course uses, from the PowerShell Gallery
Install-PSResource -Name PSScriptAnalyzer -Scope CurrentUser
Install-PSResource -Name Microsoft.Graph.Authentication, Microsoft.Graph.Users -Scope CurrentUser
Install-PSResource -Name Microsoft.PowerShell.SecretManagement, Microsoft.PowerShell.SecretStore -Scope CurrentUser
-Scope CurrentUser installs into your own modules folder, without administrator rights, so the modules affect only your account. The alternative, AllUsers, needs elevation and puts the module where every account on the machine will load it, which is rarely what an analyst wants on a workstation they alone use. Installing only the Graph submodules you need, rather than the whole SDK, keeps the install small and quick, which Module 10 explains. The full SDK is dozens of modules and takes minutes to load; the two named here are all Lesson 10.5 needs to sign in and read users.
Every module you install runs with your rights whenever you use it, and can read anything you can read, so the source matters more than convenience.
The PowerShell Gallery is Microsoft's public repository, but anyone can publish to it; install modules from publishers you can identify, check how widely used they are, and prefer the team's own repository, Lesson 11.6, where one exists. A module from an unknown author deserves the same review as a script from a forum.
Modules can also be saved to a folder with Save-PSResource and installed from there on machines without internet access, which suits isolated analysis machines. The saved copies can be hashed and kept with the workstation's build notes, so the exact versions installed are on record.
Version pinning is worth considering for modules whose output feeds evidence. Install-PSResource -Version installs a specific version, and a case's notes that record which version produced a result let anyone reproduce it later, even after the module has changed. Module 10's Graph queries, for example, depend on the SDK's behavior, which Microsoft updates often.
What Is Installed
checking the modulesAfter installing, check that the modules are there and see what the installer itself offers:
PS C:\Evidence> (Get-Module Microsoft.PowerShell.PSResourceGet -ListAvailable).Version.ToString()
1.2.0
PS C:\Evidence> (Get-Module PSScriptAnalyzer -ListAvailable).Version.ToString()
1.25.0
PS C:\Evidence> (Get-Command -Module Microsoft.PowerShell.PSResourceGet).Name -join ', '
Get-PSResource, Import-PSGetRepository, Compress-PSResource, Find-PSResource, Get-InstalledPSResource, Get-PSResourceRepository, Get-PSScriptFileInfo, Install-PSResource, New-PSScriptFileInfo, Publish-PSResource, Register-PSResourceRepository, Reset-PSResourceRepository, Save-PSResource, Set-PSResourceRepository, Test-PSScriptFileInfo, Uninstall-PSResource, Unregister-PSResourceRepository, Update-PSModuleManifest, Update-PSResource, Update-PSScriptFileInfo
PSResourceGet 1.2.0, which ships with PowerShell 7.6, and PSScriptAnalyzer 1.25.0.
If a module you expected is missing, Get-Module -ListAvailable with its name returns nothing, and $env:PSModulePath shows the folders PowerShell searched. A module installed into Windows PowerShell's folder, or for a different user, will not appear in your pwsh session, which is the most common reason an installed module seems to be absent.
Get-Command -Module listed the installer's eighteen commands, a quick way to see what a module offers before reading any of its help.
PSResourceGet's commands cover finding, installing, updating, saving, publishing and removing modules, plus registering repositories such as a team's own feed, the one Lesson 11.6 publishes the analyst toolkit to. They replace the older PowerShellGet commands, Install-Module and its relatives, which still work but which new instructions in this course do not use.
Update-PSResource, run every few weeks, keeps installed modules current, and Get-InstalledPSResource lists what you have with versions, which is worth recording when an investigation's results depend on a module's behavior. The same list is the first thing to compare when a colleague's results differ from yours.
A Small Profile
defaults that prevent quiet mistakesLesson 0.4 described profiles as code that runs at every start, and Module 8 hunts them on the case's fleet as a persistence location. Your own should therefore be small: defaults, never connections or downloads. Three lines cover the quiet mistakes this module has shown, and each line maps to a lesson:
# Documents\PowerShell\profile.ps1 (CurrentUserAllHosts): defaults only, nothing that runs or connects
$PSDefaultParameterValues['ConvertTo-Json:Depth'] = 10
$PSDefaultParameterValues['*:Encoding'] = 'utf8'
Set-PSReadLineOption -AddToHistoryHandler { param($line) $line -notmatch 'password|secret|apikey|token|-AsPlainText' }
The first sets ConvertTo-Json's depth, the second makes every cmdlet with an Encoding parameter write UTF-8, and the third keeps lines that look like secrets out of the PSReadLine history file.
None of the three changes what a command does, only the defaults it starts from, and each can be overridden on any single command by stating the parameter. The effect of the first, shown on the process chain from Lesson 0.2:
PS> $o | ConvertTo-Json -CompressPS> $PSDefaultParameterValues['ConvertTo-Json:Depth'] = 10
PS> $o | ConvertTo-Json -CompressThe same chain, converted live before and after the default is set, followed by the table of defaults now in force:
PS C:\Evidence> $o = [ordered]@{ Host = 'NE-SHARMA-LT'; Process = [ordered]@{ Name = 'rundll32.exe'; Parent = [ordered]@{ Name = 'powershell.exe'; Parent = [ordered]@{ Name = 'WINWORD.EXE' } } } }
PS C:\Evidence> $o | ConvertTo-Json -Compress
WARNING: Resulting JSON is truncated as serialization has exceeded the set depth of 2.
{"Host":"NE-SHARMA-LT","Process":{"Name":"rundll32.exe","Parent":{"Name":"powershell.exe","Parent":"System.Collections.Specialized.OrderedDictionary"}}}
PS C:\Evidence> $PSDefaultParameterValues['ConvertTo-Json:Depth'] = 10
PS C:\Evidence> $o | ConvertTo-Json -Compress
{"Host":"NE-SHARMA-LT","Process":{"Name":"rundll32.exe","Parent":{"Name":"powershell.exe","Parent":{"Name":"WINWORD.EXE"}}}}
PS C:\Evidence> $PSDefaultParameterValues.Keys
ConvertTo-Json:Depth
Before the default, the chain was cut at depth 2 with a warning; after it, ConvertTo-Json kept every level with no -Depth on the command.
The last line shows it is the only default set in this session; with the full profile loaded, the encoding default would appear beside it. $PSDefaultParameterValues is a table of command and parameter pairs whose values PowerShell supplies automatically, unless a command states its own. Keys can use wildcards, as the encoding line does with *:Encoding, which applies to every command that has an Encoding parameter.
Defaults in a profile apply only where the profile runs: your console and VS Code, not scheduled tasks, remote sessions or a colleague's machine. Scripts you share should still state -Depth and -Encoding explicitly. The profile protects your interactive work; the script protects everyone else's.
Other defaults are worth considering as your work develops: a default -ErrorAction for a module you use often, or a default output folder for Export-Csv. Add them one at a time, each for a reason you can state, and remember that every line in a profile is a line someone reviewing your machine has to understand.
A Case Folder
evidence apart from workEvery investigation, however small, gets its own folder, created before the first file arrives, with evidence kept separate from working files and reports. A layout for the case this course follows, with one evidence file copied in and hashed:
PS C:\Evidence> $case = './cases/IR-2026-0309-01'
PS C:\Evidence> 'evidence', 'working', 'reports' | ForEach-Object { New-Item -ItemType Directory -Path (Join-Path $case $_) -Force } | Out-Null
PS C:\Evidence> Copy-Item ./procs/proc-sharma.json (Join-Path $case 'evidence')
PS C:\Evidence> Get-ChildItem (Join-Path $case 'evidence') -File | ForEach-Object { [pscustomobject]@{ Path = $_.Name; SHA256 = (Get-FileHash $_.FullName).Hash.Substring(0, 16) } } | Format-Table
Path SHA256
---- ------
proc-sharma.json 7BE0C754F19DFB11
PS C:\Evidence> (Get-ChildItem $case -Directory).Name -join ', '
evidence, reports, working
PS C:\Evidence> Remove-Item ./cases -Recurse
Three folders: evidence, for files exactly as collected, never edited; working, for extracts, notes and intermediate results; and reports, for what you deliver. The folder is named for the case, with the date in the name, so cases sort in order and a path in any note says which investigation it belongs to.
The evidence file's SHA256 is recorded on arrival, so any later change to it can be detected, the practice Module 9's collector follows for every package. The display shows the first sixteen characters only; the case notes keep the full hash, and Module 9 writes it to a manifest that travels with the evidence.
The separation is what makes an analyst's conclusions defensible when someone later asks how they were reached. It also makes mistakes recoverable: a working file damaged by a bad script is recreated from evidence, but an evidence file damaged the same way is gone, and with it the ability to show what the host really contained.
A finding drawn from a file in evidence can be traced to what was collected; a finding drawn from an edited copy cannot. Scripts read from evidence and write to working or reports, never the other way round, and a read-only attribute on the evidence folder enforces it on Windows.
Keep the case folder off shared and synced locations unless your organization's evidence-handling procedure requires them, and follow that procedure where one exists. Where a case must be shared, share it through a location with access limited to the people working the case, and record who received copies.
Evidence can contain credentials, personal data and live malware, and a folder synced to the cloud copies all of it somewhere you no longer control.
For this course, the evidence of each module downloads into its own folder and is read in place, which works the same way: the lesson's files stay as provided, and anything you produce goes beside them in a folder of your own. Practicing the habit on course evidence makes it automatic on a real case.
Testing the Setup
a function that says what is missingA workstation should be checkable in one command, by you or by anyone else, the same way Module 11 tests the tools it builds, so that "is this machine ready?" has an answer rather than an opinion. A small function that checks the version, the two modules, the JSON default and the case folder, shown first with the setup complete:
PS C:\Evidence> function Test-AnalystWorkstation {
>> param([string]$CaseRoot = './cases')
>> $checks = [ordered]@{
>> 'PowerShell 7.4 or later' = $PSVersionTable.PSVersion -ge [version]'7.4'
>> 'PSResourceGet installed' = [bool](Get-Module Microsoft.PowerShell.PSResourceGet -ListAvailable)
>> 'PSScriptAnalyzer present' = [bool](Get-Module PSScriptAnalyzer -ListAvailable)
>> 'JSON depth default set' = $PSDefaultParameterValues['ConvertTo-Json:Depth'] -ge 5
>> 'Case folder exists' = Test-Path $CaseRoot
>> }
>> foreach ($k in $checks.Keys) { [pscustomobject]@{ Check = $k; Pass = $checks[$k] } }
>> }
PS C:\Evidence> New-Item -ItemType Directory ./cases -Force | Out-Null
PS C:\Evidence> $PSDefaultParameterValues['ConvertTo-Json:Depth'] = 10
PS C:\Evidence> Test-AnalystWorkstation | Format-Table
Check Pass
----- ----
PowerShell 7.4 or later True
PSResourceGet installed True
PSScriptAnalyzer present True
JSON depth default set True
Case folder exists True
PS C:\Evidence> Remove-Item ./cases
All five checks pass on this build. The function's output is objects, one per check, so the result can be filtered, counted or saved like any other evidence of how the work was done. In a fresh session, with no profile loaded and no case folder, the same function lists what is missing:
PS C:\Evidence> function Test-AnalystWorkstation {
>> param([string]$CaseRoot = './cases')
>> $checks = [ordered]@{
>> 'PowerShell 7.4 or later' = $PSVersionTable.PSVersion -ge [version]'7.4'
>> 'PSResourceGet installed' = [bool](Get-Module Microsoft.PowerShell.PSResourceGet -ListAvailable)
>> 'PSScriptAnalyzer present' = [bool](Get-Module PSScriptAnalyzer -ListAvailable)
>> 'JSON depth default set' = $PSDefaultParameterValues['ConvertTo-Json:Depth'] -ge 5
>> 'Case folder exists' = Test-Path $CaseRoot
>> }
>> foreach ($k in $checks.Keys) { [pscustomobject]@{ Check = $k; Pass = $checks[$k] } }
>> }
PS C:\Evidence> Test-AnalystWorkstation | Where-Object { -not $_.Pass } | Format-Table
Check Pass
----- ----
JSON depth default set False
Case folder exists False
Two failures: the JSON depth default and the case folder, the two things this lesson's profile and layout provide. A new colleague running the same function on their first day would see exactly this list and know what to do about each line. The software checks pass because PowerShell, PSResourceGet and PSScriptAnalyzer are installed.
Grow the function as your workstation grows, one check per requirement: a check for VS Code, for the Graph modules before Module 10, for a vault before storing API keys. A test that you can run on any machine in seconds turns "it works on my laptop" into a list of exactly what a new machine is missing.
Run the test after every change to the workstation, and keep its output with the build notes. Saving it is one line, Test-AnalystWorkstation | Export-Csv, with the date in the file name. When something stops working months later, the last passing run says what the machine looked like when it last worked, which narrows the search to whatever changed since.
Keeping It Safe
an analyst workstation is a targetAn analyst workstation holds what attackers want: credentials for security tools, evidence of what defenders know, and scripts that run with privileged rights. A few rules keep it from becoming the weakest point of an investigation, and each is a habit rather than a product.
Two of those habits, written as commands: a vault for API keys, and reading rather than running a suspect script from the case folder.
# Before storing an API key: a vault for your account, never a script or history (Lesson 10.4)
Register-SecretVault -Name Analyst -ModuleName Microsoft.PowerShell.SecretStore
Set-Secret -Name VirusTotal -Secret (Read-Host -AsSecureString 'VirusTotal key')
# A script from a compromised host: read it, never run it
Get-Content .\cases\IR-2026-0309-01\evidence\update.ps1 -TotalCount 40
The vault keeps the key encrypted for your account and out of every script and history file; the script from the case folder is read as text, never executed, so whatever it would have done stays on the page. Beyond those two, do not analyze malware samples by running them on your workstation; this course never does, and Module 5 reads files without executing them.
Keep API keys in a vault, never in scripts or history, as Lesson 10.4 showed. Use a separate, less privileged account for everyday work and an administrator account only when a task needs it, as Northgate's analysts do with their adm. accounts. Sign the tools you share, as Lesson 11.7 does.
When evidence must be opened in a program that might execute it, a document with macros or an archive from an attacker, use an isolated virtual machine with no network access instead.
The virtual machine can be reverted after each use, so nothing a sample does persists, and its lack of network access means nothing it fetches or sends leaves the box. The workstation reads evidence; it does not run it, and it never needs to.
Finally, keep the workstation's own PowerShell logging switched on, even though it records your own work. Script block logging and the PSReadLine history are as useful on your machine as on a suspect one: if your workstation is ever compromised, they are how you, or a colleague, will find out what ran and when.
The Workstation, Checked
five factsThe setup's facts as checks, run in one block: every workstation check passes, the JSON default is 10, the process chain survives conversion without -Depth, PSScriptAnalyzer's command is available, and the edition is PowerShell 7:
PS C:\Evidence> function Test-AnalystWorkstation {
>> param([string]$CaseRoot = './cases')
>> $checks = [ordered]@{
>> 'PowerShell 7.4 or later' = $PSVersionTable.PSVersion -ge [version]'7.4'
>> 'PSResourceGet installed' = [bool](Get-Module Microsoft.PowerShell.PSResourceGet -ListAvailable)
>> 'PSScriptAnalyzer present' = [bool](Get-Module PSScriptAnalyzer -ListAvailable)
>> 'JSON depth default set' = $PSDefaultParameterValues['ConvertTo-Json:Depth'] -ge 5
>> 'Case folder exists' = Test-Path $CaseRoot
>> }
>> foreach ($k in $checks.Keys) { [pscustomobject]@{ Check = $k; Pass = $checks[$k] } }
>> }
PS C:\Evidence> New-Item -ItemType Directory ./cases -Force | Out-Null
PS C:\Evidence> $PSDefaultParameterValues['ConvertTo-Json:Depth'] = 10
PS C:\Evidence> @(
>> @(Test-AnalystWorkstation | Where-Object Pass).Count -eq 5
>> ($PSDefaultParameterValues['ConvertTo-Json:Depth']) -eq 10
>> ([ordered]@{ Host = 'NE-SHARMA-LT'; Process = [ordered]@{ Name = 'rundll32.exe'; Parent = [ordered]@{ Name = 'powershell.exe'; Parent = [ordered]@{ Name = 'WINWORD.EXE' } } } } | ConvertTo-Json -Compress) -match 'WINWORD'
>> [bool](Get-Command Invoke-ScriptAnalyzer)
>> $PSVersionTable.PSEdition -eq 'Core'
>> )
True
True
True
True
True
PS C:\Evidence> Remove-Item ./cases
Five True, on this build. The first is the workstation test itself, so the five checks of Section 6 are contained in this one line. The workstation is ready for the rest of the course: PowerShell 7.6, the modules it needs, defaults that prevent the mistakes Module 0 showed, a place for evidence, and a test that proves all of it.
That completes Module 0. You now know why defenders use PowerShell, which PowerShell you are running, how it is built, where to type it, what kinds of command it runs, how to find your way around it, how attackers and administrators both use it, and how to set up a workstation for the work.
Module 1 starts on the case itself, with the pipeline and object model on the evidence of the first hour of the intrusion.
Practice
- Install. PowerShell 7.6 and VS Code with the PowerShell extension, from known sources.
- Add modules. Install for your own account with PSResourceGet, from publishers you can identify.
- Configure. A small profile: JSON depth, UTF-8 encoding and a history filter, nothing that runs or connects.
- Organize and test. A case folder with evidence apart and hashed, and a function that checks the setup.
Then build the workstation on a machine of your own: PowerShell 7.6, VS Code with the extension, PSScriptAnalyzer, the three-line profile, and a case folder, then run Test-AnalystWorkstation until every check passes.
Next: Module 1, the pipeline and the object model, worked on the first hour of the intrusion.