Building a NIS2-Compliant AWS Landing Zone - Detection
Last week I built the governance layer: an organization, three Service Control Policies that hold even against an admin, and short-lived SSO access. The account knows who's allowed to do what. This week is the part where it starts watching — and where it finally pages someone when something looks wrong.
Everything so far has been about evidence and control: logs that can't be switched off, guardrails that can't be bypassed. But a landing zone that only records and restricts is still passive. Week 4 adds the active half — threat detection that generates findings, a place those findings aggregate and get scored, and one channel that turns a finding into a notification. NIS2 Article 21 calls for (e) vulnerability handling and (g) basic cyber hygiene, and reinforces (b) incident handling. Three measures, give or take, in one week.
I ended last week's post expecting GuardDuty and Security Hub to have "their own emulation quirks." They did — just not the quirk I was bracing for.
What I built this week
modules/guardduty— a GuardDuty detector with S3 data-event and EBS malware protection, publishing findings every fifteen minutes.modules/security-hub— Security Hub enabled, subscribed to the AWS Foundational Security Best Practices standard, with GuardDuty wired in as a finding source.modules/alerting— a KMS-encrypted SNS topic fed by EventBridge rules that match GuardDuty and Security Hub findings. The one place alerts come out.
Two of those three never ran locally. That's the story of the week, and it's worth being precise about why.
The shape of the detection layer

GuardDuty consumes the logs from earlier weeks automatically — no wiring, we just enable it. Its findings flow into Security Hub for scoring and into EventBridge for routing. EventBridge matches the findings worth alerting on and drops them into a single encrypted SNS topic. One detector, one scoring surface, one notification channel.
A different kind of "the emulator can't"
Last week I hit a plan-only module — Identity Center — because LocalStack emulated most of it but returned a 501 on one endpoint the AWS provider polls. That was a partial gap, and I only found it mid-apply.
GuardDuty and Security Hub are a different case entirely: LocalStack doesn't implement them at all. Not partially, not with a missing sub-call — there's simply no detector to create and no hub to enable. I checked the coverage before writing a line of apply code, which meant I could make the call up front instead of discovering it in a failed run. Both modules are plan-only from the start: correct, complete Terraform, validated with terraform validate and plan-mode tests, never wired into the local composition, with the real apply waiting for the one paid AWS run in Week 6.
I wrote this up as its own decision record, separate from last week's, because the two are genuinely different and the difference is the interesting part. One is "the emulator emulates this but not that specific poll." The other is "the emulator doesn't know this service exists." Both end in plan-only, but the engineering response differs — one you discover by probing, the other you decide by checking coverage first. Naming that distinction is the kind of thing I'd want a reviewer to notice, because it's the difference between flailing at a failed apply and making a deliberate call.
There's a quiet upside, too. It means the Week 6 real-AWS run isn't a formality — it's the first time the detection layer lights up with authentic findings instead of mocked ones. That run has real weight now.
The one module that does run, and the chain inside it
The alerting module is apply-mode, because SNS and EventBridge are both well-covered on LocalStack. I probed all three pieces first — SNS topic, EventBridge rule, KMS key — and they came back with real ARNs, so I built it for real and it composes into the local stack alongside everything from the earlier weeks.
The part worth slowing down on is encryption. The SNS topic is KMS-encrypted, because everything in this project is. But an encrypted topic that EventBridge needs to publish to has a permission chain that's easy to get subtly wrong. EventBridge needs two separate grants: the SNS topic policy has to allow the EventBridge service to publish, and — the one people miss — the KMS key policy has to let EventBridge generate a data key and decrypt. Without the second grant, publishing fails on real AWS with an error that doesn't obviously point at KMS.
Here's the catch that makes this a good story rather than just a config detail: LocalStack doesn't enforce KMS key policies. So the module applies cleanly locally whether or not I got that second grant right. The test passing tells me the resources exist; it tells me nothing about whether the permission chain actually works. I wrote both grants correctly and then wrote down, explicitly, that this specific path is correct-but-unverifiable locally — it's one of the things the real-AWS run exists to confirm. A green local test that proves less than it appears to is worth being honest about.
Keeping the pieces from depending on each other
There's a design decision hiding in how alerting connects to the two plan-only modules: it doesn't. The EventBridge rules match on the shape of a finding event — its source (aws.guardduty) and its type — not on any resource ID from the GuardDuty or Security Hub modules. So the alerting module composes and applies locally even though the two services feeding it aren't present at all. Match the event pattern, not the producer, and the consumer stops caring whether the producer exists yet. That's what let one apply-mode module sit cleanly alongside two plan-only ones without any awkward conditional wiring.
Closing two old findings the right way
Two Checkov findings had been sitting in my decision log since Week 2, both deferred to "the detection week." This was the week, and both closed — but neither closed the way the scanner literally asked.
The first was CloudTrail not defining an SNS topic. The mechanical fix would be to bolt an SNS topic onto the trail. But that fragments alerting — every log producer ending up with its own notification path. The centralized EventBridge-to-SNS design makes a per-trail topic unnecessary, so I closed the finding as superseded by architecture, with the reasoning written down. The underlying need — get incident notifications out — is met better by the central channel than by what the rule suggested.
The second was the S3 log bucket lacking event notifications. That one I did implement, because it genuinely belongs: a single line enabling EventBridge notifications on the bucket, so object activity flows into the same routing layer as everything else. One finding closed by saying "the architecture already handles this, here's how," the other by "yes, that's a real gap, fixed." Both got a written disposition. That, more than a green dashboard, is what a decision log is for.
Where this leaves things
The landing zone can now watch itself and speak up. GuardDuty and Security Hub are built and correct, waiting on real AWS to prove they apply. The alerting backbone runs locally today, encrypted, with its permission chain written correctly and honestly flagged as the thing the real run will confirm. Every finding in the decision log has an answer next to it.
NIS2 Article 21 coverage after Week 4: measures (a), (b), (c), (e), (f), (g), (h), (i), (j) — nine of ten. The only one left is (d), supply chain security, and it isn't really a module — it's an architecture-and-documentation question about dependencies, pinning, and provenance. Which is a good lead-in to what's next.
The remaining two weeks change character. Week 5 is consolidation — the NIS2-to-ISO crosswalk, the supply-chain write-up that closes the last measure, the architecture docs pulled together into something a reviewer can read top to bottom. Week 6 is the one paid AWS run: the moment all the plan-only modules and the unverifiable permission chains finally meet real infrastructure. The build is nearly done; what's left is proving it and explaining it.
Repo Link: github.com/olanak/aws-nis2-baseline