Internet-facing SSH honeypot → Microsoft Sentinel
An SSH honeypot disguised as a finance-database server, wired into Sentinel through a CEF pipeline — built to produce genuine attacker telemetry for detection engineering.
The problem
Detection engineering needs attacker telemetry, and simulated telemetry is authored telemetry — every field is something a person decided to put there. A rule tuned against it is tuned against an assumption.
An internet-facing honeypot fixes that. The attackers are real; only the target is fake. Every credential tried, every command typed, every packet is something an actual adversary — a bot or a person — chose to do, unprompted, against a host they found on their own. Within minutes of the first build going live it was being brute-forced from several countries.
The design
One small Ubuntu VM on an unpeered virtual network — nothing it can reach if
it's compromised — with TCP/22 open to the internet on purpose and real SSH
moved to a high port. It wears a cover identity: a finance-database server, with
a naming convention chosen up front to survive Azure's platform-populated
_ResourceId column.
Four independent layers of evidence, all landing in one Sentinel table:
| Layer | Tool | Contributes |
|---|---|---|
| Application | Cowrie | an emulated shell — records every credential and command, executes nothing |
| Network IDS/IPS | Suricata (ET Open) | signature detection inline via NFQUEUE, so it can drop as well as alert |
| Host firewall | nftables | real allow/deny decisions with logging, and the hand-off to Suricata |
| Normalisation | 3 Python CEF converters | Cowrie / Suricata / firewall events → CEF on syslog local4 → CommonSecurityLog |
Everything queries CommonSecurityLog through a Data Collection Rule — nothing
regex-scrapes raw Syslog.
What got built
The deception content is planted to be cross-consistent: RSA keys that are
cryptographically valid but authorise nothing, a .bash_history showing an
admin who ran mysqldump with an inline password then history -c, a
backup.sh carrying the same password, an .env with a connection string. An
attacker who reads the history, extracts the password and reuses it produces a
complete "found a leaked credential → reused it" narrative across two rules —
a far better training incident than any single event.
On top: ten analytics rules covering credential attack, post-compromise activity, network reconnaissance and cross-source correlation.
What broke
- A rebuild forced by an un-renameable resource ID. The cover identity had to be baked in from the first deployment, not bolted on.
- Every rule filtered on
Computer == "<the old hostname>". When the VM was rebuilt under the disguised name, all ten rules would have silently gone dark — still enabled, still running, matching zero rows forever. The fix: filter onDeviceVendorandDeviceEventClassID, which describe what the event is and survive a rename.Computeris an output column, not a predicate. - A CEF key that silently emptied a column. The converters emitted
deviceAction=; the real key isact=. CEF dumps unrecognised keys into theAdditionalExtensionscatch-all with no error, soDeviceActionwas blank on every firewall row and a "Denied" filter could never have matched. - A port-scan rule that never fires — because the NSG in front of the host drops traffic to those ports before the firewall's deny rule ever sees it. The counter is inert by design, not broken.
- A few thousand log rows leaked in a twenty-minute window before a converter fix — caught on the ingestion volume, not by reading logs.
What it taught
Naming has to survive the columns the platform populates for you. Detections should key on event identity, never on a hostname. When a column you expect is blank, look in the catch-all field before assuming the value never arrived. And capture everything, filter the display — a capture filter is a permanent decision made before you know what you're looking for.