The goal is to get a readable answer on screen without writing anything to the endpoint. That comes down to three skills: get the data, filter it down, and format it so nothing truncates.

The shape of every command

Get-Something | Where-Object { filter } | Select-Object fields | Sort-Object field | Format-List

Each | hands the objects from the left into the command on the right. PowerShell passes objects, not text, which is why you can filter on a property name instead of grepping.

Think of it as a conveyor belt:

Get-Something    -> the belt starts loaded with objects
| Where-Object   -> station 1: throw out the ones that don't qualify
| Select-Object  -> station 2: keep only the parts you need
| Sort-Object    -> station 3: put them in order
| Format-List    -> station 4: package them to be read
Get-ChildItem "C:\Users\<user>\AppData\Local\Temp" -Recurse -Force -ErrorAction SilentlyContinue |
    Where-Object { $_.Extension -eq '.exe' } |
    Select-Object Name, Length, CreationTimeUtc, FullName |
    Sort-Object CreationTimeUtc -Descending |
    Format-List

Verbs you will actually use

Cmdlet What it gets
Get-CimInstance WMI data: processes, services, drivers, disks
Get-ChildItem Files and folders (ls / dir are aliases)
Get-Content The contents of a file (cat is an alias)
Get-ItemProperty Registry values under a key
Get-Item A single file, folder, or registry key object
Select-String Pattern search inside files (grep equivalent)
Get-FileHash SHA256 and other hashes
Get-AuthenticodeSignature Code-signing status
Get-ScheduledTask Scheduled tasks
Get-NetTCPConnection Active TCP connections
Get-WinEvent Event log records
Get-Process Running processes. Lighter than CIM, but no command line in Windows PowerShell 5.1 (PowerShell 7 adds CommandLine)

Filtering: do it early

Where-Object { $_.Name -match 'powershell|cmd' }
Where-Object Name -match 'powershell|cmd'        # simplified syntax, same result

$_ means “the current object coming down the pipe.” The two forms are equivalent for a single comparison; use the braces version when you need more than one condition.

Operator Meaning
-eq -ne equals, not equals
-like wildcard match, use *
-match regular expression match
-notmatch regex non-match
-in -contains membership in a list: $x -in $list, or $list -contains $x
-gt -lt greater than, less than (works on dates)
-and -or combine conditions

Comparisons are case-insensitive by default. Prefix with c for case-sensitive (-cmatch).

Selecting fields

Select-Object ProcessId, Name, CommandLine       # keep these properties
Select-Object -First 20                          # first 20 objects
Select-Object -Last 20                           # last 20
Select-Object -ExpandProperty Hash               # return the bare value, not an object

A calculated property builds a new column. Here it adds the owning process name to each network connection:

Get-NetTCPConnection -State Established |
    Select-Object LocalPort, RemoteAddress, RemotePort,
        @{n='Process'; e={ (Get-Process -Id $_.OwningProcess).ProcessName }}

n is the column name, e is the expression that produces its value.

Formatting so nothing truncates

This is the single most important section for shell-based review.

Command Use it when
Format-List Long values: command lines, paths, hashes. One property per line, never clipped. Default choice.
Format-Table -AutoSize Short values, many rows: you want to scan a list
Format-Table -AutoSize -Wrap Table, but let long cells wrap instead of clip
Out-String -Width 4096 Force a wide render when the host reports a narrow console
ConvertTo-Json -Depth 3 Structured output that ignores console width entirely
ConvertTo-Csv -NoTypeInformation Easy to paste into case notes

Why output shows the ellipsis

For objects without a built-in view, PowerShell picks Format-Table when there are four or fewer properties, and a table clips cells to the console width. The data is not lost; the renderer threw it away. Switch to Format-List and it comes back.

Collections inside a property are also cut off, after four items by default. Set this once at the start of a session so they print in full:

$FormatEnumerationLimit = -1

Common flags

Flag Meaning
-Force Include hidden and system items
-Recurse Descend into subfolders
-LiteralPath Treat the path as literal. Required when it contains [ ] or other wildcard characters
-ErrorAction SilentlyContinue (-EA 0) Suppress access-denied noise so the command completes
-Filter Provider-level filter, much faster than piping into Where-Object
-MaxEvents n Cap event log results
-Depth n How far to recurse, or how deep to serialize
-Raw Return a file as one string instead of an array of lines

Use -ErrorAction SilentlyContinue on anything recursive across user profiles, or the first denied folder buries your output.

Keeping output small

Volume is the enemy of shell review. In order of preference:

  1. Filter at the source: -Filter, or a specific path instead of C:\
  2. Filter early in the pipe: Where-Object before Select-Object
  3. Cap the rows: Select-Object -First 25
  4. Bound by time: Where-Object LastWriteTimeUtc -gt (Get-Date).ToUniversalTime().AddHours(-24)
  5. Count first, then look: Measure-Object tells you whether it is worth printing
Get-ChildItem "C:\Path" -Recurse -ErrorAction SilentlyContinue | Measure-Object | Select-Object Count

Page through a long result set by moving -Skip:

Get-ChildItem "C:\Path" -Recurse -ErrorAction SilentlyContinue |
    Select-Object -Skip 0 -First 25 |
    Format-Table Name, Length -AutoSize

Time handling

Always work in UTC. The Utc properties exist on file objects for exactly this reason.

(Get-Date).ToUniversalTime()
(Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ssZ')
Get-ChildItem "C:\Path" | Select-Object LastWriteTimeUtc, CreationTimeUtc, Name

.AddHours(-24) and .AddDays(-7) build relative windows.

String formatting for clean lines

When a table will not fit, build the line yourself:

Get-ChildItem "C:\Path" -File |
    ForEach-Object { "{0}  {1}" -f (Get-FileHash $_.FullName).Hash, $_.FullName }

-f fills {0} {1} in order. `t is a tab. The backtick is PowerShell’s escape character, and also continues a command onto the next line.

Aliases you will see

Alias Real cmdlet
ls dir gci Get-ChildItem
cat gc Get-Content
ps gps Get-Process
where ? Where-Object
select Select-Object
sort Sort-Object
fl ft Format-List Format-Table

Write cmdlets in full in case notes. Aliases are fine while typing, unreadable six months later.

Safety habits

  • Never pipe an enumeration into Remove-Item “to save a step”
  • Never use wildcards in a delete
  • -WhatIf on any destructive cmdlet shows what would happen without doing it
  • Read a script with Get-Content; never run it to find out what it does

Triage starters

Ready-to-run, read-only one-liners that put the patterns above to work. Each one filters early, selects only what matters, and formats so nothing truncates.

Processes with command line and parent

$procs = Get-CimInstance Win32_Process
$names = @{}; $procs | ForEach-Object { $names[$_.ProcessId] = $_.Name }
$procs |
    Select-Object ProcessId, Name,
        @{n='Parent'; e={ $names[$_.ParentProcessId] }}, ParentProcessId, CommandLine |
    Format-List

A parent that no longer exists shows up blank, which is worth noticing on its own.

Network connections with the owning process

Get-NetTCPConnection -State Established |
    Select-Object RemoteAddress, RemotePort, LocalPort, OwningProcess,
        @{n='Process'; e={ (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName }} |
    Sort-Object RemoteAddress |
    Format-Table -AutoSize

Run keys, machine and per user

In a remote response shell you usually run as SYSTEM, so HKCU is SYSTEM’s hive, not the user’s. Read per-user keys through HKEY_USERS instead:

$keys = @(
    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run',
    'HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnce',
    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'
)
$keys += Get-ChildItem 'Registry::HKEY_USERS' |
    Where-Object { $_.PSChildName -match '^S-1-5-21-[\d-]+$' } |
    ForEach-Object { "Registry::HKEY_USERS\$($_.PSChildName)\Software\Microsoft\Windows\CurrentVersion\Run" }

$keys | ForEach-Object {
    $key = $_
    $item = Get-ItemProperty -Path $key -ErrorAction SilentlyContinue
    if ($item) {
        $item.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } |
            ForEach-Object { [pscustomobject]@{ Key = $key; Name = $_.Name; Value = $_.Value } }
    }
} | Format-List

Only hives of users with a loaded profile appear under HKEY_USERS.

Scheduled tasks that run something outside Windows

Filtering out the \Microsoft\ task folder misses attackers who hide tasks there. Filter on what the task executes instead:

Get-ScheduledTask | ForEach-Object {
    $task = $_
    $task.Actions | Where-Object { $_.Execute } | ForEach-Object {
        [pscustomobject]@{
            Task      = "$($task.TaskPath)$($task.TaskName)"
            State     = $task.State
            Execute   = $_.Execute
            Arguments = $_.Arguments
        }
    }
} | Where-Object { $_.Execute -notmatch '^"?(%windir%|%SystemRoot%|C:\\Windows\\)' } |
    Format-List

Recent executables and scripts in user-writable paths

Count first (see above); this can be large on a busy profile.

$since = (Get-Date).ToUniversalTime().AddDays(-7)
Get-ChildItem 'C:\Users\*\AppData', 'C:\Users\Public', 'C:\ProgramData' -Recurse -Force -File `
        -Include *.exe, *.dll, *.ps1, *.bat, *.cmd, *.vbs, *.js -ErrorAction SilentlyContinue |
    Where-Object { $_.CreationTimeUtc -gt $since } |
    Select-Object CreationTimeUtc, Length, FullName |
    Sort-Object CreationTimeUtc -Descending |
    Format-Table -AutoSize -Wrap

Logons in the last 24 hours

Interactive (2), network (3), and remote interactive (10) logons from the Security log:

Get-WinEvent -FilterHashtable @{ LogName = 'Security'; Id = 4624; StartTime = (Get-Date).AddHours(-24) } `
        -MaxEvents 500 -ErrorAction SilentlyContinue |
    ForEach-Object {
        $data = @{}
        ([xml]$_.ToXml()).Event.EventData.Data | ForEach-Object { $data[$_.Name] = $_.'#text' }
        [pscustomobject]@{
            TimeUtc   = $_.TimeCreated.ToUniversalTime()
            User      = "$($data.TargetDomainName)\$($data.TargetUserName)"
            LogonType = $data.LogonType
            SourceIp  = $data.IpAddress
            Process   = $data.ProcessName
        }
    } |
    Where-Object { $_.LogonType -in '2', '3', '10' } |
    Format-Table -AutoSize -Wrap

Remote interactive logons (type 10) with an unexpected source IP are usually the first thing to chase.