PowerShell Security Lab in Docker

Analyze mock auth logs for threats using PowerShell in Docker.

Introduction

30 Second Summary

Every time you get a notification about an unusual login attempt on your personal accounts, a security system flagged that activity automatically. Organizations do this at massive scale, analyzing thousands of authentication events to catch threats before they escalate.

In this project, you will launch a PowerShell environment inside a Docker container and build a script that analyzes authentication logs for suspicious activity. You will learn core commands, create mock security data, and flag anomalies like failed logins and after-hours access.

What You'll Build

A containerized PowerShell security analysis lab that parses mock authentication logs and identifies suspicious login activity.

By the end of this project, you'll have:

  • A reusable Docker-based PowerShell lab environment that resets on demand.
  • Mock authentication log data in JSON and CSV formats for security analysis.
  • A working PowerShell analysis script that filters, groups, and summarizes suspicious login activity across multiple detection categories.
  • Secret Mission: A styled HTML security report generated with PowerShell's ConvertTo-Html cmdlet.

Are there any prerequisites?

You need Docker Desktop installed on your Mac and basic comfort with your terminal (opening it, typing commands, pressing Enter). No prior PowerShell experience is required.

Launch Your PowerShell Container

PowerShell is the go-to automation tool for security work across cloud environments. To practice it safely, you need an isolated sandbox where you can experiment without affecting your Mac.

In this step, you will use Docker to pull the official Microsoft PowerShell image and launch an interactive container. Once inside, you will explore the commands available to you.

In this step, get ready to:
  • Pull and run the official PowerShell Docker container.
  • Explore PowerShell's verb-noun cmdlet convention and object pipeline.
  • Create an isolated working directory for the lab exercises.
Pull and run the PowerShell container

Microsoft publishes an official PowerShell image on the Microsoft Container Registry. You will download it and start an interactive session inside it.

  • In your terminal, pull the official PowerShell image by running this command:
docker pull mcr.microsoft.com/powershell

You should see individual layers downloading, followed by a Status: Downloaded newer image for mcr.microsoft.com/powershell:latest confirmation.

Pull command hanging or failing?

Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your menu bar.

If you see a network timeout, check your internet connection and try running the command again.

help me fix a docker pull error

  • Start an interactive PowerShell session inside a new container by running:
docker run -it --name ps-lab mcr.microsoft.com/dotnet/sdk:8.0 pwsh

What do these flags do?

  • -it gives you an interactive terminal session so you can type commands directly inside the container.
  • --name ps-lab gives the container a friendly name so you can easily restart or remove it later.

Your terminal prompt changes to PS />. You are now inside a live PowerShell session running in a Docker container.

Seeing a 'name already in use' error?

This means a container named ps-lab already exists from a previous attempt. Remove it first, then try again:

docker rm ps-lab

help me fix a Docker container name conflict

  • Verify your PowerShell version by running:
$PSVersionTable

You should see a table of properties. Confirm the PSVersion row shows 7.6.x (the exact patch number may vary).

Explore the environment

PowerShell ships with hundreds of built-in commands called cmdlets. Every cmdlet follows a Verb-Noun naming pattern that makes them predictable once you learn the convention.

  • See how many cmdlets are available in this container by running:
Get-Command -CommandType Cmdlet | Measure-Object

You should see a Count property showing several hundred cmdlets available to you.

  • Preview the standard verb list to see the naming convention by running:
Get-Verb | Select-Object -First 10

You should see verbs like Get, Set, and New listed in a table. Every cmdlet starts with one of these approved verbs.

  • Discover that PowerShell outputs objects (not plain text) by running:
Get-Process | Get-Member

You should see a long list of properties and methods. This proves that PowerShell passes structured objects through the pipeline, not raw text.

Why objects instead of text?

Traditional shells like Bash pass text between commands. You have to parse that text to extract useful values.

PowerShell passes full .NET objects with typed properties. You can filter, sort, and group by any property without writing a regex or splitting strings. This is what makes PowerShell's pipeline so powerful for security analysis.

Understand container isolation

Everything you do inside this container is isolated from your Mac. Files you create here disappear when you remove the container. This makes it a safe sandbox for experimentation.

  • See the Linux filesystem inside your container by running:
Get-ChildItem /

You should see standard Linux directories like bin, etc, and usr. This is the container's own filesystem, completely separate from your Mac.

What is container isolation?

A Docker container is a lightweight, self-contained environment with its own filesystem, processes, and network. Anything you create inside the container stays inside the container.

If you break something, just remove the container and start fresh. Your Mac stays untouched.

  • Create a working directory for the lab by running:
New-Item -ItemType Directory -Path /lab
  • Set /lab as your current location by running:
Set-Location /lab

What does this command do?

  • New-Item -ItemType Directory -Path /lab creates a new folder called /lab at the root of the container's filesystem.
  • Set-Location /lab changes your working directory to /lab. All files you create from now on will live here.
  • Confirm you are in the right directory by running:
Get-Location

You should see /lab as your current path. Your prompt also updates to PS /lab>.

Directory not found?

If Set-Location reports an error, make sure the New-Item command above ran successfully first. Run it again if needed.

help me fix a directory not found error in PowerShell

Your PowerShell lab environment is ready. Next up, you will put the object pipeline through its paces with real filtering, sorting, and grouping exercises.

Master the Object Pipeline

Your PowerShell environment is up and running. In the previous step, you discovered that PowerShell outputs objects instead of plain text.

Now you will chain multiple commands together using the pipeline. Each command passes structured objects to the next, letting you sort, filter, group, and measure data without writing a single script file.

In this step, get ready to:
  • Build multi-stage pipeline chains that sort, select, and filter objects.
  • Practice filtering with comparison operators and compound conditions.
  • Group and measure data for security-style analysis.
Build a pipeline chain

A pipeline connects commands with the | operator. Each command receives objects from the previous one, processes them, and passes the results forward.

  • See the top 5 CPU-consuming processes by running this command:
Get-Process | Sort-Object -Property CPU -Descending | Select-Object -First 5 -Property Name, CPU, Id

What does this pipeline do?

  • Get-Process retrieves all running processes as objects with properties like Name, CPU, and Id.
  • Sort-Object -Property CPU -Descending reorders those objects by their CPU property, highest first.
  • Select-Object -First 5 picks only the top 5 results and shows just the columns you chose.

Each pipe passes full objects (with all their properties) to the next command. This is different from Bash, which passes plain text between commands.

You should see a table with five rows showing process names, CPU time, and process IDs.

Seeing an error or no output?

  • Make sure you are inside your PowerShell container. Your prompt should start with PS. If you see a regular terminal prompt, re-enter with docker start -i ps-lab.
  • If the CPU column shows 0 for all processes, that is normal inside a container with few running services.

Why isn't my PowerShell pipeline command returning results?

  • Now add a filter to the pipeline by running this command:
Get-Process | Where-Object { $_.CPU -gt 0 } | Sort-Object CPU -Descending | Format-Table Name, CPU, Id -AutoSize

What is the $_ variable?

The $_ automatic variable represents the current object in the pipeline. Inside the Where-Object script block, $_.CPU refers to the CPU property of each process object as it flows through.

The -gt operator means "greater than". So $_.CPU -gt 0 keeps only processes that have consumed some CPU time.

This time the output only shows processes with actual CPU usage, formatted as a clean auto-sized table.

Practice filtering with Where-Object

Filtering is the core skill you will use later to find suspicious events in security logs. Let's practice with files on the container's filesystem.

  • Find large files in the /usr directory by running this command:
Get-ChildItem /usr -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt 1MB } | Select-Object FullName, Length

What do the comparison operators mean?

PowerShell uses word-based comparison operators instead of symbols:

  • -gt means greater than.
  • -lt means less than.
  • -eq means equal to.
  • -like matches with wildcards (e.g. *.log).
  • -match matches with regular expressions.

The -ErrorAction SilentlyContinue parameter tells the command to skip folders it cannot access instead of printing error messages.

You should see a list of file paths and their sizes in bytes. Any file larger than 1 MB appears in the results.

  • Combine multiple conditions into a single filter by running this command:
Get-ChildItem /usr -Recurse -File -ErrorAction SilentlyContinue | Where-Object { ($_.Length -gt 100KB) -and ($_.Extension -eq '.so') }

What does -and do here?

The -and operator combines two conditions. Both must be true for an object to pass through the filter. This command finds shared library files (.so extension) that are also larger than 100 KB.

In security work, compound filters let you narrow down events by multiple criteria at once. For example, you could find login events that are both failed AND from an unknown IP.

The output shows only files that match both conditions. You may see several shared library files or none at all depending on what is installed in the container.

Group and measure data

Security analysis often means counting events and finding outliers. PowerShell's grouping and measurement cmdlets handle this directly in the pipeline.

  • Group running processes by name and find the most common ones by running this command:
Get-Process | Group-Object -Property ProcessName | Sort-Object Count -Descending | Select-Object -First 5

You should see a table showing process names grouped together with a count of how many instances each has.

  • Measure the total and average size of files in the /usr directory by running this command:
Get-ChildItem /usr -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum -Average

Why do Group-Object and Measure-Object matter for security?

Group-Object lets you count events by category. In a security context, you would group login attempts by username to spot accounts under attack.

Measure-Object gives you aggregate statistics like sum, average, minimum, and maximum. This helps you find outliers in numeric data, such as unusually large data transfers or abnormal request counts.

The output shows the total count of files, their combined size (Sum), and the average file size (Average). These are the same aggregation patterns you will use on your security logs in the next steps.

You now have the core pipeline skills to sort, filter, group, and measure any collection of objects. Next up, you will create mock security log data and put these skills to work on a real analysis task.

Create Mock Security Log Data

Your PowerShell container is running and you know how to pipe objects through the pipeline. Now you need data to analyze.

Real security teams investigate authentication logs to catch suspicious activity. In this step, you will create mock log data that mimics a real authentication system, complete with normal logins, failed login attempts, and after-hours access from unknown IP addresses.

In this step, get ready to:
  • Build mock authentication log entries as PowerShell custom objects.
  • Write the data to a JSON file and verify it.
  • Export the same data to CSV and compare the two formats.
Build the mock authentication log data

Authentication logs track every login event in a system. Each entry records who logged in, when, from where, and whether it succeeded or failed.

You will create a PowerShell script that defines 16 log entries as an array of custom objects. The data includes normal activity and deliberate suspicious patterns that your analysis script will detect later.

  • Create the script file with the log data by running this command:
Set-Content -Path /lab/create-logs.ps1 -Value @'
# Create mock authentication log data
$logs = @(
    [PSCustomObject]@{ Timestamp = "2026-06-18T08:15:00"; Username = "jsmith"; SourceIP = "10.0.1.50"; Action = "Login"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T08:20:00"; Username = "jdoe"; SourceIP = "10.0.1.51"; Action = "Login"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T09:00:00"; Username = "admin"; SourceIP = "10.0.1.10"; Action = "Login"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T09:05:00"; Username = "hacker1"; SourceIP = "203.0.113.42"; Action = "FailedLogin"; Status = "Failure" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T09:06:00"; Username = "hacker1"; SourceIP = "203.0.113.42"; Action = "FailedLogin"; Status = "Failure" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T09:07:00"; Username = "hacker1"; SourceIP = "203.0.113.42"; Action = "FailedLogin"; Status = "Failure" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T09:08:00"; Username = "hacker1"; SourceIP = "203.0.113.42"; Action = "FailedLogin"; Status = "Failure" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T10:30:00"; Username = "jsmith"; SourceIP = "10.0.1.50"; Action = "Logout"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T23:45:00"; Username = "jdoe"; SourceIP = "192.168.5.99"; Action = "Login"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T23:50:00"; Username = "jdoe"; SourceIP = "192.168.5.99"; Action = "Logout"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-19T02:15:00"; Username = "admin"; SourceIP = "198.51.100.77"; Action = "Login"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-19T02:20:00"; Username = "admin"; SourceIP = "198.51.100.77"; Action = "Logout"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-19T08:00:00"; Username = "jsmith"; SourceIP = "10.0.1.50"; Action = "Login"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-19T08:30:00"; Username = "guest"; SourceIP = "203.0.113.99"; Action = "FailedLogin"; Status = "Failure" }
    [PSCustomObject]@{ Timestamp = "2026-06-19T08:31:00"; Username = "guest"; SourceIP = "203.0.113.99"; Action = "FailedLogin"; Status = "Failure" }
    [PSCustomObject]@{ Timestamp = "2026-06-19T08:32:00"; Username = "guest"; SourceIP = "203.0.113.99"; Action = "FailedLogin"; Status = "Failure" }
)
'@

What does this code do?

Set-Content writes a block of text to a file. The @'...'@ syntax is a PowerShell "here-string" that preserves all formatting and line breaks inside it.

Each [PSCustomObject]@{} creates a structured object with named properties. This is how PowerShell represents structured data, similar to a row in a spreadsheet or a record in a database.

💡 What suspicious patterns are in this data?

  • hacker1 has 4 failed login attempts in rapid succession from IP 203.0.113.42.
  • jdoe logs in at 23:45 (after hours) from an unfamiliar IP 192.168.5.99.
  • admin logs in at 02:15 from unknown IP 198.51.100.77.
  • guest has 3 failed login attempts from 203.0.113.99.
  • Verify the file was created by running:
Get-Content /lab/create-logs.ps1 | Select-Object -First 5

You should see the first five lines of the script, starting with the comment and the $logs array declaration.

Seeing an error about the path?

Make sure your current location is /lab. Run Get-Location to check. If you are somewhere else, run Set-Location /lab to get back.

help me fix a path error when creating my script file in the PowerShell Docker container

Write the data to a JSON file

The script defines the data, but it does not yet save it anywhere useful. You need to serialize the objects into JSON format and write that to a file your analysis script can read later.

  • Append the JSON write commands to your script by running:
Add-Content -Path /lab/create-logs.ps1 -Value @'

# Write JSON log file
$logs | ConvertTo-Json | Set-Content -Path /lab/auth-logs.json
Write-Host "Created /lab/auth-logs.json with $($logs.Count) entries" -ForegroundColor Green
'@

What does this code do?

ConvertTo-Json takes the array of PowerShell objects and serializes them into JSON text. The pipe sends that text to Set-Content, which writes it to the file at /lab/auth-logs.json.

JSON preserves the data types and structure of each object, making it easy for other scripts (or other languages entirely) to read the data back.

  • Run the script to generate the JSON file:
./create-logs.ps1

You should see a green message confirming the JSON file was created with 16 entries.

  • Verify the file contents by reading it back and counting the records:
Get-Content /lab/auth-logs.json | ConvertFrom-Json | Measure-Object

You should see Count: 16 in the output, confirming all 16 log entries were written.

What is ConvertFrom-Json doing here?

ConvertFrom-Json reads JSON text and converts it back into PowerShell objects. Piping those objects into Measure-Object counts them. This round-trip (objects to JSON to objects) confirms your file is valid and complete.

🙋‍♀️ Seeing Count: 1 instead of 16?

This happens when Get-Content passes the JSON line-by-line instead of as one string. Use the -Raw parameter to read the entire file as a single string:

Get-Content -Raw /lab/auth-logs.json | ConvertFrom-Json | Measure-Object

help me understand why ConvertFrom-Json gives a different count with and without -Raw

Create a CSV version for practice

JSON is great for preserving object structure, but CSV is the format you will encounter most often in spreadsheet exports and legacy systems. Creating both formats from the same data lets you compare how each one represents structured information.

  • Append the CSV export commands to your script by running:
Add-Content -Path /lab/create-logs.ps1 -Value @'

# Write CSV log file
$logs | Export-Csv -Path /lab/auth-logs.csv -NoTypeInformation
Write-Host "Created /lab/auth-logs.csv with $($logs.Count) entries" -ForegroundColor Green
'@

What does this code do?

Export-Csv converts each object into a comma-separated row. The -NoTypeInformation parameter suppresses an extra header line that PowerShell adds by default, giving you a clean CSV file.

  • Re-run the script to generate both files:
./create-logs.ps1

You should see two green confirmation messages, one for the JSON file and one for the CSV file.

  • Read the CSV file back and display it as a table:
Import-Csv /lab/auth-logs.csv | Format-Table

You should see all 16 entries displayed in neat columns with headers for Timestamp, Username, SourceIP, Action, and Status.

How does CSV compare to JSON?

CSV flattens everything into plain text strings. Every value in the CSV is a string, even if the original object had numbers or dates.

JSON preserves the original data types and can represent nested objects. For the analysis script you will build next, you will use the JSON version because ConvertFrom-Json recreates the full object structure.

🙋‍♀️ Format-Table cutting off your columns?

If columns are truncated with ... at the end, the terminal window is too narrow. Try widening your terminal, or use Format-List instead to see each record vertically.

help me display the full CSV data without columns being cut off in PowerShell

✔️ Awesome, I've got everything!

Great. Double check you have both auth-logs.json and auth-logs.csv in your /lab directory by running Get-ChildItem /lab.

ⓧ I'd like to double check the full code

Here is the complete create-logs.ps1 script. Compare it against what you have in your /lab folder:

# Create mock authentication log data
$logs = @(
    [PSCustomObject]@{ Timestamp = "2026-06-18T08:15:00"; Username = "jsmith"; SourceIP = "10.0.1.50"; Action = "Login"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T08:20:00"; Username = "jdoe"; SourceIP = "10.0.1.51"; Action = "Login"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T09:00:00"; Username = "admin"; SourceIP = "10.0.1.10"; Action = "Login"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T09:05:00"; Username = "hacker1"; SourceIP = "203.0.113.42"; Action = "FailedLogin"; Status = "Failure" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T09:06:00"; Username = "hacker1"; SourceIP = "203.0.113.42"; Action = "FailedLogin"; Status = "Failure" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T09:07:00"; Username = "hacker1"; SourceIP = "203.0.113.42"; Action = "FailedLogin"; Status = "Failure" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T09:08:00"; Username = "hacker1"; SourceIP = "203.0.113.42"; Action = "FailedLogin"; Status = "Failure" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T10:30:00"; Username = "jsmith"; SourceIP = "10.0.1.50"; Action = "Logout"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T23:45:00"; Username = "jdoe"; SourceIP = "192.168.5.99"; Action = "Login"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-18T23:50:00"; Username = "jdoe"; SourceIP = "192.168.5.99"; Action = "Logout"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-19T02:15:00"; Username = "admin"; SourceIP = "198.51.100.77"; Action = "Login"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-19T02:20:00"; Username = "admin"; SourceIP = "198.51.100.77"; Action = "Logout"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-19T08:00:00"; Username = "jsmith"; SourceIP = "10.0.1.50"; Action = "Login"; Status = "Success" }
    [PSCustomObject]@{ Timestamp = "2026-06-19T08:30:00"; Username = "guest"; SourceIP = "203.0.113.99"; Action = "FailedLogin"; Status = "Failure" }
    [PSCustomObject]@{ Timestamp = "2026-06-19T08:31:00"; Username = "guest"; SourceIP = "203.0.113.99"; Action = "FailedLogin"; Status = "Failure" }
    [PSCustomObject]@{ Timestamp = "2026-06-19T08:32:00"; Username = "guest"; SourceIP = "203.0.113.99"; Action = "FailedLogin"; Status = "Failure" }
)

# Write JSON log file
$logs | ConvertTo-Json | Set-Content -Path /lab/auth-logs.json
Write-Host "Created /lab/auth-logs.json with $($logs.Count) entries" -ForegroundColor Green

# Write CSV log file
$logs | Export-Csv -Path /lab/auth-logs.csv -NoTypeInformation
Write-Host "Created /lab/auth-logs.csv with $($logs.Count) entries" -ForegroundColor Green

You now have realistic security log data in two formats, ready for analysis. Next up, you will build the script that scans these logs and flags every suspicious event.

Build the Security Analysis Script

Your mock log data is ready and waiting for analysis. Now comes the real security work: writing a script that automatically sifts through those 16 authentication events and surfaces the suspicious ones.

In this step, you will build Analyze-AuthLogs.ps1, a PowerShell script that chains together the pipeline skills you practiced earlier. The script performs three distinct analyses on your log data to flag failed login patterns, after-hours access, and logins from unknown IP addresses.

In this step, get ready to:
  • Create the script file and write the data-loading section.
  • Implement three security analysis pipelines using Where-Object, Group-Object, and Select-Object.
  • Add color-coded console output and a summary report.
Create the script file and write the header

Since the Docker container does not have a graphical text editor, you will write your script using PowerShell's Set-Content and Add-Content cmdlets. You will wrap multi-line code in a single-quoted here-string (@'...'@) so that dollar signs and backticks are preserved literally in the file.

  • Write the script header and data-loading section by running this command:
@'
# Analyze-AuthLogs.ps1
# Security analysis script for authentication log data

# Read and parse the JSON log file
$logs = Get-Content -Raw /lab/auth-logs.json | ConvertFrom-Json

Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host "  AUTHENTICATION LOG ANALYSIS REPORT" -ForegroundColor Cyan
Write-Host "========================================`n" -ForegroundColor Cyan
Write-Host "Total log entries: $($logs.Count)`n"
'@ | Set-Content -Path /lab/Analyze-AuthLogs.ps1

What does this code do?

The @'...'@ syntax is a single-quoted here-string. Everything between the markers is written to the file exactly as-is, without PowerShell expanding variables like $logs or escape sequences like `n.

When the script eventually runs, Get-Content -Raw reads the entire JSON file as one string. Then ConvertFrom-Json parses it into PowerShell objects that the pipeline can filter and sort.

  • Confirm the file was created by running:
Get-ChildItem /lab/Analyze-AuthLogs.ps1
  • You should see Analyze-AuthLogs.ps1 listed with its file size and timestamp.

Don't see the file?

Make sure the closing '@ is on its own line with no leading spaces. PowerShell requires here-string closing markers to start at column 1.

Also confirm your current location is /lab by running Get-Location.

Help me troubleshoot my here-string file creation in PowerShell.

Implement the three analysis pipelines

Each analysis uses the same pattern: filter the log entries with Where-Object, group or select relevant properties, then output alerts for anything suspicious. You will add each analysis to the script file using Add-Content.

  • Add Analysis 1 (excessive failed logins) by running:
@'

# --- Analysis 1: Failed Login Attempts ---
Write-Host "[Analysis 1] Users with excessive failed logins (threshold: >2)" -ForegroundColor Yellow
Write-Host "---------------------------------------------------------------"

$failedLogins = $logs |
    Where-Object { $_.Action -eq "FailedLogin" } |
    Group-Object -Property Username |
    Where-Object { $_.Count -gt 2 } |
    Select-Object @{Name="Username";Expression={$_.Name}}, Count

if ($failedLogins) {
    $failedLogins | Format-Table -AutoSize
    foreach ($user in $failedLogins) {
        Write-Host "  ALERT: $($user.Username) has $($user.Count) failed login attempts!" -ForegroundColor Red
    }
} else {
    Write-Host "  No users exceeded the failed login threshold.`n" -ForegroundColor Green
}
'@ | Add-Content -Path /lab/Analyze-AuthLogs.ps1

What does this analysis do?

  • Where-Object { $_.Action -eq "FailedLogin" } filters down to only failed login events.
  • Group-Object -Property Username clusters those failures by who triggered them.
  • The second Where-Object { $_.Count -gt 2 } keeps only users who failed more than twice. This threshold surfaces brute-force attempts while ignoring occasional typos.
  • Select-Object creates a calculated property that renames the group's Name field to Username for cleaner output.
  • Add Analysis 2 (after-hours logins) by running:
@'

# --- Analysis 2: After-Hours Access ---
Write-Host "`n[Analysis 2] After-hours logins (before 06:00 or after 22:00)" -ForegroundColor Yellow
Write-Host "---------------------------------------------------------------"

$afterHours = $logs |
    Where-Object { $_.Action -eq "Login" -and $_.Status -eq "Success" } |
    Where-Object {
        $hour = ([datetime]$_.Timestamp).Hour
        $hour -lt 6 -or $hour -ge 22
    } |
    Select-Object Timestamp, Username, SourceIP

if ($afterHours) {
    $afterHours | Format-Table -AutoSize
    foreach ($event in $afterHours) {
        Write-Host "  ALERT: $($event.Username) logged in at $($event.Timestamp) from $($event.SourceIP)" -ForegroundColor Red
    }
} else {
    Write-Host "  No after-hours logins detected.`n" -ForegroundColor Green
}
'@ | Add-Content -Path /lab/Analyze-AuthLogs.ps1

What does this analysis do?

  • The first Where-Object filters to successful logins only (not failed attempts or logouts).
  • [datetime]$_.Timestamp casts the string to a DateTime object so you can extract the .Hour property.
  • The condition $hour -lt 6 -or $hour -ge 22 flags any login before 6 AM or at/after 10 PM as suspicious.
  • Add Analysis 3 (unknown source IPs) by running:
@'

# --- Analysis 3: Unknown Source IPs ---
Write-Host "`n[Analysis 3] Logins from unknown source IPs" -ForegroundColor Yellow
Write-Host "---------------------------------------------------------------"

$knownIPs = @("10.0.1.50", "10.0.1.51", "10.0.1.10")

$unknownIPLogins = $logs |
    Where-Object { $_.Action -eq "Login" -and $_.Status -eq "Success" } |
    Where-Object { $_.SourceIP -notin $knownIPs } |
    Select-Object Timestamp, Username, SourceIP

if ($unknownIPLogins) {
    $unknownIPLogins | Format-Table -AutoSize
    foreach ($event in $unknownIPLogins) {
        Write-Host "  ALERT: $($event.Username) logged in from unknown IP $($event.SourceIP)" -ForegroundColor Red
    }
} else {
    Write-Host "  All logins from known IPs.`n" -ForegroundColor Green
}
'@ | Add-Content -Path /lab/Analyze-AuthLogs.ps1

What does this analysis do?

  • $knownIPs defines a whitelist of trusted internal IP addresses.
  • The -notin operator checks whether each login's source IP is absent from the known-good list. Any IP not on the list gets flagged.
  • In real-world security operations, this list would come from your network team's asset inventory or a configuration management database.
  • Verify all three analyses are in the file by running:
Get-Content /lab/Analyze-AuthLogs.ps1 | Measure-Object -Line
  • You should see approximately 55-60 lines. If the count is much lower, one of your Add-Content commands may not have run successfully.

Line count much lower than expected?

Run Get-Content /lab/Analyze-AuthLogs.ps1 | Select-Object -Last 3 to see where the file ends. Re-run the missing Add-Content block from above to append the missing section.

Help me figure out which analysis section is missing from my script.

Add the summary section

The final section tallies all findings and reports how many suspicious events were found across how many unique users. This gives a quick at-a-glance count when the script finishes.

  • Append the summary section by running:
@'

# --- Summary ---
$totalAlerts = @($failedLogins).Count + @($afterHours).Count + @($unknownIPLogins).Count
$affectedUsers = ($failedLogins.Username + $afterHours.Username + $unknownIPLogins.Username) |
    Select-Object -Unique

Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host "  SUMMARY" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "  Total suspicious findings: $totalAlerts" -ForegroundColor White
Write-Host "  Affected users: $($affectedUsers.Count)" -ForegroundColor White
Write-Host "========================================`n" -ForegroundColor Cyan
'@ | Add-Content -Path /lab/Analyze-AuthLogs.ps1

What does this section do?

  • @($failedLogins).Count wraps each result in an array before counting. This ensures the count works correctly even if only one item is returned (PowerShell does not auto-wrap single objects in an array).
  • The $affectedUsers line concatenates all usernames from all three analyses, then pipes through Select-Object -Unique to deduplicate.
  • Confirm the script is complete by checking the last few lines:
Get-Content /lab/Analyze-AuthLogs.ps1 | Select-Object -Last 5
  • You should see the final Write-Host "========================================`n" line near the bottom, confirming the summary section was added.

See an incomplete file?

If the last lines show content from Analysis 3 instead of the summary, re-run the summary Add-Content block above. Each Add-Content call appends to the end, so running it again is safe as long as you only run it once.

If the file has duplicate sections, delete it with Remove-Item /lab/Analyze-AuthLogs.ps1 and start from the first Set-Content command again.

Help me fix my Analyze-AuthLogs.ps1 file.

✔️ Awesome, I've got everything!

Great. Make sure you see a clean file with all five sections: header, Analysis 1, Analysis 2, Analysis 3, and Summary.

ⓧ I'd like to double check the full code

Here is the complete Analyze-AuthLogs.ps1 file. Compare your version by running Get-Content /lab/Analyze-AuthLogs.ps1 and checking it matches.

# Analyze-AuthLogs.ps1
# Security analysis script for authentication log data

# Read and parse the JSON log file
$logs = Get-Content -Raw /lab/auth-logs.json | ConvertFrom-Json

Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host "  AUTHENTICATION LOG ANALYSIS REPORT" -ForegroundColor Cyan
Write-Host "========================================`n" -ForegroundColor Cyan
Write-Host "Total log entries: $($logs.Count)`n"

# --- Analysis 1: Failed Login Attempts ---
Write-Host "[Analysis 1] Users with excessive failed logins (threshold: >2)" -ForegroundColor Yellow
Write-Host "---------------------------------------------------------------"

$failedLogins = $logs |
    Where-Object { $_.Action -eq "FailedLogin" } |
    Group-Object -Property Username |
    Where-Object { $_.Count -gt 2 } |
    Select-Object @{Name="Username";Expression={$_.Name}}, Count

if ($failedLogins) {
    $failedLogins | Format-Table -AutoSize
    foreach ($user in $failedLogins) {
        Write-Host "  ALERT: $($user.Username) has $($user.Count) failed login attempts!" -ForegroundColor Red
    }
} else {
    Write-Host "  No users exceeded the failed login threshold.`n" -ForegroundColor Green
}

# --- Analysis 2: After-Hours Access ---
Write-Host "`n[Analysis 2] After-hours logins (before 06:00 or after 22:00)" -ForegroundColor Yellow
Write-Host "---------------------------------------------------------------"

$afterHours = $logs |
    Where-Object { $_.Action -eq "Login" -and $_.Status -eq "Success" } |
    Where-Object {
        $hour = ([datetime]$_.Timestamp).Hour
        $hour -lt 6 -or $hour -ge 22
    } |
    Select-Object Timestamp, Username, SourceIP

if ($afterHours) {
    $afterHours | Format-Table -AutoSize
    foreach ($event in $afterHours) {
        Write-Host "  ALERT: $($event.Username) logged in at $($event.Timestamp) from $($event.SourceIP)" -ForegroundColor Red
    }
} else {
    Write-Host "  No after-hours logins detected.`n" -ForegroundColor Green
}

# --- Analysis 3: Unknown Source IPs ---
Write-Host "`n[Analysis 3] Logins from unknown source IPs" -ForegroundColor Yellow
Write-Host "---------------------------------------------------------------"

$knownIPs = @("10.0.1.50", "10.0.1.51", "10.0.1.10")

$unknownIPLogins = $logs |
    Where-Object { $_.Action -eq "Login" -and $_.Status -eq "Success" } |
    Where-Object { $_.SourceIP -notin $knownIPs } |
    Select-Object Timestamp, Username, SourceIP

if ($unknownIPLogins) {
    $unknownIPLogins | Format-Table -AutoSize
    foreach ($event in $unknownIPLogins) {
        Write-Host "  ALERT: $($event.Username) logged in from unknown IP $($event.SourceIP)" -ForegroundColor Red
    }
} else {
    Write-Host "  All logins from known IPs.`n" -ForegroundColor Green
}

# --- Summary ---
$totalAlerts = @($failedLogins).Count + @($afterHours).Count + @($unknownIPLogins).Count
$affectedUsers = ($failedLogins.Username + $afterHours.Username + $unknownIPLogins.Username) |
    Select-Object -Unique

Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host "  SUMMARY" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "  Total suspicious findings: $totalAlerts" -ForegroundColor White
Write-Host "  Affected users: $($affectedUsers.Count)" -ForegroundColor White
Write-Host "========================================`n" -ForegroundColor Cyan

Your security analysis script is built and ready to go. Next up, you will run it against your mock data and see the alerts light up in your terminal.

Run and Validate Your Script

In the previous step, you built a full security analysis script with three detection functions. Now it is time to run it against your mock data and confirm it catches every suspicious pattern you planted.

After validation, you will learn how to modify your data on the fly to test new scenarios. You will also explore the Docker container lifecycle so you understand when your work persists and when it disappears.

In this step, get ready to:
  • Execute the analysis script and verify it detects all embedded anomalies.
  • Test the script with modified data to confirm it adapts to new threats.
  • Understand container lifecycle and learn how to persist your scripts across sessions.
Execute the script

Your script reads the JSON log file, runs three analyses, and prints color-coded results. Running it will immediately show whether the detection logic works against the suspicious patterns you embedded earlier.

  • Run the analysis script by executing this command:
./Analyze-AuthLogs.ps1

What should I see?

The script prints a formatted report with three analysis sections plus a summary. You should see:

  • Analysis 1 flags hacker1 with 4 failed login attempts (red ALERT text).
  • Analysis 2 flags jdoe (23:45) and admin (02:15) for after-hours logins.
  • Analysis 3 flags logins from unknown IPs 192.168.5.99 and 198.51.100.77.
  • The summary line shows total suspicious findings and affected user count.
  • Confirm that the red ALERT lines appear for each suspicious finding.
  • Verify the summary at the bottom shows findings across multiple users.

Seeing an error when you run the script?

  • Make sure you are in the /lab directory. Run Set-Location /lab if needed.
  • Check that auth-logs.json exists by running Test-Path /lab/auth-logs.json. If it returns False, re-run ./create-logs.ps1 to regenerate the log data.
  • If you see a permissions error, run chmod +x ./Analyze-AuthLogs.ps1 to make the script executable.

Help me debug my Analyze-AuthLogs.ps1 script that is not producing the expected output.

Test with modified data

A good security script should detect new threats without modification. You will add more suspicious entries to the log file and re-run the script to confirm it picks them up automatically.

  • Add new suspicious entries to the log file by running these commands:
# Read the existing log data
$logs = Get-Content -Raw /lab/auth-logs.json | ConvertFrom-Json

# Add a new suspicious entry: failed login from a different attacker
$logs += [PSCustomObject]@{ Timestamp = "2026-06-19T04:30:00"; Username = "intruder"; SourceIP = "203.0.113.200"; Action = "FailedLogin"; Status = "Failure" }
$logs += [PSCustomObject]@{ Timestamp = "2026-06-19T04:31:00"; Username = "intruder"; SourceIP = "203.0.113.200"; Action = "FailedLogin"; Status = "Failure" }
$logs += [PSCustomObject]@{ Timestamp = "2026-06-19T04:32:00"; Username = "intruder"; SourceIP = "203.0.113.200"; Action = "FailedLogin"; Status = "Failure" }

# Write the updated data back to the JSON file
$logs | ConvertTo-Json | Set-Content -Path /lab/auth-logs.json
Write-Host "Updated auth-logs.json: now has $($logs.Count) entries" -ForegroundColor Green

What does this code do?

This reads the existing JSON, converts it to PowerShell objects, appends three new failed login entries for a user called intruder, then writes the updated array back to the same file.

Since intruder now has 3 failed attempts (exceeding the threshold of 2), the script should flag this user in Analysis 1.

  • Re-run the analysis script to verify it detects the new anomalies:
./Analyze-AuthLogs.ps1
  • Confirm that Analysis 1 now shows both hacker1 and intruder in the failed login alerts.
  • Notice that the total findings count in the summary has increased.

Now experiment with the detection threshold to see how it changes the results.

  • In Analyze-AuthLogs.ps1, find this line in the Analysis 1 section:
    Where-Object { $_.Count -gt 2 } |
  • Change 2 to 1, so the line now reads:
    Where-Object { $_.Count -gt 1 } |
  • Run the script again:
./Analyze-AuthLogs.ps1

What changed?

With a lower threshold of 1, the script now also flags guest (who had 3 failed attempts, previously caught) along with any user with 2+ failures. This demonstrates how adjusting thresholds makes your detection more or less sensitive.

  • Change the threshold back to 2 before moving on. Find the line you just edited and restore it to:
    Where-Object { $_.Count -gt 2 } |

Script not picking up new entries?

  • Confirm the JSON file was actually updated by running Get-Content /lab/auth-logs.json | ConvertFrom-Json | Measure-Object. The count should be 19 (16 original + 3 new).
  • Make sure you ran the data-update commands in the PowerShell session, not in a separate terminal.

Why is my script not detecting the new entries I added to auth-logs.json?

Understand container lifecycle

Everything you have built so far lives inside the Docker container. Understanding when that data persists and when it disappears is critical for using containers as a reusable lab environment.

  • Exit the PowerShell session inside the container by running:
exit

You are now back in your macOS terminal. The container has stopped, but it still exists with all your files intact.

  • Re-enter the container by running this command in your macOS terminal:
docker start -i ps-lab

What does docker start -i do?

The -i flag keeps STDIN open so you get an interactive session. This restarts your stopped container and reconnects you to the PowerShell prompt with all your files still in place.

  • Verify your files survived the stop/start cycle:
Get-ChildItem /lab
  • Confirm you see Analyze-AuthLogs.ps1, auth-logs.json, auth-logs.csv, and create-logs.ps1 in the output.

When do container files disappear?

Files persist across docker stop and docker start cycles. They are only destroyed when you remove the container entirely with docker rm ps-lab.

To keep your scripts permanently (even after removing the container), you can mount a local folder from your Mac into the container. This is called a volume mount.

For future projects, you can mount a local directory so your scripts live on your Mac and survive even if the container is deleted.

  • Exit the container again:
exit
  • See the volume mount pattern by reviewing this command (you do not need to run it now):
docker run -it -v ${PWD}/ps-scripts:/lab --name ps-lab-persist mcr.microsoft.com/powershell

What does -v do?

The -v ${PWD}/ps-scripts:/lab flag maps a folder called ps-scripts on your Mac (in your current directory) to /lab inside the container. Any files you create in /lab will appear in ps-scripts on your Mac, and vice versa.

  • Re-enter your original container to keep working:
docker start -i ps-lab
  • Confirm you are back inside PowerShell by running $PSVersionTable. You should see the PSVersion value.

Container not starting?

  • Run docker ps -a to check the container status. If the STATUS column shows Exited, the docker start -i ps-lab command should bring it back.
  • If you accidentally removed the container, you can recreate it with docker run -it --name ps-lab mcr.microsoft.com/powershell and re-run the setup scripts.

Help me troubleshoot my Docker container that is not starting or reconnecting.

Your security analysis script is fully validated and your container lab is working exactly as expected. Next up, you will take this further with a Secret Mission that generates a styled HTML report from your findings.

Secret mission

Generate an HTML Security Report

Your script prints alerts to the console, but what if you needed to share findings with a team? Extend your analysis script to produce a styled HTML security report using PowerShell's ConvertTo-Html cmdlet. You will collect findings into custom objects, inject CSS for professional formatting, and export a shareable report file.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Docker container ps-lab (contains all your scripts and log files).
  • Docker image mcr.microsoft.com/powershell (the PowerShell base image).
  • Local security-report.html file copied to your Mac.

Keep everything running

No action needed. Choose this if you are still actively experimenting with PowerShell commands inside the container.

  • Your ps-lab container stays running with all scripts and log files intact.
  • You can re-enter the container at any time with docker start -i ps-lab if you have exited.
  • The Docker image remains cached locally for quick container creation in the future.

Pause - I'll come back to this later

Shut down the running container to free up memory. Your files inside the container are preserved and you can restart it later.

  • If you are still inside the PowerShell session, type exit to leave the container.
  • The container stops automatically when you exit.
  • To resume later, run:
docker start -i ps-lab

All your scripts and data files will still be inside /lab when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes the container and all files inside it.

  • If you are still inside the PowerShell session, type exit to leave the container.
  • Stop and remove the container by running:
docker rm -f ps-lab
  • Remove the PowerShell Docker image by running:
docker rmi mcr.microsoft.com/powershell
  • Delete the HTML report file you copied to your Mac by running:
rm security-report.html

What about Docker Desktop?

Docker Desktop was a prerequisite for this project, not something you installed during it. Keep it if you plan to do more container-based projects in the future.

Nice Work!

Nice Work!

You just built a containerized security analysis lab from scratch. Starting with nothing but a Docker image, you explored PowerShell's object pipeline, created structured log data, and wrote a script that automatically flags suspicious authentication activity.

You've learned how to:

  • Launch and manage an isolated PowerShell 7 environment using Docker, giving you a resettable sandbox for experimentation.
  • Master the object pipeline by chaining cmdlets with Where-Object, Sort-Object, Select-Object, Group-Object, and Measure-Object to filter, shape, and summarize data.
  • Build a security analysis script that parses JSON log data and identifies failed login patterns, after-hours access, and unknown source IPs.
  • Secret Mission: Generate a styled HTML security report using ConvertTo-Html with custom CSS for professional presentation of findings.

Ready to quiz yourself?