Initial commit

This commit is contained in:
Blake Ridgway 2025-07-12 10:06:52 -05:00
commit ceda8957b7
14 changed files with 408 additions and 0 deletions

42
.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv/
*/.pytest_cache/
.mypy_cache/
.dmypy.json
celerybeat-schedule
.vscode/
# Flask-specific
instance/
.flaskenv
# Database
# For SQLite, the database file
siem.db
*.sqlite3
*.db
*.db-* # for potential Flask-SQLAlchemy-generated temp files
# Logs
*.log
logs/
run.log
# IDEs
.idea/ # PyCharm
*.iml # PyCharm
*.ipr # PyCharm
*.iws # PyCharm
.vscode/ # VS Code (if not managed by specific user settings)
# OS generated files
.DS_Store
.Trashes
Thumbs.db

8
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View file

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

6
.idea/misc.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.11 (sentinel)" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/sentinel.iml" filepath="$PROJECT_DIR$/.idea/sentinel.iml" />
</modules>
</component>
</project>

10
.idea/sentinel.iml generated Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.11 (sentinel)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

14
README.md Normal file
View file

@ -0,0 +1,14 @@
# Sentinel
**Sentinel** is a lightweight Security Information and Event Management (SIEM) application built with Flask, designed to collect and display network events from various sources using SNMP Traps and Syslog. It aims to provide basic visibility into network activity, security incidents, and operational events.
## Features
* **SNMP Trap Listener**: Receives and processes SNMP traps (v1/v2c) from network devices.
* **Syslog Listener**: Collects syslog messages over UDP from firewalls, routers, and other logging sources.
* **Unified Event Storage**: Stores both SNMP and Syslog events in a SQLite database for easy access and querying.
* **Event Normalization & Enrichment**:
* Maps common SNMP OIDs to human-readable names.
* Assigns severity (Critical, High, Medium, Low, Informational) to events based on predefined rules for both SNMP and Syslog messages.
* **Web-based Dashboard**: A simple Flask web interface to view recent collected events, including their type, source, and assigned severity.
* **Background Listeners**: SNMP and Syslog listeners run in separate threads, allowing continuous collection while the Flask web server operates.

26
app.py Normal file
View file

@ -0,0 +1,26 @@
from flask import Flask, render_template
from models import db, Event
from snmp_listener import start_snmp_listener_in_thread
from syslog_listener import start_syslog_listener_in_thread
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///siem.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
initialized = False
@app.route('/')
def index():
global initialized
if not initialized:
with app.app_context():
db.create_all()
start_snmp_listener_in_thread(port=1162)
start_syslog_listener_in_thread(port=1514)
initialized = True
events = Event.query.order_by(Event.timestamp.desc()).limit(100).all()
return render_template('index.html', events=events)
if __name__ == '__main__':
app.run(debug=True)

100
event_processor.py Normal file
View file

@ -0,0 +1,100 @@
from models import db, Event
import re
SNMP_OID_MAPPING = {
".1.3.6.1.6.3.1.1.5.1": "SNMP Cold Start",
".1.3.6.1.6.3.1.1.5.2": "SNMP Warm Start",
".1.3.6.1.6.3.1.1.5.3": "SNMP Link Down",
".1.3.6.1.6.3.1.1.5.4": "SNMP Link Up",
".1.3.6.1.6.3.1.1.5.5": "SNMP Authentication Failure",
".1.3.6.1.6.3.1.1.5.6": "SNMP EGP Neighbor Loss",
# TODO Add FortiGate/pfSense
}
SEVERITY_RULES = {
"snmp_trap": {
"critical": [
".1.3.6.1.6.3.1.1.5.3", # Link Down
".1.3.6.1.6.3.1.1.5.5", # Authentication Failure
"fail", "error", "down", "critical", "unauthorized", "deny"
],
"high": [
".1.3.6.1.6.3.1.1.5.1", # Cold Start
"warn", "warning", "threshold", "exceeded"
],
"informational": [
".1.3.6.1.6.3.1.1.5.4", # Link Up
"up", "start", "config", "change", "success", "info"
]
},
"syslog": {
"critical": [
r"deny\s", r"rejected\s", r"failed\slogin", r"authentication\sfailed",
r"intrusion\sdetected", r"critical\serror"
],
"high": [
r"failed\s", r"blocked\s", r"unauthorized", r"warning", r"error"
],
"informational": [
r"login\ssuccess", r"accepted", r"connected", r"info", r"start", r"stop", r"status"
]
}
}
def get_event_severity(event_type, identifier, value=""):
"""
Determines the severity of an event based on its type, OID/keyword, and value.
Identifier can be OID for SNMP, or a keyword for Syslog.
"""
rules = SEVERITY_RULES.get(event_type, {})
# Check Critical rules
for pattern in rules.get("critical", []):
if event_type == "snmp_trap" and identifier == pattern:
return "Critical"
if event_type == "syslog" and re.search(pattern, value, re.IGNORECASE):
return "Critical"
# Check High rules
for pattern in rules.get("high", []):
if event_type == "snmp_trap" and identifier == pattern:
return "High"
if event_type == "syslog" and re.search(pattern, value, re.IGNORECASE):
return "High"
# Check Informational rules
for pattern in rules.get("informational", []):
if event_type == "snmp_trap" and identifier == pattern:
return "Informational"
if event_type == "syslog" and re.search(pattern, value, re.IGNORECASE):
return "Informational"
return "Unknown" # Default if no rule matches
def process_event_for_enrichment(event_id):
"""
Fetches an event by ID, enriches it, and updates it in the database.
This function should be called within a Flask app context.
"""
with db.session.begin_nested():
event = db.session.get(Event, event_id)
if not event:
print(f"Event with ID {event_id} not found for enrichment.")
return
original_oid_or_value = ""
if event.event_type == "snmp_trap":
if event.oid:
event.value_original_oid = event.oid
event.oid = SNMP_OID_MAPPING.get(event.oid, event.oid)
original_oid_or_value = event.value_original_oid
event.severity = get_event_severity(event.event_type, original_oid_or_value, event.value)
elif event.event_type == "syslog":
original_oid_or_value = event.value # Use full message for rules
event.severity = get_event_severity(event.event_type, None, event.value)
db.session.add(event)
db.session.commit()
print(f"Event {event.id} enriched: Type={event.event_type}, Severity={event.severity}")

18
models.py Normal file
View file

@ -0,0 +1,18 @@
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
db = SQLAlchemy()
class Event(db.Model):
id = db.Column(db.Integer, primary_key=True)
timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
source_ip = db.Column(db.String(45), nullable=False)
event_type = db.Column(db.String(50), nullable=False)
protocol = db.Column(db.String(10), nullable=False)
oid = db.Column(db.String(255), nullable=True)
value_original_oid = db.Column(db.String(255), nullable=True)
value = db.Column(db.String(1024), nullable=False)
severity = db.Column(db.String(20), default="Unknown", nullable=False)
def __repr__(self):
return f'<Event {self.id} Type: {self.event_type} Source: {self.source_ip} Severity: {self.severity}>'

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
Flask
Flask-SQLAlchemy
pysnmp

61
snmp_listener.py Normal file
View file

@ -0,0 +1,61 @@
import asyncio
import threading
from datetime import datetime
from pysnmp.entity import engine, config
from pysnmp.carrier.asyncio.dgram import udp
from pysnmp.entity.rfc3413 import ntfrcv
from flask import Flask
from models import db, Event
from event_processor import process_event_for_enrichment
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///siem.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
def trap_callback(snmpEngine, stateReference, contextEngineId, contextName,
varBinds, cbCtx):
source_ip = "unknown"
timestamp = datetime.utcnow()
with app.app_context():
event_ids_to_process = []
for name, val in varBinds:
event = Event(
timestamp=timestamp,
source_ip=source_ip,
event_type="snmp_trap",
protocol="udp",
oid=name.prettyPrint(),
value=val.prettyPrint(),
severity="Unknown"
)
db.session.add(event)
db.session.flush()
event_ids_to_process.append(event.id)
db.session.commit()
print(f'Received SNMP Trap (source IP: {source_ip})')
for event_id in event_ids_to_process:
process_event_for_enrichment(event_id)
async def run_snmp_listener(port=1162):
snmpEngine = engine.SnmpEngine()
config.addTransport(
snmpEngine,
udp.domainName,
udp.UdpAsyncioTransport().openServerMode(('0.0.0.0', port))
)
config.addV1System(snmpEngine, 'my-area', 'public')
ntfrcv.NotificationReceiver(snmpEngine, trap_callback)
print(f'SNMP Trap listener started on 0.0.0.0:{port}')
await asyncio.Event().wait()
def start_snmp_listener_in_thread(port=1162):
def runner():
asyncio.run(run_snmp_listener(port))
thread = threading.Thread(target=runner, daemon=True)
thread.start()

53
syslog_listener.py Normal file
View file

@ -0,0 +1,53 @@
import socketserver
import threading
from datetime import datetime
from flask import Flask
from models import db, Event
from event_processor import process_event_for_enrichment
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///siem.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
class SyslogUDPHandler(socketserver.BaseRequestHandler):
def handle(self):
data = bytes.decode(self.request[0].strip())
socket = self.request[1]
source_ip = self.client_address[0]
timestamp = datetime.utcnow()
message = data
with app.app_context():
event = Event(
timestamp=timestamp,
source_ip=source_ip,
event_type="syslog",
protocol="udp",
oid=None,
value=message[:1024],
severity="Unknown"
)
db.session.add(event)
db.session.flush()
event_id = event.id
db.session.commit()
print(f"Received Syslog from {source_ip}: {message[:80]}...")
process_event_for_enrichment(event_id)
def run_syslog_listener(port=1514):
HOST, PORT = "0.0.0.0", port
try:
socketserver.UDPServer.allow_reuse_address = True
server = socketserver.UDPServer((HOST, PORT), SyslogUDPHandler)
print(f"Syslog listener started on 0.0.0.0:{PORT}")
server.serve_forever(poll_interval=0.5)
except Exception as e:
print(f"Error starting Syslog listener: {e}")
def start_syslog_listener_in_thread(port=1514):
thread = threading.Thread(target=run_syslog_listener, args=(port,), daemon=True)
thread.start()

53
templates/index.html Normal file
View file

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>SentinelSNMP - All Events</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; color: #555; }
tr:nth-child(even) { background-color: #f9f9f9; }
tr:hover { background-color: #f1f1f1; }
/* Severity Colors */
.severity-critical { background-color: #ffcccc; } /* Light red */
.severity-high { background-color: #ffe0b3; } /* Light orange */
.severity-medium { background-color: #ffffcc; } /* Light yellow */
.severity-low { background-color: #e6ffe6; } /* Light green */
.severity-informational { background-color: #e0f2f7; } /* Light blue */
.severity-unknown { background-color: #f0f0f0; } /* Light grey */
</style>
</head>
<body>
<h1>SentinelSNMP - Recent Events</h1>
<table>
<thead>
<tr>
<th>Timestamp (UTC)</th>
<th>Type</th>
<th>Severity</th>
<th>Source IP</th>
<th>OID (if SNMP)</th>
<th>Value / Message</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr class="severity-{{ event.severity.lower() }}">
<td>{{ event.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>{{ event.event_type.replace('_', ' ').title() }}</td>
<td>{{ event.severity }}</td>
<td>{{ event.source_ip }}</td>
<td>{{ event.oid if event.oid else 'N/A' }}</td>
<td>{{ event.value }}</td>
</tr>
{% else %}
<tr><td colspan="6">No events found.</td></tr>
{% endfor %}
</tbody>
</table>
</body>
</html>