CHAPTER 09 40 MIN READ INTERMEDIATE

PowerShell Security & Logging

PowerShell ships on every modern Windows host and gets used constantly for entirely legitimate work: patch scripts, inventory pulls, Active Directory administration, scheduled maintenance, and countless one-off admin tasks that never touch a ticketing system. That ubiquity means a SOC analyst's job is not to treat PowerShell activity as inherently suspicious, because flagging every invocation produces an unworkable volume of noise. The job is to know exactly what visibility exists into PowerShell execution, what a normal baseline looks like in your environment, and which logs to pull the moment something needs a closer look. This chapter covers the logging mechanisms Windows provides for PowerShell, the specific Event IDs each one produces, and the configuration controls that turn PowerShell from a visibility gap into one of the richest telemetry sources on the endpoint.

PowerShell logging script block logging JEA

PowerShell and Why Visibility Matters

Deep Integration, Minimal Footprint

PowerShell is not a standalone scripting language bolted onto Windows. It is built directly on the .NET Common Language Runtime and has first-class access to WMI, COM, and the Windows API surface. A single PowerShell command can query Active Directory, touch the registry, manipulate services, and reach out over the network, all without invoking a separate executable for each step.

That depth of integration is exactly why PowerShell activity is fundamentally different from a suspicious binary dropped to disk. A malicious PowerShell script does not need to write a new file at all. It can be handed to the engine as a string, assembled dynamically at runtime, decoded from an encoded argument, or pulled down and executed entirely in memory.

Why Antivirus Alone Falls Short

That in-memory, script-based execution model is precisely why traditional file-based antivirus scanning is a weak control against PowerShell misuse on its own. A signature-based scanner looks for known-bad files on disk; if nothing is ever written to disk, there is nothing for it to find. This is not a statement about attacker cleverness, it is a statement about where the actual control has to live: in PowerShell's own logging and execution-policy layer, not in the file system.

Microsoft built PowerShell with this reality in mind. The logging features covered in this chapter exist specifically to give defenders visibility into commands and scripts regardless of whether they ever touch disk.

Treat It Like Core Telemetry

PowerShell logging configuration deserves the same deliberate rollout as any other core telemetry source, alongside process creation auditing (Event ID 4688) and EDR agent coverage. An organization with strong process-level logging but no PowerShell-specific logging only sees that powershell.exe launched, not what it actually ran.

The reverse gap matters too. An organization with rich PowerShell logging but no baseline for normal administrative usage will drown analysts in alert volume with no way to prioritize.

A Defense-in-Depth Stack

The remainder of this chapter treats PowerShell logging as a defense-in-depth stack: multiple independent mechanisms, each with a different blind spot, that together give a SOC meaningfully complete visibility. None of these controls is sufficient alone.

  • Capture mechanisms. Module Logging, Script Block Logging, and Transcription each record a different slice of an execution.
  • Restriction mechanisms. Constrained Language Mode, JEA, AppLocker, and WDAC restrict what can run in the first place.

Understanding what each one does, and does not, capture is the foundation for building reliable PowerShell detections.

Note: All of the logging features in this chapter are configured through Group Policy under Administrative Templates > Windows Components > Windows PowerShell, or through the equivalent registry keys under HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell. None of them are enabled by default on a stock Windows installation, which is the single most common visibility gap this chapter addresses.

Module Logging

What It Captures

Module Logging records pipeline execution details for the specific PowerShell modules an administrator has enabled it for. When active, it writes the commands that ran through a given module, plus their parameters, as Event ID 4103 to the Microsoft-Windows-PowerShell/Operational log.

In practical terms, it tells an analyst that a particular cmdlet from a particular module executed, and what arguments were passed to it. That is genuinely useful when investigating an incident where a known module was involved, for example confirming that a particular Active Directory cmdlet ran against a specific object.

The Opt-In Limitation

The practical limitation of Module Logging is right in its name: it only logs modules that have been explicitly enabled for logging. Windows PowerShell ships with dozens of built-in modules, and organizations frequently install more, so an administrator must select which ones generate 4103 events, commonly through the "Turn on Module Logging" Group Policy setting with a wildcard or an explicit module list.

A module that was never added to that list produces no 4103 telemetry no matter what runs through it. Enabling logging for every module (using a wildcard such as *) is the common recommendation for security-relevant environments, but that decision has a real cost in log volume that needs to be sized for before rollout.

The Content-Completeness Gap

Module Logging captures pipeline execution details, command names, and parameters, but it was never designed to be a full-fidelity record of everything a script does internally. Code that runs without invoking a logged module's cmdlets, or logic that operates purely on .NET objects and language constructs rather than module commands, can execute without generating a corresponding 4103 event.

This is why Module Logging is described as complementary rather than a complete solution: it is strong at telling you which commands ran, weaker at reconstructing a script's full logic.

When to Pull It

Because of that gap, Module Logging is rarely the first log source an analyst pulls during an investigation. It is more often used to corroborate a finding from Script Block Logging, confirming that a specific command with specific parameters actually executed against a specific module, or to answer narrow questions like "did anyone run Get-ADUser against this account in the last week."

Treat it as a supporting log source that adds command-level detail once you already have a lead, not as your primary detection surface.

Tip: If storage or SIEM ingestion budget forces a choice between logging sources, prioritize Script Block Logging over Module Logging. Script Block Logging captures the executed code itself and covers a broader range of activity; Module Logging is a useful supplement once that foundation is in place.

Script Block Logging

What Gets Logged

Script Block Logging is the single most valuable PowerShell log source available to a defender. When enabled through the "Turn on PowerShell Script Block Logging" policy, it writes Event ID 4104 to the Microsoft-Windows-PowerShell/Operational log every time a block of PowerShell code is executed, whether that code was typed interactively, run from a saved script file, or built dynamically at runtime.

What makes this the highest-value source is that it logs the code as it is actually compiled and executed by the PowerShell engine, not merely the command line that launched the process. If a script assembles a command dynamically, for example by concatenating strings or decoding a Base64-encoded block before running it, Script Block Logging captures the resulting executed content, not just the original wrapper that process-creation logging alone would show. That distinction, as-executed versus as-typed, is what separates a log source that can be trivially starved of content from one that reliably captures the substance of what ran.

The Built-In Heuristic Safety Net

PowerShell also has a built-in heuristic layer independent of the "Turn on Script Block Logging" policy: certain patterns considered suspicious by the engine's own detection logic trigger a warning-level 4104 event automatically, even in environments that have not explicitly enabled full Script Block Logging.

This is a useful safety net, but it should never be relied on as a substitute for deliberately enabling the policy, because it only fires on a defined set of patterns rather than logging comprehensively.

Multi-Part Script Blocks

Very long script blocks are split across multiple 4104 events that share a common identifier so they can be reassembled. Each event's fields, including MessageNumber and MessageTotal, tell you how to stitch a multi-part script back together.

When building detections or doing manual triage, remember that a single suspicious script may show up as several linked 4104 records rather than one, and your query logic or SIEM parser needs to account for that.

The Volume Tradeoff

Volume is the honest tradeoff here. Because Script Block Logging captures essentially all executed PowerShell code across the environment, including routine administrative scripts, scheduled tasks, and management tooling that itself runs on PowerShell under the hood, 4104 can be one of the higher-volume event IDs a SOC ingests.

That is a reason to plan storage and SIEM licensing accordingly, not a reason to avoid enabling it. The alternative, no visibility into executed code content at all, is a materially worse position for an investigator to be in.

Log SourceCapturesEnabled By Default
Module LoggingPipeline execution details for enabled modulesNo
Script Block LoggingActual code as compiled and executed, including dynamically assembled contentNo (heuristic warnings only)

PowerShell Transcription

What It Captures That the Others Don't

PowerShell Transcription writes a plain-text record of an entire interactive session, both the commands entered and the output that was returned, to a file on disk. The field that distinguishes it from the other two logging mechanisms is output: Script Block Logging and Module Logging both tell you what code ran, but neither tells you what that code returned or printed to the console.

Transcription fills exactly that gap. If a script queries a system and prints results, enumerates data and displays it, or an operator runs a diagnostic command and reads the output on screen, that output is captured in the transcript file. For an investigation trying to reconstruct not just what an operator or a script did but what they saw and knew as a result, transcript output can be the deciding piece of evidence.

Configuration and Storage

Transcription is enabled through the "Turn on PowerShell Transcription" Group Policy setting, which corresponds to the registry key under HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription with the EnableTranscripting value set. Administrators can also start a transcript manually within a session using the Start-Transcript cmdlet, though for enterprise visibility the policy-driven, always-on configuration is what matters.

Transcript files are written to disk as text, by default to each user's Documents folder unless an OutputDirectory is configured, with a filename that includes the computer name and a timestamp. In a managed environment, that output directory is almost always redirected to a centralized, write-restricted network share so an operator cannot simply delete their own local transcript to cover their tracks, and so a SIEM or log shipper can pick the files up for centralized retention.

Limitations

Transcription records what appears in the session's input and output stream, which for content assembled or executed silently in memory without any interactive echo may be thinner than what Script Block Logging captures for the same activity.

The two features are designed to be run together, and Microsoft's own guidance treats them as complementary: Script Block Logging for the as-executed code, Transcription for the human-readable narrative of a session including its results.

Note: Because transcripts are plain text files rather than structured Windows Event Log entries, they need to be pulled into your SIEM through a separate file-collection or log-shipping pipeline. Confirm your logging agent is actually watching the configured OutputDirectory before assuming transcript coverage exists.

Constrained Language Mode and JEA

Quick glossary, keep these straight:
  • CLM (Constrained Language Mode): a PowerShell language mode that blocks calls to arbitrary .NET/COM types, leaving only approved commands.
  • JEA (Just Enough Administration): role-based, constrained PowerShell endpoints that limit what cmdlets an operator can run.
  • AppLocker / WDAC: application control policies whose enforcement is what triggers CLM (covered fully later in this chapter).

Constrained Language Mode

PowerShell supports several language modes, and Constrained Language Mode is the one built specifically as a security boundary. It is designed to support ordinary day-to-day administrative tasks while restricting access to the language elements that allow scripts to call arbitrary .NET methods, instantiate arbitrary .NET or COM types, or otherwise reach outside the set of approved commands. A script running under CLM can still do a great deal of legitimate administrative work, but the sensitive building blocks that let it interact directly with low-level system APIs are cut off.

The important operational detail is how CLM actually gets applied. PowerShell does not switch into it on its own; it is triggered automatically when PowerShell is running under an enforced application control policy, specifically AppLocker or Windows Defender Application Control (WDAC), and the script or session in question falls outside what that policy explicitly allows to run in Full Language Mode. Without an application control policy enforcing it, PowerShell defaults to Full Language Mode regardless of any other setting.

That means CLM is not something you toggle in isolation, it is a downstream effect of an AppLocker or WDAC deployment, which is why it belongs in the same conversation as execution restriction rather than as a purely standalone control. An analyst can check the active mode for a given session with $ExecutionContext.SessionState.LanguageMode.

Just Enough Administration (JEA)

JEA takes a different but complementary approach: rather than restricting what language constructs are available inside a session, it restricts what an operator can do in the first place by defining role-based, constrained PowerShell endpoints. An administrator connecting through a JEA endpoint is limited to a specific, pre-approved set of cmdlets, functions, and parameters defined in that endpoint's role capability file, rather than getting an open-ended session with their full standing permissions.

JEA sessions commonly run under a temporary virtual account or a group-managed service account, so an operator can perform an action that genuinely requires elevated rights without ever holding standing admin credentials themselves.

From a logging perspective, JEA has a built-in advantage: because every JEA session runs through a defined, constrained endpoint, it is straightforward to configure transcription and detailed logging specifically for that endpoint, giving you a clean audit trail of exactly which commands a given operator ran during a privileged session. That pairing, least-privilege access plus mandatory session logging, directly supports incident response by narrowing the set of actions any single compromised credential could have taken.

ControlWhat It RestrictsHow It's Triggered
Constrained Language ModeLanguage elements: arbitrary .NET/COM calls, low-level API accessAutomatically, by AppLocker or WDAC enforcement
JEAWhich cmdlets/parameters an operator can invoke, via a role-defined endpointExplicitly configured endpoint an operator connects to

Why They're Not Logging Mechanisms

Neither CLM nor JEA is a logging mechanism in itself, and it is worth being explicit about that distinction. They are hardening controls that shrink what is possible to execute or which endpoint an operator can reach, which reduces the attack surface a defender has to monitor in the first place. Deploying them alongside Script Block Logging and Transcription, rather than instead of them, is the combination that produces both fewer bad outcomes and better evidence when something does happen.

PowerShell Event IDs to Monitor

Two Logs, Two Eras

Two separate Windows Event Logs carry PowerShell telemetry, and analysts building detections need to know which one to query for which purpose. The legacy "Windows PowerShell" log carries the classic engine lifecycle and pipeline events going back to early PowerShell versions.

The newer Microsoft-Windows-PowerShell/Operational log, introduced with PowerShell v3 and the module-based logging architecture, carries the higher-fidelity events this chapter has focused on. A mature detection stack pulls from both, because some tooling and some older systems still only populate the legacy log.

The Reference Table

The table below is the practical reference for a SOC analyst wiring up detection content: which Event ID fires, in which log, and what it actually tells you.

Event IDLogMeaning
400Windows PowerShellEngine state changed to Available; a new PowerShell session/pipeline has started
403Windows PowerShellEngine state changed to Stopped; the session has ended
600Windows PowerShellA provider (such as the registry or file system provider) was started within the session
800Windows PowerShellPipeline execution details; a summary record of a command or script that ran, useful as a fallback when Operational-log detail is unavailable
4103Microsoft-Windows-PowerShell/OperationalModule Logging: pipeline execution details for a module enabled for logging, including command and parameters
4104Microsoft-Windows-PowerShell/OperationalScript Block Logging: the actual code compiled and executed, including dynamically assembled or decoded content

Filtering by Level

4104 deserves special attention when building alerting logic. Most 4104 events are logged at an informational or verbose level and represent completely routine execution. A smaller subset are logged at warning level because they matched PowerShell's built-in suspicious-content heuristics.

That Level field is a cheap, high-value filter for surfacing the events worth a first look without having to inspect every single script block logged across the fleet.

Correlating Sessions

When correlating across these events, the HostID and RunspaceID fields are what tie a session together. A single investigation often needs to walk from a 400 (session start), through a series of 4104 events (the code that ran), to a 403 (session end), all sharing the same identifiers.

Building that correlation into your SIEM's parsing logic ahead of time saves significant time during an actual incident rather than reconstructing it manually under pressure.

Warning: Do not assume the Microsoft-Windows-PowerShell/Operational log alone is sufficient. If Script Block Logging was only enabled recently, or was disabled on a given host, the legacy Windows PowerShell log's 400/403/800 events may be the only PowerShell telemetry available for the time window you are investigating. Check both logs before concluding that no record exists.

Establishing a Baseline and Spotting Anomalies

Build the Baseline First

Effective PowerShell detection depends on knowing what normal looks like in your specific environment before trying to define what abnormal looks like. Every organization has its own mix of scheduled maintenance scripts, configuration management tooling, and admin habits, and a pattern that would be alarming in one environment is background noise in another.

Spend time reviewing a representative sample of 4104 events during a quiet period to build that baseline: what modules run routinely, what parent processes typically launch PowerShell, and roughly how long or complex a normal script block tends to be.

Four Signals Worth Correlating

Against that baseline, four patterns consistently earn a closer look. None of them is proof on its own, each is a prioritization signal.

  • Script length and entropy. A script block that is unusually long, or that consists largely of high-entropy character sequences such as long runs of random-looking encoded or compressed text, is worth a look. This is not proof of anything on its own, legitimate tooling sometimes embeds compressed resources or generated data, but it is a low-cost, high-signal filter for triage. Pairing a length or entropy threshold with the warning-level flag on 4104 events is a reasonable starting point for a detection rule.
  • Parent process. PowerShell launched interactively by a logged-on administrator from a normal shell looks very different, from a process-tree perspective, than PowerShell spawned as a child of an Office application, a scripting host, or another process that does not typically launch PowerShell in your environment. Correlating 4688 process-creation events (or your EDR's equivalent) with the PowerShell logs described in this chapter surfaces execution chains worth investigating even when the script block content itself looks unremarkable.
  • Command-line flags. Flags that suppress the normal interactive experience, such as running without loading the usual profile, running with a hidden or minimized window, or bypassing an execution policy check, are commonly used by both legitimate automation (a scheduled task has no reason to load an interactive profile) and by activity that specifically wants to avoid drawing attention. The presence of such flags does not distinguish the two cases by itself; combining them with the parent process, the script content, and whether the calling account and time of day fit the baseline does.
  • Frequency and timing. A service account that runs the same PowerShell task every night at 2 AM is baseline. The same account suddenly running an unfamiliar script block at an unusual hour, or a workstation account invoking PowerShell at all when it never has before, is a deviation worth a look regardless of what the script content contains. Building alerting around deviation from an established per-host or per-account baseline, rather than around any single static rule, produces far better signal-to-noise than one universal "this script is bad" pattern.

Signals, Not Verdicts

The consistent theme across all of these signals is that none of them, on their own, means malicious activity occurred. Encoded content, unusual parent processes, quiet-hour execution, and profile-suppressing flags are all things legitimate administration does routinely somewhere in most environments.

The analyst's job is to treat each as a prioritization signal that earns a script a closer read, not as an automatic verdict, and to build detection logic that scores and correlates these signals rather than firing hard on any single one in isolation.

Restricting Execution with AppLocker and WDAC

Two Controls, One Goal

Logging tells you what happened. AppLocker and Windows Defender Application Control (WDAC) are the complementary side of the equation: controls that restrict which scripts are allowed to run in the first place, rather than simply recording that they ran. Both let an administrator define policy governing which scripts, executables, and modules are permitted to execute on a system, evaluated against rules based on file path, publisher signature, or file hash.

AppLocker vs WDAC

AppLocker is the older and more approachable of the two, letting administrators build rules that determine whether a script is allowed to run at all, and separately, whether it runs in Full Language Mode or is dropped into Constrained Language Mode. It is a reasonable fit for organizations that need targeted script restriction without the deeper platform integration that WDAC requires.

WDAC (the modern evolution of what used to be called Device Guard) is a kernel-enforced control that locks down which scripts, modules, and PowerShell commands can run at a lower, harder-to-bypass level, and its enforcement is what automatically triggers Constrained Language Mode for anything that falls outside the allowed policy.

ControlEnforcement LevelBest Fit
AppLockerUser-mode policy, rule-based (path, publisher, hash)Targeted script restriction without deep platform integration
WDACKernel-enforced, harder to bypassOrganizations needing lockdown-grade control

Division of Responsibility

It is worth being precise about how these two controls divide responsibility, since they solve different problems. WDAC and AppLocker govern whether a given script is permitted to execute at all. Constrained Language Mode governs what a script is allowed to do even once it has been permitted to run, restricting it away from the sensitive .NET and COM access described earlier in this chapter.

Neither one replaces logging: a permitted script that runs under Constrained Language Mode is still worth capturing in Script Block Logging, both to confirm the restriction held and to have a record of exactly what executed.

Why Both Together

This is why execution restriction and logging are described as complementary rather than redundant. A logging-only posture gives you excellent visibility but no ability to actually stop something from running before it does damage. An execution-restriction-only posture can block a lot of unauthorized activity but, without logging, gives an investigator little to go on when trying to understand what a permitted script actually did.

Deploying both together, backed by the baseline-driven triage approach from the previous section, is what gives a SOC realistic, defensible coverage of PowerShell activity across an enterprise.

Tip: Roll AppLocker or WDAC PowerShell policy out in Audit mode first, not Enforce mode. Audit mode generates the same "would have been blocked" telemetry without actually breaking legitimate scripts, giving you a realistic view of what your policy would impact before it can disrupt production administration.

Key Takeaways

  • PowerShell's deep .NET and WMI integration and its ability to run entirely in memory make logging configuration, not antivirus, the primary visibility control for this activity.
  • Module Logging (Event ID 4103) records pipeline execution details but only for modules explicitly enabled for logging, and does not by itself capture the full logic of a script.
  • Script Block Logging (Event ID 4104) captures the actual code as compiled and executed, including dynamically assembled or decoded content, making it the highest-value PowerShell log source.
  • Transcription writes a full text record of session input and output, filling the gap that neither Module Logging nor Script Block Logging covers: what a script actually returned.
  • Constrained Language Mode and JEA are hardening controls, not logging mechanisms. CLM is triggered by AppLocker or WDAC enforcement; JEA constrains operators to role-defined, auditable endpoints.
  • None of the anomaly signals covered here, script length, entropy, parent process, suppressed-profile flags, or off-baseline timing, are proof of malicious activity on their own. Treat each as a reason to look closer, not a verdict.
  • AppLocker and WDAC restrict what can execute at all, and their enforcement is what triggers Constrained Language Mode. Execution restriction plus logging together cover both prevention and investigation in a way neither does alone.

Knowledge Check

Click an answer to reveal the explanation.

During an investigation, you find Event ID 4103 records showing a specific cmdlet ran, but you need to see the exact code that assembled and executed the payload dynamically at runtime. Which log source should you pull next?

Script Block Logging (4104) is designed exactly for this case: it captures the resulting executed content after any dynamic assembly or decoding, not merely the original command that triggered it. Module Logging (4103) tells you which command and module ran but not the full reconstructed code. 400/403 are lifecycle events with no script content at all.

You are scoping a PowerShell logging rollout and need to prioritize due to SIEM ingestion cost. Which single logging feature should be enabled first if only one can be deployed initially?

Script Block Logging captures the as-executed code across essentially all PowerShell activity, including dynamically assembled content that other sources would miss, making it the highest-value single log source. Module Logging only covers explicitly enabled modules and lacks full script content. Transcription adds valuable output context but is a complement to, not a substitute for, capturing executed code.

A workstation account that has never run PowerShell before suddenly generates a 4104 event at 3 AM for a script block that suppresses the normal profile-loading behavior. What is the correct analyst response?

No single signal here, the flag, the timing, or the account type, is proof of malicious activity on its own; each is common in legitimate automation somewhere. But the combination of a workstation account deviating from its own baseline, off-hours execution, and profile-suppressing flags together is exactly the kind of correlated deviation worth a closer look, which means pulling the full 4104 content and correlating parent process before making any determination.