# Detection rules for log-triage, written as plain TB maps.
# Each rule says what "suspicious" looks like; detect.run() does the rest.
# One rule can cover several log formats because the logs library gives
# Linux and Windows records the same field names (user, ip, action, success).

import detect

let WEB_ATTACK = "(\.\./|/etc/passwd|union(\s|%20|\+)+select|<script|%3cscript|/\.env|/\.git/)"

let RULES = [
  # --- web servers (Apache / nginx access logs) ---
  detect.rule({
    "name": "Web scanner: many 404s",
    "level": "medium",
    "where": {"status": 404},
    "count": 15, "within": 120, "by": "ip",
  }),
  detect.rule({
    "name": "Web attack in the URL",
    "level": "high",
    "where": {"path|re|i": WEB_ATTACK},
    "by": "ip",
  }),
  detect.rule({
    "name": "Known attack tool user agent",
    "level": "medium",
    "where": {"agent|contains": ["sqlmap", "nikto", "nmap", "masscan", "zgrab", "nuclei", "gobuster", "dirbuster"]},
    "by": "ip",
  }),
  detect.rule({
    "name": "Password spraying on a web login",
    "level": "high",
    "where": {"method": "POST", "status": 401},
    "count": 10, "within": 120, "by": "ip",
  }),
  # --- accounts (Linux auth.log and Windows Security) ---
  detect.rule({
    "name": "New user account created",
    "level": "high",
    "where": {"action": "user_created"},
    "unless": {"user|endswith": "$"},
    "by": "user",
  }),
  detect.rule({
    "name": "User added to a group",
    "level": "high",
    "where": {"action": "group_member_added"},
    "by": "group",
  }),
  detect.rule({
    "name": "Account locked out",
    "level": "medium",
    "where": {"action": "account_locked"},
    "by": "user",
  }),
  detect.rule({
    "name": "sudo used to manage accounts or get a shell",
    "level": "medium",
    "where": {"action": "sudo", "command|re": "(useradd|usermod|userdel|passwd|visudo|/bin/(ba|z)?sh\b)"},
    "by": "user",
  }),
  # --- Windows ---
  detect.rule({
    "name": "Security log cleared",
    "level": "critical",
    "where": {"action": ["audit_log_cleared", "log_cleared"]},
  }),
  detect.rule({
    "name": "Encoded PowerShell command",
    "level": "high",
    "where": {"command_line|re|i": "powershell.*\s-(e|ec|enc|encodedcommand)\s"},
    "by": "user",
  }),
]

# The built-in hunts, with the thresholds this app uses.
let BRUTE_FORCE_ATTEMPTS = 5
let BRUTE_FORCE_WITHIN = 600
let PORT_SCAN_PORTS = 10
let PORT_SCAN_WITHIN = 60

# Run every rule and built-in hunt over some records; alerts come back
# most serious first.
fn triage(records) {
  let alerts = detect.run(RULES, records)
  for a in detect.brute_force(records, BRUTE_FORCE_ATTEMPTS, BRUTE_FORCE_WITHIN) {
    alerts.push(a)
  }
  for a in detect.port_scan(records, PORT_SCAN_PORTS, PORT_SCAN_WITHIN) {
    alerts.push(a)
  }
  let order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "informational": 4}
  return alerts.sort_by(fn(a) { return order[a.level] * 10000000000 + (a.first or 0) })
}
