Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 48 additions & 19 deletions filters/audits/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ are rejected while the exact original vendor value is preserved. Tests cover
account and workstation fallback, no qualified fallback, and a valid mapped-IPv4
control. These alternate-spelling regressions are synthetic; no additional
customer occurrence is claimed. Authentication correlation chooses a real source IP,
then workstation, then actor account, recorded in `log.authenticationSource`
and `log.authenticationSourceType`. The account fallback identifies an account,
not a network client. Every affected history search scopes that identity by
its kind, domain scope, the agent's `dataSource`, and event code. Brute-force rules also
require the target account; Kerberos searches constrain ticket encryption and,
for AS-REP, preauthentication type. No shared placeholder is an identity. A username-only fallback requires its
then workstation, then actor account, recorded in `log.authenticationSource`.
The account fallback identifies an account, not a network client. Every affected
history search pairs that identity with the agent's `dataSource`. The brute-force
rule counts failures from one source whatever account they target; success after
failures also requires the same account. Kerberos searches use their candidate
marker, which already fixes the event code and ticket encryption. No shared
placeholder is an identity. A username-only fallback requires its
domain or an already qualified UPN; an unqualified username without a domain
is not treated as a safe correlation identity.

Expand All @@ -38,12 +39,37 @@ correlation fields, so the updated history windows warm up after deployment.

Golden Ticket's historical query previously depended on `origin.host`, not
`origin.ip`; native Kerberos records can lack that workstation too. Its
correlation now uses the same identity selection. Filter-derived `log.authenticationCandidate.*` markers repeat each exact
trigger predicate so benign events with the same event code cannot satisfy the
historical threshold. The tests assert marker/predicate parity. Success after
failures searches the failed-logon marker. The update preserves each rule's
existing count and time window. It does not claim that the existing
Golden/Silver Ticket heuristics prove forged tickets.
correlation now uses the same identity selection. Filter-derived `log.authenticationCandidate.*` markers repeat the
trigger predicates that a history search cannot express (Kerberoasting, AS-REP
roasting, Silver and Golden Ticket, AD FS), so benign events with the same event
code cannot satisfy the historical threshold. The tests assert marker/predicate
parity. The two logon rules need no marker: their exact terms (event 4625,
`dataSource`, source and, for success, the account) already are the predicate.
The update preserves each rule's existing count and time window. It does not
claim that the existing Golden/Silver Ticket heuristics prove forged tickets.

## Simplification after production use (2026-09-30)

The first version also stored the kind of source (`log.authenticationSourceType`)
and a domain scope (`log.authenticationSourceDomain`), and marked every failed
and successful logon. Read-only counts on 27 v11.2.15 servers, which run this
filter unchanged, showed that none of it changed which events were counted:

- Across 26,418 source values seen in two days, no value ever appeared with two
kinds. The domain scope separated five values; four of them were one domain
written two ways (short and full name), so it split one account in two.
- The failed-logon marker only repeated the event code and the presence of the
source and account, which the search terms already require. The success
marker was written on every successful logon (about three million a day) and
no rule read it.

Both fields and both logon markers are removed. Counting failures per source
instead of per source and account follows the rule's description and also
catches password spraying. Replaying two days of production failures, the
per-account version would have raised 100 alerts, up to 26 in one hour on one
server; the per-source version raises 69, at most 5 in one hour. The success
rule no longer searches history for computer accounts, which are 44% of
successful logons and whose passwords are machine-generated.

## Standard field promotion

Expand All @@ -70,15 +96,18 @@ placeholder cleanup, and event-versus-alert grouping remain included.

- `windows_contract_test.go` is standalone and runs with `go test ./...` in
`plugins/alerts`, without the shared test-runner PR.
- 60 sanitized raw JSON fixtures exercise valid IPv4/IPv6, missing/placeholder
addresses, host/account fallback, valid/invalid ports, host roles and time.
- 50 positive predicate cases cover all seven changed correlation consumers.
Negative identity cases also compile/evaluate all 38 shipped Windows rules.
- 77 sanitized raw JSON fixtures exercise valid IPv4/IPv6, missing/placeholder
addresses, host/account fallback, valid/invalid ports, host roles and time,
and the LSASS, certificate, AdminSDHolder, SMBv1, ransomware and loopback
Remote Desktop rules.
- Positive predicate cases cover every changed correlation consumer. Negative
identity cases also compile/evaluate all 48 shipped Windows rules.
- The real SDK executes historical requests against a local mock OpenSearch
server, including mapping resolution, placeholder expansion, query creation,
time/count boundaries and separation of different sources, identity kinds,
collectors, account domains, event types and non-candidate history. The old missing-IP regression is reproduced with
the SDK, without a customer connection.
time/count boundaries and separation by every exact search term. A spray of
failures against different accounts fills the brute-force threshold but not
the success-after-failures threshold. The old missing-IP regression is
reproduced with the SDK, without a customer connection.
- The shared manifest adds seven nonempty rule assertions to its normalization
cases. Its runner is supplied by draft #2590.

Expand Down
108 changes: 18 additions & 90 deletions filters/windows/windows-events.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Windows_Agent filter, version 3.2.1
# Windows_Agent filter, version 3.2.2
# Based on winlogbeat fields, reference [8.15]
# See https://www.elastic.co/guide/en/beats/winlogbeat/current/exported-fields-winlog.html

Expand Down Expand Up @@ -3291,140 +3291,92 @@ pipeline:
to: int

# Correlation requires a real identity, never the shared '-' placeholder.
# Prefer the network source, then workstation, then authenticated account.
# Rules also scope this derived value by its kind and by dataSource.
# Prefer the network source, then the workstation, then an account that a
# domain or UPN qualifies. Rules pair this value with dataSource.
- delete:
fields: [log.authenticationSource, log.authenticationSourceType, log.authenticationSourceDomain, log.authenticationCandidate]
fields: [log.authenticationSource, log.authenticationCandidate]
- grok:
source: origin.ip
patterns:
- fieldName: log.authenticationSource
pattern: '{{.greedy}}'
where: exists("origin.ip")
- add:
function: string
params:
key: log.authenticationSourceType
value: ip
where: exists("log.authenticationSource")
- grok:
source: origin.host
patterns:
- fieldName: log.authenticationSource
pattern: '{{.greedy}}'
where: >-
!exists("log.authenticationSource") && exists("origin.host")
- add:
function: string
params:
key: log.authenticationSourceType
value: host
where: exists("log.authenticationSource") && !exists("log.authenticationSourceType")
- grok:
source: origin.user
patterns:
- fieldName: log.authenticationSource
pattern: '{{.greedy}}'
where: >-
!exists("log.authenticationSource") && exists("origin.user") && (exists("origin.domain") || regexMatch("origin.user", "^[^@]+@[^@]+$"))
- add:
function: string
params:
key: log.authenticationSourceType
value: user
where: exists("log.authenticationSource") && !exists("log.authenticationSourceType")

# User-only identities require a domain or an already qualified UPN. The
# explicit scope prevents equal usernames from different domains joining.
- grok:
source: origin.domain
patterns:
- fieldName: log.authenticationSourceDomain
pattern: '{{.greedy}}'
where: equals("log.authenticationSourceType", "user") && exists("origin.domain")
- add:
function: string
params:
key: log.authenticationSourceDomain
value: qualified-upn
where: equals("log.authenticationSourceType", "user") && !exists("log.authenticationSourceDomain")
- add:
function: string
params:
key: log.authenticationSourceDomain
value: network-source
where: oneOf("log.authenticationSourceType", ["ip", "host"])

# Historical searches support exact terms, not a CEL predicate. Mark the
# same candidates tested by these authentication rules so routine events
# with the same event ID cannot fill an attack threshold. Keep predicates
# and markers together; the raw/CEL regression checks assert their parity.
# candidates of the authentication rules whose predicate a history search
# cannot repeat, so routine events with the same event ID cannot fill an
# attack threshold. Keep predicates and markers together; the raw/CEL
# regression checks assert their parity.
- add:
function: string
params:
key: log.authenticationCandidate.kerberoastingDetection
value: match
where: |
!oneOf("dataSource", ["", "unknown"]) &&
exists("log.authenticationSource") && exists("log.authenticationSourceType") && exists("log.authenticationSourceDomain") &&
(
exists("log.authenticationSource") &&
equals("log.eventCode", "4769") &&
equals("log.channel", "Security") &&
equals("log.eventDataTicketEncryptionType", "23") &&
!regexMatch("log.eventDataServiceName", "(?i)\\$$") &&
!equals("log.eventDataServiceName", "krbtgt") &&
!oneOf("log.eventDataTicketOptions", ["1082195968", "1082130432", "1082130432"]) &&
!oneOf("log.eventDataTicketOptions", ["1082195968", "1082130432"]) &&
exists("log.eventDataServiceName")
)
- add:
function: string
params:
key: log.authenticationCandidate.asrepRoastingDetection
value: match
where: |
!oneOf("dataSource", ["", "unknown"]) &&
exists("log.authenticationSource") && exists("log.authenticationSourceType") && exists("log.authenticationSourceDomain") &&
(
exists("log.authenticationSource") &&
equals("log.eventCode", "4768") &&
equals("log.channel", "Security") &&
equals("log.eventDataTicketEncryptionType", "23") &&
equals("log.eventDataPreAuthType", "0") &&
!regexMatch("target.user", "(?i)\\$$") &&
exists("target.user")
)
- add:
function: string
params:
key: log.authenticationCandidate.silverTicketDetection
value: match
where: |
!oneOf("dataSource", ["", "unknown"]) &&
exists("log.authenticationSource") && exists("log.authenticationSourceType") && exists("log.authenticationSourceDomain") &&
(
exists("log.authenticationSource") &&
equals("log.eventCode", "4769") &&
equals("log.channel", "Security") &&
(
equals("log.eventDataTicketEncryptionType", "23") &&
!regexMatch("log.eventDataServiceName", "(?i)(krbtgt|\\$$)") &&
!oneOf("log.eventDataStatus", ["0", "6"]) &&
exists("log.authenticationSource")
)
)
equals("log.eventDataTicketEncryptionType", "23") &&
!regexMatch("log.eventDataServiceName", "(?i)(krbtgt|\\$$)") &&
!oneOf("log.eventDataStatus", ["0", "6"])
- add:
function: string
params:
key: log.authenticationCandidate.goldenTicketDetection
value: match
where: |
!oneOf("dataSource", ["", "unknown"]) &&
exists("log.authenticationSource") && exists("log.authenticationSourceType") && exists("log.authenticationSourceDomain") &&
exists("log.authenticationSource") &&
(
(
equals("log.eventCode", "4769") &&
equals("log.channel", "Security") &&
equals("log.eventDataServiceName", "krbtgt") &&
!equals("log.eventDataStatus", "0") &&
exists("log.authenticationSource")
!equals("log.eventDataStatus", "0")
) ||
(
equals("log.eventCode", "4768") &&
Expand All @@ -3448,29 +3400,5 @@ pipeline:
value: match
where: |
!oneOf("dataSource", ["", "unknown"]) &&
exists("log.authenticationSource") && exists("log.authenticationSourceType") && exists("log.authenticationSourceDomain") &&
(
exists("log.authenticationSource") &&
equals("log.providerName", "AD FS") && (equals("log.eventCode", "342") || equals("log.eventCode", "516")) && contains("log.message", "token validation failed")
)
- add:
function: string
params:
key: log.authenticationCandidate.bruteforceAttack
value: match
where: |
!oneOf("dataSource", ["", "unknown"]) &&
exists("log.authenticationSource") && exists("log.authenticationSourceType") && exists("log.authenticationSourceDomain") && exists("target.user") &&
(
equals("log.eventCode", 4625)
)
- add:
function: string
params:
key: log.authenticationCandidate.bruteforceMultipleLogonFailureFollowedBySuccess
value: match
where: |
!oneOf("dataSource", ["", "unknown"]) &&
exists("log.authenticationSource") && exists("log.authenticationSourceType") && exists("log.authenticationSourceDomain") && exists("target.user") &&
(
equals("log.eventCode", 4624)
)
Loading
Loading