YOUR CAPSTONE PROJECT · LESSON 17

Capstone: build a case reporting tool

Combine time filtering, detection functions, evidence references, file output, and error handling into a reusable triage tool.

40 min · learn at your pace

Your brief

A teammate has exported authentication and DNS observations for a fictional case. Build New-TriageReport -CaseDirectory -OutputDirectory so they can rerun the same analysis on another supplied case. Start with case-a; case-b changes the identities, dates, indicators, and threshold. case-empty is a valid case with no events. case-broken contains malformed configuration.

Each valid case directory contains case.json, auth.csv, dns.csv, and indicators.json. Configuration supplies CaseId, StartUtc, EndUtc, and FailureThreshold. Both event files include Time with an explicit offset. Domains are under the Domains property in the indicator JSON. Inputs are already normalised; you do not need to collect live events.

Two explicit rules

Apply the half-open configured time window to both sources. Count 4625 authentication failures by Computer, TargetUser, and SourceIp, excluding empty or - addresses. Emit a FailedLogonBurst finding when the count is at least the configured threshold. For DNS, normalise Query and the indicator list to lowercase without trailing dots, then emit an IndicatorMatch for each exact matching observation.

Every finding has Rule, Computer, Subject, Value, Count, SourceIds. For a burst, Subject is the account, Value is the source IP, Count is the failure count, and SourceIds is the sorted record IDs joined by semicolons. For DNS, Subject is DNS, Value is the normalised domain, Count is 1, and SourceIds is the single record ID. Preserve separate matching DNS observations.

A report another person can use

Write findings.csv and summary.json inside OutputDirectory, creating it if needed. The CSV must retain its six-column header even with no findings. The JSON has CaseId, Status (Needs review), FindingCount, a sorted unique Hosts array, StartUtc, and EndUtc copied from the configuration. An empty Hosts array must remain an array.

Return one result object with boolean Success, FindingCount, and Error. Successful runs have an empty Error. Missing required files or malformed JSON return Success false, FindingCount 0, and a non-empty Error, without an uncaught exception. Read and validate inputs before creating a report so a failed import cannot masquerade as a successful empty case. Use a fresh output directory per case.

Build it in stages

Start with import and the two time boundaries. Test each rule separately, combine the findings, then add exports and catch input errors. Avoid hardcoded case IDs or expected findings. The checker invokes your function on all supplied cases, including empty and failed inputs. It re-imports the files to assess their data, not their formatting or your choice of loop versus pipeline.

These findings describe leads for review. The report does not establish compromise, attribute an actor, or replace a complete investigation. After this course, the shared Advanced PowerShell path will build tools used across security, systems, and networking, starting with a TCP port scanner.

Try an example.ps1
Get-Content '/home/user/case-a/case.json' -Raw | ConvertFrom-Json -DateKind String
Import-Csv '/home/user/case-a/auth.csv' | Select-Object -First 2
YOUR TURN

Put it into practice.

Implement New-TriageReport with the input, finding, output, and error contracts above. Try case-a into /home/user/report-a. The checks also call case-b, case-empty, malformed input, and a missing case. Keep all evidence unchanged, derive the report from the supplied configuration, and return a single explicit result for each call.

See an explained solution
One way to solve it.ps1
function New-TriageReport {
    param([string]$CaseDirectory, [string]$OutputDirectory)
    try {
        $config = Get-Content -LiteralPath (Join-Path $CaseDirectory 'case.json') -Raw -ErrorAction Stop | ConvertFrom-Json -DateKind String -ErrorAction Stop
        $auth = @(Import-Csv -LiteralPath (Join-Path $CaseDirectory 'auth.csv') -ErrorAction Stop)
        $dns = @(Import-Csv -LiteralPath (Join-Path $CaseDirectory 'dns.csv') -ErrorAction Stop)
        $indicators = Get-Content -LiteralPath (Join-Path $CaseDirectory 'indicators.json') -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
        $start = [DateTimeOffset]::Parse($config.StartUtc)
        $end = [DateTimeOffset]::Parse($config.EndUtc)
        $known = @($indicators.Domains | ForEach-Object { $_.TrimEnd('.').ToLowerInvariant() })
        $findings = @(
            $auth | Where-Object { $_.EventId -eq 4625 -and $_.SourceIp -and $_.SourceIp -ne '-' -and [DateTimeOffset]::Parse($_.Time) -ge $start -and [DateTimeOffset]::Parse($_.Time) -lt $end } |
                Group-Object Computer, TargetUser, SourceIp | Where-Object Count -GE $config.FailureThreshold | ForEach-Object {
                    $first = $_.Group[0]
                    [pscustomobject]@{ Rule='FailedLogonBurst'; Computer=$first.Computer; Subject=$first.TargetUser; Value=$first.SourceIp; Count=$_.Count; SourceIds=($_.Group.RecordId | Sort-Object) -join ';' }
                }
            foreach ($row in $dns) {
                $time = [DateTimeOffset]::Parse($row.Time)
                $query = $row.Query.TrimEnd('.').ToLowerInvariant()
                if ($time -ge $start -and $time -lt $end -and $query -in $known) {
                    [pscustomobject]@{ Rule='IndicatorMatch'; Computer=$row.Computer; Subject='DNS'; Value=$query; Count=1; SourceIds=$row.RecordId }
                }
            }
        )
        New-Item -ItemType Directory -Path $OutputDirectory -Force -ErrorAction Stop | Out-Null
        $csvPath = Join-Path $OutputDirectory 'findings.csv'
        if ($findings.Count) { $findings | Export-Csv -LiteralPath $csvPath -NoTypeInformation -ErrorAction Stop }
        else { 'Rule,Computer,Subject,Value,Count,SourceIds' | Set-Content -LiteralPath $csvPath -ErrorAction Stop }
        [pscustomobject]@{ CaseId=$config.CaseId; Status='Needs review'; FindingCount=$findings.Count; Hosts=@($findings.Computer | Sort-Object -Unique); StartUtc=$config.StartUtc; EndUtc=$config.EndUtc } |
            ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -ErrorAction Stop
        [pscustomobject]@{ Success=$true; FindingCount=$findings.Count; Error='' }
    }
    catch { [pscustomobject]@{ Success=$false; FindingCount=0; Error=$_.Exception.Message } }
}
New-TriageReport -CaseDirectory '/home/user/case-a' -OutputDirectory '/home/user/report-a'

The tool reads its scope from each case, keeps failure grouping and domain matching distinct, and exports traceable findings. Empty cases remain successful empty reports, while import errors are explicit failures. Source IDs and time boundaries make the analysis reproducible.