Build an AI-Powered SOC Triage Agent
Automate security alert triage with an LLM-powered Python pipeline.
Introduction
30 Second Summary
Security teams receive thousands of alerts every day, and most of them are noise. The real skill is figuring out which ones signal a genuine threat before it's too late.
In this project, you will build a command-line AI agent that reads raw security alerts, classifies their severity, maps them to known attack frameworks, and produces an actionable triage report.
What You'll Build
A Python-based SOC Triage Agent that processes mock intrusion detection alerts through an LLM-powered analysis pipeline, producing a professional Markdown report sorted by severity.
By the end of this project, you'll have:
- A complete alert ingestion pipeline that loads, normalizes, and deduplicates raw IDS alerts.
- An LLM triage engine that classifies each alert with a severity rating, MITRE ATT&CK mapping, and analyst verdict.
- A report generator that outputs a professional Markdown triage report with executive summary tables and per-alert details.
- Secret Mission: Add alert correlation that groups alerts by source IP to detect multi-stage attack campaigns.
Are there any prerequisites?
You should be comfortable with basic Python (functions, dictionaries, and file I/O). You also need a free OpenRouter account with an API key ready to go.
Before We Start
Security Operations Centers process thousands of alerts every day. Tier-1 analysts spend hours manually reading each alert, classifying its severity, and deciding whether it needs escalation.
In this step, you'll set up the project structure and tools your SOC triage agent needs. By the end, you'll have a working connection to an AI model ready to classify security alerts for you.
In this step, get ready to:
- Create the project folder structure with directories for source code, alerts, and output.
- Install the required Python packages for HTTP requests and environment configuration.
- Verify your OpenRouter API connection works by sending a test request.
Create the project folder structure
Your triage agent needs separate directories to organize its source code, raw alert data, and generated reports. You'll create this structure from your terminal.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and set up the directory structure by running these commands:
macOS
mkdir soc-triage-agent
cd soc-triage-agent
mkdir src alerts output
touch src/__init__.py
Windows
mkdir soc-triage-agent
cd soc-triage-agent
mkdir src, alerts, output
New-Item -Path src/__init__.py -ItemType File
What does this structure do?
- src/ holds your Python modules (parser, triage engine, report generator).
- alerts/ stores the raw security alert data your agent will process.
- output/ is where the final triage report gets saved.
- src/__init__.py marks the src folder as a Python package so you can import modules from it.
- Confirm your folders were created by running:
ls
You should see src, alerts, and output listed in the terminal output.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code (or Cursor if that's your editor) and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the soc-triage-agent folder, and click Open.
Don't see the folder on your Desktop?
Make sure your terminal was pointed at ~/Desktop before you ran the mkdir command. If the folder ended up somewhere else, you can find it by searching your system for soc-triage-agent.
Help me find my soc-triage-agent folder
Install the required Python packages
Your triage agent needs two packages: one for making HTTP requests to the AI model, and one for securely loading your API key from environment files.
- In VS Code, create a new file called requirements.txt in the root of your soc-triage-agent folder by clicking the new file icon in the Explorer sidebar.
- Add the following content to requirements.txt:
requests==2.34.2
python-dotenv==1.2.2
What are these packages?
- requests lets you send HTTP requests to the OpenRouter API. This is how your agent talks to the LLM.
- python-dotenv loads environment variables from a .env file so your API key stays out of your code.
- Save requirements.txt.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Install both packages by running:
pip install -r requirements.txt
- Verify the packages installed by running:
pip show requests python-dotenv
You should see version 2.34.2 for requests and version 1.2.2 for python-dotenv in the output.
Seeing 'pip not found'?
Try pip3 instead of pip. On some systems, Python 3's package manager is only available as pip3. If neither works, make sure Python 3.12 is on your system PATH.
Help me fix my pip installation issue
✔️ Awesome, I've got everything!
Great. Make sure you've saved requirements.txt before moving on.
ⓧ I'd like to double check the full code
requests==2.34.2
python-dotenv==1.2.2
Create your environment file and verify the API connection
Your API key needs to live in a secure location outside your main code. A .env file keeps sensitive credentials separate so they never get accidentally shared.
- Create a new file called .env in the root of your soc-triage-agent folder.
- Add the following line, replacing the placeholder with your actual OpenRouter API key:
OPENROUTER_API_KEY=your-api-key-here
Where do I find my OpenRouter API key?
Log in to openrouter.ai, click your profile icon, then go to Keys. Copy your existing key or create a new one. Paste it after the = sign with no spaces.
- Save .env.
Now you'll confirm the API key and model access work by sending a quick test request.
- Create a new file called test_connection.py in the root of your soc-triage-agent folder and add the following code:
import os
import requests
from dotenv import load_dotenv
# Load environment variables from .env
load_dotenv()
# Get API key from environment
api_key = os.getenv("OPENROUTER_API_KEY")
# Send a test request to OpenRouter
response = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": "nvidia/nemotron-3-super-120b-a12b:free",
"messages": [{"role": "user", "content": "Say hello in one sentence."}],
"max_tokens": 50,
},
)
# Print the result
print(f"Status: {response.status_code}")
print(f"Response: {response.json()['choices'][0]['message']['content']}")
print("\nAPI connection successful!")
What does this test script do?
- load_dotenv() reads your .env file and makes OPENROUTER_API_KEY available as an environment variable.
- The script sends a simple prompt to the nvidia/nemotron-3-super-120b-a12b:free model via OpenRouter's API.
- A 200 status code and a text response confirm your key is valid and the model is accessible.
- Save test_connection.py.
- Run the test script in your VS Code terminal:
python test_connection.py
You should see a 200 status code followed by a short greeting from the model, then the message "API connection successful!" printed to the terminal.
Seeing a 401 or 403 error?
This means your API key is invalid or missing. Double check that your .env file has no spaces around the = sign and that the key matches exactly what OpenRouter shows on your Keys page.
If you see a connection error instead, check that you have internet access and that no firewall is blocking outbound HTTPS requests.
Help me debug my OpenRouter API connection
✔️ Awesome, I've got everything!
Great. Make sure you've saved your .env file before moving on.
ⓧ I'd like to double check the full code
OPENROUTER_API_KEY=your-api-key-here
Your environment is ready and your AI model is responding. Next up, you'll create the mock security alerts that your triage agent will analyze.
Create Mock Security Alerts
Your environment is connected to OpenRouter and ready to process data. Now you need something to analyze.
A real SOC (Security Operations Center) receives hundreds of alerts per hour from intrusion detection systems. In this step, you will create a set of realistic mock alerts that simulate what a Suricata IDS produces. These alerts give your triage agent something meaningful to classify.
In this step, get ready to:
- Create a sample alerts file with 6 realistic security events.
- Understand what each field in the alert structure means in a real SOC environment.
Create the sample alerts file
Your triage agent needs raw data to work with. You will create a JSON file containing 6 alerts in Suricata EVE JSON format. This is the same format used by real-world IDS tools like Suricata, Snort, and Wazuh.
- Create a new file called sample_alerts.json inside the alerts/ folder of your project.
- Paste the following JSON into the file:
{
"alerts": [
{
"timestamp": "2026-06-12T08:23:41.000Z",
"src_ip": "192.168.1.105",
"src_port": 54821,
"dest_ip": "10.0.0.2",
"dest_port": 22,
"proto": "tcp",
"alert": {
"signature": "ET SCAN Potential SSH Brute Force Attempt",
"category": "Attempted Administrator Privilege Gain",
"severity": 1
},
"payload": "SSH-2.0-libssh2_1.4.3"
},
{
"timestamp": "2026-06-12T08:24:02.000Z",
"src_ip": "10.0.0.50",
"src_port": 49221,
"dest_ip": "198.51.100.23",
"dest_port": 53,
"proto": "udp",
"alert": {
"signature": "ET DNS Query to Known Malicious Domain (malware-c2.evil.com)",
"category": "A Network Trojan was Detected",
"severity": 1
},
"payload": ""
},
{
"timestamp": "2026-06-12T08:25:15.000Z",
"src_ip": "203.0.113.45",
"src_port": 12345,
"dest_ip": "10.0.0.10",
"dest_port": 80,
"proto": "tcp",
"alert": {
"signature": "ET WEB_SERVER SQL Injection Attempt SELECT FROM",
"category": "Web Application Attack",
"severity": 1
},
"payload": "GET /products?id=1 UNION SELECT username,password FROM users-- HTTP/1.1"
},
{
"timestamp": "2026-06-12T08:26:30.000Z",
"src_ip": "172.16.0.88",
"src_port": 443,
"dest_ip": "45.33.32.156",
"dest_port": 443,
"proto": "tcp",
"alert": {
"signature": "ET POLICY Outbound Large Data Transfer Detected",
"category": "Potential Corporate Privacy Violation",
"severity": 2
},
"payload": ""
},
{
"timestamp": "2026-06-12T08:27:00.000Z",
"src_ip": "192.168.1.105",
"src_port": 54822,
"dest_ip": "10.0.0.2",
"dest_port": 22,
"proto": "tcp",
"alert": {
"signature": "ET SCAN Potential SSH Brute Force Attempt",
"category": "Attempted Administrator Privilege Gain",
"severity": 1
},
"payload": "SSH-2.0-libssh2_1.4.3"
},
{
"timestamp": "2026-06-12T08:28:45.000Z",
"src_ip": "192.168.1.105",
"src_port": 51234,
"dest_ip": "10.0.0.1",
"dest_port": 445,
"proto": "tcp",
"alert": {
"signature": "ET SCAN Nmap SYN Scan Detected",
"category": "Detection of a Network Scan",
"severity": 2
},
"payload": ""
}
]
}
What does this file contain?
This file contains 6 security alerts that simulate what a real IDS would produce:
- Alert 1 and 5 are SSH brute force attempts from the same IP (192.168.1.105). Alert 5 is an intentional duplicate so you can test deduplication later.
- Alert 2 is a DNS query to a known malware command-and-control domain.
- Alert 3 is a SQL injection attempt targeting a web server.
- Alert 4 is a suspicious large outbound data transfer (possible exfiltration).
- Alert 6 is an Nmap port scan from the same IP as the SSH brute force.
- Save alerts/sample_alerts.json.
- Verify the file loads correctly by running this command in your terminal:
python -c "import json; data = json.load(open('alerts/sample_alerts.json')); print(f'Loaded {len(data[\"alerts\"])} alerts successfully')"
You should see Loaded 6 alerts successfully printed in the terminal.
Seeing a FileNotFoundError?
- Make sure you are running the command from your project's root folder (the one containing the alerts/ directory).
- Check that the file is named exactly sample_alerts.json (not sample_alerts.txt or similar).
- If you see a JSONDecodeError, double check for missing commas or mismatched braces in your JSON file.
Help me fix my sample_alerts.json file
✔️ Awesome, I've got everything!
Great. Double check you have saved alerts/sample_alerts.json and the verification command printed 6 alerts.
ⓧ I'd like to double check the full code
Here is the complete alerts/sample_alerts.json file for comparison:
{
"alerts": [
{
"timestamp": "2026-06-12T08:23:41.000Z",
"src_ip": "192.168.1.105",
"src_port": 54821,
"dest_ip": "10.0.0.2",
"dest_port": 22,
"proto": "tcp",
"alert": {
"signature": "ET SCAN Potential SSH Brute Force Attempt",
"category": "Attempted Administrator Privilege Gain",
"severity": 1
},
"payload": "SSH-2.0-libssh2_1.4.3"
},
{
"timestamp": "2026-06-12T08:24:02.000Z",
"src_ip": "10.0.0.50",
"src_port": 49221,
"dest_ip": "198.51.100.23",
"dest_port": 53,
"proto": "udp",
"alert": {
"signature": "ET DNS Query to Known Malicious Domain (malware-c2.evil.com)",
"category": "A Network Trojan was Detected",
"severity": 1
},
"payload": ""
},
{
"timestamp": "2026-06-12T08:25:15.000Z",
"src_ip": "203.0.113.45",
"src_port": 12345,
"dest_ip": "10.0.0.10",
"dest_port": 80,
"proto": "tcp",
"alert": {
"signature": "ET WEB_SERVER SQL Injection Attempt SELECT FROM",
"category": "Web Application Attack",
"severity": 1
},
"payload": "GET /products?id=1 UNION SELECT username,password FROM users-- HTTP/1.1"
},
{
"timestamp": "2026-06-12T08:26:30.000Z",
"src_ip": "172.16.0.88",
"src_port": 443,
"dest_ip": "45.33.32.156",
"dest_port": 443,
"proto": "tcp",
"alert": {
"signature": "ET POLICY Outbound Large Data Transfer Detected",
"category": "Potential Corporate Privacy Violation",
"severity": 2
},
"payload": ""
},
{
"timestamp": "2026-06-12T08:27:00.000Z",
"src_ip": "192.168.1.105",
"src_port": 54822,
"dest_ip": "10.0.0.2",
"dest_port": 22,
"proto": "tcp",
"alert": {
"signature": "ET SCAN Potential SSH Brute Force Attempt",
"category": "Attempted Administrator Privilege Gain",
"severity": 1
},
"payload": "SSH-2.0-libssh2_1.4.3"
},
{
"timestamp": "2026-06-12T08:28:45.000Z",
"src_ip": "192.168.1.105",
"src_port": 51234,
"dest_ip": "10.0.0.1",
"dest_port": 445,
"proto": "tcp",
"alert": {
"signature": "ET SCAN Nmap SYN Scan Detected",
"category": "Detection of a Network Scan",
"severity": 2
},
"payload": ""
}
]
}
Understand the alert structure
Each alert follows the Suricata EVE JSON format. This is the industry-standard log format produced by open-source intrusion detection systems.
What does each field mean?
- timestamp is when the IDS detected the suspicious activity.
- src_ip and src_port identify where the traffic originated (the potential attacker).
- dest_ip and dest_port identify the target system and service being attacked.
- proto is the network protocol (TCP or UDP).
- alert.signature is the rule name that triggered the detection.
- alert.category classifies the type of threat the IDS believes it found.
- alert.severity is the IDS priority level (1 = highest priority, 3 = lowest).
- payload contains the raw data captured from the suspicious packet (when available).
Why include a duplicate alert?
Alert 5 is a near-identical copy of Alert 1. They share the same signature, source IP, and destination IP. In a real SOC, duplicate alerts flood in constantly as attackers retry.
Your parser will need to detect and remove these duplicates before sending alerts to the LLM. This saves API tokens and keeps your triage report clean.
Your mock alert data is ready. Next up, you will build a parser that normalizes these alerts into a consistent structure and removes duplicates before they reach the AI.
Build the Alert Parser
You now have realistic security alerts ready for analysis. The next piece of the pipeline is a parser that transforms raw alert data into a clean, consistent format the AI can work with.
Raw IDS data from tools like Suricata often contains duplicates and inconsistent field naming. Your parser will solve both problems before alerts ever reach the LLM triage engine.
In this step, get ready to:
- Write three parser functions to load, normalize, and deduplicate alerts.
- Test the parser to confirm it reduces 6 raw alerts to 5 unique alerts.
Write the alert parser module
The parser module handles three responsibilities: loading raw JSON, normalizing fields into a consistent structure, and removing duplicates. Each one becomes its own function inside src/parser.py.
- Create a new file called parser.py inside your src folder.
- Add the imports and the load_alerts() function by pasting this code:
import json
import hashlib
def load_alerts(filepath):
"""Load raw alerts from a JSON file."""
with open(filepath, "r") as f:
data = json.load(f)
return data["alerts"]
What does this code do?
The two imports bring in json for reading JSON files and hashlib for creating fingerprint hashes (used in the deduplication function later).
The load_alerts() function opens the JSON file at the given path and returns just the list inside the "alerts" key. This isolates the raw alert data from any wrapper structure.
You won't see a change yet. The next function builds on this one.
- Add the normalize_alerts() function below load_alerts() by pasting this code:
def normalize_alerts(raw_alerts):
"""Normalize alert fields into a consistent structure."""
normalized = []
for alert in raw_alerts:
normalized_alert = {
"timestamp": alert.get("timestamp", ""),
"src_ip": alert.get("src_ip", "unknown"),
"src_port": alert.get("src_port", 0),
"dest_ip": alert.get("dest_ip", "unknown"),
"dest_port": alert.get("dest_port", 0),
"protocol": alert.get("proto", "unknown").upper(),
"alert_signature": alert.get("alert", {}).get("signature", "Unknown Alert"),
"alert_category": alert.get("alert", {}).get("category", "Uncategorized"),
"severity": alert.get("alert", {}).get("severity", 3),
"payload": alert.get("payload", ""),
}
normalized.append(normalized_alert)
return normalized
What does this code do?
This function takes raw Suricata alerts and extracts each field into a flat, consistent dictionary. The .get() calls provide safe defaults so the parser never crashes on missing data.
Notice that nested fields like alert.signature get flattened into top-level keys like alert_signature. This makes the data easier for the LLM to process later.
Why normalize before sending to the LLM?
Different IDS tools structure their output differently. By normalizing first, your triage engine receives a predictable schema every time. This means the LLM prompt can reference exact field names without worrying about whether the source was Suricata, Snort, or another tool.
You won't see a change yet. The next function completes the module.
- Add the deduplicate_alerts() function below normalize_alerts() by pasting this code:
def deduplicate_alerts(alerts):
"""Remove duplicate alerts based on signature + source + destination."""
seen = set()
unique = []
for alert in alerts:
fingerprint = hashlib.md5(
f"{alert['alert_signature']}|{alert['src_ip']}|{alert['dest_ip']}".encode()
).hexdigest()
if fingerprint not in seen:
seen.add(fingerprint)
unique.append(alert)
return unique
What does this code do?
This function creates a unique fingerprint for each alert by hashing together three fields: the alert signature, source IP, and destination IP. If two alerts produce the same hash, the second one is a duplicate and gets dropped.
The seen set tracks fingerprints already encountered. Only alerts with a new fingerprint make it into the unique list.
Why MD5 for fingerprinting?
MD5 is fast and produces a fixed-length string. For deduplication (not security), it's the standard choice in SOC tooling. Combining signature + source + destination means two alerts from the same attacker hitting the same target with the same technique are treated as one event.
- Save src/parser.py.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on to test it.
ⓧ I'd like to double check the full code
Here's the complete src/parser.py file. Make sure yours matches exactly.
import json
import hashlib
def load_alerts(filepath):
"""Load raw alerts from a JSON file."""
with open(filepath, "r") as f:
data = json.load(f)
return data["alerts"]
def normalize_alerts(raw_alerts):
"""Normalize alert fields into a consistent structure."""
normalized = []
for alert in raw_alerts:
normalized_alert = {
"timestamp": alert.get("timestamp", ""),
"src_ip": alert.get("src_ip", "unknown"),
"src_port": alert.get("src_port", 0),
"dest_ip": alert.get("dest_ip", "unknown"),
"dest_port": alert.get("dest_port", 0),
"protocol": alert.get("proto", "unknown").upper(),
"alert_signature": alert.get("alert", {}).get("signature", "Unknown Alert"),
"alert_category": alert.get("alert", {}).get("category", "Uncategorized"),
"severity": alert.get("alert", {}).get("severity", 3),
"payload": alert.get("payload", ""),
}
normalized.append(normalized_alert)
return normalized
def deduplicate_alerts(alerts):
"""Remove duplicate alerts based on signature + source + destination."""
seen = set()
unique = []
for alert in alerts:
fingerprint = hashlib.md5(
f"{alert['alert_signature']}|{alert['src_ip']}|{alert['dest_ip']}".encode()
).hexdigest()
if fingerprint not in seen:
seen.add(fingerprint)
unique.append(alert)
return unique
Test the parser standalone
Your sample alerts file contains 6 alerts, but two of them are duplicate SSH brute force attempts from the same source IP targeting the same destination. The parser should reduce that down to 5 unique alerts.
- Test the full parser pipeline by running this command in your terminal from the project root folder:
python -c "from src.parser import load_alerts, normalize_alerts, deduplicate_alerts; raw = load_alerts('alerts/sample_alerts.json'); norm = normalize_alerts(raw); unique = deduplicate_alerts(norm); print(f'Raw alerts: {len(raw)}, Unique alerts: {len(unique)}')"
- You should see this output:
Raw alerts: 6, Unique alerts: 5
What should I see?
The output confirms that 6 raw alerts were loaded from the file. After deduplication, only 5 remain. The duplicate SSH brute force alert (same signature, same source IP 192.168.1.105, same destination IP 10.0.0.2) was correctly identified and removed.
Seeing a ModuleNotFoundError?
Make sure you're running the command from your project's root folder (the one containing the src directory), not from inside src itself.
Also confirm that src/__init__.py exists (even if it's empty). Python needs this file to treat src as a package.
Help me debug my ModuleNotFoundError when running the parser test.
Your parser is working. It can ingest raw alerts, flatten them into a consistent format, and strip out noise. Next up, you'll build the triage engine that sends these clean alerts to an LLM for severity classification and MITRE ATT&CK mapping.
Build the LLM Triage Engine
Your parser can now read, normalize, and deduplicate raw alerts. The next challenge is deciding what each alert actually means. Is that SSH brute force attempt a genuine attack or just a misconfigured service? That judgment call is exactly what a Tier-1 SOC analyst makes dozens of times per shift.
In this step, you will build the AI triage engine that automates that judgment. You will craft a system prompt that instructs an LLM to classify alerts with severity levels, MITRE ATT&CK mappings, and actionable verdicts.
In this step, get ready to:
- Write a system prompt that turns the LLM into a SOC analyst.
- Implement a function that sends each alert to OpenRouter for classification.
- Build a loop that triages all alerts with graceful error handling.
Write the system prompt and API configuration
The system prompt is the most important piece of the triage engine. It tells the LLM exactly what role to play and what structured output to return for every alert.
- Create a new file called src/triage.py inside your src folder.
- Add the imports, API constants, and system prompt by pasting the following code:
import requests
import json
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"
SYSTEM_PROMPT = """You are an expert SOC (Security Operations Center) Tier-1 analyst performing alert triage. For each security alert provided, analyze it and return a JSON object with the following fields:
- "severity": One of "critical", "high", "medium", or "low"
- "mitre_tactic": The most relevant MITRE ATT&CK tactic (e.g., "Initial Access", "Execution", "Persistence", "Privilege Escalation", "Defense Evasion", "Credential Access", "Discovery", "Lateral Movement", "Collection", "Command and Control", "Exfiltration", "Impact", "Reconnaissance")
- "mitre_technique": The specific MITRE ATT&CK technique ID and name (e.g., "T1110 - Brute Force")
- "verdict": One of "true_positive", "false_positive", or "suspicious"
- "confidence": A float between 0.0 and 1.0 indicating your confidence
- "analyst_summary": A 2-3 sentence plain-English explanation of what this alert means, why you classified it this way, and what action a SOC analyst should take
Be deterministic and conservative. When in doubt, escalate (classify as suspicious rather than false_positive).
Respond ONLY with valid JSON. No additional text."""
What does this code do?
OPENROUTER_API_URL points to the OpenRouter chat completions endpoint. This is the same endpoint format used by OpenAI, making your code portable to other providers.
MODEL_ID specifies the NVIDIA Nemotron 3 Super model. It is a 120B-parameter model with strong reasoning capabilities, and it costs $0 per token on OpenRouter's free tier.
SYSTEM_PROMPT defines the exact JSON schema the LLM must return. By spelling out each field, its allowed values, and the reasoning style, you get consistent structured output every time.
Why temperature=0 for security triage?
Security triage decisions must be deterministic and auditable. The same alert should always produce the same classification. Setting temperature to 0 removes randomness from the model's output so your triage results are repeatable.
- Save src/triage.py.
Seeing a syntax error on the triple-quoted string?
Make sure the opening and closing """ are both present. Missing one will cause Python to read the rest of the file as part of the string.
Help me fix a syntax error in my SYSTEM_PROMPT string in triage.py.
Implement the single-alert triage function
Each alert gets sent to the LLM individually so you can track which alerts succeed and which fail. The function formats the alert as JSON, builds the API request, and parses the structured response.
- In src/triage.py, add the triage_single_alert() function below the SYSTEM_PROMPT string:
def triage_single_alert(alert, api_key):
"""Send a single alert to the LLM for triage analysis."""
alert_text = json.dumps(alert, indent=2)
user_message = f"""Triage the following security alert:
{alert_text}
Respond with a JSON object containing: severity, mitre_tactic, mitre_technique, verdict, confidence, analyst_summary."""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": MODEL_ID,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
],
"temperature": 0,
"max_tokens": 500,
"response_format": {"type": "json_object"},
}
response = requests.post(OPENROUTER_API_URL, headers=headers, json=payload)
response.raise_for_status()
result = response.json()
content = result["choices"][0]["message"]["content"]
try:
triage_result = json.loads(content)
except json.JSONDecodeError:
triage_result = {
"severity": "medium",
"mitre_tactic": "Unknown",
"mitre_technique": "Unknown",
"verdict": "suspicious",
"confidence": 0.0,
"analyst_summary": f"Failed to parse LLM response: {content[:200]}",
}
return triage_result
What does this function do?
- json.dumps(alert, indent=2) converts the alert dict into a readable JSON string for the LLM.
- response_format: {"type": "json_object"} forces the model into JSON mode so it only outputs valid JSON.
- The try/except block handles cases where the model returns malformed JSON. Instead of crashing, it returns a safe fallback with a "suspicious" verdict.
- Save src/triage.py.
Not sure where to paste this function?
Place it directly below the closing """ of the SYSTEM_PROMPT string, with two blank lines separating them. Python functions must be at the top level of the file (no extra indentation).
Help me figure out where to add triage_single_alert in my triage.py file.
Implement the batch triage loop
A single API failure should not crash your entire triage run. The batch function loops through all alerts and wraps each call in error handling so one bad response does not stop the pipeline.
- In src/triage.py, add the triage_alerts() function below triage_single_alert():
def triage_alerts(alerts, api_key):
"""Triage all alerts and return combined results."""
results = []
for i, alert in enumerate(alerts):
print(f" Analyzing alert {i + 1}/{len(alerts)}: {alert['alert_signature']}")
try:
triage_result = triage_single_alert(alert, api_key)
results.append({
"alert": alert,
"triage": triage_result,
})
except Exception as e:
print(f" Error triaging alert {i + 1}: {e}")
results.append({
"alert": alert,
"triage": {
"severity": "medium",
"mitre_tactic": "Unknown",
"mitre_technique": "Unknown",
"verdict": "suspicious",
"confidence": 0.0,
"analyst_summary": f"Triage failed due to API error: {str(e)}",
},
})
return results
What does this function do?
- The for i, alert in enumerate(alerts) loop processes each alert one at a time and prints progress so you can see which alert is being analyzed.
- The try/except catches any API failures (network errors, rate limits, timeouts) and substitutes a safe fallback instead of crashing.
- Each result is a dict with both the original alert and its triage classification, keeping them paired for the report generator.
- Save src/triage.py.
- Verify the triage engine works by running this test command in your terminal:
python -c "
import os, json
from dotenv import load_dotenv
from src.parser import load_alerts, normalize_alerts, deduplicate_alerts
from src.triage import triage_single_alert
load_dotenv()
api_key = os.getenv('OPENROUTER_API_KEY')
raw = load_alerts('alerts/sample_alerts.json')
normalized = normalize_alerts(raw)
unique = deduplicate_alerts(normalized)
result = triage_single_alert(unique[0], api_key)
print(json.dumps(result, indent=2))
"
You should see a JSON object printed to your terminal with all six fields: severity, mitre_tactic, mitre_technique, verdict, confidence, and analyst_summary.
Getting a 401 or authentication error?
Double check that your .env file contains a valid OPENROUTER_API_KEY value. Make sure there are no extra spaces or quotes around the key.
If you see a timeout or connection error, check that you have internet access and try running the command again.
Help me debug an API error when calling OpenRouter from my triage engine.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/triage.py before moving on.
ⓧ I'd like to double check the full code
import requests
import json
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"
SYSTEM_PROMPT = """You are an expert SOC (Security Operations Center) Tier-1 analyst performing alert triage. For each security alert provided, analyze it and return a JSON object with the following fields:
- "severity": One of "critical", "high", "medium", or "low"
- "mitre_tactic": The most relevant MITRE ATT&CK tactic (e.g., "Initial Access", "Execution", "Persistence", "Privilege Escalation", "Defense Evasion", "Credential Access", "Discovery", "Lateral Movement", "Collection", "Command and Control", "Exfiltration", "Impact", "Reconnaissance")
- "mitre_technique": The specific MITRE ATT&CK technique ID and name (e.g., "T1110 - Brute Force")
- "verdict": One of "true_positive", "false_positive", or "suspicious"
- "confidence": A float between 0.0 and 1.0 indicating your confidence
- "analyst_summary": A 2-3 sentence plain-English explanation of what this alert means, why you classified it this way, and what action a SOC analyst should take
Be deterministic and conservative. When in doubt, escalate (classify as suspicious rather than false_positive).
Respond ONLY with valid JSON. No additional text."""
def triage_single_alert(alert, api_key):
"""Send a single alert to the LLM for triage analysis."""
alert_text = json.dumps(alert, indent=2)
user_message = f"""Triage the following security alert:
{alert_text}
Respond with a JSON object containing: severity, mitre_tactic, mitre_technique, verdict, confidence, analyst_summary."""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": MODEL_ID,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
],
"temperature": 0,
"max_tokens": 500,
"response_format": {"type": "json_object"},
}
response = requests.post(OPENROUTER_API_URL, headers=headers, json=payload)
response.raise_for_status()
result = response.json()
content = result["choices"][0]["message"]["content"]
try:
triage_result = json.loads(content)
except json.JSONDecodeError:
triage_result = {
"severity": "medium",
"mitre_tactic": "Unknown",
"mitre_technique": "Unknown",
"verdict": "suspicious",
"confidence": 0.0,
"analyst_summary": f"Failed to parse LLM response: {content[:200]}",
}
return triage_result
def triage_alerts(alerts, api_key):
"""Triage all alerts and return combined results."""
results = []
for i, alert in enumerate(alerts):
print(f" Analyzing alert {i + 1}/{len(alerts)}: {alert['alert_signature']}")
try:
triage_result = triage_single_alert(alert, api_key)
results.append({
"alert": alert,
"triage": triage_result,
})
except Exception as e:
print(f" Error triaging alert {i + 1}: {e}")
results.append({
"alert": alert,
"triage": {
"severity": "medium",
"mitre_tactic": "Unknown",
"mitre_technique": "Unknown",
"verdict": "suspicious",
"confidence": 0.0,
"analyst_summary": f"Triage failed due to API error: {str(e)}",
},
})
return results
Your triage engine can now classify any security alert with a severity level, MITRE ATT&CK mapping, and an actionable verdict. Next up, you will build the report generator that turns these raw triage results into a professional Markdown report sorted by severity.
Generate the Report and Run the Pipeline
Your triage engine now classifies each alert with a severity, MITRE ATT&CK mapping, and analyst verdict. The raw JSON output is useful for machines, but a human analyst needs a scannable summary they can act on immediately.
In this step, you will build a report generator that transforms triage results into a professional Markdown document. Then you will wire all of the modules together into a single orchestrator script and run the full pipeline end-to-end.
In this step, get ready to:
- Write a report generator that produces a Markdown triage report sorted by severity.
- Write the main orchestrator script that ties the full pipeline together.
- Run the pipeline and review the generated report.
Write the report generator
A real SOC triage report has two sections: an executive summary for managers (how many critical alerts? how many true positives?) and a detailed breakdown for the analyst who picks up the ticket.
- Create a new file called src/report.py and add the following code:
from datetime import datetime
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
def generate_report(triage_results):
"""Generate a Markdown triage report from triage results."""
sorted_results = sorted(
triage_results,
key=lambda x: SEVERITY_ORDER.get(x["triage"].get("severity", "medium"), 2),
)
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
verdict_counts = {"true_positive": 0, "false_positive": 0, "suspicious": 0}
for result in sorted_results:
sev = result["triage"].get("severity", "medium")
ver = result["triage"].get("verdict", "suspicious")
severity_counts[sev] = severity_counts.get(sev, 0) + 1
verdict_counts[ver] = verdict_counts.get(ver, 0) + 1
report_lines = []
report_lines.append("# SOC Triage Report")
report_lines.append(f"\nGenerated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
report_lines.append(f"Total Alerts Analyzed: {len(sorted_results)}")
report_lines.append("\n## Executive Summary\n")
report_lines.append("### Severity Breakdown\n")
report_lines.append("| Severity | Count |")
report_lines.append("|----------|-------|")
for sev in ["critical", "high", "medium", "low"]:
count = severity_counts[sev]
if count > 0:
report_lines.append(f"| {sev.upper()} | {count} |")
report_lines.append("\n### Verdict Breakdown\n")
report_lines.append("| Verdict | Count |")
report_lines.append("|---------|-------|")
for ver in ["true_positive", "suspicious", "false_positive"]:
count = verdict_counts[ver]
if count > 0:
label = ver.replace("_", " ").title()
report_lines.append(f"| {label} | {count} |")
report_lines.append("\n## Alert Details\n")
for i, result in enumerate(sorted_results, 1):
alert = result["alert"]
triage = result["triage"]
severity = triage.get("severity", "medium").upper()
report_lines.append(f"### Alert {i}: {alert['alert_signature']}\n")
report_lines.append(f"- Severity: {severity}")
report_lines.append(f"- Verdict: {triage.get('verdict', 'unknown').replace('_', ' ').title()}")
report_lines.append(f"- Confidence: {triage.get('confidence', 0):.0%}")
report_lines.append(f"- MITRE ATT&CK: {triage.get('mitre_tactic', 'N/A')} / {triage.get('mitre_technique', 'N/A')}")
report_lines.append(f"- Source: {alert['src_ip']}:{alert['src_port']}")
report_lines.append(f"- Destination: {alert['dest_ip']}:{alert['dest_port']}")
report_lines.append(f"- Protocol: {alert['protocol']}")
report_lines.append(f"- Timestamp: {alert['timestamp']}")
report_lines.append(f"\nAnalyst Summary: {triage.get('analyst_summary', 'No summary available.')}\n")
report_lines.append("---\n")
return "\n".join(report_lines)
What does this code do?
SEVERITY_ORDER maps severity labels to numbers so the report can sort critical alerts to the top. Lower number means higher priority.
The generate_report() function builds the report in three stages:
- It sorts all triage results by severity (critical first, low last).
- It counts how many alerts fall into each severity level and each verdict category, then formats those counts as Markdown tables in the Executive Summary.
- It loops through each sorted alert and writes a detailed section with the MITRE ATT&CK mapping, network metadata, and the LLM's analyst summary.
- Save src/report.py.
Seeing an import error for datetime?
The datetime module is part of Python's standard library. If you see an error, double-check that the import line at the top reads from datetime import datetime exactly.
Ask for help: Why am I getting an import error for datetime in my report module?
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/report.py file:
from datetime import datetime
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
def generate_report(triage_results):
"""Generate a Markdown triage report from triage results."""
sorted_results = sorted(
triage_results,
key=lambda x: SEVERITY_ORDER.get(x["triage"].get("severity", "medium"), 2),
)
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
verdict_counts = {"true_positive": 0, "false_positive": 0, "suspicious": 0}
for result in sorted_results:
sev = result["triage"].get("severity", "medium")
ver = result["triage"].get("verdict", "suspicious")
severity_counts[sev] = severity_counts.get(sev, 0) + 1
verdict_counts[ver] = verdict_counts.get(ver, 0) + 1
report_lines = []
report_lines.append("# SOC Triage Report")
report_lines.append(f"\nGenerated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
report_lines.append(f"Total Alerts Analyzed: {len(sorted_results)}")
report_lines.append("\n## Executive Summary\n")
report_lines.append("### Severity Breakdown\n")
report_lines.append("| Severity | Count |")
report_lines.append("|----------|-------|")
for sev in ["critical", "high", "medium", "low"]:
count = severity_counts[sev]
if count > 0:
report_lines.append(f"| {sev.upper()} | {count} |")
report_lines.append("\n### Verdict Breakdown\n")
report_lines.append("| Verdict | Count |")
report_lines.append("|---------|-------|")
for ver in ["true_positive", "suspicious", "false_positive"]:
count = verdict_counts[ver]
if count > 0:
label = ver.replace("_", " ").title()
report_lines.append(f"| {label} | {count} |")
report_lines.append("\n## Alert Details\n")
for i, result in enumerate(sorted_results, 1):
alert = result["alert"]
triage = result["triage"]
severity = triage.get("severity", "medium").upper()
report_lines.append(f"### Alert {i}: {alert['alert_signature']}\n")
report_lines.append(f"- Severity: {severity}")
report_lines.append(f"- Verdict: {triage.get('verdict', 'unknown').replace('_', ' ').title()}")
report_lines.append(f"- Confidence: {triage.get('confidence', 0):.0%}")
report_lines.append(f"- MITRE ATT&CK: {triage.get('mitre_tactic', 'N/A')} / {triage.get('mitre_technique', 'N/A')}")
report_lines.append(f"- Source: {alert['src_ip']}:{alert['src_port']}")
report_lines.append(f"- Destination: {alert['dest_ip']}:{alert['dest_port']}")
report_lines.append(f"- Protocol: {alert['protocol']}")
report_lines.append(f"- Timestamp: {alert['timestamp']}")
report_lines.append(f"\nAnalyst Summary: {triage.get('analyst_summary', 'No summary available.')}\n")
report_lines.append("---\n")
return "\n".join(report_lines)
Write the pipeline orchestrator
The orchestrator is the entry point that ties every module together into one command. It loads your environment variables, runs each pipeline stage in sequence, and saves the final report to disk.
- Create a new file called main.py in the root of your project folder (the same level as requirements.txt) and add the following code:
import os
import json
from dotenv import load_dotenv
from src.parser import load_alerts, normalize_alerts, deduplicate_alerts
from src.triage import triage_alerts
from src.report import generate_report
def main():
load_dotenv()
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
print("Error: OPENROUTER_API_KEY not found in .env file")
return
print("=" * 60)
print("SOC TRIAGE AGENT - AI-Powered Alert Analysis")
print("=" * 60)
print("\n[1/4] Loading alerts...")
raw_alerts = load_alerts("alerts/sample_alerts.json")
print(f" Loaded {len(raw_alerts)} raw alerts")
print("[2/4] Normalizing and deduplicating...")
normalized = normalize_alerts(raw_alerts)
unique_alerts = deduplicate_alerts(normalized)
print(f" {len(unique_alerts)} unique alerts after deduplication")
print("[3/4] Running AI triage analysis...")
triage_results = triage_alerts(unique_alerts, api_key)
print(f" Triaged {len(triage_results)} alerts")
print("[4/4] Generating triage report...")
report = generate_report(triage_results)
os.makedirs("output", exist_ok=True)
report_path = "output/triage_report.md"
with open(report_path, "w") as f:
f.write(report)
print(f"\n Report saved to: {report_path}")
print("\n" + "=" * 60)
print("TRIAGE COMPLETE")
print("=" * 60)
print(report)
if __name__ == "__main__":
main()
What does this code do?
The main() function runs the pipeline in four numbered stages:
- Stage 1 loads the raw alerts from alerts/sample_alerts.json.
- Stage 2 normalizes and deduplicates them (6 raw alerts become 5 unique alerts).
- Stage 3 sends each unique alert to the LLM for triage via OpenRouter.
- Stage 4 generates the Markdown report and saves it to output/triage_report.md.
The os.makedirs("output", exist_ok=True) line creates the output folder if it does not already exist, so you never hit a "directory not found" error.
- Save main.py.
Seeing a ModuleNotFoundError?
If you see an error about src.parser or src.triage not being found, make sure your src/__init__.py file exists. This empty file tells Python to treat the src/ folder as a package.
Also confirm you are running the command from your project's root folder (the one containing main.py), not from inside the src/ folder.
Ask for help: Why is Python saying it cannot find my src module when I run main.py?
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.py file:
import os
import json
from dotenv import load_dotenv
from src.parser import load_alerts, normalize_alerts, deduplicate_alerts
from src.triage import triage_alerts
from src.report import generate_report
def main():
load_dotenv()
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
print("Error: OPENROUTER_API_KEY not found in .env file")
return
print("=" * 60)
print("SOC TRIAGE AGENT - AI-Powered Alert Analysis")
print("=" * 60)
print("\n[1/4] Loading alerts...")
raw_alerts = load_alerts("alerts/sample_alerts.json")
print(f" Loaded {len(raw_alerts)} raw alerts")
print("[2/4] Normalizing and deduplicating...")
normalized = normalize_alerts(raw_alerts)
unique_alerts = deduplicate_alerts(normalized)
print(f" {len(unique_alerts)} unique alerts after deduplication")
print("[3/4] Running AI triage analysis...")
triage_results = triage_alerts(unique_alerts, api_key)
print(f" Triaged {len(triage_results)} alerts")
print("[4/4] Generating triage report...")
report = generate_report(triage_results)
os.makedirs("output", exist_ok=True)
report_path = "output/triage_report.md"
with open(report_path, "w") as f:
f.write(report)
print(f"\n Report saved to: {report_path}")
print("\n" + "=" * 60)
print("TRIAGE COMPLETE")
print("=" * 60)
print(report)
if __name__ == "__main__":
main()
Run the full pipeline end-to-end
Everything is wired up. Time to run your SOC Triage Agent and see the full report.
- Run the pipeline by executing this command in your terminal:
python main.py
What should I see?
The script prints progress for each of its four stages. Stage 3 takes the longest because it sends 5 separate API requests to the LLM. You should see output that starts with:
============================================================
SOC TRIAGE AGENT - AI-Powered Alert Analysis
============================================================
[1/4] Loading alerts...
Loaded 6 raw alerts
[2/4] Normalizing and deduplicating...
5 unique alerts after deduplication
[3/4] Running AI triage analysis...
Analyzing alert 1/5: ET SCAN Potential SSH Brute Force Attempt
After all 5 alerts are analyzed, the full Markdown report prints to your terminal and is saved to output/triage_report.md.
- Wait for the script to finish (this may take 30-60 seconds as each alert is sent to the LLM).
- Confirm the report was saved by running:
ls output/
- You should see triage_report.md listed in the output.
- Open output/triage_report.md in your editor to review the full report. Each alert should have a severity rating, MITRE ATT&CK mapping, verdict, and analyst summary.
Script hangs or times out?
Free-tier models on OpenRouter can occasionally be slow under high load. If the script seems stuck on one alert for more than 2 minutes, press Ctrl+C to cancel and try running it again.
If you see an HTTP 401 error, your API key is incorrect or missing. Open your .env file and confirm the OPENROUTER_API_KEY value is correct.
Ask for help: My SOC triage agent is failing when I run python main.py. How do I debug this?
Secret mission
Add Alert Correlation
Your triage agent analyzes alerts one at a time. But real attackers don't stop at one move. Can you add campaign-level correlation that spots multi-stage attacks by grouping alerts from the same source IP and asking the LLM to identify coordinated attack patterns?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses a free-tier LLM model. There are no ongoing costs from cloud resources or API usage.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Project folder on your Desktop (contains main.py, src/, alerts/, output/, .env).
- Python packages: requests and python-dotenv.
Keep everything running
No action needed. Choose this if you want to keep experimenting with the triage agent, add more alerts, or tweak the prompts.
- Your project folder stays on your Desktop.
- Your .env file keeps your OpenRouter API key ready for future runs.
- The installed Python packages remain available for other projects.
Pause - I'll come back to this later
There are no running processes to stop. Your project is already paused by default since it only runs when you execute python main.py.
- No action required. Everything is file-based and nothing runs in the background.
- When you come back, navigate to your project folder and run python main.py again.
Delete - I don't want to use this again
Remove all project files and uninstall the Python packages to start fresh.
- Delete the project folder from your Desktop. Open your terminal and run:
macOS
cd ~/Desktop
rm -rf soc-triage-agent
Windows
cd ~\Desktop
rmdir /s /q soc-triage-agent
If your project folder has a different name, replace soc-triage-agent with the actual folder name you see on your Desktop.
- Uninstall the Python packages by running:
pip uninstall requests python-dotenv -y
This removes both packages from your Python environment.
Nice Work!
Nice Work!
Nice work! You just built a fully functional AI-powered SOC Triage Agent that ingests raw security alerts, classifies them with an LLM, and produces a professional triage report.
Here's what you accomplished:
- Built a complete alert ingestion and parsing pipeline with MD5-based deduplication that filters duplicate alerts before they reach the LLM.
- Engineered a system prompt that produces deterministic, structured security classifications with MITRE ATT&CK mappings and SOC triage verdicts.
- Integrated a free LLM via OpenRouter for zero-cost AI-powered triage and generated professional Markdown triage reports sorted by severity.
- Secret Mission: Added campaign-level alert correlation that groups alerts by source IP to detect multi-stage attack campaigns.
Ready to quiz yourself?