Building sift — AI-Powered Alert Triage for SOC Teams
sift is a command-line tool for SOC alert triage. It ingests SIEM exports, clusters and prioritizes alerts using rule-based logic, and optionally generates AI-powered narrative summaries with actionable recommendations. It’s the third tool in the portfolio — and the one where the methodology for building with AI became something I’d actually call a workflow.
| Field | Details |
|---|---|
| Type | Python CLI Project |
| Repository | github.com/duathron/sift |
| PyPI | pypi.org/project/sift-triage |
| Version | v1.1.101 |
| Last updated | 2026-05-28 |
| Stack | Python, Typer, Rich, Pydantic v2, SQLite |
| Built with | Claude Code |
Updated 2026-05-28: Added Injection Scanner — Detection Logic: the five pattern classes, NFKC Unicode pre-processing, the Base64 false-positive trade-off, the redaction policy, and what pattern matching doesn’t cover.
Updated 2026-05-12: This post now covers v1.1.101. See What’s New in v1.1.x for ticketing integration, IOC extractor expansion, and security hardening added since the original release.
The Problem
Alert fatigue is one of the most frequently cited problems in SOC work — and from everything I’ve read and practiced in the SOC Level 1 path, the description is consistent: large volumes of alerts, most of which turn out to be noise, with the ones that actually matter buried somewhere in the middle. The challenge isn’t that detection tools don’t fire — it’s that they fire too much, and working through the results manually doesn’t scale.
sift is meant to address the earlier part of that problem: before an analyst digs into individual IOCs with vex or checks URLs with barb, there’s a prior question of where to look first. That’s what sift tries to answer — take a batch of alerts, group the ones that seem related, and surface the clusters most likely to need attention.
The gap sift fills is different from Building vex — An IOC Enrichment Tool Built with Claude Code and Building barb — A Phishing URL Analyzer Built with Claude Code. vex answers: is this hash or IP something to worry about? barb answers: does this URL look like a phishing attempt? sift answers the question one step earlier: out of these alerts, where do I even start?
How the Workflow Has Evolved
With vex, the MeetUp format was introduced. With barb, it was formalised — agents deliberated before code was written, votes were recorded with rationale, dissents were documented. With sift, it became something closer to a project methodology.
The single MeetUp log for sift (VEX-2026-008) runs to six blocks, twenty-three decisions, and over forty feature votes. Eight agents participated. The blocks covered architecture, core scope, AI integration strategy, cross-tool integration, naming, and test data — in that order, before a single module was created.
That sequencing matters more than it might seem. With vex, some architectural decisions were made mid-implementation when the original design ran into friction. With barb, the architecture block happened first, but integration questions were left somewhat open. With sift, cross-tool integration was its own block — Block 4 — with the same level of formal deliberation as the architecture itself. That produced a much cleaner design from the start.
The other change is in how disagreement is handled. The AI Specialist dissented on whether --summarize should be opt-in or the default, arguing that a template fallback makes “always on” safe enough. The Code Security Agent dissented on the send_url default in the LLM explainer. These aren’t footnotes — they’re documented, with rationale, in the MeetUp log. Future development can revisit them with the original argument on record.
What this approach produces is not just a codebase but a decision history. Anyone reading the repository can understand not just what was built, but why every significant choice was made, what the alternatives were, and what the minority positions argued. For a solo project built with AI assistance, that kind of traceable reasoning is surprisingly valuable.
What sift Does
The Pipeline
sift processes alerts through a five-stage pipeline, each stage independently testable:
1
Ingest → Normalize → Cluster → Prioritize → Summarize
Ingest accepts Generic JSON, Splunk JSON export, and CSV with headers. A NormalizerProtocol maps each format onto sift’s internal Alert model — so new SIEM formats can be added without touching the pipeline logic.
Normalize also runs deduplication (near-identical alerts are collapsed before clustering) and IOC extraction — regex-based identification of IPs, hashes, domains, and URLs buried in alert fields. Those extracted IOCs become the bridge to barb and vex.
Cluster groups alerts by shared source IP, alert type, and time window using a sliding-window algorithm. The result is a set of AlertCluster objects, each with a confidence score reflecting how cohesive the grouping is.
Prioritize re-scores clusters based on severity, recency, and whether the extracted IOCs match any known-bad indicators. Clusters are ranked: critical first, noise last.
Summarize is where AI enters — and only when explicitly requested via --summarize. Without the flag, sift produces structured output from the rule-based stages alone. With the flag, a SummarizerProtocol implementation generates a narrative summary of each cluster and a list of actionable recommendations as structured JSON.
Structured AI Output
The AI integration is more opinionated than it was in barb’s --explain flag. barb’s LLM explains a single URL’s signals in natural language — the output is prose. sift’s --summarize flag produces structured JSON that can be consumed downstream:
1
2
3
4
5
6
7
8
{
"executive_summary": "Three clusters detected...",
"critical_clusters": [...],
"recommendations": [
{"action": "block", "target": "192.168.1.105", "priority": "immediate"},
{"action": "escalate", "cluster_id": "c-002", "reason": "lateral movement pattern"}
]
}
The decision to enforce structured output came from a unanimous P0-Veto in Block 3: if the AI summary can’t be parsed downstream, it can’t be fed into a SOAR playbook. Structured recommendations as machine-readable JSON was non-negotiable for the SOC Analyst and AI Specialist agents.
Prompt injection detection was similarly non-negotiable: alert fields can contain attacker-controlled strings. A User-Agent header, a process name, an email subject — any of these could carry a payload designed to manipulate the LLM. sift runs five pattern checks against alert content before it reaches the prompt.
Field-level redaction is configurable in ~/.sift/config.yaml: specify which fields are masked before LLM submission. Hostnames, usernames, internal IP ranges — anything that shouldn’t leave the environment can be stripped before the API call.
Provider Strategy
Four summarizer implementations follow the same SummarizerProtocol: Anthropic Claude, OpenAI, Ollama (local), and a template-based fallback. The template fallback was unanimous from the start — the tool must function without any API key. Ollama support was also included from v1.0, not deferred as it was in barb, because enterprise use cases for sift make local LLMs more critical than for URL analysis.
The Cross-Tool Integration
This is what makes sift structurally different from the previous two tools: it’s designed to sit at the end of a pipeline.
Three Integration Tiers
Tier 1: Standalone. sift works on alert data alone. No barb, no vex required.
1
sift triage alerts.json --summarize -o rich
Tier 2: JSON pipeline. barb and vex produce JSON output. sift accepts that output via --context flags, merging external enrichment data into the clustering and summarization stages.
1
2
3
4
5
6
barb analyze -f urls.txt -o json > barb_results.json
vex triage -f iocs.txt -o json > vex_results.json
sift triage alerts.json \
--context barb_results.json \
--context vex_results.json \
--summarize -o rich
Tier 3: Library integration. With pip install sift-triage[enrich], the --enrich flag calls barb and vex internally as Python libraries — IOCs extracted from alert fields are automatically analyzed without building the pipeline manually.
1
sift triage alerts.json --enrich --summarize -o rich
The consent model for --enrich is explicit: on first use, sift warns that IOCs will be sent to VirusTotal via vex. --yes suppresses the prompt for automation. --enrich-local restricts enrichment to barb (always offline) and vex’s local cache (no API calls).
Why This Pipeline Makes Sense
vex v1.2.0, released between barb and the start of sift’s development, introduced a --from-barb flag — a bridge that accepts barb’s JSON output and uses it to enrich the IOC analysis with URL-level signals. That was the first concrete expression of what the portfolio was becoming: not three independent tools, but a toolkit where each tool covers a different part of the analyst’s workflow.
The sequence is:
- barb checks URLs extracted from alert data for phishing indicators, offline
- vex enriches hashes, IPs, and domains against VirusTotal with MITRE ATT&CK mapping
- sift takes all of that, clusters the alert landscape, and gives the analyst a prioritized starting point
Any stage is optional. But together, they cover the initial triage phase of an incident response workflow — from raw alert export to an actionable cluster summary — without leaving the terminal.
sift doctor
The sift doctor command was a P0-Veto from the QM and UX Design agents: before an analyst uses the pipeline in a real incident, they need to know if everything is configured correctly. sift doctor checks whether barb and vex are installed and at compatible versions, whether API keys are set, whether the LLM provider is reachable, and whether the internal JSON Schema validation passes.
1
2
3
4
5
6
7
$ sift doctor
✓ sift v1.0.0
✓ barb v1.0.0 installed (compatible)
✓ vex v1.2.0 installed (compatible)
✓ SIFT_LLM_KEY set (Anthropic)
✓ LLM schema validation: pass
⚠ VT_API_KEY not set — vex enrichment will use cache only
It’s a small feature, but it reflects something that became clear across all three tools: the configuration surface is what breaks things in practice. A missing key, a version mismatch, a wrong config path — doctor surfaces those before they cause confusion at the wrong moment.
Technical Architecture
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
sift/
├── main.py # CLI: triage, doctor, config, version
├── config.py # Pydantic v2 config with priority hierarchy
├── models.py # Alert, AlertCluster, SiftResult, IOC
├── pipeline/
│ ├── ingester.py # File/stdin input handling
│ ├── normalizer.py # NormalizerProtocol + SIEM implementations
│ ├── deduplicator.py # Near-duplicate removal
│ ├── extractor.py # IOC regex extraction from alert fields
│ ├── clusterer.py # Sliding-window rule-based clustering
│ └── prioritizer.py # Severity re-scoring + ranking
├── enrichment/
│ ├── bridge.py # EnrichmentRunner (subprocess-based)
│ ├── barb_bridge.py # barb library integration
│ └── vex_bridge.py # vex library integration
├── summarize/
│ ├── protocol.py # SummarizerProtocol
│ ├── template.py # Template-based fallback
│ ├── anthropic.py # Claude integration
│ ├── openai.py # OpenAI integration
│ ├── ollama.py # Local Ollama integration
│ ├── injection.py # Prompt injection detection
│ └── redaction.py # Field-level redaction
├── cache.py # SQLite result caching (WAL mode, TTL, LRU)
├── filtering.py # Boolean DSL for alert filtering
├── metrics.py # Alert statistics and reporting
└── output/ # Rich + console + JSON + CSV + STIX 2.1
The pipeline/ separation is the most visible architectural difference from barb and vex, where pipeline stages were more tightly integrated. Here each stage is its own module with a defined protocol interface — a direct result of the MeetUp’s emphasis on testability. The 1130 tests cover each stage independently, which makes regressions fast to locate.
Result caching (v0.7.0) uses the same SQLite-with-WAL pattern as vex’s API cache. The clustering optimization moved from O(n²) to O(n log n) using a sliding-window time-bucketing approach — relevant for large SIEM exports where the naive pairwise comparison becomes slow.
Where Things Stand
sift shipped v1.0.0 with the full core pipeline, enrichment integration, STIX 2.1 export, filtering, and result caching. Between v0.7.0 and the final release, v0.8.0 added field-level alert redaction, alert chunking for large batches, ATT&CK technique ID validation, and an --enrich-mode local flag for fully offline enrichment using only local heuristics (Shannon entropy, suspicious TLD checks, IP-in-URL detection) — no API calls, no consent prompt.
The beta test before v1.0.0 included an adversarial review of the security-sensitive code paths. It found several issues that only showed up under deliberate attack conditions: the injection detector’s first pattern could be bypassed by splitting a payload across newlines (re.DOTALL was missing), Alert.redact() cleared the structured fields but left the raw dict intact so IOC extraction could re-surface the redacted value, and the barb/vex bridges didn’t protect against IOC strings formatted as CLI flags (--flag=value). None of those would have appeared in normal usage.
The path from v0.1 to v1.0 across roughly two evenings of development reflects what the MeetUp methodology produces in practice: a clear scope, no major architectural reversals, and a test suite that grew alongside the implementation. 1145 tests at 100% pass rate isn’t a vanity metric — it’s what made it possible to run the adversarial review without being afraid of what the fixes might break.
Lessons Learned
Deliberating on integration before writing code is worth the time. Block 4 of the MeetUp — cross-tool integration — produced the three-tier model (standalone, JSON pipeline, library integration) before any of those tiers existed. Having that architecture decided upfront meant the --context, --enrich, and --enrich-local flags were designed together rather than bolted on sequentially.
Prompt injection is not an edge case. Alert data contains attacker-controlled strings. A phishing alert, for example, might carry the original email subject or a malicious URL in one of its fields — content that an attacker could craft to manipulate an LLM processing it. I hadn’t thought about this until the Code Security Agent raised it in the MeetUp. Treating injection detection as a P0 requirement rather than a future hardening task changed how the summarization module was architected from the start.
Structured AI output is more valuable than expressive AI output. barb’s --explain flag produces readable prose. That’s appropriate for explaining why a single URL looks suspicious to a human analyst. sift’s --summarize flag produces structured JSON with typed recommendations. That’s appropriate for a tool whose output might feed a SOAR playbook. The right choice depends on where in the workflow the AI output lands.
Building a pipeline forced me to think about how the tools relate, not just what each one does. When sift needed to consume barb and vex output, the design questions became concrete: what format does each tool produce, what does the next tool actually need, and where does the handoff break down? That kind of thinking — about integration and data flow — is something I wouldn’t have encountered by building each tool in isolation.
What’s New in v1.1.x
v1.1.101 (hotfix, 2026-05-12): Injection scanner quiet mode. Running sift against large alert sets previously flooded the log with one WARNING line per alert. Now a single summary line by default: WARNING: Injection scanner: 14 pattern(s) across 8 alert(s) — redacted. Two new flags: --injection-detail restores per-alert output, --findings-file writes full findings JSON to disk. 1145 tests.
v1.1.03 — Ticketing Integration
--ticket jira, --ticket thehive, and --ticket dry-run generate a structured ticket draft directly from the prioritized cluster — title, summary, severity, timeline, IOCs, ATT&CK techniques, and recommendations. dry-run writes the JSON to stdout; the other providers push directly to the target system.
The dry-run flag was built first intentionally: a tool that writes tickets automatically has to prove its drafts are good enough before it writes anything. That’s not a limitation — it’s how trust in automated output should work.
v1.1.07–1.1.10 — IOC Extractor Expansion + Security Hardening
The IOC extractor now covers 15+ types: CVE IDs, MITRE ATT&CK technique IDs, PowerShell encoded blocks, Windows registry keys, tunnel/abuse domains (ngrok, serveo, trycloudflare), extended hashes (SHA512, ssdeep, TLSH, JARM, JA3/JA3S), and filename patterns. A refang preprocessor handles hxxp, [.], [dot], and similar defanging conventions used in threat intel reports.
The prompt injection detector was expanded to cover the new IOC types, with NFKC normalization and base64 field skipping. PowerShell encoded blocks are sanitized before reaching the LLM — replaced with a stub containing the SHA256 of the original payload.
Injection Scanner — Detection Logic
The scanner runs four pattern classes against every alert field before it reaches the LLM, each with an assigned severity:
| Pattern class | Example | Severity |
|---|---|---|
instruction_override | “ignore previous instructions” | Critical |
output_manipulation | “respond with . instead” | Critical |
encoded_payload | suspicious Base64 / Hex | Warning |
shell_injection | $(cmd), backticks, $var | Critical |
A fifth step runs before the patterns: NFKC Unicode normalization.
NFKC pre-processing
The Cyrillic і (U+0456) looks identical to the Latin i (U+0069) in most fonts. A raw string match against “ignore previous instructions” won’t catch “іgnore previous instructions” — the first character is a different code point. sift normalises every field value before pattern matching runs:
1
2
normalized = unicodedata.normalize("NFKC", value)
# patterns always run on normalized text
This was a finding from the adversarial review. The first version matched on raw field values, which meant any lookalike character in an attacker-controlled string bypassed the check entirely.
The Base64 trade-off
encoded_payload is rated Warning rather than Critical because SOC alert data regularly contains legitimate Base64: hashes, JARM fingerprints, encoded PowerShell blocks from Sysmon events. A filter that flags every Base64-looking token produces false positives on normal alert traffic. A filter that ignores all Base64 is no filter at all.
The heuristic sift uses to separate the two cases:
1
2
3
4
# example rule:
if len(token) < 15 and token.isalnum():
# → hash, benign, skip
return SKIP
Short alphanumeric tokens match the profile of a hash or fingerprint. Longer, mixed-character tokens get flagged. The threshold is a deliberate trade-off — which is why this class stays at Warning rather than Critical.
On a hit: redaction before the LLM
When a pattern fires, sift redacts the field and logs the finding as structured JSON:
1
2
3
4
5
6
{
"field": "alert.description",
"pattern_type": "instruction_override",
"severity": "CRITICAL",
"redaction": "[REDACTED: instruction override attempt]"
}
Three things happen in parallel: sift logs the attempt, the analyst sees the original alert value in sift’s output, and the LLM receives only the sanitised version. The injection attempt is visible to the analyst and never reaches the model.
What pattern matching doesn’t cover
The scanner catches known patterns, known encodings, and Unicode substitutions for characters in those patterns. New phrasings, multilingual variants, and semantic injection that achieves the same effect without trigger words won’t match. Pattern matching is one layer of defence, not a complete solution.
What’s Next
The v1.3 backlog for vex includes deterministic batch IOC correlation and AI-enhanced correlation narratives — features that make more sense now that sift exists to provide the alert context. For sift itself, the MeetUp backlog has items deferred from v1.0: streaming LLM output, workflow presets, a REST API mode for SOAR integration, and a performance benchmark suite for large alert batches.
Try the Pipeline
All three tools are available on PyPI.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Install the toolkit
pip install barb-phish
pip install vex-ioc
pip install sift-triage
# Standalone barb → vex pipeline
barb analyze -f suspicious_urls.txt -o json > barb_results.json
vex triage -f iocs.txt --from-barb barb_results.json -o json > vex_results.json
# Full pipeline
sift triage alerts.json \
--context barb_results.json \
--context vex_results.json \
--summarize -o rich
Source code: github.com/duathron/sift
