Why I Built a Fake Power Grid to Learn NERC CIP
Kicking off a series where I stand up a simulated Bulk Electric System on KVM/libvirt, drive it with real grid physics and OT protocols, and run a mock NERC CIP assessment against it — starting with the scenario, the architecture, and an honest caveat about what a mock audit can and can't prove.
For this project I built a small fake power grid. It isn’t real hardware, but it works: it solves actual grid physics, it speaks the same industrial protocols a real utility uses, and you can open a circuit breaker on it by sending a command across the network. Over the next six posts I build on it. I attack it, I defend it, and I set up monitoring until things like breaker commands and config changes show up as alerts in Splunk.
I did this because I kept reading the NERC CIP standards and not really getting them. Reading the rules teaches you the vocabulary, not how the work actually happens. They only started to click once I had something to point them at. So that is what this series is: I build the thing first, then I assess it.
I’m not assuming you already know any of this. I’ll explain every term and tool as it comes up.
What is NERC CIP?
NERC CIP — North American Electric Reliability Corporation, Critical Infrastructure Protection — is the set of mandatory reliability standards that govern how the entities running the Bulk Electric System (BES) secure the cyber systems that keep the grid up. Regular IT security usually cares most about confidentiality, keeping data secret. The grid cares more about availability and integrity: keeping the power flowing and the equipment doing what it’s supposed to. A breaker that opens when it shouldn’t is a much bigger problem than a database that leaks.
A few terms I’ll use, in the industry’s own vocabulary:
- Bulk Electric System (BES) — the high-voltage backbone: the generation and transmission that moves power across regions, as opposed to the local wires feeding your street.
- BES Cyber System (BCS) — the “critical systems” whose loss would affect the reliable operation of the grid.
- Electronic Security Perimeter (ESP) — the logical boundary around those systems.
- Electronic Access Point (EAP) — the firewall interface where traffic crosses the ESP.
- Responsible Entity — the utility on the hook for compliance.
- OT (Operational Technology) — the computers that touch physical equipment: breakers, turbines, pumps. As opposed to IT, the computers that touch data. The distinction drives everything else here.
The honest caveat, up front. I am not a NERC-registered entity and this is not a real audit. Physical-security standards (CIP-006/014) can only be documented here, not tested. I’m framing this entire series as a simulated assessment / mock audit.
The scenario I’m modeling
I picked a Medium-impact BES Cyber System with External Routable Connectivity (ERC). Think of a small Transmission or Generator Operator control center. I chose that rating on purpose. It’s high enough to pull in the controls I actually wanted to work with (the ESP, Interactive Remote Access, and most of CIP-007 and CIP-010), and because it has ERC, it also triggers CIP-015 (Internal Network Security Monitoring), which is the big new 2026 control a lot of these posts lead up to.
The architecture
Everything runs as KVM guests on a single Linux host, managed with libvirt (virt-manager
for the clicking, virsh for the scripting). The lab is split into two trust zones — two
isolated libvirt networks — joined only by a firewall VM that acts as the EAP and defines
the ESP:
flowchart TB
vendor["Internet / "vendor""] --> fw
subgraph mgmt["Management / Access zone — EACMS"]
fw["pfSense / OPNsense<br/>(EAP — defines the ESP)"]
jump["Linux jump host<br/>(Intermediate System, IRA)"]
ad["Samba AD / Windows Server<br/>(access control)"]
splunk["Splunk<br/>(SIEM, CIP-007 R4 logging)"]
sensor["Zeek + Suricata sensor<br/>(CIP-015 INSM, east-west)"]
end
subgraph esp["OT / process zone — BES Cyber System (inside the ESP)"]
pp["pandapower<br/>(grid physics)"]
plc["OpenPLC<br/>(DNP3 outstation)"]
scada["SCADA master / HMI"]
pp <--> plc <--> scada
end
fw === esp
The cast, in plain language
Every one of these is free and open source, and none of them are assumed knowledge:
| Piece | What it actually is |
|---|---|
| pandapower | A Python library that does the electrical math — given a network of generators, lines, and loads, it solves what voltage each point sits at and how much current flows down each wire. This is the physics side of the lab. |
| OpenPLC | A software programmable logic controller — a stand-in for the industrial computer that sits in a substation and does the actual switching. This is the part that pulls the trigger. |
| DNP3 | The language substations speak. An old, trusted, unauthenticated-by-default protocol for “tell me your readings” and “open that breaker.” This is what runs on the wire between them. |
| SCADA master / HMI | The control-room end: the software an operator watches and clicks in. It polls equipment for readings and sends commands back. |
| OPNsense | An open-source firewall, running here as a VM. It’s the only door between the two zones. |
| Zeek | A network monitor that turns raw traffic into readable logs — a running record of who talked to who and what they said. It doesn’t block anything, it just watches and logs. |
| Suricata | A detection engine that watches the same traffic and fires an alert when it matches a rule. Zeek just records what it sees; Suricata is the one that actually raises the alarm. |
| Splunk | The place logs go to be stored, searched, and alerted on — a SIEM (Security Information and Event Management system). |
| KVM / libvirt | The Linux virtualization stack running every VM above. libvirt is the management layer; it becomes surprisingly important in post 7. |
The point of the whole thing is the closed control loop in the OT zone: the SCADA master polls the outstation → an operator (or a script) issues a breaker open/close → the outstation writes that command back into the grid model → the model recomputes power flows and pushes new analog values back up on the next poll. An unauthorized DNP3 command that opens a breaker is basically what happened in the 2015 Ukraine attack. Modeling that is what lets me talk about the CIP standards with a real example instead of just in the abstract.
The series roadmap
Here’s the plan. Each post builds something worth protecting, then something to protect it with:
- This post — scenario, architecture, honest framing.
- Grid physics from scratch — pandapower and the closed control loop.
- Speaking the grid’s language — DNP3 (and optionally IEC 61850) on the wire, and why the BES attack surface is these protocols.
- Drawing the perimeter — the ESP, the EAP ruleset, and Interactive Remote Access through a jump host (CIP-005).
- Watching the inside — CIP-015 INSM with Zeek and Suricata; detecting a rogue breaker command.
- GRC-as-code — automating CIP-010 baselines and evidence collection.
- CIP in a virtual world — categorizing the whole lab under FERC Order 919, where the hypervisor itself stops being invisible plumbing.
Across those seven, the ground covered is: CIP-002 categorization, CIP-005 (ESP, EAP, Interactive Remote Access), CIP-007 R4 logging, CIP-010 configuration baselines, CIP-015 internal network security monitoring, DNP3 and the ICS protocol attack surface, and FERC Order 919’s virtualization definitions. If you found this from a job posting that lists half of those, this is the roadmap for it.
Why bother
The payoff is that every piece of the build maps to a specific standard: CIP-005 for the perimeter, CIP-007 R4 for logging, CIP-015 for internal monitoring. That makes me deal with the grid one control at a time, the way a real utility has to, instead of treating “security” as one big vague goal.
Wrap-up
That’s the plan and the honest version of what this is. In the next post I build the thing there is to protect: a working power-flow simulation in pandapower, running on a loop so the grid keeps updating and the protocol layer has real values to read.