← Back home
Case Study

Foundry: an applied intelligence platform for IT/OT security

A ground-up build covering ingestion, behavior classification, detection engineering, and analyst workflow -- one pipeline for both conventional IT security telemetry and industrial-protocol traffic.

The problem

Security and OT teams are flooded with raw event volume, and most of it is routine noise -- a connection got allowed, a service restarted, a session opened. The expensive resource is analyst attention, not data collection. Foundry's job is to compress that volume into a small number of things actually worth a human's time, while keeping a durable, auditable record of what was decided and why.

The harder constraint: OT/ICS environments (PLCs, SCADA, industrial protocols like Modbus and EtherNet/IP) usually get a separate, disconnected toolchain from conventional IT security. Foundry decodes both into the same findings pipeline, with the same review workflow.

57+MITRE ATT&CK-mapped detection patterns
23Intelligence-staff personas
9Pcap-replay protocol classifiers
4OT protocols decoded (Modbus, DNP3, CIP, S7comm)

Architecture

One message's path from wire to analyst review: a UDP syslog listener (or the pcap-replay/Zeek passive-capture path) enqueues raw traffic into a Redis-backed queue, durable enough to absorb load-test-scale bursts without dropping anything. Queue workers classify each message through a source adapter (FortiGate, Windows, Linux, FortiDeceptor, FortiPAM, or a generic fallback) into a structured Observation.

From there, every observation runs through the same pipeline synchronously: it's added to a partitioned behavior graph, fingerprinted into an event group for noise dedup, checked against the pattern library for a hypothesis match, and checked for evidentiary links to existing evidence -- all backed by a single PostgreSQL registry with per-key advisory locks to stay correct under concurrent workers.

Separately and asynchronously, a rule-based scheduler (Helios) assigns each observation to one or more of 23 intelligence-staff personas -- Circuit for OT/ICS context, Spectre for threat reasoning, Atlas for asset/topology context, and so on -- each producing a confidence-scored decision that feeds into evidence. When a hypothesis needs a human call, the system explicitly raises a Decision Need rather than leaving it buried in a queue.

The noise-reduction feedback loop

This is the mechanism that lets the system actually get smarter over time instead of re-flagging the same judged pattern forever. Every observation is fingerprinted by source/behavior/identity/port/service/ action; repeat occurrences of the same underlying flow accumulate under one event-group record instead of spawning a new one each time -- a single recurring OT poll between two hosts can rack up thousands of events under one group.

When an analyst marks a group "Expected," that judgment is captured as a durable Learning, and four specific personas (Spectre, Atlas, OT Engineer, Reliability Analyst) honor it on every subsequent matching observation by downgrading their decision instead of escalating. Safety Analyst deliberately never does -- a safety-interlock-keyword behavior keeps escalating regardless of prior judgment, since a network-level "this is fine" call isn't authorization for a safety-system change. That asymmetry was a deliberate design choice, not an oversight.

Detection engineering: Windows/Sysmon coverage

Bringing a domain controller online as the platform's first real Windows/Sysmon source surfaced how much of the "obvious" detection engineering isn't obvious in practice. Windows Event IDs are only unique per (provider, channel) -- Sysmon's EventID "1" means something completely different from anywhere else, so the Sysmon behavior map had to be kept deliberately isolated from the classic Security-channel one rather than merged in.

Getting there also meant a real NXLog debugging session: the obvious Query = <QueryList>...</QueryList> string-assignment form failed identically across every shape tried, with an opaque "channel path is invalid" error -- even though the channel itself was provably valid and the service ran with full permissions. The actual fix was using <QueryXML> as a nested block directive instead of a string value, which isn't how most examples in the wild show it.

Detection engineering: pcap replay expansion

Uploaded pcaps replay through the same live pipeline real traffic does -- but the classifier coverage started narrow: DNS, FTP, HTTP, SMTP only. Testing against a real kerberoasting capture produced a "successful," zero-error job that silently extracted nothing, because none of the four covered protocols matched Kerberos traffic at all -- a job that looks identical in the UI whether it legitimately found nothing or the traffic type just isn't supported.

Expanding coverage to Kerberos, SMB, TLS, LDAP, and RDP/SSH/VNC surfaced two more subtle bugs worth noting because they're the kind that don't show up as errors, just as wrong-but-plausible-looking data:

  • A TGS-REQ packet carries two Kerberos principal names -- the embedded TGT's own name first, the actually-requested target SPN second. Grabbing only the first occurrence (the reasonable default everywhere else in the file) silently returned krbtgt for every single request regardless of actual target, collapsing 12 distinct SPN requests into what looked like one repeated value and making the kerberoasting burst-detector a no-op.
  • Fixing that by reading all field occurrences exposed a second issue: a TGS-REQ's own message-type field comes back multi-valued too ("12,14" -- the outer request plus its embedded AP-REQ), which broke an exact-string match that had worked fine under the old, wrong assumption. Root-caused by inspecting raw tshark field output directly rather than trusting either version's aggregate counts.

Verified end-to-end against the real capture: 12 individual service-ticket-request observations with correctly attributed target SPNs, and a single credential_access.kerberoasting_suspected finding on the actual attacking host.

Operating it for real

A platform is only as good as what happens when it breaks. A few patterns that came out of running this against real traffic:

  • A single stuck Postgres backend can cascade into a full UI outage -- one INSERT stuck on disk I/O was enough; the fix is finding and killing it immediately, not investigating first.
  • More workers isn't always more throughput. Adding workers against a single high-volume source increased lock contention instead of processing speed -- a scaling assumption that held for multi-source traffic and broke for single-source.
  • A GIN index on the core records table bloated under heavy UPSERT churn and was dropped for good in favor of targeted btree expression indexes on the actual query patterns in use, rather than one general-purpose index trying to serve all of them.

Packaging it as a product

The installer (install.sh) turns all of the above into a single guided setup on a fresh Ubuntu box: Postgres, Redis, nginx with TLS, the app, and an optional Zeek passive-capture cluster with OT protocol parsers, chosen interactively (syslog-only, Zeek-only, or both) without the operator having to know the pipeline internals. Validating it meant running it repeatedly against genuinely fresh machines -- which is what surfaced gaps like link-local IPv6 addresses (every IPv6-capable device generates one for neighbor discovery whether or not real IPv6 is in use) inflating a small fresh-install asset list by nearly 30%, invisible in a mature environment with thousands of accumulated assets but impossible to miss on a clean one.