Lab notes · Enterprise homelab notes

Watching the Front Door: Detection Rules for an Application's Identity Log

8 min readNoble Antwi
detection-engineeringwazuhidentitymitre-attacksamloauthscimhomelab

An application that signs people in also writes down every refusal. Thirty Wazuh rules turn that log into alerts mapped to MITRE ATT&CK, tested before they reached the SIEM. Most of the work was deciding what should never page anyone, and two rules that were wrong in ways no test of the happy path would have shown.

Contents
  1. The log, before any rule
  2. Thirty rules: twenty that page, ten that only record
  3. The quiet rules were most of the work
  4. Tested before it reached the SIEM
  5. Two rules that were wrong
  6. What I took from it

Most of my lab’s alerts come from operating systems: Windows event logs, Sysmon, a Linux audit trail. They see a process start or a file land. They do not see a forged SAML assertion, a token used past its scope, or someone resetting the break-glass account. Only the application that checked the credential knows it refused one, and why.

Stock Copilot is an internal app on APP01, in the DevOps segment. It signs people in through more than one identity provider, provisions accounts over SCIM, issues API tokens and rotates its own signing keys. Every one of those decisions writes a line to an audit log. This note is about turning that log into alerts.

The log, before any rule

Each event is one line of JSON with the same shape: who (user.name), from where (source.tailnet_node, the device on the tailnet), which provider (idp.key), what happened, and for refusals a detail.reason in plain words. That consistency is what made the rest possible. A rule that has to guess at field names fails silently, and Wazuh never tells you a rule did not match.

The Wazuh agent on APP01 (agent 005) reads the file with log_format json, so the fields arrive already decoded. Every rule hangs off one parent, rule 100400, which claims an event only when service.name is stock-copilot and service.type is identity. On its own it is silent, at level 0. Its job is to make sure nothing else in the ruleset ever looks at an event that is not this app’s.

Thirty rules: twenty that page, ten that only record

What it watchesATT&CKLevel
A token signed with alg: none or an HMAC algorithmT160612
An unsigned, wrapped or tampered SAML assertionT1606.00212
The break-glass owner reset on APP01T109812
A provider’s signing keys changed outside any declared rotationT155612
A refused attempt to link an identityT109812
Five refused credentials from one device in two minutesT111010
A SCIM call with no credential, or a guessed oneT113610
A replayed nonce, callback state or assertionT1550.00110
A new identity provider trustedT1484.00210
The break-glass login used at allT1078.00310
An API token used beyond its scopesT1550.0018
A sign-in link that would land on another siteT1566.0028

That is a sample. The full table, all thirty with their parents and MITRE mapping, is in the repository’s detections folder.

Some mappings are nearest fits, and I wrote the reason down rather than pretend they are exact. A signing key the app trusts is its authentication process, so an unplanned key change is T1556, Modify Authentication Process: whoever controls that key can sign in as anyone.

The quiet rules were most of the work

Ten of the thirty never page anyone. A sign-in is level 3. A refused sign-in is level 5. A scheduled key rotation, an access review signed off, the directory deactivating someone: all level 3, on the record and in nobody’s queue.

They matter for two reasons. First, they are the building blocks. The password-guessing alert does not look at raw events; it counts a quiet rule firing. Second, they encode the decisions that are hardest to get right: which routine actions should never alert. A key rotation that was declared in advance is a drill. The same change with no declaration is a level 12. The difference is one field, and the rule has to know which is which before it goes anywhere near a pager.

Tested before it reached the SIEM

A malformed rules file stops wazuh-manager from starting at all, which takes the whole SIEM offline, not just one rule. So nothing was copied to SIEM01 until it had passed somewhere disposable.

The test starts a throwaway Wazuh manager container with no network, loads the rules file, and replays events through wazuh-logtest, the tool that reports which decoder parsed an event and which rule it reached:

  • 61 sample events, one written by the app’s own audit code for every rule, all 61 reaching the rule they were written for;
  • 676 real events from the lab’s own history: earlier attack scenarios, key rotation drills and an attack probe. Every refusal in them landed on its rule. The forged-token rule fired nine times, the forged-SAML rule twice, the SCIM rule thirteen times.

The rules went live on SIEM01 on 23 October 2026. That evening the app’s attack probe fired at 19:42, and all 18 of its events arrived on SIEM01 decoded, as alerts under rule.groups: stock_copilot.

Two rules that were wrong

Both of these passed a test of the happy path. Both would have paged the wrong thing.

Count credentials, not refusals. The first password-guessing rule counted every refused sign-in. On the lab’s own traffic it raised seven alerts, and none of them were guessing. They were refusals by policy: a person who was never provisioned, a group with no mapping to any role. A wrong password and “you are not allowed in here” are different events, even though both end in a refusal. The rule now counts only refused credentials, and it counts them per tailnet device rather than per username, because a refused passkey names nobody, and a guesser can change the email on every try.

A word is not a meaning. The forged-SAML rule first matched any refusal whose reason contained the word “assertion”. That would have caught “assertion expired”, which is usually two clocks a minute apart, and paged it at level 12 as forgery. It now matches only the signature failures. A test in the app’s repository checks every rule against the app’s real refusal wording, so if the wording changes, the test fails before the rule quietly stops matching.

What I took from it

  • The application is the only witness to its own refusals. Operating-system logs cannot see a forged token being turned away.
  • One consistent log shape is worth more than any single rule. Every rule here is short because every event has the same fields.
  • Decide what stays quiet first. The alerting rules were the easy part. The ten that never page are where the judgment is.
  • Test against your own history, not only samples. Synthetic events prove a rule can fire. Real traffic shows what else it fires on.
  • A refusal is not an attack. Most refusals are policy doing its job. An alert has to tell the difference, or people stop reading it.

The rules, the numbering blocks and the deployment steps are in the homelab’s detections folder. The tuning that came before them, for the operating-system alerts, is in The Alert That Cried Wolf.

Noble Antwi, the author
Noble AntwiCloud Security & Identity Engineer · M.S. Cybersecurity & Digital Forensics, Illinois Tech · Get in touch