npx skills add mukul975/Anthropic-Cybersecurity-SkillsMITRE ATT&CK
When to Use
Use this skill when:
- SOC teams need to detect compromised accounts through abnormal authentication patterns
- Insider threat programs require behavioral monitoring beyond rule-based detection
- Impossible travel or geographic anomalies indicate credential compromise
- Privileged account monitoring requires baseline deviation detection
Do not use as the sole basis for disciplinary action — UEBA findings are indicators requiring investigation, not proof of malicious intent.
Prerequisites
- SIEM with 30+ days of authentication and access log history for baseline creation
- VPN, O365, and Active Directory authentication logs normalized to CIM
- GeoIP database (MaxMind GeoLite2) for location-based anomaly detection
- Identity enrichment data (department, role, manager, typical work hours)
- Splunk Enterprise Security with UBA module or equivalent UEBA capability
Workflow
Step 1: Build User Authentication Baselines
Create behavioral baselines from historical data:
index=auth sourcetype IN ("o365:management:activity", "vpn_logs", "WinEventLog:Security")
earliest=-30d latest=-1d
| stats dc(src_ip) AS unique_ips,
dc(src_country) AS unique_countries,
dc(app) AS unique_apps,
count AS total_logins,
earliest(_time) AS first_login,
latest(_time) AS last_login,
values(src_country) AS countries,
avg(eval(strftime(_time, "%H"))) AS avg_login_hour,
stdev(eval(strftime(_time, "%H"))) AS stdev_login_hour
by user
| eval avg_daily_logins = round(total_logins / 30, 1)
| eval login_hour_range = round(avg_login_hour, 0)." +/- ".round(stdev_login_hour, 1)." hrs"
| table user, unique_ips, unique_countries, unique_apps, avg_daily_logins,
login_hour_range, countriesStep 2: Detect Impossible Travel
Identify logins from geographically distant locations within impossible timeframes:
index=auth sourcetype IN ("o365:management:activity", "vpn_logs")
action=success earliest=-24h
| iplocation src_ip
| sort user, _time
| streamstats current=f last(lat) AS prev_lat, last(lon) AS prev_lon,
last(_time) AS prev_time, last(City) AS prev_city,
last(Country) AS prev_country, last(src_ip) AS prev_ip
by user
| where isnotnull(prev_lat)
| eval distance_km = round(
6371 * acos(
cos(pi()/180 * lat) * cos(pi()/180 * prev_lat) *
cos(pi()/180 * (lon - prev_lon)) +
sin(pi()/180 * lat) * sin(pi()/180 * prev_lat)
), 0)
| eval time_diff_hours = round((_time - prev_time) / 3600, 2)
| eval speed_kmh = if(time_diff_hours > 0, round(distance_km / time_diff_hours, 0), 0)
| where speed_kmh > 900 AND distance_km > 500
| eval alert = "IMPOSSIBLE TRAVEL: ".prev_city.", ".prev_country." -> ".City.", ".Country
| table _time, user, prev_city, prev_country, City, Country, distance_km,
time_diff_hours, speed_kmh, alert
| sort - speed_kmhStep 3: Detect Anomalous Login Timing
Identify logins outside a user's normal working hours:
index=auth action=success earliest=-7d
| eval hour = strftime(_time, "%H")
| eval day_of_week = strftime(_time, "%A")
| eval is_weekend = if(day_of_week IN ("Saturday", "Sunday"), 1, 0)
| eval is_off_hours = if(hour < 6 OR hour > 22, 1, 0)
| join user type=left [
search index=auth action=success earliest=-60d latest=-7d
| eval hour = strftime(_time, "%H")
| stats avg(hour) AS baseline_avg_hour, stdev(hour) AS baseline_stdev_hour,
perc95(hour) AS baseline_latest_hour by user
]
| where (is_off_hours=1 OR is_weekend=1) AND
(hour > baseline_latest_hour + 2 OR hour < baseline_avg_hour - baseline_stdev_hour * 2)
| stats count, values(hour) AS login_hours, values(day_of_week) AS login_days,
values(src_ip) AS source_ips
by user, baseline_avg_hour, baseline_latest_hour
| where count > 0
| sort - countStep 4: Detect Unusual Data Access Patterns
Monitor for abnormal file or database access volumes:
index=file_access OR index=sharepoint earliest=-24h
| stats sum(bytes) AS total_bytes, dc(file_path) AS unique_files,
count AS access_count by user
| join user type=left [
search index=file_access OR index=sharepoint earliest=-30d latest=-1d
| stats avg(eval(count)) AS baseline_avg_files,
stdev(eval(count)) AS baseline_stdev_files,
avg(eval(sum(bytes))) AS baseline_avg_bytes
by user
]
| eval bytes_gb = round(total_bytes / 1073741824, 2)
| eval z_score_files = round((unique_files - baseline_avg_files) / baseline_stdev_files, 2)
| where z_score_files > 3 OR bytes_gb > 5
| eval anomaly_level = case(
z_score_files > 5, "CRITICAL",
z_score_files > 3, "HIGH",
bytes_gb > 10, "CRITICAL",
bytes_gb > 5, "HIGH",
1=1, "MEDIUM"
)
| sort - z_score_files
| table user, unique_files, bytes_gb, baseline_avg_files, z_score_files, anomaly_levelStep 5: Detect Privilege Abuse Patterns
Monitor privileged account usage anomalies:
index=wineventlog sourcetype="WinEventLog:Security"
(EventCode=4672 OR EventCode=4624 OR EventCode=4648) earliest=-24h
| eval is_privileged = if(EventCode=4672, 1, 0)
| eval is_explicit_cred = if(EventCode=4648, 1, 0)
| stats sum(is_privileged) AS priv_events,
sum(is_explicit_cred) AS explicit_cred_events,
dc(ComputerName) AS unique_hosts,
values(ComputerName) AS hosts_accessed
by TargetUserName, src_ip
| join TargetUserName type=left [
search index=wineventlog EventCode IN (4672, 4624, 4648) earliest=-30d latest=-1d
| stats dc(ComputerName) AS baseline_hosts,
avg(eval(count)) AS baseline_daily_events by TargetUserName
]
| where unique_hosts > baseline_hosts * 2 OR priv_events > baseline_daily_events * 3
| eval risk_score = (unique_hosts / baseline_hosts * 30) + (priv_events / baseline_daily_events * 20)
| sort - risk_score
| table TargetUserName, src_ip, unique_hosts, baseline_hosts, priv_events,
baseline_daily_events, risk_score, hosts_accessedStep 6: Generate Risk Score and Prioritize Investigation
Aggregate all UEBA signals into a composite risk score:
| inputlookup ueba_impossible_travel.csv
| append [| inputlookup ueba_off_hours_access.csv]
| append [| inputlookup ueba_data_access_anomaly.csv]
| append [| inputlookup ueba_privilege_abuse.csv]
| stats sum(risk_points) AS total_risk,
values(anomaly_type) AS anomaly_types,
dc(anomaly_type) AS anomaly_count
by user
| lookup identity_lookup_expanded identity AS user
OUTPUT department, managedBy, priority AS user_priority
| eval final_risk = total_risk * case(
user_priority="critical", 2.0,
user_priority="high", 1.5,
user_priority="medium", 1.0,
1=1, 0.8
)
| sort - final_risk
| head 20
| table user, department, managedBy, anomaly_types, anomaly_count, total_risk, final_riskKey Concepts
| Term | Definition |
|---|---|
| UEBA | User and Entity Behavior Analytics — behavioral analysis detecting anomalies against established baselines |
| Impossible Travel | Login events from geographically distant locations within timeframes making physical travel impossible |
| Behavioral Baseline | Statistical profile of normal user activity patterns built from 30-90 days of historical data |
| Z-Score | Statistical measure of how many standard deviations an observation is from the mean — values > 3 indicate anomalies |
| Risk Score | Composite numerical score aggregating multiple behavioral anomalies weighted by asset criticality |
| Peer Group Analysis | Comparing a user's behavior to others in the same department/role to identify outliers |
Tools & Systems
- Splunk UBA: Dedicated User Behavior Analytics module integrating with Splunk ES for ML-driven anomaly detection
- Microsoft Sentinel UEBA: Built-in UEBA capability in Azure Sentinel with entity pages and investigation graphs
- Exabeam Advanced Analytics: Standalone UEBA platform with session stitching and automatic timeline creation
- Securonix: Cloud-native SIEM/UEBA with pre-built behavioral models for insider threat detection
Common Scenarios
- Compromised Account: Impossible travel + off-hours login + unusual app access = likely credential compromise
- Insider Data Theft: Employee accessing 10x normal file volume in notice period before departure
- Privilege Escalation Abuse: Admin account used from unusual location accessing systems outside normal scope
- Shared Account Detection: Service account logging in from multiple geographies simultaneously
- Dormant Account Reactivation: Account with no activity for 90+ days suddenly performing privileged operations
Output Format
UEBA ANOMALY REPORT — Weekly Summary
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Period: 2024-03-11 to 2024-03-17
Users Baselined: 2,847
Anomalies Detected: 23
TOP RISK USERS:
# User Dept Risk Anomalies
1. jsmith Finance 94.5 Impossible travel (NYC->Moscow, 2h), off-hours access, 15GB download
2. admin_svc01 IT Ops 82.0 Login from 12 new IPs, 47 hosts accessed (baseline: 8)
3. mwilson HR 67.3 Off-hours file access (2AM), 3x normal download volume
INVESTIGATION STATUS:
jsmith: Escalated to Tier 2 — possible account compromise (IR-2024-0445)
admin_svc01: Under review — may be new automation deployment (checking with IT Ops)
mwilson: Pending HR context — employee on notice period, monitoring increasedReferences and resources
Everything below is rendered for inspection. Script files are read-only and never run.
References 1
api-reference.md2.0 KB
API Reference: User Behavior Analytics (UEBA) Agent
Overview
Detects anomalous user behavior using Elasticsearch authentication logs: impossible travel via haversine distance, off-hours access against baselines, and composite risk scoring.
Dependencies
| Package | Version | Purpose |
|---|---|---|
| elasticsearch | >= 8.0 | Elasticsearch Python client |
| math | stdlib | Haversine distance calculation |
Core Functions
build_user_baselines(es, index, days)
Builds 30-day behavioral baselines per user: unique IPs, countries, login hour stats, daily averages.
- Returns:
dict[str, dict]- user to baseline mapping
detect_impossible_travel(es, index, hours)
Detects sequential logins from locations requiring >900 km/h travel speed over >500 km distance.
- Algorithm: Haversine distance / time between consecutive logins per user
- Returns:
list[dict]- alerts with from/to locations, distance, speed
detect_off_hours_access(es, baselines, index, hours)
Flags logins outside 2 standard deviations from user's average login hour, on weekends, or between midnight-6am / after 10pm.
- Returns:
list[dict]- alerts with user, timestamp, login hour, baseline
calculate_risk_scores(impossible_travel, off_hours, baselines)
Aggregates anomalies into composite risk scores: +40 for impossible travel, +20 for off-hours.
- Returns:
list[tuple]- (user, {risk, anomalies}) sorted descending
haversine(lat1, lon1, lat2, lon2)
Great-circle distance between two geographic coordinates in km.
- Returns:
float- distance in kilometers
Elasticsearch Index Requirements
| Index | Fields Required |
|---|---|
logs-auth-* |
user.name, source.ip, source.geo.location, @timestamp, event.outcome |
Risk Score Weights
| Anomaly Type | Points |
|---|---|
| Impossible travel | +40 |
| Off-hours access | +20 |
| Weekend access | +20 |
Usage
python agent.py https://elastic.corp.local:9200Scripts 1
agent.py8.3 KB
#!/usr/bin/env python3
"""User Behavior Analytics (UEBA) agent using elasticsearch-py."""
import math
import os
import sys
from datetime import datetime
try:
from elasticsearch import Elasticsearch
except ImportError:
print("Install: pip install elasticsearch")
sys.exit(1)
EARTH_RADIUS_KM = 6371
def get_es_client(host=None, api_key=None):
host = host or os.environ.get("ES_HOSTS", "https://localhost:9200")
kwargs = {"hosts": [host], "verify_certs": False}
if api_key:
kwargs["api_key"] = api_key
return Elasticsearch(**kwargs)
def haversine(lat1, lon1, lat2, lon2):
"""Calculate distance in km between two coordinates."""
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
return EARTH_RADIUS_KM * 2 * math.asin(math.sqrt(a))
def build_user_baselines(es, index="logs-auth-*", days=30):
"""Build behavioral baselines from historical authentication data."""
query = {
"size": 0,
"query": {
"bool": {
"must": [
{"range": {"@timestamp": {"gte": f"now-{days}d", "lt": "now-1d"}}},
{"term": {"event.outcome": "success"}},
]
}
},
"aggs": {
"by_user": {
"terms": {"field": "user.name", "size": 5000},
"aggs": {
"unique_ips": {"cardinality": {"field": "source.ip"}},
"unique_countries": {"cardinality": {"field": "source.geo.country_name"}},
"login_hours": {"stats": {"script": "doc['@timestamp'].value.getHour()"}},
"daily_count": {
"date_histogram": {"field": "@timestamp", "calendar_interval": "day"},
},
}
}
},
}
result = es.search(index=index, body=query)
baselines = {}
for bucket in result["aggregations"]["by_user"]["buckets"]:
user = bucket["key"]
daily_counts = [b["doc_count"] for b in bucket["daily_count"]["buckets"]]
avg_daily = sum(daily_counts) / max(len(daily_counts), 1)
baselines[user] = {
"unique_ips": bucket["unique_ips"]["value"],
"unique_countries": bucket["unique_countries"]["value"],
"avg_login_hour": bucket["login_hours"]["avg"],
"stdev_login_hour": bucket["login_hours"].get("std_deviation", 4),
"avg_daily_logins": round(avg_daily, 1),
"total_logins": bucket["doc_count"],
}
return baselines
def detect_impossible_travel(es, index="logs-auth-*", hours=24):
"""Detect logins from geographically distant locations within impossible timeframes."""
query = {
"size": 10000,
"query": {
"bool": {
"must": [
{"range": {"@timestamp": {"gte": f"now-{hours}h"}}},
{"term": {"event.outcome": "success"}},
{"exists": {"field": "source.geo.location"}},
]
}
},
"sort": [{"user.name": "asc"}, {"@timestamp": "asc"}],
}
result = es.search(index=index, body=query)
events_by_user = {}
for hit in result["hits"]["hits"]:
src = hit["_source"]
user = src.get("user", {}).get("name")
if not user:
continue
events_by_user.setdefault(user, []).append({
"timestamp": src.get("@timestamp"),
"ip": src.get("source", {}).get("ip"),
"lat": src.get("source", {}).get("geo", {}).get("location", {}).get("lat"),
"lon": src.get("source", {}).get("geo", {}).get("location", {}).get("lon"),
"city": src.get("source", {}).get("geo", {}).get("city_name"),
"country": src.get("source", {}).get("geo", {}).get("country_name"),
})
alerts = []
for user, events in events_by_user.items():
for i in range(1, len(events)):
prev, curr = events[i - 1], events[i]
if not all([prev.get("lat"), prev.get("lon"), curr.get("lat"), curr.get("lon")]):
continue
dist = haversine(prev["lat"], prev["lon"], curr["lat"], curr["lon"])
try:
t1 = datetime.fromisoformat(prev["timestamp"].replace("Z", "+00:00"))
t2 = datetime.fromisoformat(curr["timestamp"].replace("Z", "+00:00"))
hours_diff = (t2 - t1).total_seconds() / 3600
except (ValueError, TypeError):
continue
if hours_diff <= 0:
continue
speed = dist / hours_diff
if speed > 900 and dist > 500:
alerts.append({
"user": user,
"from": f"{prev.get('city', '?')}, {prev.get('country', '?')}",
"to": f"{curr.get('city', '?')}, {curr.get('country', '?')}",
"distance_km": round(dist),
"time_hours": round(hours_diff, 2),
"speed_kmh": round(speed),
"prev_time": prev["timestamp"],
"curr_time": curr["timestamp"],
})
return alerts
def detect_off_hours_access(es, baselines, index="logs-auth-*", hours=168):
"""Detect logins outside user's normal working hours."""
query = {
"size": 5000,
"query": {
"bool": {
"must": [
{"range": {"@timestamp": {"gte": f"now-{hours}h"}}},
{"term": {"event.outcome": "success"}},
]
}
},
}
result = es.search(index=index, body=query)
alerts = []
for hit in result["hits"]["hits"]:
src = hit["_source"]
user = src.get("user", {}).get("name")
ts = src.get("@timestamp", "")
if not user or user not in baselines:
continue
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
except (ValueError, TypeError):
continue
hour = dt.hour
baseline = baselines[user]
avg_hour = baseline.get("avg_login_hour", 12)
stdev = baseline.get("stdev_login_hour", 4)
if avg_hour and stdev:
if hour < (avg_hour - 2 * stdev) or hour > (avg_hour + 2 * stdev):
if hour < 6 or hour > 22 or dt.weekday() >= 5:
alerts.append({
"user": user,
"timestamp": ts,
"login_hour": hour,
"baseline_avg": round(avg_hour, 1),
"weekend": dt.weekday() >= 5,
"ip": src.get("source", {}).get("ip"),
})
return alerts
def calculate_risk_scores(impossible_travel, off_hours, baselines):
"""Aggregate anomalies into composite risk scores per user."""
scores = {}
for alert in impossible_travel:
user = alert["user"]
scores.setdefault(user, {"risk": 0, "anomalies": []})
scores[user]["risk"] += 40
scores[user]["anomalies"].append(f"Impossible travel: {alert['from']} -> {alert['to']}")
for alert in off_hours:
user = alert["user"]
scores.setdefault(user, {"risk": 0, "anomalies": []})
scores[user]["risk"] += 20
scores[user]["anomalies"].append(f"Off-hours login at {alert['login_hour']}:00")
sorted_users = sorted(scores.items(), key=lambda x: -x[1]["risk"])
return sorted_users
def print_report(travel_alerts, offhours_alerts, risk_scores):
print("UEBA ANOMALY REPORT")
print("=" * 50)
print(f"Date: {datetime.now().isoformat()}")
print(f"Impossible Travel Alerts: {len(travel_alerts)}")
print(f"Off-Hours Access Alerts: {len(offhours_alerts)}")
print(f"\nTOP RISK USERS:")
for user, data in risk_scores[:10]:
print(f" {user:20s} Risk: {data['risk']:>5}")
for a in data["anomalies"][:3]:
print(f" - {a}")
if __name__ == "__main__":
host = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("ES_HOSTS", "https://localhost:9200")
es = get_es_client(host)
baselines = build_user_baselines(es)
travel = detect_impossible_travel(es)
offhours = detect_off_hours_access(es, baselines)
risk = calculate_risk_scores(travel, offhours, baselines)
print_report(travel, offhours, risk)