Eric P. Security Engineering
← All labs
intermediate June 17, 2025 7 min read completed

Differential Triage for Windows Incident Response

A PowerShell collector and a Python merge script that diff a suspect Windows host against a known-good baseline, cutting the artifacts an analyst has to review by roughly 90 percent. No EDR, no licence, no coding required to run it.

PowerShellPythonpandasopenpyxlExcel

Objective

A lot of organizations lack the people and the tooling to run effective incident response. The hardest part is not collecting data, it is knowing what is normal and what might be malicious. That is the question that keeps you up at night.

The usual advice is to establish a baseline. That is easier said than done in most environments, and even once you have one, you still need a way to use it. Software is expensive, it takes time to configure, and analysts need training before any of it pays off.

This lab builds a repeatable, scalable triage method for teams with none of that: no dedicated IR staff, no EDR, no budget. It uses tools that are either free or already sitting in your environment. It uses PowerShell and Python, but running it requires no coding.

The core idea is differential analysis. If you know what normal looks like, you can compare those indicators against the ones on a host you suspect is compromised, and review only what is present on the target but absent from the baseline.

Differential analysis for Windows incident response: a baseline set of indicators is compared against the target host, and the indicator present only on the target is surfaced as unusual

That single filter is what makes the workload tractable. Instead of sifting through every service, task, and connection on a machine, you look at the residue.

Setup

The toolkit is three files:

  • 01_Artifact_Collector.ps1, run on the target machine
  • CSV_Merge.py, run on the analysis machine
  • CF_config.json, which drives the Excel conditional formatting

You need Python 3.10 or newer with pandas, openpyxl, and xlsxwriter installed. Optionally, any third-party tools you already use, such as SQLite3 or the Eric Zimmerman forensic suite, can be folded into the collector.

Nothing is hard-coded. The Python script reads four environment variables, so the paths are yours to choose:

WinIR_Baseline_Backup=C:\WinIR\Baseline
WinIR_Case_Data=C:\WinIR\Case_Data\Target
WinIR_Case_folder=C:\WinIR\Cases
WinIR_Config_Folder=C:\WinIR\Configs

Collecting a baseline

Decide which artifacts you want before you collect anything. I would recommend a separate baseline for each asset type you run, such as finance, shop floor, or engineering. The closer the baseline is to the target’s role, the less noise survives the filter.

To capture one, run the collector on a known-good machine of that type and move the results into the folder you set for WinIR_Baseline_Backup.

The collector itself is intentionally bare-bones. Other tools exist for this, such as KAPE and Velociraptor, each with their own strengths. I kept this one minimal to push you to think about your own approach: how you move files, what artifacts you generate, and, most importantly, what you can reasonably interpret once you have them.

Out of the box it captures services, scheduled tasks, DNS cache, the process list, autoruns, and a one-minute sample of TCP connections:

Write-Host "Services Collection In Progress"
Get-WmiObject win32_service |
  Select-Object name,state,displayname,processid,startmode,pathname,startname |
  Export-Csv C:\temp\_temp\services.csv -NoTypeInformation

Write-Host "Process list Collection In Progress"
Get-WmiObject win32_process |
  Select-Object name,executablepath,processid,parentprocessid,commandline |
  Export-Csv C:\temp\_temp\plist.csv -NoTypeInformation

Network connections are sampled on a timer rather than captured once, because a single snapshot misses anything short-lived:

$timespan = New-TimeSpan -Minutes 1
$timer = [diagnostics.stopwatch]::startnew()
while ($timer.elapsed -lt $timespan) {
    Get-NetTCPConnection | Where-Object state -ne "Bound" |
      Select-Object localaddress,localport,remoteaddress,remoteport,state,owningprocess,
        @{Name="process";Expression={(Get-Process -id $_.OwningProcess).ProcessName}} |
      Export-Csv C:\Temp\_temp\net_connect.csv -NoTypeInformation -Append
    Start-Sleep -Seconds 3
}

Everything lands in a Target folder stamped with the hostname and collection time, which becomes the unit you move to the analysis machine.

Managing the conditional formatting rules

The last piece of setup is CF_config.json. You will not touch it often, only when you add a new artifact and want it included in the highlighting.

Each entry is one sheet:

{
  "Sheet_name": "target_services",
  "Cell_Range": "F2:F1000",
  "Formula": "AND(COUNTIF(baseline_services!$F:$F, F1) = 0, F1<>\"\")"
}

To add an artifact, copy an existing block, keeping the comma between entries. Update Sheet_name to match your new file, and keep the target_ prefix or no formatting will be applied. Then pick the column you want evaluated and change every column letter in the formula to match.

The formula is the whole method in one line: highlight the cell when its value does not appear anywhere in the baseline’s matching column, and the cell is not blank.

Implementation

CSV_Merge.py runs in four stages. It opens a numbered case folder, arranges the baseline and target files, merges every CSV into one workbook, and applies the highlighting.

Case numbering is automatic, so each run is self-contained and comparable later:

def get_next_case_number(case_root_path):
    case_numbers = [int(num) for num in os.listdir(case_root_path)]
    return max(case_numbers) + 1

Baseline and target files share the same filenames, so each set is prefixed before the merge. That prefix is what the Excel formulas key on later:

rename_files_with_prefix(paths["baseline"], "baseline")
rename_files_with_prefix(paths["target"], "target")

Every CSV then becomes a worksheet in a single workbook. Control characters are stripped on the way in, because a stray byte in a command line or path will otherwise break the Excel writer:

ILLEGAL_CHARACTERS_RE = re.compile(r'[\000-\010]|[\013-\014]|[\016-\037]')

def merge_csvs_to_excel(output_folder):
    csv_data_dict = []
    for file in os.listdir(output_folder):
        if not file.endswith(".csv"):
            continue
        df = pd.read_csv(os.path.join(output_folder, file))
        csv_data_dict.append({"Sheet_Name": file.split(".")[0], "Data_Frame": df})

    with pd.ExcelWriter(os.path.join(output_folder, "unified_book.xlsx"),
                        engine="xlsxwriter") as writer:
        for entry in csv_data_dict:
            df_clean = entry["Data_Frame"].map(
                lambda x: ILLEGAL_CHARACTERS_RE.sub('---', x) if isinstance(x, str) else x
            )
            df_clean.to_excel(writer, sheet_name=entry["Sheet_Name"], index=False)

Finally the rules from CF_config.json are applied to the target sheets and the workbook opens:

yellowfill = PatternFill(start_color='FFFF00', end_color='FFFF00', fill_type='solid')

for entry in config_data:
    try:
        sheet = xl_workbook[entry["Sheet_name"]]
        sheet.conditional_formatting.add(
            entry["Cell_Range"], FormulaRule(formula=[entry["Formula"]], fill=yellowfill)
        )
    except Exception as e:
        print(f"Formatting failed for sheet {entry['Sheet_name']}: {e}")

Wrapping each rule in its own try matters more than it looks. A config entry pointing at a sheet that was not collected this run should skip that one rule, not abandon the whole workbook.

Walkthrough

Move the collector to the host you want to investigate and run it. It reports each stage as it goes.

The artifact collector running in PowerShell on the target host, writing services and scheduled task CSVs into a working folder

When it finishes, the generated Target folder can be moved wholesale to the analysis machine. Clean up afterwards: revert any config changes and delete the tools from the target host.

The generated Target folder alongside the collector script and autorunsc.exe on the target host

With the target data in place, run the Python script. In a second or so it prints the CSVs it processed and Excel opens with the marked-up workbook.

Using the workbook is deliberately simple. On each target worksheet, select all columns and enable the filter from the Data ribbon. Then open the filter dropdown, choose “Filter by Color”, and pick the highlight color.

Excel filter dropdown on a target worksheet with Filter by Color selected, showing the yellow highlight option

What is left is the set of artifacts that exist on the target and have no match in the baseline.

Findings

Filtering by color typically leaves under 10 percent of the original rows to review per host, though the exact reduction depends on how variable the field you filter on actually is. Process lists and services collapse hard. Network connections vary more, which is why they are sampled rather than snapshotted.

A few things that came out of building it:

  • The baseline’s specificity drives everything. A generic golden image produces far more noise than a baseline taken from the same asset class as the target.
  • Choosing the right column matters as much as the artifact. Diffing on a service’s binary path is useful. Diffing on a PID is worthless, because it changes every boot.
  • Keeping the collector minimal was the right call. It forces you to decide what you can actually interpret, rather than collecting everything and drowning in it.
  • Excel is the right output. Not because it is elegant, but because every analyst already knows how to filter a spreadsheet, and that removes the training barrier entirely.

The reduction is the point. It does not tell you what is malicious, it tells you where to look, which is the expensive part of triage when you have no tooling to do it for you.

Next Steps

  • Extend the collector with artifacts you can interpret, such as prefetch, shimcache, or registry run keys, adding a matching CF_config.json block for each.
  • Replace the manual color filter with a generated summary sheet listing every unmatched row across all artifacts.
  • Track how often a highlighted artifact turns out to be benign, and use that to prune fields that produce noise rather than signal.
#incident-response#dfir#differential-analysis#triage#baselining

Related labs