Skip to content

feat: extract Windows identity from LDAP + Kerberos traffic (#809) - #810

Open
NotYuSheng wants to merge 7 commits into
devfrom
issue-809-ldap-kerberos-identity
Open

NotYuSheng wants to merge 7 commits into
devfrom
issue-809-ldap-kerberos-identity

Conversation

@NotYuSheng

Copy link
Copy Markdown
Owner

Closes #809. Motivated by #808 — a CTF-demo walkthrough of a real malware-traffic-analysis.net incident found TracePcap correctly identifies malware C2 (Suricata) and victim IP/MAC/hostname unprompted, but had zero LDAP/Kerberos parsing and missed the victim's real identity (ccollier / "Clark Collier"), which the exercise's own official answer key found via a manual LDAP filter.

Two corroborating signals, verified directly against the real capture

  • Kerberos AS-REQ (kerberos.msg_type==10, kerberos.CNameString) — sent by the client itself, so this is MEASURED-grade: unambiguous proof of who is logged in at that IP. AS-REP/TGS-REP are deliberately excluded: their source IP is the KDC, not the client, so including them would mislabel the domain controller's own IP with every account it has ever issued a ticket for.
  • LDAP searchRequest DN (ldap.baseObject) — REPORTED-grade and weaker (ambiguous: could be a self-lookup, or a query about someone else). Filtering on which attributes were requested (givenName, sn, displayName, ...) was tried and abandoned: a certificate-template DN in the real capture legitimately requests displayName/cn too, producing a false positive. The robust discriminator is the DN's own shape (CN=<name>,CN=Users,DC=...) — AD person objects live under CN=Users; infrastructure objects do not.

Machine-account principals (DESKTOP-SKBR25F$) are excluded. No cleartext LDAP bind credential (ldap.simple) is ever requested or parsed — stated as a deliberate design constraint in the resolver's own class doc.

Architecture — two existing patterns, not a new one

  • Extraction: the conflict-preserving hostname-claims pattern (HostnameResolverService/HostnameClaimWriter) — a username is testimony, not a measurement to pick a winner for at write time.
  • Adjudication: the generic Adjudicator/HumanOverrideEntity framework (not the older static-priority HostnameAdjudicator) — a new "windows-identity" question gets human override, staleness, and audit trail for free, with zero new table or REST endpoint for override support. Deliberately simpler than HostIdentityService: source-priority (Kerberos > LDAP-DN), not weighted voting, since there's no equivalent of device-type classification signals to combine.

A new WindowsIdentityClaimLookup SPI port (analysis.spi) is required by the frozen ArchUnit layer rule (modules_use_analysis_spi_not_its_internals) — the insights-module adjudicator can't import analysis' repository/entity directly. LayerDependencyTest passes with the frozen store unchanged.

Frontend needed zero new components: AdjudicationPanel is already question-agnostic by design — this is one new instance in HostIdentitySection.tsx.

Verified end-to-end against the real exercise capture

Rebuilt the backend, uploaded the actual "You dirty rat!" exercise pcap, confirmed via GET /files/{fileId}/windows-identities:

[
  {
    "ip": "172.16.1.66",
    "primaryLabel": "ccollier",
    "basis": "MACHINE",
    "confidence": 90,
    "contested": false,
    "candidates": [
      { "label": "ccollier", "score": 90, "source": "kerberos_as_req" },
      { "label": "Clark Collier", "score": 55, "source": "ldap_dn" }
    ]
  }
]

Also confirmed: exactly one row total (no false positive on the domain controller 172.16.1.4), and no $-suffixed machine-account principal ever surfaces as a primary label anywhere.

Test plan

  • mvn test -Dtest=WindowsIdentityResolverServiceTest,WindowsIdentityServiceTest — 11/11 pass (DN-regex discriminator including the real certificate-template false-positive case, machine-account exclusion, Kerberos-over-LDAP priority, contested-on-multiple-distinct-usernames, human-override-preserves-machine-candidates, no-claims-no-row)
  • mvn test -Dtest=LayerDependencyTest — passes, frozen ArchUnit store unchanged
  • mvn test (full backend suite) — 494/494 effective pass (1 skipped, unrelated @Tag("system") test)
  • npm run typecheck / npm run test / npm run lint (frontend) — typecheck clean, 642/642 tests pass, new code lint-clean
  • Migrations V42/V43 applied cleanly on a real Postgres via Flyway (Successfully applied 2 migrations ... now at version v43)
  • Adjudicator auto-registered with zero manual wiring, confirmed via startup log: Adjudicate: 2 adjudicator(s) (2 DETERMINISTIC) — questions: host-identity, windows-identity
  • End-to-end: real exercise pcap uploaded to a rebuilt backend, ccollier surfaces correctly at GET /files/{fileId}/windows-identities with both signals cited
  • OpenAPI contract regenerated (openapi/baseline.json, frontend/src/services/api/generated/schema.d.ts) against the live rebuilt backend

🤖 Generated with Claude Code

Closes the gap found via the #808 CTF-demo walkthrough: TracePcap correctly
identified the STRRAT C2 IP and malware family, unprompted, on a real
malware-traffic-analysis.net incident capture — but missed the victim's
Windows username (ccollier / "Clark Collier"), which the exercise's own
official answer key found via a manual LDAP filter. TracePcap had zero
LDAP/Kerberos parsing.

Two corroborating signals, verified directly against the real capture:

- Kerberos AS-REQ (kerberos.msg_type==10, kerberos.CNameString) — sent by
  the client itself, so this is MEASURED-grade: unambiguous proof of who is
  logged in at that IP. AS-REP/TGS-REP are deliberately excluded: their
  source IP is the KDC, not the client, so including them would mislabel
  the domain controller's own IP with every account it has issued a ticket
  for.
- LDAP searchRequest DN (ldap.baseObject) — REPORTED-grade and weaker
  (ambiguous: could be a self-lookup or a query about someone else).
  Filtering on which attributes were requested was tried and abandoned: a
  certificate-template DN in the real capture legitimately requests
  displayName/cn too, producing a false positive. The robust discriminator
  is the DN's own shape (CN=<name>,CN=Users,DC=...) — AD person objects
  live under CN=Users, infrastructure objects do not.

Architecture follows two existing patterns rather than inventing a new one:

- Extraction: the conflict-preserving hostname-claims pattern
  (HostnameResolverService/HostnameClaimWriter) — a username is testimony,
  not a measurement to pick a winner for at write time.
- Adjudication: the generic Adjudicator/HumanOverrideEntity framework (not
  the older static-priority HostnameAdjudicator) — a new "windows-identity"
  question gets human override, staleness, and audit trail for free, with
  zero new table or REST endpoint for override support. Deliberately
  simpler than HostIdentityService: source-priority (Kerberos > LDAP-DN),
  not weighted voting, since there's no equivalent of device-type
  classification signals to combine.

New WindowsIdentityClaimLookup SPI port (analysis.spi) is required by the
frozen ArchUnit layer rule (modules_use_analysis_spi_not_its_internals) —
the insights-module adjudicator cannot import analysis' repository/entity
directly. Verified LayerDependencyTest passes with the frozen store
unchanged. Frontend needed zero new components: AdjudicationPanel is
already question-agnostic by design; this is one new instance in
HostIdentitySection.tsx.

Verified end-to-end against the real exercise capture through a rebuilt
backend: victim 172.16.1.66 -> primaryLabel "ccollier", basis MACHINE,
confidence 90, not contested, with the LDAP "Clark Collier" claim carried
as a corroborating candidate — and confirmed no false positive on the
domain controller and no $-suffixed machine-account principal ever surfaces
as a primary label. Full backend suite (494/494 effective, 1 skipped
unrelated) and frontend suite (642/642) green; new code lint-clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmsoLQT1zESF94noajQ4t8
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 53b95b78-817c-4cf9-9015-26999d82dd2a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

NotYuSheng and others added 6 commits September 13, 2026 21:03
)

New docs/features/windows-identity.rst, matching the existing feature-doc
conventions (see label-staleness.rst) rather than folding the explanation
into the #808 demo walkthrough — the capability documents like any other
TracePcap feature, independent of the specific incident that motivated it.

Covers the mechanism (Kerberos AS-REQ vs. LDAP DN, source priority, the
machine-account and cleartext-credential exclusions), a worked example using
the real verified capture from #809's implementation, where it surfaces in
the UI, and what it deliberately does not do. Added to the Features toctree
in index.rst, next to mac-lookup (the other host-identification feature).

Built cleanly with sphinx-build -W: the only warnings/errors in the full
build are pre-existing, in network-monitor.rst and streaming-upload.rst,
neither touched here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmsoLQT1zESF94noajQ4t8
…uidance

Two small UI fixes found while verifying #809's Windows Identity panel,
folded into this PR at the user's request:

- HostIdentitySection's "Evidence weighed" axis facts were joined into one
  run-on line (facts.join(' · ')) instead of appearing one per line. Now a
  proper unstyled list, one <li> per fact. Also switched the row's
  align-items from center to start, since the label/chevron no longer sit
  next to a single-line value.
- AdjudicationPanel's "Add evidence" weight slider (1-100) had no guidance
  on what value to pick. Added a short calibration hint anchored to the
  scores already visible in the same panel's "Why" section, since every
  question type using this generic component shows per-candidate scores
  there.

Both are pre-existing code, unmodified by #809 itself, unrelated to the
Windows Identity feature — found only because manually verifying that
feature in the browser surfaced them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmsoLQT1zESF94noajQ4t8
- WindowsIdentitiesController: gate the lazy-backfill on there being an
  actual unadjudicated claim or human override, not just on the rows
  table being empty. Fixes permanent re-adjudication on every GET for
  files with no Kerberos/LDAP signal, where an empty result is a
  legitimate terminal state.
- HostIdentitySection.tsx: reset windowsIdentity at the top of load().
  It's an independent fetch not gated by the `loading` flag, so a
  stale value from the previous host could render under the new host's
  panel while the (slower) windows-identity fetch was still in flight.
- WindowsIdentityResolverService: drop the unused ldap.AttributeDescription
  field extraction (still used in the -Y filter, just never read from
  the row), and widen the CN capture group to consume RFC 4514
  backslash-escaped commas instead of truncating at them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rework the Windows identity feature per review: instead of a separate
adjudicated "windows-identity" question with its own table, endpoint, and
UI panel, a Windows domain sign-in (Kerberos AS-REQ / LDAP) is now what it
actually is — evidence that the host is a domain-joined Windows workstation,
and a person-level attribute of that host.

- New WindowsDomainAuthSignal (device classifier): votes toward
  LAPTOP_DESKTOP, with reasons naming the principal and protocol, so the
  sign-in shows up under the host's "Evidence weighed" breakdown and the
  differing Kerberos (+40, authenticated) vs LDAP (+15, lookup) weights are
  self-explanatory.
- New signed-in-user host attribute (logged_in_user / _source on
  host_classifications), set in AnalysisService from the claims — mirrors the
  hostname attribute and surfaces as a "Signed-in user" row with a
  Kerberos/LDAP source badge, next to Hostname.
- Set the attribute in AnalysisService (analysis module, which owns the
  entity) rather than in the classifier, so no new hostclassification ->
  analysis.entity dependency is introduced; the ArchUnit store is refreshed
  only for the classify() signature change (29 -> 29, no growth).
- Remove the bespoke stack: WindowsIdentityService adjudicator, its entity/
  repo/dto/controller, the claims persistence layer (entity/repo/writer/SPI/
  adapter), migrations V42/V43, and the frontend windows-identity panel/
  endpoint/type. The resolver is kept and feeds the signal in-memory.
- Regenerate OpenAPI baseline + schema.d.ts (endpoint removed, loggedInUser
  added); rewrite the feature doc to describe a signal + attribute.

Verified end-to-end against the real STRRAT exercise pcap: 172.16.1.66
classifies LAPTOP_DESKTOP with "ccollier" (Kerberos) as signed-in user and
both signals cited in evidence; the domain controller 172.16.1.4 correctly
has no signed-in user; no machine-account principal is ever surfaced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Let analysts show the Windows signed-in user (ccollier) as a line under each
node in the network diagram, alongside hostname/IP/device type. Added to the
node-label field set (default off, appended to existing configs via the
normalizer) with a live-preview example and value resolution from
node.data.loggedInUser, which the classification data already carries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- RFC 4514 regression: personNameFromDn now unescapes the CN value, so a
  "Last, First" display name (CN=Collier\, Clark,...) is stored/shown as
  "Collier, Clark", not with the wire-format backslash. Handles both \c and
  \XX hex escapes; test updated to assert the unescaped value.
- LDAP recall gap: the tshark -Y filter no longer also requires
  ldap.AttributeDescription. A searchRequest for all attributes carries no
  attribute list, and requiring it silently dropped those person lookups —
  the DN-shape regex is the real person-vs-infrastructure discriminator.
  Verified against the exercise pcap: still resolves only "Clark Collier",
  no false positives on the certificate-template/config DNs.
- WindowsDomainAuthSignal Javadoc corrected to match the additive behavior
  (both Kerberos and LDAP votes stack; LDAP weighs less, it isn't suppressed).
- Accessibility: the "Evidence weighed" per-line facts are now spans in a
  flex column, not a <ul>/<li> nested inside a role="button" element (invalid
  content model that confuses accessible-name computation).
- De-duplicated the source-chip badges: new shared SourceBadge parameterised
  by a source-info map; HostnameSourceBadge and LoggedInUserSourceBadge now
  delegate to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant