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.
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
krbtgtfor 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.