new file: .gitignore
modified: README.md new file: app/dashboard.py new file: app/main.py new file: docker-compose.yml new file: snort/local.rules new file: snort/snort-logs/soc_actions.log new file: snort/snort.lua new file: snort/snort3-community.rules
This commit is contained in:
218
app/dashboard.py
Normal file
218
app/dashboard.py
Normal file
@@ -0,0 +1,218 @@
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from fastapi import FastAPI, Form, HTTPException
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
app = FastAPI(title="Duct-Tape SOC Dashboard")
|
||||
|
||||
LOG_FILE = "/var/log/snort/alert_json.txt"
|
||||
RULES_FILE = "/etc/snort/rules/local.rules"
|
||||
ACTION_LOG = "/var/log/snort/soc_actions.log"
|
||||
|
||||
HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Duct-Tape SOC Dashboard v3</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 font-sans min-h-screen p-6">
|
||||
<div class="max-w-7xl mx-auto space-y-6">
|
||||
<header class="border-b border-gray-800 pb-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-indigo-400">Duct-Tape SOC</h1>
|
||||
<p class="text-sm text-gray-400">Advanced Snort 3 & LLM Firewall Automation Simulator</p>
|
||||
</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="text-right mr-4 text-sm text-gray-400">
|
||||
Active Rules: <span id="ruleCount" class="font-bold text-green-400">0</span><br>
|
||||
Alerts Logged: <span id="alertCount" class="font-bold text-blue-400">0</span>
|
||||
</div>
|
||||
<button onclick="clearRules()" class="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded transition">
|
||||
Wipe Active Rules
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="bg-gray-800 p-6 rounded-lg border border-gray-700 space-y-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-indigo-300 mb-4">Quick Scenarios</h2>
|
||||
<select id="scenarioSelect" class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white mb-3">
|
||||
<option value="ssh_attack">External SSH Brute Force</option>
|
||||
<option value="nmap_scan">External Nmap Scan</option>
|
||||
<option value="ssdp_noise">Internal SSDP Noise</option>
|
||||
<option value="mdns_noise">Internal mDNS Noise</option>
|
||||
</select>
|
||||
<button onclick="injectStandard()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 rounded transition">
|
||||
Fire Quick Payload
|
||||
</button>
|
||||
</div>
|
||||
<hr class="border-gray-700">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-orange-400 mb-4">Chaos Mode</h2>
|
||||
<button onclick="injectRandom()" class="w-full bg-orange-600 hover:bg-orange-700 text-white font-medium py-2 rounded transition">
|
||||
Generate Random Attack
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 p-6 rounded-lg border border-gray-700 flex flex-col h-[350px]">
|
||||
<h2 class="text-xl font-semibold text-pink-400 mb-4">Python & LLM Action Stream</h2>
|
||||
<pre id="actionBox" class="bg-gray-950 p-4 rounded border border-gray-800 overflow-y-auto text-xs font-mono flex-1 text-gray-300 whitespace-pre-wrap">Awaiting actions...</pre>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 p-6 rounded-lg border border-gray-700 flex flex-col h-[350px]">
|
||||
<h2 class="text-xl font-semibold text-indigo-300 mb-4">Active local.rules</h2>
|
||||
<pre id="rulesBox" class="bg-gray-950 p-4 rounded border border-gray-800 overflow-y-auto text-xs font-mono flex-1 text-green-400">Loading rules...</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 p-6 rounded-lg border border-gray-700">
|
||||
<h2 class="text-xl font-semibold text-indigo-300 mb-4">Live Snort JSON Tail</h2>
|
||||
<div id="alertsContainer" class="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
<p class="text-sm text-gray-500 italic">Streaming logs...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function updateDashboardData() {
|
||||
try {
|
||||
const response = await fetch('/api/data');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('rulesBox').textContent = data.rules || "No rules active.";
|
||||
document.getElementById('ruleCount').textContent = data.rule_count;
|
||||
document.getElementById('alertCount').textContent = data.total_alerts;
|
||||
|
||||
// Update new Action Log Box
|
||||
const actionBox = document.getElementById('actionBox');
|
||||
actionBox.textContent = data.actions || "No actions logged.";
|
||||
|
||||
const container = document.getElementById('alertsContainer');
|
||||
if (data.alerts && data.alerts.length > 0) {
|
||||
container.innerHTML = data.alerts.map(alert =>
|
||||
`<pre class="bg-gray-950 p-3 rounded border border-gray-800 text-xs font-mono text-gray-300 overflow-x-auto mb-2">${JSON.stringify(alert, null, 2)}</pre>`
|
||||
).join('');
|
||||
} else {
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 italic">No alerts seen yet.</p>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Dashboard poll failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function injectStandard() {
|
||||
const formData = new FormData();
|
||||
formData.append('scenario', document.getElementById('scenarioSelect').value);
|
||||
await fetch('/inject-standard', { method: 'POST', body: formData });
|
||||
updateDashboardData();
|
||||
}
|
||||
|
||||
async function injectRandom() {
|
||||
await fetch('/inject-random', { method: 'POST' });
|
||||
updateDashboardData();
|
||||
}
|
||||
|
||||
async function clearRules() {
|
||||
await fetch('/clear-rules', { method: 'POST' });
|
||||
updateDashboardData();
|
||||
}
|
||||
|
||||
setInterval(updateDashboardData, 2000);
|
||||
window.onload = updateDashboardData;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def write_alert(payload):
|
||||
try:
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(json.dumps(payload) + "\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to write to log: {e}")
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index():
|
||||
return HTML_TEMPLATE
|
||||
|
||||
@app.get("/api/data")
|
||||
def get_data():
|
||||
rules_content = "File empty or missing."
|
||||
rule_count = 0
|
||||
if os.path.exists(RULES_FILE):
|
||||
with open(RULES_FILE, "r") as f:
|
||||
rules_content = f.read().strip()
|
||||
rule_count = rules_content.count("drop ")
|
||||
|
||||
actions_content = ""
|
||||
if os.path.exists(ACTION_LOG):
|
||||
with open(ACTION_LOG, "r") as f:
|
||||
# Grab the last 25 lines of the action stream
|
||||
actions_content = "".join(f.readlines()[-25:])
|
||||
|
||||
parsed_alerts = []
|
||||
total_alerts = 0
|
||||
if os.path.exists(LOG_FILE):
|
||||
with open(LOG_FILE, "r") as f:
|
||||
lines = f.readlines()
|
||||
total_alerts = len(lines)
|
||||
for line in reversed(lines[-10:]):
|
||||
try:
|
||||
parsed_alerts.append(json.loads(line))
|
||||
except:
|
||||
continue
|
||||
|
||||
return JSONResponse(content={
|
||||
"rules": rules_content,
|
||||
"alerts": parsed_alerts,
|
||||
"actions": actions_content.strip(),
|
||||
"rule_count": rule_count,
|
||||
"total_alerts": total_alerts
|
||||
})
|
||||
|
||||
@app.post("/inject-standard")
|
||||
def inject_standard(scenario: str = Form(...)):
|
||||
scenarios = {
|
||||
"ssh_attack": {"proto": "TCP", "src_ap": "185.220.101.5:43210", "dst_ap": "192.168.1.50:22", "rule": "1:2000123:1", "msg": "SSH Brute Force Attempt"},
|
||||
"nmap_scan": {"proto": "TCP", "src_ap": "45.33.32.156:59832", "dst_ap": "192.168.1.50:80", "rule": "1:2000456:1", "msg": "Nmap Port Scan"},
|
||||
"ssdp_noise": {"proto": "UDP", "src_ap": "192.168.1.121:1900", "dst_ap": "239.255.255.250:1900", "rule": "116:6:1", "msg": "SSDP Broadcast"},
|
||||
"mdns_noise": {"proto": "UDP", "src_ap": "192.168.1.83:5353", "dst_ap": "224.0.0.251:5353", "rule": "116:6:1", "msg": "mDNS Multicast"}
|
||||
}
|
||||
if scenario not in scenarios:
|
||||
raise HTTPException(status_code=400, detail="Invalid scenario")
|
||||
write_alert(scenarios[scenario])
|
||||
return {"status": "success"}
|
||||
|
||||
@app.post("/inject-random")
|
||||
def inject_random():
|
||||
src_ip = f"{random.randint(1, 223)}.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 254)}"
|
||||
src_port = random.randint(1024, 65535)
|
||||
dst_ip = f"192.168.1.{random.randint(2, 254)}"
|
||||
dst_port = random.choice([22, 80, 443, 3389, 8080])
|
||||
|
||||
payload = {
|
||||
"proto": random.choice(["TCP", "UDP"]),
|
||||
"src_ap": f"{src_ip}:{src_port}",
|
||||
"dst_ap": f"{dst_ip}:{dst_port}",
|
||||
"rule": f"1:{random.randint(10000, 99999)}:1",
|
||||
"msg": "Simulated Random Attack"
|
||||
}
|
||||
write_alert(payload)
|
||||
return {"status": "success"}
|
||||
|
||||
@app.post("/clear-rules")
|
||||
def clear_rules():
|
||||
try:
|
||||
with open(RULES_FILE, "w") as f:
|
||||
f.write("")
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to clear rules: {e}")
|
||||
233
app/main.py
Normal file
233
app/main.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import re
|
||||
|
||||
LOG_FILE = "/var/log/snort/alert_json.txt"
|
||||
RULES_FILE = "/app/local.rules"
|
||||
ACTION_LOG = "/var/log/snort/soc_actions.log"
|
||||
WEBHOOK = os.environ.get("WEBHOOK_URL")
|
||||
OPENROUTER_KEY = os.environ.get("OPENROUTER_API_KEY")
|
||||
|
||||
LLM_MODEL = "anthropic/claude-3.5-haiku"
|
||||
INTERNAL_PREFIXES = ("192.168.", "10.", "172.")
|
||||
|
||||
def log_msg(msg):
|
||||
"""Writes to standard output and the shared action log for the dashboard."""
|
||||
print(msg)
|
||||
try:
|
||||
with open(ACTION_LOG, "a") as f:
|
||||
f.write(msg + "\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def append_snort_rule(rule_string):
|
||||
rule_string = rule_string.strip()
|
||||
|
||||
valid_starts = ["drop tcp", "drop udp", "drop icmp", "drop ip"]
|
||||
if not any(rule_string.startswith(prefix) for prefix in valid_starts):
|
||||
log_msg(f"CRITICAL: Blocked invalid protocol syntax: {rule_string}")
|
||||
return "Rejected: Rule must start with drop tcp, udp, icmp, or ip."
|
||||
|
||||
if "any any -> any any" in rule_string:
|
||||
log_msg("CRITICAL: Blocked LLM attempt to deploy a nuclear 'any any' drop rule.")
|
||||
return "Rejected: Rule would cause total network blackout."
|
||||
|
||||
dangerous_targets = ["255.255.255.255", "224.0.0", "239.255.255", "ff02::"]
|
||||
if any(target in rule_string for target in dangerous_targets):
|
||||
log_msg(f"CRITICAL: Blocked LLM attempt to block broadcast/multicast traffic.")
|
||||
return "Rejected: Rule targets critical local infrastructure noise."
|
||||
|
||||
if "->" not in rule_string or "sid:" not in rule_string:
|
||||
log_msg("CRITICAL: Blocked malformed rule structural syntax.")
|
||||
return "Rejected: Malformed Snort syntax."
|
||||
|
||||
parts = rule_string.split()
|
||||
if len(parts) > 2:
|
||||
src_ip = parts[2]
|
||||
|
||||
if src_ip.lower() == "any":
|
||||
log_msg("CRITICAL: Blocked rule. LLM failed to identify a specific attacker IP.")
|
||||
return "Rejected: Source IP cannot be 'any'."
|
||||
|
||||
if src_ip.startswith(INTERNAL_PREFIXES):
|
||||
log_msg(f"CRITICAL: Blocked LLM attempt to ban internal subnet IP: {src_ip}")
|
||||
return "Rejected: Cannot block internal network IPs."
|
||||
|
||||
if os.path.exists(RULES_FILE):
|
||||
with open(RULES_FILE, "r") as f:
|
||||
if src_ip in f.read():
|
||||
log_msg(f"Skipping: Attacker {src_ip} is already blocked in local.rules")
|
||||
return "Rejected: IP already blocked."
|
||||
|
||||
try:
|
||||
with open(RULES_FILE, "a") as f:
|
||||
f.write(f"\n# Auto-generated by LLM\n{rule_string}\n")
|
||||
return "Rule successfully appended to local.rules."
|
||||
except Exception as e:
|
||||
return f"Failed to write rule: {e}"
|
||||
|
||||
def get_next_sid():
|
||||
highest_sid = 1000000
|
||||
if os.path.exists(RULES_FILE):
|
||||
with open(RULES_FILE, "r") as f:
|
||||
sids = re.findall(r'sid:(\d+);', f.read())
|
||||
if sids:
|
||||
highest_sid = max([int(s) for s in sids])
|
||||
return highest_sid + 1
|
||||
|
||||
def ask_llm_for_rule(alert_data):
|
||||
if not OPENROUTER_KEY:
|
||||
return
|
||||
|
||||
next_sid = get_next_sid()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {OPENROUTER_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
prompt = (
|
||||
"You are an automated SOC analyst generating Snort 3 block rules.\n"
|
||||
f"Analyze this alert payload:\n{json.dumps(alert_data)}\n\n"
|
||||
"CRITICAL REQUIREMENTS:\n"
|
||||
f"1. Use EXACTLY this syntax: drop [proto] [src] any -> [dst] [dst_port] (msg:\"LLM Block\"; sid:{next_sid}; rev:1;)\n"
|
||||
"2. The SOURCE port MUST ALWAYS be 'any'. Attackers use random ephemeral ports.\n"
|
||||
"3. If the alert does not specify a clear, non-local external IP address as the attacker, you MUST NOT generate a rule.\n"
|
||||
"4. NEVER target 255.255.255.255, multicast ranges, or loopback addresses.\n"
|
||||
"5. CONTEXT: The protected internal network is 192.168.1.0/24. The attacker is ALWAYS external. NEVER use an IP starting with 192.168. as the source.\n"
|
||||
"6. The source IP must be the specific external attacker. Never use 'any' for the source IP.\n"
|
||||
"7. THOUGHT PROCESS: Briefly explain your reasoning in 1-2 sentences in the text response before deciding whether to call the tool or do nothing."
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": LLM_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "append_snort_rule",
|
||||
"description": "Appends a new Snort rule.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rule_string": {
|
||||
"type": "string",
|
||||
"description": "The exact valid Snort 3 rule string."
|
||||
}
|
||||
},
|
||||
"required": ["rule_string"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
log_msg("Asking LLM for a block rule...")
|
||||
try:
|
||||
response = requests.post("https://openrouter.ai/api/v1/chat/completions", headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
response_data = response.json()
|
||||
|
||||
message = response_data["choices"][0]["message"]
|
||||
content = message.get("content", "")
|
||||
|
||||
if content:
|
||||
log_msg(f"LLM Reasoning: {content.strip()}")
|
||||
|
||||
if "tool_calls" in message and message["tool_calls"]:
|
||||
for tool_call in message["tool_calls"]:
|
||||
if tool_call["function"]["name"] == "append_snort_rule":
|
||||
args = json.loads(tool_call["function"]["arguments"])
|
||||
rule_string = args.get("rule_string")
|
||||
log_msg(f"LLM generated rule: {rule_string}")
|
||||
|
||||
result = append_snort_rule(rule_string)
|
||||
log_msg(result)
|
||||
else:
|
||||
log_msg("LLM opted not to generate a rule.")
|
||||
|
||||
except Exception as e:
|
||||
log_msg(f"Failed to communicate with LLM or parse response: {e}")
|
||||
|
||||
def send_discord_alert(data, raw_json_str, proto, src, dst, rule_id):
|
||||
embed = {
|
||||
"title": f"[SNORT] {proto} Traffic Detected",
|
||||
"description": f"**Raw Payload:**\n```json\n{raw_json_str}\n```",
|
||||
"color": 16711680,
|
||||
"fields": [
|
||||
{"name": "Source", "value": f"`{src}`", "inline": True},
|
||||
{"name": "Destination", "value": f"`{dst}`", "inline": True},
|
||||
{"name": "Rule ID", "value": f"`{rule_id}`", "inline": True}
|
||||
],
|
||||
"footer": {"text": "Duct-Tape SOC"}
|
||||
}
|
||||
|
||||
while True:
|
||||
try:
|
||||
response = requests.post(WEBHOOK, json={"embeds": [embed]})
|
||||
|
||||
if response.status_code == 429:
|
||||
wait_time = response.json().get("retry_after", 1)
|
||||
log_msg(f"Rate limited by Discord. Sleeping for {wait_time}s...")
|
||||
time.sleep(wait_time)
|
||||
continue
|
||||
elif response.status_code in [200, 204]:
|
||||
log_msg("Sent alert to Discord successfully.")
|
||||
break
|
||||
else:
|
||||
log_msg(f"Discord responded: {response.status_code} - {response.text}")
|
||||
break
|
||||
except Exception as e:
|
||||
log_msg(f"Failed to send to Discord: {e}")
|
||||
break
|
||||
|
||||
def tail_and_ship():
|
||||
log_msg(f"Waiting for Snort to create {LOG_FILE}...")
|
||||
while not os.path.exists(LOG_FILE):
|
||||
time.sleep(1)
|
||||
|
||||
log_msg("Log found. Tailing for alerts...")
|
||||
with open(LOG_FILE, "r") as f:
|
||||
f.seek(0, 2)
|
||||
while True:
|
||||
line = f.readline()
|
||||
if not line:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
proto = data.get("proto", "UNKNOWN")
|
||||
src = data.get("src_ap", "Unknown")
|
||||
dst = data.get("dst_ap", "Unknown")
|
||||
|
||||
src_ip = src.split(':')[0] if ':' in src else src
|
||||
dst_ip = dst.split(':')[0] if ':' in dst else dst
|
||||
|
||||
if dst_ip == "255.255.255.255" or dst_ip.startswith("224.") or dst_ip.startswith("239.") or dst_ip.startswith("ff02:"):
|
||||
continue
|
||||
|
||||
if src_ip.startswith(INTERNAL_PREFIXES) and dst_ip.startswith(INTERNAL_PREFIXES):
|
||||
continue
|
||||
|
||||
rule_id = data.get("rule", "Unknown")
|
||||
raw_json_str = json.dumps(data, indent=2)
|
||||
|
||||
send_discord_alert(data, raw_json_str, proto, src, dst, rule_id)
|
||||
ask_llm_for_rule(data)
|
||||
|
||||
except Exception as e:
|
||||
log_msg(f"Failed to process alert: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not WEBHOOK:
|
||||
log_msg("Error: WEBHOOK_URL environment variable is missing.")
|
||||
exit(1)
|
||||
|
||||
# Initialize the log file
|
||||
with open(ACTION_LOG, "w") as f:
|
||||
f.write("SOC Action Log Initialized.\n")
|
||||
|
||||
tail_and_ship()
|
||||
Reference in New Issue
Block a user