Skip to content

Lab 09 β€” SOC Alert Triage Automation

Difficulty: Advanced
Estimated Time: 180–240 minutes
Primary Language: Python
Security Domain: SOC / Detection Engineering / Incident Response / Security Automation
Environment: Local controlled lab
Automation Type: Defensive SOC Alert Triage

You are working as a SOC automation engineer.

Your SOC receives alerts from multiple security technologies:

SIEM
EDR / XDR
IDENTITY PLATFORM
FIREWALL
CLOUD SECURITY
VULNERABILITY MANAGEMENT
THREAT INTELLIGENCE

The challenge is not simply receiving alerts.

The challenge is determining:

WHICH ALERTS
REQUIRE ATTENTION FIRST?

Your mission is to build a Python automation pipeline that transforms raw alerts into enriched, prioritized analyst work items.

The final workflow will be:

RAW ALERTS
↓
VALIDATE
↓
NORMALIZE
↓
DEDUPLICATE
↓
ENRICH
β”Œβ”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
↓ ↓ ↓ ↓
USER ASSET IOC VULNERABILITY
↓ ↓ ↓ ↓
β””β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
CORRELATE
↓
RISK SCORE
↓
TRIAGE RECOMMENDATION
↓
ANALYST QUEUE
↓
HUMAN REVIEW

A SOC can receive:

100
1,000
10,000+
ALERTS PER DAY

Treating every alert equally creates:

ALERT FATIGUE
SLOW INVESTIGATION
MISSED CRITICAL EVENTS
INCONSISTENT TRIAGE
ANALYST BURNOUT

Security automation helps analysts answer:

WHO IS INVOLVED?
WHAT ASSET IS INVOLVED?
HOW CRITICAL IS THE ASSET?
IS THE USER PRIVILEGED?
IS THE SOURCE KNOWN?
ARE THERE VULNERABILITIES?
IS THE ASSET INTERNET-FACING?
ARE THERE RELATED ALERTS?
WHAT SHOULD BE INVESTIGATED FIRST?

This lab automates:

COLLECTION
NORMALIZATION
ENRICHMENT
CORRELATION
PRIORITIZATION
REPORTING

It does not automatically:

DISABLE ACCOUNTS
ISOLATE ENDPOINTS
BLOCK IP ADDRESSES
DELETE CLOUD RESOURCES
RESET PASSWORDS
MODIFY FIREWALL RULES

High-impact response remains:

ANALYST / INCIDENT RESPONDER
↓
VALIDATION
↓
APPROVAL
↓
CONTROLLED RESPONSE

By completing this lab, you should be able to:

PROCESS SOC ALERTS
VALIDATE SECURITY EVENTS
NORMALIZE MULTIPLE ALERT TYPES
DEDUPLICATE ALERTS
ENRICH IDENTITY CONTEXT
ENRICH ASSET CONTEXT
ENRICH IOC CONTEXT
ADD VULNERABILITY CONTEXT
ADD CLOUD CONTEXT
CORRELATE RELATED ALERTS
BUILD EXPLAINABLE RISK SCORING
ASSIGN TRIAGE PRIORITIES
GENERATE ANALYST RECOMMENDATIONS
BUILD INVESTIGATION QUEUES
EXPORT CSV
EXPORT JSON
GENERATE MARKDOWN REPORTS
PRESERVE HUMAN APPROVAL
SECURITY ALERTS
↓
ALERT INGESTION
↓
VALIDATION
↓
NORMALIZATION
↓
DEDUPLICATION
↓
ENRICHMENT
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
↓ ↓ ↓
IDENTITY ASSET IOC
↓ ↓ ↓
PRIVILEGE CRITICALITY REPUTATION
↓ ↓ ↓
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓ ↓
VULNERABILITY CLOUD
↓ ↓
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
↓
CORRELATION
↓
RISK SCORING
↓
TRIAGE RECOMMENDATION
↓
ANALYST QUEUE
↓
HUMAN REVIEW

Create:

soc-alert-triage/
|
+-- data/
|
+-- reports/
|
+-- src/
|
+-- tests/
|
+-- logs/
|
+-- README.md

Linux/macOS:

Terminal window
mkdir -p soc-alert-triage/{data,reports,src,tests,logs}
cd soc-alert-triage

PowerShell:

Terminal window
mkdir soc-alert-triage
cd soc-alert-triage
mkdir data
mkdir reports
mkdir src
mkdir tests
mkdir logs

Run:

Terminal window
python --version

Recommended:

Python 3.10+

SOC triage is the process of determining:

WHAT HAPPENED?
WHO IS INVOLVED?
WHAT SYSTEM IS INVOLVED?
HOW IMPORTANT IS IT?
WHAT CONTEXT EXISTS?
IS THERE RELATED ACTIVITY?
WHAT SHOULD THE ANALYST DO NEXT?

An:

ALERT

is a signal that requires evaluation.

An:

INCIDENT

is a security event or group of events that has been validated and managed according to the organization’s incident process.

Therefore:

ALERT
β‰ 
CONFIRMED INCIDENT

Create:

data/alerts.json

Add:

[
{
"alert_id": "ALT-1001",
"timestamp": "2026-08-29T08:15:00Z",
"source": "identity-monitor",
"alert_type": "multiple_failed_logins",
"severity": "medium",
"user": "admin01",
"asset": "JUMP01",
"source_ip": "203.0.113.25",
"description": "Multiple failed authentication attempts detected."
},
{
"alert_id": "ALT-1002",
"timestamp": "2026-08-29T08:18:00Z",
"source": "identity-monitor",
"alert_type": "successful_login_after_failures",
"severity": "high",
"user": "admin01",
"asset": "JUMP01",
"source_ip": "203.0.113.25",
"description": "Successful authentication observed after repeated failures."
},
{
"alert_id": "ALT-1003",
"timestamp": "2026-08-29T08:22:00Z",
"source": "endpoint-monitor",
"alert_type": "suspicious_process",
"severity": "high",
"user": "user01",
"asset": "WEB01",
"source_ip": "198.51.100.50",
"description": "Endpoint monitoring generated a suspicious process alert."
},
{
"alert_id": "ALT-1004",
"timestamp": "2026-08-29T08:30:00Z",
"source": "cloud-security",
"alert_type": "public_storage",
"severity": "medium",
"user": "",
"asset": "legacy-public-files",
"source_ip": "",
"description": "Cloud storage resource appears publicly accessible."
},
{
"alert_id": "ALT-1005",
"timestamp": "2026-08-29T08:35:00Z",
"source": "vulnerability-monitor",
"alert_type": "critical_vulnerability",
"severity": "critical",
"user": "",
"asset": "WEB01",
"source_ip": "",
"description": "Critical vulnerability identified on WEB01."
},
{
"alert_id": "ALT-1006",
"timestamp": "2026-08-29T08:40:00Z",
"source": "network-monitor",
"alert_type": "unusual_connection",
"severity": "medium",
"user": "service-web",
"asset": "WEB01",
"source_ip": "192.0.2.75",
"description": "Unusual network connection requires analyst review."
},
{
"alert_id": "ALT-1006",
"timestamp": "2026-08-29T08:40:00Z",
"source": "network-monitor",
"alert_type": "unusual_connection",
"severity": "medium",
"user": "service-web",
"asset": "WEB01",
"source_ip": "192.0.2.75",
"description": "Duplicate training alert."
}
]

Real pipelines can receive duplicate alerts because of:

RETRIES
MULTIPLE COLLECTORS
FORWARDER ISSUES
API PAGINATION
MESSAGE REDELIVERY

Your automation should identify duplicates before creating analyst work.

Create:

data/identities.json

Add:

[
{
"user": "admin01",
"department": "Cloud Operations",
"privileged": true,
"account_type": "human",
"enabled": true,
"risk_level": "high"
},
{
"user": "user01",
"department": "Application Engineering",
"privileged": false,
"account_type": "human",
"enabled": true,
"risk_level": "normal"
},
{
"user": "service-web",
"department": "Application Platform",
"privileged": false,
"account_type": "service",
"enabled": true,
"risk_level": "normal"
}
]

Compare:

FAILED LOGIN

for:

TRAINING-USER

versus:

PRIVILEGED CLOUD ADMINISTRATOR

The raw detection may be similar.

The security context is not.

Create:

data/assets.json

Add:

[
{
"asset": "JUMP01",
"type": "jump-host",
"environment": "production",
"criticality": "critical",
"internet_facing": false,
"owner": "Cloud Operations"
},
{
"asset": "WEB01",
"type": "web-server",
"environment": "production",
"criticality": "high",
"internet_facing": true,
"owner": "Application Engineering"
},
{
"asset": "legacy-public-files",
"type": "cloud-storage",
"environment": "production",
"criticality": "high",
"internet_facing": true,
"owner": "Cloud Platform Team"
}
]

For every alert ask:

IS IT PRODUCTION?
IS IT CRITICAL?
IS IT INTERNET-FACING?
WHO OWNS IT?
WHAT SERVICE DOES IT SUPPORT?

Create:

data/ioc_context.json

Add:

[
{
"indicator": "203.0.113.25",
"type": "ip",
"source": "synthetic-training-feed",
"confidence": 70,
"classification": "review",
"last_seen": "2026-08-28T18:00:00Z"
},
{
"indicator": "198.51.100.50",
"type": "ip",
"source": "synthetic-training-feed",
"confidence": 40,
"classification": "informational",
"last_seen": "2026-08-27T10:00:00Z"
}
]

Remember:

IOC MATCH
β‰ 
CONFIRMED COMPROMISE

IOC data contributes context.

It does not automatically determine the outcome of an investigation.

Create:

data/vulnerabilities.json

Add:

[
{
"asset": "WEB01",
"finding_id": "VULN-2001",
"severity": "critical",
"score": 9.8,
"status": "open",
"internet_exploitable": true
},
{
"asset": "JUMP01",
"finding_id": "VULN-2002",
"severity": "medium",
"score": 5.4,
"status": "open",
"internet_exploitable": false
}
]

Compare:

SUSPICIOUS ACTIVITY
+
FULLY PATCHED LOW-RISK ASSET

with:

SUSPICIOUS ACTIVITY
+
INTERNET-FACING ASSET
+
OPEN CRITICAL VULNERABILITY

The second deserves greater attention.

Create:

data/cloud_context.json

Add:

[
{
"asset": "legacy-public-files",
"provider": "aws",
"scope": "production",
"public": true,
"encrypted": false,
"security_findings": [
"storage-public-access",
"storage-encryption"
]
},
{
"asset": "WEB01",
"provider": "aws",
"scope": "production",
"public": true,
"encrypted": true,
"security_findings": []
}
]

Create:

src/soc_triage.py

Start with:

from pathlib import Path
from collections import Counter, defaultdict
from datetime import datetime, timezone
import csv
import json
import logging

Add:

BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
REPORT_DIR = BASE_DIR / "reports"
LOG_DIR = BASE_DIR / "logs"
REPORT_DIR.mkdir(
parents=True,
exist_ok=True
)
LOG_DIR.mkdir(
parents=True,
exist_ok=True
)

Add:

logging.basicConfig(
level=logging.INFO,
format=(
"%(asctime)s "
"%(levelname)s "
"%(message)s"
)
)

Add:

def load_json(
filename
):
path = (
DATA_DIR /
filename
)
try:
with path.open(
"r",
encoding="utf-8"
) as file:
return json.load(
file
)
except FileNotFoundError:
logging.error(
"Missing file: %s",
path
)
return []
except json.JSONDecodeError as error:
logging.error(
"Invalid JSON in %s: %s",
path,
error
)
return []

Create:

def load_lab_data():
return {
"alerts":
load_json(
"alerts.json"
),
"identities":
load_json(
"identities.json"
),
"assets":
load_json(
"assets.json"
),
"iocs":
load_json(
"ioc_context.json"
),
"vulnerabilities":
load_json(
"vulnerabilities.json"
),
"cloud":
load_json(
"cloud_context.json"
)
}

Create:

REQUIRED_ALERT_FIELDS = {
"alert_id",
"timestamp",
"source",
"alert_type",
"severity",
"description"
}

Create:

def validate_alert(
alert
):
missing = (
REQUIRED_ALERT_FIELDS
-
set(
alert.keys()
)
)
if missing:
return (
False,
"Missing fields: "
+
", ".join(
sorted(
missing
)
)
)
if not str(
alert.get(
"alert_id",
""
)
).strip():
return (
False,
"Empty alert_id"
)
return (
True,
None
)

Create:

def parse_timestamp(
value
):
try:
return datetime.fromisoformat(
value.replace(
"Z",
"+00:00"
)
)
except (
ValueError,
AttributeError
):
return None

Inside alert validation:

timestamp = parse_timestamp(
alert.get(
"timestamp"
)
)
if timestamp is None:
return (
False,
"Invalid timestamp"
)

Create:

VALID_SEVERITIES = {
"critical",
"high",
"medium",
"low",
"informational"
}

Add:

severity = str(
alert.get(
"severity",
""
)
).strip().lower()
if severity not in VALID_SEVERITIES:
return (
False,
"Invalid severity"
)

Create:

def normalize_alert(
alert
):
return {
"alert_id":
str(
alert[
"alert_id"
]
).strip(),
"timestamp":
str(
alert[
"timestamp"
]
).strip(),
"source":
str(
alert[
"source"
]
).strip().lower(),
"alert_type":
str(
alert[
"alert_type"
]
).strip().lower(),
"severity":
str(
alert[
"severity"
]
).strip().lower(),
"user":
str(
alert.get(
"user",
""
)
).strip().lower(),
"asset":
str(
alert.get(
"asset",
""
)
).strip().upper(),
"source_ip":
str(
alert.get(
"source_ip",
""
)
).strip(),
"description":
str(
alert[
"description"
]
).strip()
}

Create:

def validate_and_normalize(
alerts
):
valid = []
invalid = []
for alert in alerts:
is_valid, reason = (
validate_alert(
alert
)
)
if not is_valid:
invalid.append({
"alert":
alert,
"reason":
reason
})
continue
valid.append(
normalize_alert(
alert
)
)
return valid, invalid

Create:

def deduplicate_alerts(
alerts
):
unique = []
duplicates = []
seen = set()
for alert in alerts:
alert_id = alert[
"alert_id"
]
if alert_id in seen:
duplicates.append(
alert
)
continue
seen.add(
alert_id
)
unique.append(
alert
)
return unique, duplicates

Without deduplication:

ONE SECURITY EVENT
↓
FIVE DUPLICATE ALERTS
↓
FIVE ANALYST CASES

This creates unnecessary workload.

Create:

def build_identity_index(
identities
):
return {
str(
item[
"user"
]
).strip().lower():
item
for item
in identities
}

Create:

def build_asset_index(
assets
):
return {
str(
item[
"asset"
]
).strip().upper():
item
for item
in assets
}

Create:

def build_ioc_index(
iocs
):
return {
str(
item[
"indicator"
]
).strip():
item
for item
in iocs
}

One asset may have multiple vulnerabilities.

Create:

def build_vulnerability_index(
vulnerabilities
):
index = defaultdict(
list
)
for item in vulnerabilities:
asset = str(
item[
"asset"
]
).strip().upper()
index[
asset
].append(
item
)
return index

Create:

def build_cloud_index(
cloud_records
):
return {
str(
item[
"asset"
]
).strip().upper():
item
for item
in cloud_records
}

Create:

def build_indexes(
data
):
return {
"identity":
build_identity_index(
data[
"identities"
]
),
"asset":
build_asset_index(
data[
"assets"
]
),
"ioc":
build_ioc_index(
data[
"iocs"
]
),
"vulnerability":
build_vulnerability_index(
data[
"vulnerabilities"
]
),
"cloud":
build_cloud_index(
data[
"cloud"
]
)
}

Create:

def enrich_identity(
alert,
identity_index
):
user = alert[
"user"
]
if not user:
return {
"identity_found":
False
}
identity = (
identity_index.get(
user
)
)
if not identity:
return {
"identity_found":
False
}
return {
"identity_found":
True,
"department":
identity.get(
"department"
),
"privileged":
identity.get(
"privileged",
False
),
"account_type":
identity.get(
"account_type"
),
"identity_enabled":
identity.get(
"enabled"
),
"identity_risk":
identity.get(
"risk_level"
)
}

Create:

def enrich_asset(
alert,
asset_index
):
asset = alert[
"asset"
]
if not asset:
return {
"asset_found":
False
}
record = (
asset_index.get(
asset
)
)
if not record:
return {
"asset_found":
False
}
return {
"asset_found":
True,
"asset_type":
record.get(
"type"
),
"environment":
record.get(
"environment"
),
"asset_criticality":
record.get(
"criticality"
),
"internet_facing":
record.get(
"internet_facing"
),
"asset_owner":
record.get(
"owner"
)
}

Create:

def enrich_ioc(
alert,
ioc_index
):
source_ip = alert[
"source_ip"
]
if not source_ip:
return {
"ioc_found":
False
}
record = (
ioc_index.get(
source_ip
)
)
if not record:
return {
"ioc_found":
False
}
return {
"ioc_found":
True,
"ioc_source":
record.get(
"source"
),
"ioc_confidence":
record.get(
"confidence"
),
"ioc_classification":
record.get(
"classification"
),
"ioc_last_seen":
record.get(
"last_seen"
)
}

Create:

def enrich_vulnerabilities(
alert,
vulnerability_index
):
asset = alert[
"asset"
]
records = (
vulnerability_index.get(
asset,
[]
)
)
open_records = [
item
for item
in records
if item.get(
"status"
) == "open"
]
critical = [
item
for item
in open_records
if item.get(
"severity"
) == "critical"
]
exploitable = [
item
for item
in open_records
if item.get(
"internet_exploitable"
)
]
return {
"open_vulnerability_count":
len(
open_records
),
"critical_vulnerability_count":
len(
critical
),
"internet_exploitable_vulnerability_count":
len(
exploitable
)
}

Create:

def enrich_cloud(
alert,
cloud_index
):
asset = alert[
"asset"
]
record = (
cloud_index.get(
asset
)
)
if not record:
return {
"cloud_asset":
False
}
return {
"cloud_asset":
True,
"cloud_provider":
record.get(
"provider"
),
"cloud_scope":
record.get(
"scope"
),
"cloud_public":
record.get(
"public"
),
"cloud_encrypted":
record.get(
"encrypted"
),
"cloud_security_findings":
record.get(
"security_findings",
[]
)
}

Create:

def enrich_alert(
alert,
indexes
):
enriched = (
alert.copy()
)
enriched.update(
enrich_identity(
alert,
indexes[
"identity"
]
)
)
enriched.update(
enrich_asset(
alert,
indexes[
"asset"
]
)
)
enriched.update(
enrich_ioc(
alert,
indexes[
"ioc"
]
)
)
enriched.update(
enrich_vulnerabilities(
alert,
indexes[
"vulnerability"
]
)
)
enriched.update(
enrich_cloud(
alert,
indexes[
"cloud"
]
)
)
return enriched

For:

ALT-1002

you should obtain context similar to:

USER:
admin01
PRIVILEGED:
true
ASSET:
JUMP01
CRITICALITY:
critical
IOC:
203.0.113.25
IOC CONFIDENCE:
70

If:

identity_found = false

do not automatically conclude:

USER IS SAFE

It may mean:

IDENTITY INVENTORY GAP
UNKNOWN ACCOUNT
STALE CMDB
NORMALIZATION PROBLEM

Risk scoring should be:

EXPLAINABLE
REPEATABLE
TUNABLE

not mysterious.

Create:

BASE_SEVERITY_SCORE = {
"critical": 50,
"high": 40,
"medium": 25,
"low": 10,
"informational": 0
}

For this lab:

PRIVILEGED IDENTITY
+15
CRITICAL ASSET
+15
HIGH ASSET
+10
INTERNET-FACING ASSET
+10
IOC CONFIDENCE >= 70
+10
CRITICAL OPEN VULNERABILITY
+15
INTERNET-EXPLOITABLE VULNERABILITY
+10
CLOUD SECURITY FINDING
+10

These are:

TRAINING WEIGHTS

not universal SOC scoring standards.

Add:

def calculate_risk_score(
alert
):
score = (
BASE_SEVERITY_SCORE.get(
alert[
"severity"
],
0
)
)
reasons = [
(
"Base alert severity: "
f"{alert['severity']}"
)
]
if alert.get(
"privileged"
):
score += 15
reasons.append(
"Privileged identity involved"
)
criticality = alert.get(
"asset_criticality"
)
if criticality == "critical":
score += 15
reasons.append(
"Critical asset involved"
)
elif criticality == "high":
score += 10
reasons.append(
"High-criticality asset involved"
)
if alert.get(
"internet_facing"
):
score += 10
reasons.append(
"Internet-facing asset"
)
confidence = alert.get(
"ioc_confidence"
)
if (
isinstance(
confidence,
(int, float)
)
and
confidence >= 70
):
score += 10
reasons.append(
"Higher-confidence IOC context"
)
if alert.get(
"critical_vulnerability_count",
0
) > 0:
score += 15
reasons.append(
"Critical open vulnerability present"
)
if alert.get(
"internet_exploitable_vulnerability_count",
0
) > 0:
score += 10
reasons.append(
"Internet-exploitable vulnerability present"
)
if alert.get(
"cloud_security_findings",
[]
):
score += 10
reasons.append(
"Cloud security findings present"
)
return (
min(
score,
100
),
reasons
)

Use:

0–100

for predictable interpretation.

Create:

def risk_band(
score
):
if score >= 80:
return "critical"
if score >= 60:
return "high"
if score >= 40:
return "medium"
return "low"

Do not confuse:

VENDOR ALERT SEVERITY

with:

SOC TRIAGE PRIORITY

Example:

MEDIUM ALERT
+
PRIVILEGED ADMIN
+
CRITICAL ASSET
+
HIGH-CONFIDENCE IOC
=
HIGH TRIAGE PRIORITY

Create:

def score_alert(
alert
):
enriched = (
alert.copy()
)
score, reasons = (
calculate_risk_score(
alert
)
)
enriched[
"risk_score"
] = score
enriched[
"risk_band"
] = risk_band(
score
)
enriched[
"risk_reasons"
] = reasons
return enriched

Automation should recommend:

REVIEW PRIORITY

rather than declare:

COMPROMISED

Create:

def triage_recommendation(
alert
):
score = alert[
"risk_score"
]
if score >= 80:
return (
"Immediate analyst review. "
"Validate the alert, correlate related "
"activity, confirm asset and identity context, "
"and follow the approved incident-response "
"process if escalation criteria are met."
)
if score >= 60:
return (
"High-priority analyst review. "
"Validate detection evidence and investigate "
"related identity, endpoint, network, and "
"vulnerability context."
)
if score >= 40:
return (
"Standard analyst review. "
"Validate context and determine whether "
"additional investigation is required."
)
return (
"Low-priority review. "
"Retain for normal SOC triage and correlation."
)

Create:

def add_recommendation(
alert
):
result = alert.copy()
result[
"triage_recommendation"
] = (
triage_recommendation(
alert
)
)
return result

A single alert provides:

ONE SIGNAL

Correlation asks:

WHAT ELSE HAPPENED
AROUND THE SAME USER,
ASSET,
SOURCE,
OR TIME?

Useful keys include:

USER
ASSET
SOURCE IP
ALERT TYPE
TIME WINDOW

Create:

def alerts_by_user(
alerts
):
counts = Counter()
for alert in alerts:
user = alert.get(
"user"
)
if user:
counts[
user
] += 1
return counts

Create:

def alerts_by_asset(
alerts
):
counts = Counter()
for alert in alerts:
asset = alert.get(
"asset"
)
if asset:
counts[
asset
] += 1
return counts

Create:

def alerts_by_source_ip(
alerts
):
counts = Counter()
for alert in alerts:
source_ip = alert.get(
"source_ip"
)
if source_ip:
counts[
source_ip
] += 1
return counts

Create:

def add_correlation_context(
alerts
):
user_counts = (
alerts_by_user(
alerts
)
)
asset_counts = (
alerts_by_asset(
alerts
)
)
ip_counts = (
alerts_by_source_ip(
alerts
)
)
results = []
for alert in alerts:
item = alert.copy()
item[
"related_user_alerts"
] = user_counts.get(
alert.get(
"user"
),
0
)
item[
"related_asset_alerts"
] = asset_counts.get(
alert.get(
"asset"
),
0
)
item[
"related_source_ip_alerts"
] = ip_counts.get(
alert.get(
"source_ip"
),
0
)
results.append(
item
)
return results

You should see:

ALT-1001
MULTIPLE FAILED LOGINS
ALT-1002
SUCCESS AFTER FAILURES

with the same:

USER
ASSET
SOURCE IP

This is stronger context than reviewing each alert completely independently.

You should see multiple signals involving:

WEB01

including:

SUSPICIOUS PROCESS
CRITICAL VULNERABILITY
UNUSUAL CONNECTION

Remember:

SAME ASSET
+
SIMILAR TIME

does not automatically prove:

SAME ATTACK

It tells the analyst:

INVESTIGATE RELATIONSHIP

For this training lab, you may optionally add:

3+ ALERTS ON SAME ASSET
+10

and:

2+ ALERTS FOR SAME USER
+5

Create:

def correlation_bonus(
alert
):
score = 0
reasons = []
if alert.get(
"related_asset_alerts",
0
) >= 3:
score += 10
reasons.append(
"Multiple alerts involve the same asset"
)
if alert.get(
"related_user_alerts",
0
) >= 2:
score += 5
reasons.append(
"Multiple alerts involve the same user"
)
return score, reasons

Create:

def calculate_final_risk(
alert
):
base_score, reasons = (
calculate_risk_score(
alert
)
)
bonus, correlation_reasons = (
correlation_bonus(
alert
)
)
final_score = min(
base_score + bonus,
100
)
return (
final_score,
reasons
+
correlation_reasons
)

Your workflow should now be:

LOAD
↓
VALIDATE
↓
NORMALIZE
↓
DEDUPLICATE
↓
ENRICH
↓
CORRELATE
↓
SCORE
↓
RECOMMEND
↓
REPORT

Create:

def process_alerts(
data
):
valid, invalid = (
validate_and_normalize(
data[
"alerts"
]
)
)
unique, duplicates = (
deduplicate_alerts(
valid
)
)
indexes = build_indexes(
data
)
enriched = [
enrich_alert(
alert,
indexes
)
for alert
in unique
]
correlated = (
add_correlation_context(
enriched
)
)
final_alerts = []
for alert in correlated:
item = alert.copy()
score, reasons = (
calculate_final_risk(
item
)
)
item[
"risk_score"
] = score
item[
"risk_band"
] = risk_band(
score
)
item[
"risk_reasons"
] = reasons
item[
"triage_recommendation"
] = (
triage_recommendation(
item
)
)
final_alerts.append(
item
)
return (
final_alerts,
invalid,
duplicates
)

Create:

def build_analyst_queue(
alerts
):
return sorted(
alerts,
key=lambda item: (
item[
"risk_score"
],
item[
"timestamp"
]
),
reverse=True
)

Instead of:

ALERT 1
ALERT 2
ALERT 3
ALERT 4

the SOC receives:

HIGHEST CONTEXTUAL RISK
↓
NEXT HIGHEST
↓
STANDARD REVIEW
↓
LOWER PRIORITY

Create:

def build_summary(
alerts,
invalid,
duplicates
):
return {
"raw_alerts":
(
len(alerts)
+
len(duplicates)
+
len(invalid)
),
"valid_unique_alerts":
len(
alerts
),
"invalid_alerts":
len(
invalid
),
"duplicates":
len(
duplicates
),
"risk_bands":
dict(
Counter(
item[
"risk_band"
]
for item
in alerts
)
),
"sources":
dict(
Counter(
item[
"source"
]
for item
in alerts
)
)
}

Create:

def export_analyst_queue(
alerts
):
output = (
REPORT_DIR /
"analyst-triage-queue.csv"
)
fields = [
"alert_id",
"timestamp",
"risk_score",
"risk_band",
"severity",
"source",
"alert_type",
"user",
"asset",
"source_ip",
"privileged",
"asset_criticality",
"internet_facing",
"ioc_confidence",
"critical_vulnerability_count",
"related_user_alerts",
"related_asset_alerts",
"triage_recommendation"
]
with output.open(
"w",
encoding="utf-8",
newline=""
) as file:
writer = csv.DictWriter(
file,
fieldnames=fields,
extrasaction="ignore"
)
writer.writeheader()
writer.writerows(
build_analyst_queue(
alerts
)
)

Create:

def export_json(
alerts,
summary,
invalid,
duplicates
):
output = (
REPORT_DIR /
"soc-triage-results.json"
)
with output.open(
"w",
encoding="utf-8"
) as file:
json.dump(
{
"summary":
summary,
"alerts":
alerts,
"invalid_alerts":
invalid,
"duplicates":
duplicates
},
file,
indent=2
)

Create:

def export_invalid_alerts(
invalid
):
output = (
REPORT_DIR /
"invalid-alerts.json"
)
output.write_text(
json.dumps(
invalid,
indent=2
),
encoding="utf-8"
)

Create:

def export_duplicates(
duplicates
):
output = (
REPORT_DIR /
"duplicate-alerts.json"
)
output.write_text(
json.dumps(
duplicates,
indent=2
),
encoding="utf-8"
)

Create:

def generate_markdown_report(
alerts,
summary
):
queue = build_analyst_queue(
alerts
)
lines = []
lines.append(
"# SOC Alert Triage Report"
)
lines.append("")
lines.append(
"## Executive Summary"
)
lines.append("")
lines.append(
f"- Valid unique alerts: "
f"{summary['valid_unique_alerts']}"
)
lines.append(
f"- Invalid alerts: "
f"{summary['invalid_alerts']}"
)
lines.append(
f"- Duplicate alerts: "
f"{summary['duplicates']}"
)
lines.append("")
lines.append(
"## Prioritized Analyst Queue"
)
lines.append("")
for alert in queue:
lines.append(
f"### {alert['risk_band'].upper()} β€” "
f"{alert['alert_id']} β€” "
f"{alert['alert_type']}"
)
lines.append("")
lines.append(
f"- Risk score: "
f"{alert['risk_score']}"
)
lines.append(
f"- Original severity: "
f"{alert['severity']}"
)
lines.append(
f"- User: "
f"{alert['user'] or 'N/A'}"
)
lines.append(
f"- Asset: "
f"{alert['asset'] or 'N/A'}"
)
lines.append(
f"- Source IP: "
f"{alert['source_ip'] or 'N/A'}"
)
lines.append(
f"- Recommendation: "
f"{alert['triage_recommendation']}"
)
lines.append("")
lines.append(
"**Risk Factors**"
)
lines.append("")
for reason in alert[
"risk_reasons"
]:
lines.append(
f"- {reason}"
)
lines.append("")
lines.append(
"## Analyst Guidance"
)
lines.append("")
lines.append(
"Risk scores and recommendations are triage aids. "
"They do not establish compromise. Analysts should "
"validate source evidence, identity and asset context, "
"related activity, known business behavior, and approved "
"incident criteria before escalating or taking response actions."
)
output = (
REPORT_DIR /
"soc-triage-report.md"
)
output.write_text(
"\n".join(
lines
),
encoding="utf-8"
)

Create:

def main():
logging.info(
"SOC triage automation started"
)
data = load_lab_data()
alerts, invalid, duplicates = (
process_alerts(
data
)
)
summary = build_summary(
alerts,
invalid,
duplicates
)
export_analyst_queue(
alerts
)
export_json(
alerts,
summary,
invalid,
duplicates
)
export_invalid_alerts(
invalid
)
export_duplicates(
duplicates
)
generate_markdown_report(
alerts,
summary
)
logging.info(
"SOC triage automation completed"
)
print(
f"Reports saved to: "
f"{REPORT_DIR}"
)

Add:

if __name__ == "__main__":
main()

Run:

Terminal window
python src/soc_triage.py

Expected:

SOC triage automation started
SOC triage automation completed
Reports saved to: ...

You should now have:

reports/
|
+-- analyst-triage-queue.csv
|
+-- soc-triage-results.json
|
+-- invalid-alerts.json
|
+-- duplicate-alerts.json
|
+-- soc-triage-report.md

Pay special attention to:

ALT-1002

because it combines:

HIGH ALERT SEVERITY
PRIVILEGED USER
CRITICAL ASSET
IOC CONTEXT
RELATED AUTHENTICATION ALERT

Also review alerts involving:

WEB01

because it combines:

INTERNET-FACING ASSET
HIGH CRITICALITY
CRITICAL OPEN VULNERABILITY
MULTIPLE RELATED ALERTS

The lab should not automatically say:

ADMIN01 IS COMPROMISED

or:

WEB01 HAS BEEN BREACHED

Instead:

HIGH-PRIORITY INVESTIGATION
RECOMMENDED

Future analyst workflow could use:

NEW
IN_REVIEW
ESCALATED
CLOSED_BENIGN
CLOSED_EXPECTED
INCIDENT_CREATED

85 β€” Keep Automation and Analyst Decisions Separate

Section titled β€œ85 β€” Keep Automation and Analyst Decisions Separate”

Automation-generated:

risk_score
risk_band
recommendation

Analyst-generated:

disposition
investigation_notes
incident_id
closure_reason

Do not mix these concepts.

An analyst may eventually determine:

TRUE POSITIVE
FALSE POSITIVE
BENIGN TRUE POSITIVE
EXPECTED ACTIVITY
INSUFFICIENT EVIDENCE

Analyst decisions can later improve:

DETECTION RULES
THRESHOLDS
ENRICHMENT
RISK SCORING
SUPPRESSION LOGIC
ALERT
↓
TRIAGE
↓
ANALYST DECISION
↓
FEEDBACK
↓
DETECTION IMPROVEMENT
↓
BETTER ALERT

Your current correlation uses total counts.

A stronger implementation should correlate within:

5 MINUTES
15 MINUTES
30 MINUTES
1 HOUR

depending on the use case.

These alerts:

FAILED LOGIN β€” JANUARY
SUCCESSFUL LOGIN β€” AUGUST

should not normally be treated like:

FAILED LOGIN β€” 08:15
SUCCESSFUL LOGIN β€” 08:18

Create:

def minutes_between(
first,
second
):
first_time = parse_timestamp(
first
)
second_time = parse_timestamp(
second
)
if (
first_time is None
or
second_time is None
):
return None
difference = abs(
second_time
-
first_time
)
return (
difference.total_seconds()
/
60
)

Search for:

multiple_failed_logins

followed by:

successful_login_after_failures

for the same:

USER
SOURCE IP

within a controlled time window.

def find_auth_sequences(
alerts,
max_minutes=15
):
sequences = []
failures = [
item
for item
in alerts
if item[
"alert_type"
] == "multiple_failed_logins"
]
successes = [
item
for item
in alerts
if item[
"alert_type"
] == "successful_login_after_failures"
]
for failure in failures:
for success in successes:
same_user = (
failure.get(
"user"
)
==
success.get(
"user"
)
)
same_ip = (
failure.get(
"source_ip"
)
==
success.get(
"source_ip"
)
)
difference = (
minutes_between(
failure[
"timestamp"
],
success[
"timestamp"
]
)
)
if (
same_user
and
same_ip
and
difference is not None
and
difference <= max_minutes
):
sequences.append({
"failure_alert":
failure[
"alert_id"
],
"success_alert":
success[
"alert_id"
],
"user":
success[
"user"
],
"source_ip":
success[
"source_ip"
],
"minutes":
round(
difference,
2
)
})
return sequences

Your synthetic data should identify:

ALT-1001
↓
ALT-1002

for:

admin01
203.0.113.25

within approximately:

3 MINUTES

95 β€” Sequence Detection Is Still Not a Conclusion

Section titled β€œ95 β€” Sequence Detection Is Still Not a Conclusion”

A legitimate user could:

TYPE THE WRONG PASSWORD
RETRY
THEN SUCCESSFULLY LOG IN

Therefore the sequence means:

REVIEW

not:

ACCOUNT TAKEOVER CONFIRMED

A SOC often groups related alerts into:

CASES
STORIES
INCIDENT CANDIDATES

For the lab, group by:

USER
ASSET
SOURCE IP

Create:

def group_by_asset(
alerts
):
groups = defaultdict(
list
)
for alert in alerts:
asset = alert.get(
"asset"
)
if asset:
groups[
asset
].append(
alert[
"alert_id"
]
)
return dict(
groups
)

You should see:

WEB01
|
+-- ALT-1003
|
+-- ALT-1005
|
+-- ALT-1006

The analyst can now see:

WEB01
↓
SUSPICIOUS PROCESS ALERT
↓
CRITICAL VULNERABILITY
↓
UNUSUAL NETWORK CONNECTION

The automation should say:

RELATED ACTIVITY REQUIRES REVIEW

not automatically construct an attack conclusion.

Context can become stale.

Examples:

ASSET CRITICALITY
USER ROLE
IOC REPUTATION
VULNERABILITY STATUS

should have timestamps where possible.

An IOC observed:

2 YEARS AGO

should not necessarily carry the same weight as one observed:

TODAY

If the CMDB says:

WEB01
=
PRODUCTION

but was last updated:

18 MONTHS AGO

the analyst should know.

Future enrichment records can include:

source
confidence
last_updated
collection_status

A mature pipeline should distinguish:

FALSE

from:

UNKNOWN

Example:

internet_facing = false

is different from:

internet_facing = unknown

Future alerts could include:

context_completeness

Example:

IDENTITY FOUND
ASSET FOUND
IOC CHECK COMPLETED
VULNERABILITY DATA AVAILABLE

Conceptually:

AVAILABLE CONTEXT
/
EXPECTED CONTEXT

This helps analysts identify:

LOW-CONFIDENCE TRIAGE

Example:

def context_completeness(
alert
):
checks = [
alert.get(
"identity_found"
)
if alert.get(
"user"
)
else True,
alert.get(
"asset_found"
)
if alert.get(
"asset"
)
else True,
(
"ioc_found"
in alert
),
(
"open_vulnerability_count"
in alert
)
]
completed = sum(
bool(item)
for item
in checks
)
return round(
(
completed
/
len(checks)
)
*
100,
2
)

An alert with:

RISK SCORE = 85

but:

CONTEXT COMPLETENESS = 25%

should be interpreted carefully.

Future alert records may contain:

detection_confidence

Do not confuse:

DETECTION CONFIDENCE

with:

INCIDENT CONFIDENCE

Alert IDs may change across systems.

A fingerprint can combine:

SOURCE
ALERT TYPE
USER
ASSET
SOURCE IP
TIME BUCKET

They can help with:

DEDUPLICATION
CORRELATION
SUPPRESSION
TRENDING

Some alerts may be:

KNOWN
EXPECTED
APPROVED

Example:

AUTHORIZED VULNERABILITY SCANNER

A suppression should require:

OWNER
JUSTIFICATION
EXPIRATION
SCOPE

Do not create:

IGNORE THIS ALERT FOREVER

because an exception can become stale.

NOISY ALERT
↓
ANALYST REVIEW
↓
EXPECTED?
↓
DOCUMENT
↓
APPROVE
↓
TIME-LIMITED SUPPRESSION
↓
REVIEW

Organizations may define:

CRITICAL
β†’ IMMEDIATE REVIEW
HIGH
β†’ RAPID REVIEW
MEDIUM
β†’ STANDARD QUEUE
LOW
β†’ ROUTINE REVIEW

Use organizational policy rather than inventing universal response times.

Future alerts should include:

assigned_team

Examples:

SOC
CLOUD SECURITY
IDENTITY TEAM
ENDPOINT TEAM
APPLICATION SECURITY

Example:

def route_alert(
alert
):
source = alert[
"source"
]
if source == "cloud-security":
return "Cloud Security"
if source == "identity-monitor":
return "SOC / Identity"
if source == "endpoint-monitor":
return "SOC / Endpoint"
if source == "vulnerability-monitor":
return "Vulnerability Management"
return "SOC"

Add:

item[
"assigned_team"
] = route_alert(
item
)

to the final processing pipeline.

A cloud alert assigned to:

CLOUD SECURITY

may still require:

SOC
INCIDENT RESPONSE
IDENTITY
LEGAL
PRIVACY

depending on the investigation.

For high-context alerts, automation can create a:

CASE CANDIDATE

rather than automatically declaring an incident.

Example:

{
"case_candidate": true,
"reason": "Multiple related high-risk alerts"
}

For the training lab:

def case_candidate(
alert
):
return (
alert[
"risk_score"
] >= 80
and
(
alert.get(
"related_user_alerts",
0
) >= 2
or
alert.get(
"related_asset_alerts",
0
) >= 2
)
)

Because:

AUTOMATION

can identify:

PATTERN WORTH ESCALATING

but:

ANALYST

should validate whether incident criteria are actually met.

For high-priority alerts, preserve:

RAW ALERT
NORMALIZED ALERT
ENRICHMENT DATA
CORRELATION RESULTS
RISK REASONS
TIMESTAMP

Keep:

RAW DATA

separate from:

NORMALIZED DATA
RAW ALERT
↓
PRESERVE
↓
COPY
↓
NORMALIZE
↓
ENRICH
↓
ANALYZE

Create:

def export_raw_alerts(
alerts
):
output = (
REPORT_DIR /
"raw-alert-snapshot.json"
)
output.write_text(
json.dumps(
alerts,
indent=2
),
encoding="utf-8"
)

Call it before transformation.

Import:

import hashlib

Create:

def sha256_file(
path
):
digest = hashlib.sha256()
with path.open(
"rb"
) as file:
for block in iter(
lambda:
file.read(
8192
),
b""
):
digest.update(
block
)
return digest.hexdigest()

The hash helps answer:

HAS THIS FILE CHANGED
SINCE COLLECTION?

It does not by itself establish the full chain of custody.

Future high-priority case folder:

cases/
|
+-- CASE-001/
|
+-- raw-alert.json
|
+-- normalized-alert.json
|
+-- identity-context.json
|
+-- asset-context.json
|
+-- vulnerability-context.json
|
+-- analyst-notes.md

In a real authorized environment, enrichment could use:

SIEM API
EDR API
CMDB API
IDENTITY API
THREAT INTELLIGENCE API
VULNERABILITY API
CLOUD SECURITY API

Use the lessons from:

Lab 07 β€” Security API Integration

If threat-intelligence enrichment fails:

DO NOT DROP THE ALERT

Instead:

ALERT
↓
IOC ENRICHMENT FAILED
↓
MARK CONTEXT UNAVAILABLE
↓
CONTINUE TRIAGE

A resilient pipeline should continue when one enrichment source is unavailable.

IDENTITY API
βœ“
ASSET API
βœ“
IOC API
βœ—
VULNERABILITY API
βœ“

Result:

PARTIAL ENRICHMENT

not:

TOTAL PIPELINE FAILURE

Retry:

TEMPORARY NETWORK ERROR
429
5XX

Do not repeatedly retry:

401
403
INVALID REQUEST

API tokens must not appear in:

SOURCE CODE
REPORTS
LOGS
GIT
ERROR OUTPUT

Useful operational logs include:

ALERTS RECEIVED
ALERTS VALIDATED
INVALID ALERTS
DUPLICATES REMOVED
ENRICHMENT FAILURES
ALERTS SCORED
REPORTS GENERATED
PIPELINE DURATION

Create SOC automation metrics such as:

RAW ALERT COUNT
VALID ALERT COUNT
DUPLICATE RATE
ENRICHMENT SUCCESS RATE
HIGH-PRIORITY COUNT
AVERAGE RISK SCORE
PROCESSING TIME

Example:

RAW ALERTS
1000
DUPLICATES
200
VALID UNIQUE
800

Deduplication reduced analyst workload by:

20%

A lower alert count is not automatically:

BETTER SECURITY

You could reduce alerts by:

DISABLING DETECTIONS

which would be harmful.

Measure:

QUALITY
COVERAGE
PRECISION
RESPONSE VALUE

not only volume.

Future data:

{
"alert_id": "ALT-1002",
"analyst_disposition": "true_positive",
"escalated": true,
"notes": "Synthetic training result"
}

Analyze analyst feedback to identify:

HIGH FALSE-POSITIVE RULES
LOW-VALUE ALERTS
MISSING CONTEXT
BAD THRESHOLDS
USEFUL CORRELATIONS

Do not automatically disable a detection because:

MANY ALERTS WERE CLOSED

Investigate:

WHY?

Create:

tests/test_soc_triage.py
def test_valid_alert():
alert = {
"alert_id": "ALT-1",
"timestamp":
"2026-08-29T08:00:00Z",
"source":
"training",
"alert_type":
"test",
"severity":
"medium",
"description":
"Training alert"
}
valid, reason = (
validate_alert(
alert
)
)
assert valid is True
assert reason is None
def test_missing_alert_id():
alert = {
"timestamp":
"2026-08-29T08:00:00Z",
"source":
"training",
"alert_type":
"test",
"severity":
"medium",
"description":
"Training alert"
}
valid, reason = (
validate_alert(
alert
)
)
assert valid is False

Use:

severity = emergency-super-critical

Expected:

INVALID

Use:

not-a-date

Expected:

INVALID

Input:

ALT-1001
ALT-1001

Expected:

UNIQUE = 1
DUPLICATE = 1

Input:

user = admin01

Expected:

privileged = true

Input:

asset = WEB01

Expected:

criticality = high
internet_facing = true

Input:

203.0.113.25

Expected:

ioc_found = true
confidence = 70

Input:

192.0.2.200

Expected:

ioc_found = false

Do not mark it:

SAFE

just because your dataset has no record.

Input:

WEB01

Expected:

critical_vulnerability_count >= 1

Build a synthetic alert with:

HIGH SEVERITY
PRIVILEGED USER
CRITICAL ASSET

Confirm the score increases for each documented reason.

Create enough contextual factors to exceed:

100

Expected:

risk_score = 100

Input multiple alerts using:

WEB01

Confirm:

related_asset_alerts

is correct.

Confirm:

ALT-1001

and:

ALT-1002

are correlated within:

15 MINUTES

Use:

user = unknown-user

Expected:

identity_found = false

with no crash.

Use:

asset = UNKNOWN01

Expected:

asset_found = false

Replace:

ioc_context.json

temporarily with:

[]

The triage engine should still process alerts.

The pipeline should continue with:

open_vulnerability_count = 0

while ideally recording that collection status separately in a more mature implementation.

Store normalized alerts in:

soc.db

Tables:

alerts
identities
assets
vulnerabilities
triage_results

Use skills from:

Lab 05 β€” SQL Security Analytics

to query:

SELECT
asset,
COUNT(*) AS alert_count
FROM alerts
GROUP BY asset
ORDER BY alert_count DESC;

Replace one static enrichment file with a:

LOCAL TRAINING API

from Lab 07.

Architecture:

ALERT
↓
PYTHON
↓
TRAINING API
↓
JSON
↓
ENRICHMENT

Feed the output of:

Lab 08

directly into the SOC triage pipeline.

CLOUD AUDITOR
↓
CLOUD FINDINGS
↓
SOC TRIAGE

165 β€” Challenge 04 β€” Add Vulnerability Prioritization

Section titled β€œ165 β€” Challenge 04 β€” Add Vulnerability Prioritization”

Use output from:

Lab 06

to enrich affected assets.

Use normalized IOC output from:

Lab 02

rather than the simplified IOC context file.

167 β€” Challenge 06 β€” Add Log Analyzer Findings

Section titled β€œ167 β€” Challenge 06 β€” Add Log Analyzer Findings”

Use:

Lab 01

to create authentication alerts that feed this lab.

Architecture:

AUTH LOG
↓
LOG ANALYZER
↓
ALERT
↓
SOC TRIAGE

168 β€” Challenge 07 β€” Build Dashboard Dataset

Section titled β€œ168 β€” Challenge 07 β€” Build Dashboard Dataset”

Export:

dashboard.json

with:

TOTAL ALERTS
CRITICAL QUEUE
HIGH QUEUE
TOP USERS
TOP ASSETS
TOP ALERT TYPES
TOP SOURCES

169 β€” Challenge 08 β€” Add JavaScript Dashboard

Section titled β€œ169 β€” Challenge 08 β€” Add JavaScript Dashboard”

Use your:

JavaScript Fundamentals

skills to create a simple local dashboard that reads sanitized lab data.

Do not expose:

TOKENS
SECRETS
SENSITIVE RAW EVIDENCE

in browser-side code.

170 β€” Challenge 09 β€” Add MITRE ATT&CK Mapping

Section titled β€œ170 β€” Challenge 09 β€” Add MITRE ATT&CK Mapping”

Add optional fields such as:

technique_id
tactic

to synthetic alerts.

Use mappings for:

CLASSIFICATION
INVESTIGATION CONTEXT
REPORTING

not as proof that an adversary definitely performed a technique.

Every alert should eventually identify:

rule_id

Example:

AUTH-001
ENDPOINT-004
CLOUD-012

Track:

rule_version

This helps answer:

WHICH VERSION
GENERATED THE ALERT?

173 β€” Challenge 12 β€” Add Detection Source Evidence

Section titled β€œ173 β€” Challenge 12 β€” Add Detection Source Evidence”

Store:

event_id
log_source
sensor
query_reference

without copying unnecessary sensitive raw data into every report.

Automatically create:

case-candidates.json

for alerts satisfying your controlled candidate criteria.

Do not automatically mark them:

CONFIRMED INCIDENTS

Create:

critical
high
medium
low

queues.

Use organizational response targets rather than arbitrary universal SLAs.

176 β€” Challenge 15 β€” Add Analyst Notes Template

Section titled β€œ176 β€” Challenge 15 β€” Add Analyst Notes Template”

Generate:

analyst-investigation-template.md

containing:

ALERT ID
SUMMARY
IDENTITY CONTEXT
ASSET CONTEXT
IOC CONTEXT
VULNERABILITY CONTEXT
RELATED ALERTS
TIMELINE
ANALYST OBSERVATIONS
DISPOSITION
ESCALATION
FOLLOW-UP

177 β€” Challenge 16 β€” Add Pipeline Health Report

Section titled β€œ177 β€” Challenge 16 β€” Add Pipeline Health Report”

Generate:

pipeline-health.json

containing:

ALERT SOURCE STATUS
IDENTITY SOURCE STATUS
ASSET SOURCE STATUS
IOC SOURCE STATUS
VULNERABILITY SOURCE STATUS
CLOUD SOURCE STATUS

Any future response workflow should default to:

DRY RUN

Example:

PROPOSED ACTION:
Request review of account admin01
NO CHANGE PERFORMED

Future response architecture:

HIGH-RISK ALERT
↓
TRIAGE
↓
PROPOSE ACTION
↓
ANALYST APPROVAL
↓
AUTHORIZED RESPONSE
↓
VERIFY

What you are building resembles a simplified:

SOAR

workflow.

SOAR commonly stands for:

SECURITY
ORCHESTRATION
AUTOMATION
RESPONSE

Automation:

DO ONE TASK
AUTOMATICALLY

Orchestration:

CONNECT MULTIPLE
SECURITY SYSTEMS
INTO A WORKFLOW

Example:

SIEM
↓
IDENTITY
↓
CMDB
↓
THREAT INTEL
↓
VULNERABILITY PLATFORM
↓
CASE MANAGEMENT

A SOC workflow may have permission to:

DISABLE USER
ISOLATE HOST
BLOCK INDICATOR

A false positive could therefore cause:

BUSINESS OUTAGE
USER LOCKOUT
SERVICE DISRUPTION

Use human approval for high-impact decisions unless a carefully governed automation use case has been explicitly approved.

Your scoring model must be documented.

Analysts should understand:

WHY DID THIS ALERT
GET SCORE 90?

Avoid:

ALERT SCORE = 93
WHY?
UNKNOWN.

Prefer:

BASE HIGH SEVERITY
+40
PRIVILEGED USER
+15
CRITICAL ASSET
+15
HIGH-CONFIDENCE IOC
+10
RELATED USER ALERTS
+5
TOTAL
85

Every score should include:

REASONS

because explainability supports:

ANALYST TRUST
TUNING
AUDITING
DEBUGGING

Be careful when:

ALERT SEVERITY

already includes:

ASSET CRITICALITY

and your risk model adds:

ASSET CRITICALITY AGAIN

You may accidentally inflate risk.

Add:

risk_model_version

Example:

1.0

This helps explain historical differences.

Future metrics:

TRUE POSITIVE RATE
FALSE POSITIVE RATE
BENIGN TRUE POSITIVE RATE
ESCALATION RATE
MEAN TIME TO TRIAGE
ENRICHMENT COVERAGE

Conceptually:

TRIAGE TIME
-
ALERT CREATED TIME

Track this carefully using consistent timestamps.

Monitor alerts that remain:

UNREVIEWED

for too long.

ALERT
↓
QUEUE
↓
AGE
↓
ESCALATION POLICY

The system should record:

WHEN ALERT ARRIVED
WHEN ENRICHMENT OCCURRED
WHAT DATA SOURCES WERE USED
WHAT SCORE WAS ASSIGNED
WHAT MODEL VERSION WAS USED
WHO REVIEWED IT
WHAT DECISION WAS MADE

SOC data can contain:

USERNAMES
HOSTNAMES
IP ADDRESSES
SECURITY EVENTS
INVESTIGATION NOTES

Follow approved:

RETENTION
ACCESS CONTROL
PRIVACY
EVIDENCE

requirements.

The triage platform itself becomes:

SECURITY-CRITICAL INFRASTRUCTURE

Protect:

API TOKENS
DATABASE
LOGS
REPORTS
CONFIGURATION
SCORING RULES
SOURCE CODE

A triage engine that only:

READS ALERTS

should not automatically receive permission to:

ISOLATE HOSTS
DISABLE ACCOUNTS
DELETE RESOURCES

Prefer:

TRIAGE SERVICE
↓
READ-ONLY TOKEN

and a separately governed:

RESPONSE WORKFLOW
↓
APPROVED WRITE PERMISSION

If enrichment fails:

DO NOT
AUTOMATICALLY CLOSE ALERT

If scoring fails:

DO NOT
AUTOMATICALLY MARK LOW RISK

If context is unavailable:

MARK UNKNOWN
AND REQUEST REVIEW
UNCERTAINTY
↓
VISIBILITY
↓
HUMAN REVIEW

not:

UNCERTAINTY
↓
IGNORE

Avoid:

TRUSTING ALERT SEVERITY BLINDLY
NO NORMALIZATION
NO DEDUPLICATION
NO ASSET CONTEXT
NO IDENTITY CONTEXT
IOC MATCH = COMPROMISE
NO VULNERABILITY CONTEXT
NO CORRELATION
NO TIME WINDOWS
NO EXPLAINABLE SCORING
NO DATA FRESHNESS
MISSING DATA = SAFE
AUTOMATICALLY CLOSING ALERTS
AUTOMATICALLY DISABLING USERS
AUTOMATICALLY ISOLATING HOSTS
LOGGING API TOKENS
NO ANALYST FEEDBACK
NO AUDIT TRAIL

For every high-priority alert, ask:

  1. What detection generated the alert?
  2. What evidence supports it?
  3. Is the alert data complete?
  4. Which user is involved?
  5. Is the identity privileged?
  6. Is the account enabled?
  7. Is the behavior normal for this identity?
  8. Which asset is involved?
  9. Is the asset production?
  10. Is it business-critical?
  11. Is it internet-facing?
  12. Are critical vulnerabilities present?
  13. Is the source indicator known?
  14. How current is the IOC information?
  15. Are there related alerts?
  16. Are the alerts close together in time?
  17. Is there an approved exception?
  18. Could this be legitimate activity?
  19. Does it meet incident escalation criteria?
  20. What evidence should be preserved?

Your completed project should look like:

soc-alert-triage/
|
+-- data/
| +-- alerts.json
| +-- identities.json
| +-- assets.json
| +-- ioc_context.json
| +-- vulnerabilities.json
| +-- cloud_context.json
|
+-- src/
| +-- soc_triage.py
|
+-- tests/
| +-- test_soc_triage.py
|
+-- reports/
| +-- raw-alert-snapshot.json
| +-- analyst-triage-queue.csv
| +-- soc-triage-results.json
| +-- invalid-alerts.json
| +-- duplicate-alerts.json
| +-- soc-triage-report.md
|
+-- logs/
|
+-- README.md
|
+-- architecture.md

Document:

PROJECT OVERVIEW
SOC USE CASE
ARCHITECTURE
AUTHORIZED USE
DATA SOURCES
ALERT SCHEMA
VALIDATION
NORMALIZATION
DEDUPLICATION
IDENTITY ENRICHMENT
ASSET ENRICHMENT
IOC ENRICHMENT
VULNERABILITY ENRICHMENT
CLOUD CONTEXT
CORRELATION
RISK MODEL
TRIAGE RECOMMENDATIONS
ANALYST QUEUE
REPORTS
TESTING
SECURITY CONSIDERATIONS
LIMITATIONS

The lab uses:

SYNTHETIC ALERTS
STATIC JSON ENRICHMENT
SIMPLE CORRELATION
TRAINING RISK WEIGHTS
NO PRODUCTION SIEM
NO PRODUCTION EDR
NO REAL THREAT-INTELLIGENCE FEED
NO AUTOMATIC RESPONSE

Your portfolio should demonstrate:

ALERT INGESTION
DATA VALIDATION
NORMALIZATION
DEDUPLICATION
MULTI-SOURCE ENRICHMENT
CORRELATION
EXPLAINABLE RISK SCORING
ANALYST QUEUE GENERATION
REPORTING
TESTING
SAFE AUTOMATION DESIGN
  • Lab workspace created
  • Synthetic alerts created
  • Identity dataset created
  • Asset dataset created
  • IOC context created
  • Vulnerability context created
  • Cloud context created
  • JSON loader created
  • Required fields validated
  • Timestamp validated
  • Severity validated
  • Alerts normalized
  • Duplicate alerts detected
  • Identity index created
  • Asset index created
  • IOC index created
  • Vulnerability index created
  • Cloud index created
  • Identity enrichment implemented
  • Asset enrichment implemented
  • IOC enrichment implemented
  • Vulnerability enrichment implemented
  • Cloud enrichment implemented
  • Base risk scoring implemented
  • Risk reasons recorded
  • Risk bands implemented
  • User correlation implemented
  • Asset correlation implemented
  • Source-IP correlation implemented
  • Correlation bonus implemented
  • Triage recommendations implemented
  • Analyst queue generated
  • Authentication sequence reviewed
  • Raw alerts preserved
  • Invalid alerts exported
  • Duplicate alerts exported
  • JSON report generated
  • CSV queue generated
  • Markdown report generated
  • Missing context handled safely
  • No automatic disruptive response created
  • Test cases completed
  • README created
  • Limitations documented

You started with:

RAW ALERTS

containing only basic information such as:

ALERT TYPE
SEVERITY
USER
ASSET
SOURCE IP

You transformed them into:

NORMALIZED ALERT
↓
IDENTITY CONTEXT
↓
ASSET CONTEXT
↓
IOC CONTEXT
↓
VULNERABILITY CONTEXT
↓
CLOUD CONTEXT
↓
CORRELATION
↓
EXPLAINABLE RISK SCORE
↓
TRIAGE RECOMMENDATION
↓
PRIORITIZED ANALYST QUEUE

You now have the foundation of a defensive SOC triage automation platform.

It can:

INGEST
VALIDATE
NORMALIZE
DEDUPLICATE
ENRICH
CORRELATE
PRIORITIZE
ROUTE
REPORT

security alerts.

The most important lesson is:

ALERT
+
CONTEXT
β‰ 
AUTOMATIC CONCLUSION

Instead:

ALERT
+
IDENTITY
+
ASSET
+
IOC
+
VULNERABILITY
+
CLOUD
+
RELATED ACTIVITY
+
ANALYST JUDGMENT
=
BETTER SECURITY DECISION

When an alert arrives, think:

IS THE ALERT VALID?
↓
IS IT A DUPLICATE?
↓
WHO IS INVOLVED?
↓
IS THE USER PRIVILEGED?
↓
WHAT ASSET IS INVOLVED?
↓
HOW CRITICAL IS IT?
↓
IS IT INTERNET-FACING?
↓
IS THERE IOC CONTEXT?
↓
ARE VULNERABILITIES PRESENT?
↓
ARE CLOUD FINDINGS PRESENT?
↓
ARE THERE RELATED ALERTS?
↓
HOW CLOSE ARE THEY IN TIME?
↓
WHAT FACTORS DRIVE PRIORITY?
↓
WHAT SHOULD THE ANALYST REVIEW?
↓
DOES IT MEET ESCALATION CRITERIA?

The goal is not:

REMOVE THE ANALYST

The goal is:

REMOVE REPETITIVE WORK
SO THE ANALYST
CAN MAKE BETTER DECISIONS

➑️ Lab 10 β€” Enterprise Security Automation Capstone

The final Programming Lab brings the entire learning path together.

You will combine:

PYTHON
BASH
POWERSHELL
SQL
JSON
APIs
LINUX SECURITY DATA
WINDOWS SECURITY DATA
CLOUD SECURITY DATA
VULNERABILITY DATA
SOC ALERTS
IOC ENRICHMENT

into one enterprise security automation platform.

The capstone architecture will be:

ENTERPRISE SECURITY DATA
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
↓ ↓ ↓
LINUX WINDOWS CLOUD
↓ ↓ ↓
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
VULNERABILITIES
↓
IOCS
↓
SOC ALERTS
↓
INGESTION
↓
NORMALIZATION
↓
DATABASE
↓
CORRELATION
↓
RISK SCORING
↓
ANALYST REPORT
↓
HUMAN REVIEW

This will be the final lab where you build a portfolio-ready enterprise security automation project from the skills developed throughout the complete Programming learning path.