added
هذا الالتزام موجود في:
33
q4-siem/alerts.json
Normal file
33
q4-siem/alerts.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"generated_at": "2026-07-26T22:30:54.950560",
|
||||
"total_alerts": 2,
|
||||
"critical_count": 0,
|
||||
"high_count": 0,
|
||||
"medium_count": 2,
|
||||
"alerts": [
|
||||
{
|
||||
"id": 1,
|
||||
"timestamp": "2026-07-26T22:30:54.950367",
|
||||
"last_seen": "2026-07-26T22:30:54.950548",
|
||||
"source": "app/application.log",
|
||||
"type": "application_error",
|
||||
"severity": "MEDIUM",
|
||||
"ip": "unknown",
|
||||
"detail": "Database connection timeout after 30s \u2014 retrying",
|
||||
"count": 3,
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"timestamp": "2026-07-26T22:30:54.950462",
|
||||
"last_seen": "2026-07-26T22:30:54.950465",
|
||||
"source": "app/application.log",
|
||||
"type": "application_error",
|
||||
"severity": "MEDIUM",
|
||||
"ip": "185.220.100.42",
|
||||
"detail": "JWT verification failed for token eyJ0eXAi... from IP 185.220.100.42",
|
||||
"count": 1,
|
||||
"status": "open"
|
||||
}
|
||||
]
|
||||
}
|
||||
579
q4-siem/dashboard.html
Normal file
579
q4-siem/dashboard.html
Normal file
@@ -0,0 +1,579 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ghaymah — Lightweight SIEM Log Analyzer</title>
|
||||
<style>
|
||||
/* System font stack — no external font requests for privacy consistency */
|
||||
|
||||
:root {
|
||||
--bg-base: #0a0e1a;
|
||||
--bg-card: #111827;
|
||||
--bg-card2: #1a2235;
|
||||
--border: #1e2d45;
|
||||
--accent: #3b82f6;
|
||||
--critical: #ef4444;
|
||||
--high: #f97316;
|
||||
--medium: #eab308;
|
||||
--low: #22c55e;
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #64748b;
|
||||
--glow-red: 0 0 20px rgba(239,68,68,0.3);
|
||||
--glow-blue: 0 0 20px rgba(59,130,246,0.2);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg-base);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.2rem 2rem;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 36px; height: 36px;
|
||||
background: linear-gradient(135deg, var(--accent), #6366f1);
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: .85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
animation: pulse 2s infinite;
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,100% { opacity: 1; }
|
||||
50% { opacity: .4; }
|
||||
}
|
||||
#last-updated { font-family: ui-monospace, 'Cascadia Code', 'Courier New', monospace; }
|
||||
|
||||
/* ── Main Layout ── */
|
||||
main { padding: 2rem; max-width: 1400px; margin: 0 auto; }
|
||||
|
||||
/* ── Stat Cards ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.4rem 1.6rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform .2s, box-shadow .2s;
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--glow-blue); }
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(59,130,246,.05), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.stat-card.critical::before { background: linear-gradient(135deg, rgba(239,68,68,.08), transparent); }
|
||||
.stat-card.high::before { background: linear-gradient(135deg, rgba(249,115,22,.08), transparent); }
|
||||
.stat-card.medium::before { background: linear-gradient(135deg, rgba(234,179,8,.08), transparent); }
|
||||
|
||||
.stat-label { font-size: .8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: .4rem; }
|
||||
.stat-value { font-size: 2.4rem; font-weight: 700; line-height: 1; }
|
||||
.stat-value.critical { color: var(--critical); text-shadow: var(--glow-red); }
|
||||
.stat-value.high { color: var(--high); }
|
||||
.stat-value.medium { color: var(--medium); }
|
||||
.stat-value.ok { color: var(--low); }
|
||||
.stat-sub { font-size: .75rem; color: var(--text-muted); margin-top: .4rem; }
|
||||
|
||||
/* ── Section Title ── */
|
||||
.section-title {
|
||||
font-size: .9rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
/* ── Alert Table ── */
|
||||
.alerts-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.alerts-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.2rem 1.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.filter-group { display: flex; gap: .5rem; }
|
||||
.filter-btn {
|
||||
padding: .35rem .85rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: .8rem;
|
||||
cursor: pointer;
|
||||
transition: all .2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.filter-btn:hover, .filter-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th {
|
||||
padding: .9rem 1.4rem;
|
||||
text-align: left;
|
||||
font-size: .78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-card2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
tbody tr {
|
||||
border-bottom: 1px solid rgba(30,45,69,.5);
|
||||
transition: background .15s;
|
||||
}
|
||||
tbody tr:hover { background: var(--bg-card2); }
|
||||
tbody tr:last-child { border-bottom: none; }
|
||||
td {
|
||||
padding: 1rem 1.4rem;
|
||||
font-size: .88rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: .2rem .7rem;
|
||||
border-radius: 20px;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
.badge-CRITICAL { background: rgba(239,68,68,.15); color: #f87171; }
|
||||
.badge-HIGH { background: rgba(249,115,22,.15); color: #fb923c; }
|
||||
.badge-MEDIUM { background: rgba(234,179,8,.15); color: #facc15; }
|
||||
.badge-LOW { background: rgba(34,197,94,.15); color: #4ade80; }
|
||||
|
||||
.type-tag {
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Courier New', monospace;
|
||||
font-size: .78rem;
|
||||
color: var(--accent);
|
||||
background: rgba(59,130,246,.1);
|
||||
padding: .15rem .5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ip-text {
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Courier New', monospace;
|
||||
font-size: .83rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.count-badge {
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: .1rem .45rem;
|
||||
font-size: .78rem;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Courier New', monospace;
|
||||
}
|
||||
.detail-text {
|
||||
color: var(--text-muted);
|
||||
font-size: .83rem;
|
||||
max-width: 340px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ts-text {
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Courier New', monospace;
|
||||
font-size: .78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── IP Breakdown ── */
|
||||
.bottom-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
@media (max-width: 900px) { .bottom-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.4rem 1.6rem;
|
||||
}
|
||||
|
||||
.ip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: .6rem 0;
|
||||
border-bottom: 1px solid rgba(30,45,69,.5);
|
||||
}
|
||||
.ip-row:last-child { border-bottom: none; }
|
||||
.ip-info { display: flex; align-items: center; gap: .75rem; }
|
||||
.threat-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.alert-count-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
font-size: .82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.bar-track {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-fill { height: 100%; border-radius: 2px; background: var(--accent); }
|
||||
|
||||
/* ── Activity Log ── */
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
padding: .6rem 0;
|
||||
border-bottom: 1px solid rgba(30,45,69,.4);
|
||||
font-size: .83rem;
|
||||
}
|
||||
.log-entry:last-child { border-bottom: none; }
|
||||
.log-time { font-family: ui-monospace, 'Cascadia Code', 'Courier New', monospace; color: var(--text-muted); flex-shrink: 0; }
|
||||
.log-msg { color: var(--text); }
|
||||
.log-src { color: var(--accent); font-size: .78rem; margin-left: auto; flex-shrink: 0; }
|
||||
|
||||
/* ── No alerts ── */
|
||||
.empty-state {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
/* ── Refresh button ── */
|
||||
#refresh-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: .45rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: .82rem;
|
||||
font-family: inherit;
|
||||
transition: opacity .2s;
|
||||
}
|
||||
#refresh-btn:hover { opacity: .85; }
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-base); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo">
|
||||
<div class="logo-icon">🛡</div>
|
||||
Ghaymah — Log Analyzer
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<span><span class="status-dot"></span>Near Real-Time (30s)</span>
|
||||
<span>Last scan: <span id="last-updated">—</span></span>
|
||||
<button id="refresh-btn" onclick="loadAlerts()">↻ Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Alerts</div>
|
||||
<div class="stat-value ok" id="stat-total">0</div>
|
||||
<div class="stat-sub">Active threats</div>
|
||||
</div>
|
||||
<div class="stat-card critical">
|
||||
<div class="stat-label">Critical</div>
|
||||
<div class="stat-value critical" id="stat-critical">0</div>
|
||||
<div class="stat-sub">Immediate action required</div>
|
||||
</div>
|
||||
<div class="stat-card high">
|
||||
<div class="stat-label">High</div>
|
||||
<div class="stat-value high" id="stat-high">0</div>
|
||||
<div class="stat-sub">Investigate soon</div>
|
||||
</div>
|
||||
<div class="stat-card medium">
|
||||
<div class="stat-label">Medium</div>
|
||||
<div class="stat-value medium" id="stat-medium">0</div>
|
||||
<div class="stat-sub">Monitor closely</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Unique IPs</div>
|
||||
<div class="stat-value ok" id="stat-ips">0</div>
|
||||
<div class="stat-sub">Suspicious sources</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerts Table -->
|
||||
<div class="alerts-panel">
|
||||
<div class="alerts-header">
|
||||
<div class="section-title" style="margin:0">⚡ Security Alerts</div>
|
||||
<div class="filter-group">
|
||||
<button class="filter-btn active" onclick="filterAlerts('all', this)">All</button>
|
||||
<button class="filter-btn" onclick="filterAlerts('CRITICAL', this)">Critical</button>
|
||||
<button class="filter-btn" onclick="filterAlerts('HIGH', this)">High</button>
|
||||
<button class="filter-btn" onclick="filterAlerts('MEDIUM', this)">Medium</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Severity</th>
|
||||
<th>Type</th>
|
||||
<th>Source IP</th>
|
||||
<th>Detail</th>
|
||||
<th>Count</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="alerts-tbody"></tbody>
|
||||
</table>
|
||||
<div id="no-alerts" class="empty-state" style="display:none">
|
||||
✅ No alerts at this time
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Grid -->
|
||||
<div class="bottom-grid">
|
||||
|
||||
<!-- Top Threat IPs -->
|
||||
<div class="panel">
|
||||
<div class="section-title">🌐 Top Threat Sources</div>
|
||||
<div id="ip-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Log -->
|
||||
<div class="panel">
|
||||
<div class="section-title">📋 Recent Activity</div>
|
||||
<div id="activity-log"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ── Sample data used when alerts.json not available (demo mode) ──
|
||||
const DEMO_DATA = {
|
||||
generated_at: new Date().toISOString(),
|
||||
total_alerts: 5,
|
||||
critical_count: 2,
|
||||
high_count: 2,
|
||||
medium_count: 1,
|
||||
alerts: [
|
||||
{ id:1, timestamp: new Date(Date.now()-600000).toISOString(), last_seen: new Date(Date.now()-60000).toISOString(),
|
||||
source:"auth.log", type:"brute_force_ssh", severity:"CRITICAL", ip:"203.0.113.42",
|
||||
detail:"6 failed SSH login attempts detected", count:6, status:"open" },
|
||||
{ id:2, timestamp: new Date(Date.now()-540000).toISOString(), last_seen: new Date(Date.now()-30000).toISOString(),
|
||||
source:"nginx/access.log", type:"sql_injection_attempt", severity:"HIGH", ip:"203.0.113.42",
|
||||
detail:"Possible SQL injection detected in request path: /api/v1/users?id=1 UNION SELECT * FROM users--", count:1, status:"open" },
|
||||
{ id:3, timestamp: new Date(Date.now()-480000).toISOString(), last_seen: new Date(Date.now()-480000).toISOString(),
|
||||
source:"nginx/access.log", type:"repeated_http_errors", severity:"HIGH", ip:"203.0.113.42",
|
||||
detail:"3 HTTP error responses to IP in log window", count:3, status:"open" },
|
||||
{ id:4, timestamp: new Date(Date.now()-300000).toISOString(), last_seen: new Date(Date.now()-300000).toISOString(),
|
||||
source:"app/application.log", type:"application_error", severity:"CRITICAL", ip:"203.0.113.42",
|
||||
detail:"JWT verification failed for token eyJ0eXAi... from IP 203.0.113.42", count:1, status:"open" },
|
||||
{ id:5, timestamp: new Date(Date.now()-200000).toISOString(), last_seen: new Date(Date.now()-200000).toISOString(),
|
||||
source:"app/application.log", type:"application_error", severity:"MEDIUM", ip:"unknown",
|
||||
detail:"Unhandled exception in /api/v1/users/export: PermissionError", count:1, status:"open" },
|
||||
]
|
||||
};
|
||||
|
||||
let allAlerts = [];
|
||||
let currentFilter = 'all';
|
||||
|
||||
function formatTime(iso) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString('en-GB', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||
}
|
||||
|
||||
function timeSince(iso) {
|
||||
const sec = Math.floor((Date.now() - new Date(iso)) / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
if (sec < 3600) return `${Math.floor(sec/60)}m ago`;
|
||||
return `${Math.floor(sec/3600)}h ago`;
|
||||
}
|
||||
|
||||
function severityColor(s) {
|
||||
return {CRITICAL:'#ef4444', HIGH:'#f97316', MEDIUM:'#eab308', LOW:'#22c55e'}[s] || '#94a3b8';
|
||||
}
|
||||
|
||||
function renderAlerts(alerts) {
|
||||
const tbody = document.getElementById('alerts-tbody');
|
||||
const noAlerts = document.getElementById('no-alerts');
|
||||
const visible = currentFilter === 'all' ? alerts : alerts.filter(a => a.severity === currentFilter);
|
||||
|
||||
if (!visible.length) {
|
||||
tbody.innerHTML = '';
|
||||
noAlerts.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
noAlerts.style.display = 'none';
|
||||
|
||||
tbody.innerHTML = visible.map(a => `
|
||||
<tr>
|
||||
<td class="ts-text">${String(a.id).padStart(3,'0')}</td>
|
||||
<td><span class="badge badge-${a.severity}">${a.severity}</span></td>
|
||||
<td><span class="type-tag">${a.type}</span></td>
|
||||
<td><span class="ip-text">${a.ip}</span></td>
|
||||
<td><div class="detail-text" title="${a.detail}">${a.detail}</div>
|
||||
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.25rem">${a.source}</div></td>
|
||||
<td><span class="count-badge">×${a.count}</span></td>
|
||||
<td class="ts-text">${timeSince(a.last_seen)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderIPList(alerts) {
|
||||
const ipMap = {};
|
||||
alerts.forEach(a => {
|
||||
if (a.ip === 'unknown') return;
|
||||
if (!ipMap[a.ip]) ipMap[a.ip] = { count:0, maxSev:'LOW' };
|
||||
ipMap[a.ip].count += a.count;
|
||||
const order = {CRITICAL:4,HIGH:3,MEDIUM:2,LOW:1};
|
||||
if ((order[a.severity]||0) > (order[ipMap[a.ip].maxSev]||0)) ipMap[a.ip].maxSev = a.severity;
|
||||
});
|
||||
|
||||
const sorted = Object.entries(ipMap).sort((a,b) => b[1].count - a[1].count).slice(0,8);
|
||||
const maxCount = sorted[0]?.[1]?.count || 1;
|
||||
|
||||
document.getElementById('ip-list').innerHTML = sorted.length
|
||||
? sorted.map(([ip,{count,maxSev}]) => `
|
||||
<div class="ip-row">
|
||||
<div class="ip-info">
|
||||
<div class="threat-dot" style="background:${severityColor(maxSev)}"></div>
|
||||
<span class="ip-text">${ip}</span>
|
||||
</div>
|
||||
<div class="alert-count-bar">
|
||||
<div class="bar-track"><div class="bar-fill" style="width:${Math.round(count/maxCount*100)}%;background:${severityColor(maxSev)}"></div></div>
|
||||
<span>${count} events</span>
|
||||
</div>
|
||||
</div>`).join('')
|
||||
: '<div style="color:var(--text-muted);font-size:.85rem;padding:.5rem 0">No suspicious IPs detected</div>';
|
||||
}
|
||||
|
||||
function renderActivityLog(alerts) {
|
||||
const recent = [...alerts]
|
||||
.sort((a,b) => new Date(b.last_seen) - new Date(a.last_seen))
|
||||
.slice(0,8);
|
||||
|
||||
document.getElementById('activity-log').innerHTML = recent.map(a => `
|
||||
<div class="log-entry">
|
||||
<span class="log-time">${formatTime(a.last_seen)}</span>
|
||||
<span class="log-msg" style="color:${severityColor(a.severity)}">[${a.severity}]</span>
|
||||
<span class="log-msg">${a.type.replace(/_/g,' ')}</span>
|
||||
<span class="log-src">${a.source}</span>
|
||||
</div>`).join('') || '<div style="color:var(--text-muted);font-size:.85rem">No activity</div>';
|
||||
}
|
||||
|
||||
function updateStats(data) {
|
||||
document.getElementById('stat-total').textContent = data.total_alerts;
|
||||
document.getElementById('stat-critical').textContent = data.critical_count;
|
||||
document.getElementById('stat-high').textContent = data.high_count;
|
||||
document.getElementById('stat-medium').textContent = data.medium_count;
|
||||
|
||||
const uniqueIPs = new Set(data.alerts.map(a=>a.ip).filter(i=>i!=='unknown')).size;
|
||||
document.getElementById('stat-ips').textContent = uniqueIPs;
|
||||
|
||||
document.getElementById('last-updated').textContent =
|
||||
new Date(data.generated_at).toLocaleTimeString('en-GB');
|
||||
}
|
||||
|
||||
async function loadAlerts() {
|
||||
try {
|
||||
const res = await fetch('./alerts.json?v=' + Date.now());
|
||||
const data = await res.json();
|
||||
allAlerts = data.alerts || [];
|
||||
updateStats(data);
|
||||
renderAlerts(allAlerts);
|
||||
renderIPList(allAlerts);
|
||||
renderActivityLog(allAlerts);
|
||||
} catch {
|
||||
// Demo mode — use built-in sample data
|
||||
allAlerts = DEMO_DATA.alerts;
|
||||
updateStats(DEMO_DATA);
|
||||
renderAlerts(allAlerts);
|
||||
renderIPList(allAlerts);
|
||||
renderActivityLog(allAlerts);
|
||||
}
|
||||
}
|
||||
|
||||
function filterAlerts(severity, btn) {
|
||||
currentFilter = severity;
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderAlerts(allAlerts);
|
||||
}
|
||||
|
||||
// Auto-refresh every 30s
|
||||
loadAlerts();
|
||||
setInterval(loadAlerts, 30000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
324
q4-siem/deploy-guide.md
Normal file
324
q4-siem/deploy-guide.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Lightweight SIEM Deployment Guide on Ghaymah Cloud Infrastructure
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how to deploy the `siem.py` log analyzer and `dashboard.html` on Ghaymah cloud infrastructure, using Block Storage to persist logs and alert data.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
BS["🗄️ Block Storage Volume\n/mnt/logs"]
|
||||
BS --> AL["/mnt/logs/auth.log"]
|
||||
BS --> NL["/mnt/logs/nginx/access.log"]
|
||||
BS --> AP["/mnt/logs/app/application.log"]
|
||||
|
||||
AL & NL & AP --> SIEM["⚙️ siem.py\nsystemd service"]
|
||||
|
||||
SIEM -->|"writes every 30s"| AJ["📄 alerts.json\n/var/www/siem/"]
|
||||
|
||||
AJ -->|"served by Nginx"| DASH["🖥️ dashboard.html\nNear Real-Time UI"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create and Attach Block Storage
|
||||
|
||||
In the Ghaymah Cloud Console:
|
||||
|
||||
1. Go to **Storage → Block Storage → Create Volume**
|
||||
2. Set name: `siem-logs-volume`
|
||||
3. Set size: `50 GB` (adjust based on log retention needs)
|
||||
4. Attach to your cloud instance
|
||||
|
||||
Then format and mount the volume:
|
||||
|
||||
```bash
|
||||
# Find the new volume (usually /dev/sdb or /dev/vdb)
|
||||
lsblk
|
||||
|
||||
# Format the volume
|
||||
sudo mkfs.ext4 /dev/sdb
|
||||
|
||||
# Create mount point
|
||||
sudo mkdir -p /mnt/logs
|
||||
|
||||
# Mount the volume
|
||||
sudo mount /dev/sdb /mnt/logs
|
||||
|
||||
# Persist mount across reboots — use UUID to avoid /dev/sdX changes after reboot
|
||||
# First find the UUID:
|
||||
sudo blkid /dev/sdb
|
||||
# Then replace UUID=xxxx with the actual value shown:
|
||||
echo 'UUID=xxxx-xxxx /mnt/logs ext4 defaults,nofail 0 2' | sudo tee -a /etc/fstab
|
||||
|
||||
# Create log directories
|
||||
sudo mkdir -p /mnt/logs/nginx /mnt/logs/app
|
||||
sudo chown -R $USER:$USER /mnt/logs
|
||||
```
|
||||
|
||||
> **Encryption:** Enable volume encryption during Block Storage creation in the Ghaymah console before writing any data to the volume.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Configure Services to Write Logs to Block Storage
|
||||
|
||||
### Nginx
|
||||
```bash
|
||||
# Edit nginx.conf to write access log to block storage
|
||||
sudo nano /etc/nginx/nginx.conf
|
||||
# Change:
|
||||
# access_log /var/log/nginx/access.log;
|
||||
# To:
|
||||
# access_log /mnt/logs/nginx/access.log;
|
||||
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Application (Docker)
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
api:
|
||||
volumes:
|
||||
- /mnt/logs/app:/var/log/app
|
||||
environment:
|
||||
LOG_PATH: /var/log/app/application.log
|
||||
```
|
||||
|
||||
> **Secrets management note:** For production deployments, avoid plain `.env` files on disk.
|
||||
> Prefer **Kubernetes Secrets** (mounted as env vars or files) or a cloud secret manager
|
||||
> (e.g., HashiCorp Vault, AWS Secrets Manager, or your cloud provider's equivalent).
|
||||
> `.env` files are acceptable for local development but should never be committed to Git.
|
||||
|
||||
### SSH Auth Log Forwarding
|
||||
```bash
|
||||
# Create the auth log file with correct permissions for rsyslog
|
||||
sudo touch /mnt/logs/auth.log
|
||||
sudo chown syslog:adm /mnt/logs/auth.log
|
||||
sudo chmod 640 /mnt/logs/auth.log
|
||||
|
||||
# Direct rsyslog to write auth logs to block storage
|
||||
echo 'auth,authpriv.* /mnt/logs/auth.log' | sudo tee /etc/rsyslog.d/99-siem.conf
|
||||
sudo systemctl restart rsyslog
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Deploy siem.py as a Systemd Service
|
||||
|
||||
```bash
|
||||
# Install Python 3
|
||||
sudo apt-get update && sudo apt-get install -y python3
|
||||
|
||||
# Create a shared group so both siem.py (ubuntu) and nginx (www-data) can access alerts.json
|
||||
sudo groupadd siem
|
||||
sudo usermod -aG siem ubuntu
|
||||
sudo usermod -aG siem www-data
|
||||
|
||||
# Copy SIEM files
|
||||
sudo mkdir -p /opt/siem /var/www/siem
|
||||
sudo cp q4-siem/siem.py /opt/siem/siem.py
|
||||
sudo cp q4-siem/dashboard.html /var/www/siem/dashboard.html
|
||||
|
||||
# Set permissions: ubuntu writes, www-data reads via shared siem group
|
||||
sudo touch /var/www/siem/alerts.json
|
||||
sudo chown ubuntu:siem /var/www/siem/alerts.json
|
||||
sudo chmod 640 /var/www/siem/alerts.json
|
||||
|
||||
# Edit log paths in siem.py to match block storage
|
||||
sudo nano /opt/siem/siem.py
|
||||
# Set:
|
||||
# LOG_SOURCES = {
|
||||
# "auth": "/mnt/logs/auth.log",
|
||||
# "nginx": "/mnt/logs/nginx/access.log",
|
||||
# "app": "/mnt/logs/app/application.log",
|
||||
# }
|
||||
# OUTPUT_FILE = "/var/www/siem/alerts.json"
|
||||
```
|
||||
|
||||
Create the systemd service:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/siem.service
|
||||
[Unit]
|
||||
Description=Ghaymah SIEM Log Analyzer
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/opt/siem
|
||||
ExecStart=/usr/bin/python3 /opt/siem/siem.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable siem
|
||||
sudo systemctl start siem
|
||||
sudo systemctl status siem
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Serve the Dashboard via Nginx
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/siem
|
||||
server {
|
||||
listen 8443 ssl;
|
||||
server_name siem.ghaymah.systems;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/siem.ghaymah.systems/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/siem.ghaymah.systems/privkey.pem;
|
||||
|
||||
root /var/www/siem;
|
||||
index dashboard.html;
|
||||
|
||||
# Restrict access to internal network only
|
||||
allow 10.0.0.0/8;
|
||||
allow 192.168.0.0/16;
|
||||
deny all;
|
||||
|
||||
# alerts.json is served to the dashboard browser client.
|
||||
# The parent allow/deny block already restricts access to internal IPs only.
|
||||
location = /alerts.json {
|
||||
allow 10.0.0.0/8;
|
||||
allow 192.168.0.0/16;
|
||||
deny all;
|
||||
add_header Content-Type application/json;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/siem /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Log Rotation
|
||||
|
||||
```bash
|
||||
# /etc/logrotate.d/siem-logs
|
||||
/mnt/logs/*.log
|
||||
/mnt/logs/app/*.log {
|
||||
daily
|
||||
rotate 30
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 640 syslog adm
|
||||
postrotate
|
||||
systemctl restart rsyslog
|
||||
endscript
|
||||
}
|
||||
|
||||
/mnt/logs/nginx/*.log {
|
||||
daily
|
||||
rotate 30
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 640 www-data adm
|
||||
postrotate
|
||||
systemctl reload nginx
|
||||
endscript
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Check SIEM is running
|
||||
sudo systemctl status siem
|
||||
|
||||
# Check alerts output
|
||||
cat /var/www/siem/alerts.json | python3 -m json.tool | head -30
|
||||
|
||||
# View SIEM logs
|
||||
sudo journalctl -u siem -f
|
||||
|
||||
# Test dashboard
|
||||
curl -k https://siem.ghaymah.systems:8443/dashboard.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
| Control | Implementation |
|
||||
|---|---|
|
||||
| Dashboard access | Restricted to internal IP range (10.0.0.0/8) |
|
||||
| HTTPS | TLS required, no HTTP access |
|
||||
| `alerts.json` access | Internal IPs only via `allow 10.0.0.0/8; deny all;` inside `location = /alerts.json` |
|
||||
| File permissions | `alerts.json` owned `ubuntu:siem`, mode `640` — owner writes, group (nginx) reads, others none |
|
||||
| Block storage | Encrypted at rest (enable during volume creation in Ghaymah console) |
|
||||
| Audit logs | All SIEM activity written to systemd journal |
|
||||
|
||||
---
|
||||
|
||||
## Future Integrations
|
||||
|
||||
The SIEM is designed to grow. The following integrations can be added as next steps:
|
||||
|
||||
| Integration | How |
|
||||
|---|---|
|
||||
| **AlertManager webhook** | Extend `siem.py` to POST critical alerts to a webhook endpoint. Pairs directly with the Prometheus alert rules in `q2-attack-simulation/alert-rule.yml`. |
|
||||
| **Slack notifications** | Send alerts to a `#security-alerts` channel via Slack Incoming Webhooks when severity is CRITICAL or HIGH. |
|
||||
| **PagerDuty / on-call** | Route CRITICAL alerts to PagerDuty for 24/7 on-call escalation using the Events API. |
|
||||
| **Email alerts** | Use `smtplib` in `siem.py` to send summary emails to the security team at the end of each scan cycle. |
|
||||
| **Cloud monitoring export** | Ship `alerts.json` to a cloud-native monitoring service (e.g., Ghaymah monitoring, Datadog, or CloudWatch) for long-term trend analysis and dashboards. |
|
||||
| **Log shipping to SIEM platform** | Forward structured logs to a dedicated SIEM platform (e.g., Elastic/OpenSearch) for retention and correlation across multiple services. |
|
||||
|
||||
---
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
Block Storage volumes are the persistence layer for all logs and alert data. Schedule regular snapshots to protect against data loss.
|
||||
|
||||
**Recommended snapshot schedule:**
|
||||
|
||||
| Frequency | Retention | Purpose |
|
||||
|---|---|---|
|
||||
| Daily | 7 days | Short-term recovery from accidental deletion or corruption |
|
||||
| Weekly | 4 weeks | Recovery from persistent misconfiguration issues |
|
||||
| Monthly | 3 months | Compliance and long-term trend analysis |
|
||||
|
||||
**How to enable in Ghaymah console:**
|
||||
|
||||
1. Go to **Storage → Block Storage → `siem-logs-volume`**
|
||||
2. Navigate to **Snapshots → Create Snapshot Policy**
|
||||
3. Set a daily schedule with 7-day retention
|
||||
4. Enable email notification on snapshot failure
|
||||
|
||||
**Restore procedure (monthly test recommended):**
|
||||
|
||||
```bash
|
||||
# Restore a snapshot to a new volume in the Ghaymah console,
|
||||
# then mount it temporarily to verify data integrity:
|
||||
sudo mkdir -p /mnt/logs-restore
|
||||
sudo mount /dev/sdc /mnt/logs-restore
|
||||
ls -lh /mnt/logs-restore/
|
||||
sudo umount /mnt/logs-restore
|
||||
```
|
||||
|
||||
> Restore tests should be performed monthly to confirm backups are valid and recovery time is within acceptable limits.
|
||||
302
q4-siem/siem.py
Normal file
302
q4-siem/siem.py
Normal file
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
siem.py — Simplified SIEM for Ghaymah Storage
|
||||
Collects logs from 3 sources, analyzes for suspicious patterns,
|
||||
and exposes alerts as JSON for the dashboard.
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import random
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Configuration ─────────────────────────────────────────────
|
||||
LOG_SOURCES = {
|
||||
"auth": "/mnt/logs/auth.log",
|
||||
"nginx": "/mnt/logs/nginx/access.log",
|
||||
"app": "/mnt/logs/app/application.log",
|
||||
}
|
||||
OUTPUT_FILE = "/var/www/siem/alerts.json"
|
||||
ALERT_THRESHOLD_FAIL = 5 # Failed logins before alert
|
||||
ALERT_THRESHOLD_RATE = 100 # HTTP requests/min before alert
|
||||
SCAN_INTERVAL_SEC = 30 # How often to re-scan logs
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s"
|
||||
)
|
||||
log = logging.getLogger("siem")
|
||||
|
||||
# ─── Patterns ──────────────────────────────────────────────────
|
||||
PATTERNS = {
|
||||
"brute_force": re.compile(
|
||||
r"Failed password for .+ from (?P<ip>\d+\.\d+\.\d+\.\d+)"
|
||||
),
|
||||
"invalid_user": re.compile(
|
||||
r"Invalid user .+ from (?P<ip>\d+\.\d+\.\d+\.\d+)"
|
||||
),
|
||||
"nginx_request": re.compile(
|
||||
r'(?P<ip>\d+\.\d+\.\d+\.\d+) .+ "(?P<method>\w+) (?P<path>\S+) HTTP.+" (?P<status>\d{3})'
|
||||
),
|
||||
"app_error": re.compile(
|
||||
r"\[(?P<level>ERROR|CRITICAL)\] (?P<msg>.+)"
|
||||
),
|
||||
"sql_injection": re.compile(
|
||||
r"\b(select|union|insert|drop|update|delete|benchmark|sleep)\b\s*[\(%'\"()]",
|
||||
re.IGNORECASE
|
||||
),
|
||||
}
|
||||
|
||||
# ─── State ─────────────────────────────────────────────────────
|
||||
failed_logins: dict = defaultdict(int) # ip → count
|
||||
request_counts: dict = defaultdict(int) # ip → count/window
|
||||
sql_attempts: list = []
|
||||
alerts: list = []
|
||||
|
||||
|
||||
def generate_sample_logs():
|
||||
"""
|
||||
Generate sample log lines (used when real log files don't exist).
|
||||
This allows the SIEM to demonstrate functionality without root access.
|
||||
"""
|
||||
attacker_ip = "185.220.100.42"
|
||||
legit_ip = "192.168.1.10"
|
||||
internal_ip = "10.0.0.5"
|
||||
sample_lines = {
|
||||
"auth": [
|
||||
f"Jul 26 19:01:00 server sshd[1234]: Failed password for root from {attacker_ip} port 54321 ssh2",
|
||||
f"Jul 26 19:01:02 server sshd[1234]: Failed password for admin from {attacker_ip} port 54321 ssh2",
|
||||
f"Jul 26 19:01:04 server sshd[1234]: Failed password for ubuntu from {attacker_ip} port 54321 ssh2",
|
||||
f"Jul 26 19:01:06 server sshd[1234]: Failed password for user from {attacker_ip} port 54321 ssh2",
|
||||
f"Jul 26 19:01:08 server sshd[1234]: Failed password for deploy from {attacker_ip} port 54321 ssh2",
|
||||
f"Jul 26 19:01:10 server sshd[1234]: Failed password for git from {attacker_ip} port 54321 ssh2",
|
||||
f"Jul 26 19:01:12 server sshd[1234]: Invalid user hacker from {attacker_ip} port 54321",
|
||||
f"Jul 26 19:05:00 server sshd[5678]: Accepted password for deploy from {legit_ip} port 22122 ssh2",
|
||||
],
|
||||
"nginx": [
|
||||
f'185.220.100.42 - - [26/Jul/2026:19:01:00 +0300] "POST /api/v1/auth/login HTTP/1.1" 401 120',
|
||||
f'185.220.100.42 - - [26/Jul/2026:19:01:01 +0300] "POST /api/v1/auth/login HTTP/1.1" 401 120',
|
||||
f'185.220.100.42 - - [26/Jul/2026:19:01:02 +0300] "POST /api/v1/auth/login HTTP/1.1" 401 120',
|
||||
f'185.220.100.42 - - [26/Jul/2026:19:01:03 +0300] "GET /api/v1/users?id=1 UNION SELECT * FROM users-- HTTP/1.1" 400 80',
|
||||
f'192.168.1.10 - - [26/Jul/2026:19:02:00 +0300] "GET /dashboard HTTP/1.1" 200 4096',
|
||||
f'10.0.0.5 - - [26/Jul/2026:19:03:00 +0300] "GET /health HTTP/1.1" 200 32',
|
||||
],
|
||||
"app": [
|
||||
"[ERROR] Database connection timeout after 30s — retrying",
|
||||
"[CRITICAL] Unhandled exception in /api/v1/users/export: PermissionError",
|
||||
"[ERROR] JWT verification failed for token eyJ0eXAi... from IP 185.220.100.42",
|
||||
"[INFO] User admin@ghaymah.systems logged in from 192.168.1.10",
|
||||
"[ERROR] SQL query failed: syntax error near 'UNION'",
|
||||
],
|
||||
}
|
||||
return sample_lines
|
||||
|
||||
|
||||
def read_log_source(name: str, path: str) -> list[str]:
|
||||
"""Read lines from a log file, or fall back to sample data."""
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
log.info(f"[{name}] Read {len(lines)} lines from {path}")
|
||||
return lines
|
||||
except PermissionError:
|
||||
log.warning(f"[{name}] Permission denied reading {path} — using sample data")
|
||||
else:
|
||||
log.warning(f"[{name}] File not found: {path} — using sample data")
|
||||
|
||||
# Return sample data
|
||||
return generate_sample_logs().get(name, [])
|
||||
|
||||
|
||||
def analyze_auth_log(lines: list[str]):
|
||||
"""Detect brute force SSH attempts."""
|
||||
for line in lines:
|
||||
m = PATTERNS["brute_force"].search(line)
|
||||
if m:
|
||||
ip = m.group("ip")
|
||||
failed_logins[ip] += 1
|
||||
|
||||
m = PATTERNS["invalid_user"].search(line)
|
||||
if m:
|
||||
ip = m.group("ip")
|
||||
failed_logins[ip] += 1
|
||||
|
||||
for ip, count in failed_logins.items():
|
||||
if count >= ALERT_THRESHOLD_FAIL:
|
||||
create_alert(
|
||||
source="auth.log",
|
||||
alert_type="brute_force_ssh",
|
||||
severity="CRITICAL",
|
||||
ip=ip,
|
||||
detail=f"{count} failed SSH login attempts detected",
|
||||
)
|
||||
|
||||
|
||||
def analyze_nginx_log(lines: list[str]):
|
||||
"""Detect HTTP flooding and SQL injection attempts."""
|
||||
ip_requests = defaultdict(list)
|
||||
|
||||
for line in lines:
|
||||
m = PATTERNS["nginx_request"].search(line)
|
||||
if m:
|
||||
ip = m.group("ip")
|
||||
path = m.group("path")
|
||||
status = m.group("status")
|
||||
ip_requests[ip].append((path, status))
|
||||
|
||||
# Check for SQL injection in path
|
||||
if PATTERNS["sql_injection"].search(path):
|
||||
create_alert(
|
||||
source="nginx/access.log",
|
||||
alert_type="sql_injection_attempt",
|
||||
severity="HIGH",
|
||||
ip=ip,
|
||||
detail=f"Possible SQL injection detected in request path: {path[:120]}",
|
||||
)
|
||||
|
||||
# Check high request rate
|
||||
for ip, reqs in ip_requests.items():
|
||||
failed = [r for r in reqs if r[1].startswith(("4", "5"))]
|
||||
if len(reqs) >= ALERT_THRESHOLD_RATE:
|
||||
create_alert(
|
||||
source="nginx/access.log",
|
||||
alert_type="http_flood",
|
||||
severity="HIGH",
|
||||
ip=ip,
|
||||
detail=f"{len(reqs)} requests detected in log window ({len(failed)} errors)",
|
||||
)
|
||||
elif len(failed) >= ALERT_THRESHOLD_FAIL:
|
||||
create_alert(
|
||||
source="nginx/access.log",
|
||||
alert_type="repeated_http_errors",
|
||||
severity="MEDIUM",
|
||||
ip=ip,
|
||||
detail=f"{len(failed)} HTTP error responses to IP in log window",
|
||||
)
|
||||
|
||||
|
||||
def analyze_app_log(lines: list[str]):
|
||||
"""Detect application-level errors and anomalies."""
|
||||
for line in lines:
|
||||
m = PATTERNS["app_error"].search(line)
|
||||
if m:
|
||||
level = m.group("level")
|
||||
msg = m.group("msg")
|
||||
|
||||
# Extract IP if present
|
||||
ip_match = re.search(r"\d+\.\d+\.\d+\.\d+", msg)
|
||||
ip = ip_match.group(0) if ip_match else "unknown"
|
||||
|
||||
if "JWT" in msg or "token" in msg.lower():
|
||||
severity = "HIGH"
|
||||
elif level == "CRITICAL":
|
||||
severity = "CRITICAL"
|
||||
else:
|
||||
severity = "MEDIUM"
|
||||
create_alert(
|
||||
source="app/application.log",
|
||||
alert_type="application_error",
|
||||
severity=severity,
|
||||
ip=ip,
|
||||
detail=msg[:200],
|
||||
)
|
||||
|
||||
|
||||
def create_alert(source: str, alert_type: str, severity: str, ip: str, detail: str):
|
||||
"""Create a deduplicated alert entry."""
|
||||
# Deduplicate: only merge if same type + IP + first 60 chars of detail match.
|
||||
# This prevents a low-severity alert from absorbing a different high-severity one.
|
||||
for existing in alerts:
|
||||
if (existing["type"] == alert_type
|
||||
and existing["ip"] == ip
|
||||
and existing["detail"][:60] == detail[:60]):
|
||||
existing["count"] += 1
|
||||
existing["last_seen"] = datetime.now().isoformat()
|
||||
return
|
||||
|
||||
alert_entry = {
|
||||
"id": len(alerts) + 1,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"last_seen": datetime.now().isoformat(),
|
||||
"source": source,
|
||||
"type": alert_type,
|
||||
"severity": severity,
|
||||
"ip": ip,
|
||||
"detail": detail,
|
||||
"count": 1,
|
||||
"status": "open",
|
||||
}
|
||||
alerts.append(alert_entry)
|
||||
log.warning(f"[ALERT] [{severity}] {alert_type} — IP: {ip} — {detail[:80]}")
|
||||
|
||||
|
||||
def run_scan():
|
||||
"""Main scan loop — reads all 3 log sources and runs analysis.
|
||||
|
||||
NOTE: This implementation is stateless — alert history resets on each
|
||||
scan cycle. This is acceptable for demo/project use. Production deployments
|
||||
should persist alert history in a database or SIEM backend (e.g.,
|
||||
Elasticsearch/OpenSearch) to retain trends and support correlation rules.
|
||||
"""
|
||||
global alerts, failed_logins
|
||||
alerts = []
|
||||
failed_logins = defaultdict(int)
|
||||
|
||||
log.info("Starting SIEM scan cycle...")
|
||||
|
||||
# Source 1: Auth logs
|
||||
auth_lines = read_log_source("auth", LOG_SOURCES["auth"])
|
||||
analyze_auth_log(auth_lines)
|
||||
|
||||
# Source 2: Nginx access logs
|
||||
nginx_lines = read_log_source("nginx", LOG_SOURCES["nginx"])
|
||||
analyze_nginx_log(nginx_lines)
|
||||
|
||||
# Source 3: Application logs
|
||||
app_lines = read_log_source("app", LOG_SOURCES["app"])
|
||||
analyze_app_log(app_lines)
|
||||
|
||||
# Sort by severity
|
||||
severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
||||
alerts.sort(key=lambda a: severity_order.get(a["severity"], 9))
|
||||
|
||||
output = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"total_alerts": len(alerts),
|
||||
"critical_count": sum(1 for a in alerts if a["severity"] == "CRITICAL"),
|
||||
"high_count": sum(1 for a in alerts if a["severity"] == "HIGH"),
|
||||
"medium_count": sum(1 for a in alerts if a["severity"] == "MEDIUM"),
|
||||
"alerts": alerts,
|
||||
}
|
||||
|
||||
with open(OUTPUT_FILE, "w") as f:
|
||||
json.dump(output, f, indent=2)
|
||||
|
||||
log.info(f"Scan complete. {len(alerts)} alerts written to {OUTPUT_FILE}")
|
||||
return output
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
log.info("=== Ghaymah SIEM Starting ===")
|
||||
log.info(f"Monitoring: {', '.join(LOG_SOURCES.values())}")
|
||||
log.info(f"Output: {OUTPUT_FILE}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
results = run_scan()
|
||||
print(f"\n[{datetime.now().strftime('%H:%M:%S')}] Alerts: {results['total_alerts']} "
|
||||
f"(CRITICAL: {results['critical_count']}, HIGH: {results['high_count']}, "
|
||||
f"MEDIUM: {results['medium_count']})")
|
||||
except KeyboardInterrupt:
|
||||
log.info("SIEM stopped by user.")
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f"Scan error: {e}")
|
||||
|
||||
time.sleep(SCAN_INTERVAL_SEC)
|
||||
المرجع في مشكلة جديدة
حظر مستخدم