build 4.6 Demo

This commit is contained in:
DaStra3003
2026-08-19 08:04:59 +02:00
commit ff8f1ca989
123 changed files with 17696 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
# IoT PreTester 4.6 Demo — Architecture
## Purpose and boundary
IoT PreTester 4.6 Demo analyzes existing `.pcap` and `.pcapng` files and turns packet-level observations into prioritized security findings. It is intentionally limited to network-capture analysis. Live capture, streaming, and firmware binary analysis are outside the demo scope.
## Runtime components
| Component | Responsibility |
| --- | --- |
| Next.js frontend | Authentication flow, device administration, capture upload, findings display, report download, and optional deep-analysis controls |
| Django REST backend | API, validation, persistence, user/device administration, and integration with the analysis core |
| `analyzer` package | Django-independent capture extraction, detection, scoring, provenance, and report rendering |
| SQLite | Demo persistence for devices, files, findings, extracted artifacts, and analysis status |
| tshark/pyshark | Protocol dissection and normalized capture extraction |
| nmap/hashcat | Optional, explicitly initiated endpoint probing and offline hash recovery |
## Analysis flow
1. The frontend sends the capture and metadata to the authenticated Django API.
2. Django validates the upload, stores it in a dedicated volume, and creates the database records.
3. `extract_pyshark.py` parses the capture once into a normalized `CaptureContext`.
4. Ten independent detectors evaluate that shared context.
5. The engine deduplicates and prioritizes findings, then attaches capture, packet, stream, and artifact provenance.
6. Django persists the result and extracted URL/hash artifacts.
7. The frontend displays findings and can request Markdown, text, or JSON reports.
8. Optional URL/hash enrichment runs only for artifacts selected by the user.
## Analysis package
```text
django_project/analyzer/
├── context.py Normalized capture data model
├── extract_pyshark.py PCAP/PCAPNG extraction
├── engine.py Detector orchestration and result assembly
├── findings.py Shared finding taxonomy
├── provenance.py Packet- and stream-level evidence references
├── scoring.py Deduplication and prioritization
├── artifacts.py URL and hash extraction
├── detectors/ Independent detection rules
├── enrich/ Optional active enrichment
├── reporting/ Markdown, text, and JSON output
└── tests/ Synthetic unit and regression tests
```
The package does not import Django. pyshark and external tools are loaded only at the integration points that need them, so detector tests can run on synthetic contexts without tshark.
## Finding model
Every detector produces the same core fields: ID, title, category, severity, confidence, affected device, source and destination, protocol, port, time range, evidence, risk description, recommendation, packet references, and stream references.
Severity measures the potential impact if a finding is true. Confidence measures how strongly the capture supports it. Keeping these dimensions separate prevents low-confidence indicators from being presented as confirmed critical events.
## Detector set
1. Plaintext credentials
2. Password hashes and challenge-response material
3. Session tokens and secrets
4. Malware or command-and-control beaconing
5. Possible data exfiltration
6. Man-in-the-middle and local network manipulation
7. Insecure or unexpected protocols
8. Weak or obsolete TLS
9. DNS anomalies and tunneling indicators
10. Brute force, scanning, and suspicious file transfer
## Design decisions
- Parse each capture once and share normalized records across all detectors.
- Keep detector logic isolated from persistence, presentation, and external tools.
- Preserve evidence provenance so every finding can be traced back to the capture.
- Keep active probing opt-in because authorization can differ from authorization to inspect a capture.
- Use a small demo wordlist by default to keep distribution compact and behavior predictable.
- Store complete analysis results as JSON while keeping extracted artifacts and job status queryable.
## Known demo limitations
- SQLite and in-process background threads are suitable for a controlled demo, not horizontal production scaling.
- Capture quality and protocol encryption directly limit what can be detected.
- URL probes observe the endpoint at probe time, which can differ from capture time.
- The demo does not provide live packet capture, multi-tenant isolation, or production identity-provider integration.
+41
View File
@@ -0,0 +1,41 @@
# IoT PreTester 4.6 Demo backend
FROM python:3.10-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DEBIAN_FRONTEND=noninteractive
WORKDIR /app
# tshark performs capture dissection; nmap and hashcat support opt-in enrichment.
RUN apt-get update && apt-get install -y --no-install-recommends \
hashcat \
nmap \
pocl-opencl-icd \
sqlite3 \
tshark \
wget \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -g 1000 appgroup \
&& useradd -m -u 1000 -g 1000 appuser \
&& mkdir -p /app/data /app/files/uploads /app/logs /app/demo_files /app/django_project/staticfiles \
&& chown -R appuser:appgroup /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appgroup django_project/ /app/django_project/
COPY --chown=appuser:appgroup demo_files/demo_wordlist.txt /app/demo_files/demo_wordlist.txt
COPY --chown=appuser:appgroup docker/entrypoint-backend.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8000/api/health || exit 1
ENTRYPOINT ["/app/entrypoint.sh"]
+37
View File
@@ -0,0 +1,37 @@
# IoT PreTester 4.6 Demo frontend
FROM node:18-alpine AS dependencies
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY frontend/ ./
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api}
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
RUN chown -R node:node /app
COPY --chown=node:node docker/entrypoint-frontend.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/login || exit 1
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["node", "server.js"]
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
# Convenience commands for IoT PreTester 4.6 Demo
.PHONY: help setup build rebuild up down restart status logs logs-backend logs-frontend test check shell-backend shell-frontend migrate
help:
@echo "IoT PreTester 4.6 Demo"
@echo " make setup Create .env when needed and build the images"
@echo " make up Start the demo"
@echo " make down Stop and remove the containers"
@echo " make status Show container health"
@echo " make logs Follow all logs"
@echo " make test Run the backend test suite"
@echo " make check Check the public health endpoints"
@echo " make rebuild Rebuild and recreate both containers"
setup:
@test -f .env || cp .env.example .env
docker compose build
@echo "Edit .env if needed, then run 'make up'."
build:
docker compose build
rebuild:
docker compose up -d --build --force-recreate
up:
docker compose up -d
@echo "Frontend: http://localhost:3000"
@echo "Backend: http://localhost:8000/api/health"
down:
docker compose down
restart:
docker compose restart
status:
docker compose ps
logs:
docker compose logs -f
logs-backend:
docker compose logs -f backend
logs-frontend:
docker compose logs -f frontend
test:
docker compose exec backend python /app/django_project/manage.py test analyzer
check:
@curl --fail --silent http://localhost:8000/api/health
@curl --fail --silent http://localhost:3000/login >/dev/null
@echo "IoT PreTester 4.6 Demo is reachable."
shell-backend:
docker compose exec --workdir /app/django_project backend sh
shell-frontend:
docker compose exec frontend sh
migrate:
docker compose exec backend python /app/django_project/manage.py migrate
+102
View File
@@ -0,0 +1,102 @@
# IoT PreTester 4.6 Demo
IoT PreTester 4.6 Demo is a partner-ready demonstration platform for security analysis of IoT network captures. A Django REST backend extracts and evaluates PCAP/PCAPNG traffic, while a Next.js frontend manages devices, starts analyses, and presents prioritized findings with packet-level evidence.
## Demo scope
- Upload and analyze `.pcap` and `.pcapng` captures.
- Detect credential exposure, secrets, suspicious traffic patterns, weak protocols, TLS issues, DNS anomalies, scanning, and possible exfiltration.
- Review affected endpoints, evidence, packet references, severity, confidence, and recommendations.
- Export reports as Markdown, plain text, or JSON.
- Optionally run user-selected URL probes and offline hash checks.
- Manage demo devices, manufacturers, device types, and users.
Firmware binary analysis and historical prototype pages are intentionally not part of this demo.
## Requirements
- Docker Desktop or Docker Engine with Docker Compose
- At least 4 GB of free memory for the two application containers
- A modern web browser
No local Python, Node.js, tshark, nmap, or hashcat installation is required for the Docker workflow.
## Quick start
1. Create the local configuration:
```bash
cp .env.example .env
```
2. Set secure values for `SECRET_KEY` and `DJANGO_SUPERUSER_PASSWORD` in `.env`.
3. Build and start the demo:
```bash
docker compose up -d --build
docker compose ps
```
4. Open [http://localhost:3000](http://localhost:3000) and sign in with the admin credentials from `.env`.
5. Open the sample device, upload [`demo_files/demo_capture.pcapng`](./demo_files/demo_capture.pcapng), enter the device IP and firmware metadata, then start the scan.
The initial image build can take several minutes because the backend includes network-analysis tools. The application is ready when both containers are healthy.
## Common commands
| Task | Command |
| --- | --- |
| Start | `docker compose up -d` |
| Stop | `docker compose stop` |
| Status | `docker compose ps` |
| Follow logs | `docker compose logs -f` |
| Run backend tests | `make test` |
| Rebuild | `docker compose up -d --build --force-recreate` |
Equivalent shortcuts are available through `make help`.
## Repository structure
```text
.
├── frontend/ Next.js user interface
├── django_project/
│ ├── IoTAnalyzer/ Django API and persistence adapters
│ ├── IoTPreTester/ Django configuration
│ └── analyzer/ Framework-independent analysis engine
├── demo_files/ Partner-safe sample capture and wordlist
├── docker/ Container entrypoints
├── ARCHITECTURE.md Architecture and security-detection design
├── docker-compose.yml Demo runtime
└── Makefile Convenience commands
```
## Headless analysis
The analysis core can also run without Django:
```bash
cd django_project
python -m analyzer.cli ../demo_files/demo_capture.pcapng -f md -o report.md
```
Exit code `0` means no high/critical findings, `2` means at least one high/critical finding, and `1` indicates an execution error.
## Demo safety notes
- Only upload captures that you are authorized to inspect. Captures can contain credentials, tokens, personal data, and internal addresses.
- Active URL probing is started manually for selected endpoints. Do not probe third-party systems without permission.
- Hash recovery uses the deliberately small `demo_files/demo_wordlist.txt`. Supply an authorized external wordlist only when required.
- This configuration is intended for controlled demonstrations, not public internet exposure.
- Runtime data, uploaded captures, logs, and `.env` are excluded from version control.
## Documentation
- [`ARCHITECTURE.md`](./ARCHITECTURE.md) describes the data flow, detector model, and module boundaries.
- [`django_project/analyzer/README.md`](./django_project/analyzer/README.md) documents the standalone analysis package.
## Distribution
This repository is supplied as **IoT PreTester 4.6 Demo** for an agreed partner demonstration. No public license is granted by the repository; redistribution or commercial use requires the owner's written permission.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
# Deliberately small wordlist for the IoT PreTester 4.6 Demo.
admin
admin123
changeme
password
password123
root
test
+51
View File
@@ -0,0 +1,51 @@
"""Django admin registrations for demo operations."""
from django.contrib import admin
from .models import (
AnalyzedDataset,
CipherSuite,
DeepAnalysisJob,
Device,
DeviceType,
File,
FindingReport,
Firmware,
FirmwareDevices,
HashAnalyzed,
HashRaw,
HashType,
Manufacturer,
Port,
Product,
RawDataset,
TlsVersion,
Tool,
UrlAnalyzed,
UrlRaw,
)
admin.site.register(
[
Manufacturer,
DeviceType,
Product,
Device,
Firmware,
FirmwareDevices,
File,
RawDataset,
HashRaw,
UrlRaw,
Tool,
Port,
TlsVersion,
CipherSuite,
HashType,
AnalyzedDataset,
UrlAnalyzed,
HashAnalyzed,
FindingReport,
DeepAnalysisJob,
]
)
@@ -0,0 +1,168 @@
"""Optional hash cracking and endpoint probing with progress persistence."""
from __future__ import annotations
import logging
import threading
from django.db import connection
logger = logging.getLogger(__name__)
def start_deep_analysis(file_id: int, url_ids=None, hash_ids=None,
synchronous: bool = False) -> None:
"""Start enrichment for all artifacts or the explicitly selected raw IDs."""
if synchronous:
_run(file_id, url_ids, hash_ids, close_conn=False)
return
thread = threading.Thread(target=_run, args=(file_id, url_ids, hash_ids), daemon=True,
name=f"deep-analysis-{file_id}")
thread.start()
def _analyzed_dataset_for(raw_obj):
"""Return the single analyzed dataset associated with a capture."""
from IoTAnalyzer.models import AnalyzedDataset
file_obj = raw_obj.raw_dataset.file
ds, _ = AnalyzedDataset.objects.get_or_create(file=file_obj)
return ds
def crack_raw_hash(raw_hash) -> None:
"""Crack one raw hash and persist the result idempotently."""
from django.db import transaction
from IoTAnalyzer.models import Tool, HashType, HashAnalyzed
from analyzer.artifacts import HashArtifact
from analyzer.enrich import crack_hash
try:
res = crack_hash(HashArtifact(value=raw_hash.hash_value))
cracked = bool(res.cracked and res.plaintext)
logger.info("Hash id=%s mode=%s state=%s cracked=%s error=%s",
raw_hash.id, res.hashcat_mode, res.state, cracked, res.error)
ds = _analyzed_dataset_for(raw_hash)
tool, _ = Tool.objects.get_or_create(tool_name="Hashcat", defaults={"version": "6.x.x"})
htype, _ = HashType.objects.get_or_create(hash_type=(res.hash_format or "unknown"))
crack_time = f"{res.duration_seconds}s" if res.duration_seconds is not None else None
with transaction.atomic():
ha, created = HashAnalyzed.objects.get_or_create(
analyzed_dataset=ds, hash_value=res.value,
defaults={"tool": tool, "cracked": cracked,
"cracked_value": res.plaintext if cracked else None,
"crack_time": crack_time},
)
if not created:
ha.tool = tool
ha.cracked = cracked
ha.cracked_value = res.plaintext if cracked else None
ha.crack_time = crack_time
ha.save()
ha.hash_type.add(htype)
logger.info("Stored hash result id=%s (created=%s, cracked=%s)", ha.id, created, cracked)
except Exception as exc:
logger.exception("Hash enrichment failed (id=%s): %s", raw_hash.id, exc)
def probe_raw_url(raw_url) -> None:
"""Probe one raw endpoint and persist DNS, port, and TLS results."""
from django.db import transaction
from IoTAnalyzer.models import Tool, UrlAnalyzed, Port, TlsVersion, CipherSuite
from analyzer.enrich import probe_url
try:
probe = probe_url(raw_url.url, active=True)
logger.info("URL id=%s ports=%s tls=%s error=%s",
raw_url.id, probe.open_ports, probe.tls_versions, probe.error)
ds = _analyzed_dataset_for(raw_url)
tool, _ = Tool.objects.get_or_create(tool_name="DNS Analyzer", defaults={"version": "1.0"})
with transaction.atomic():
ua, created = UrlAnalyzed.objects.get_or_create(
analyzed_dataset=ds, url=raw_url.url, defaults={"tool": tool})
for p in probe.open_ports:
port, _ = Port.objects.get_or_create(port=p)
ua.ports.add(port)
for v in (probe.tls_versions or ["N/A"]):
tls, _ = TlsVersion.objects.get_or_create(tls_version=v)
ua.tls_versions.add(tls)
for c in (probe.cipher_suites or ["N/A"]):
cs, _ = CipherSuite.objects.get_or_create(cipher_suite=c)
ua.cipher_suites.add(cs)
logger.info("Stored endpoint result id=%s (created=%s)", ua.id, created)
except Exception as exc:
logger.exception("Endpoint enrichment failed (id=%s): %s", raw_url.id, exc)
def _run(file_id: int, url_ids=None, hash_ids=None, close_conn: bool = True) -> None:
from IoTAnalyzer.models import UrlRaw, HashRaw, HashAnalyzed, DeepAnalysisJob
job = None
try:
url_qs = UrlRaw.objects.filter(raw_dataset__file_id=file_id)
hash_qs = HashRaw.objects.filter(raw_dataset__file_id=file_id)
# None selects all records; an empty list intentionally selects none.
if url_ids is not None:
url_qs = url_qs.filter(id__in=url_ids)
if hash_ids is not None:
hash_qs = hash_qs.filter(id__in=hash_ids)
urls = list(url_qs)
hashes = list(hash_qs)
DeepAnalysisJob.objects.filter(file_id=file_id).delete()
job = DeepAnalysisJob.objects.create(
file_id=file_id, state='running',
url_total=len(urls), hash_total=len(hashes),
message='Enrichment started',
)
logger.info("Enrichment started: file=%s urls=%s hashes=%s",
file_id, len(urls), len(hashes))
# Crack hashes first because they usually complete quickly.
for raw_hash in hashes:
try:
crack_raw_hash(raw_hash)
except Exception as exc:
logger.exception("Hash cracking failed (id=%s): %s", raw_hash.id, exc)
job.hash_done += 1
job.message = f"Hash-Cracking {job.hash_done}/{job.hash_total}"
job.save(update_fields=['hash_done', 'message', 'updated'])
# Endpoint probing may perform DNS, port, and TLS checks.
for raw_url in urls:
try:
probe_raw_url(raw_url)
except Exception as exc:
logger.exception("Endpoint probing failed (id=%s): %s", raw_url.id, exc)
job.url_done += 1
job.message = f"Endpoint probing {job.url_done}/{job.url_total}"
job.save(update_fields=['url_done', 'message', 'updated'])
try:
job.cracked = HashAnalyzed.objects.filter(
analyzed_dataset__file_id=file_id, cracked=True
).count()
except Exception:
pass
job.state = 'done'
job.message = 'Enrichment completed'
job.save()
logger.info("Enrichment completed: file=%s cracked=%s", file_id, job.cracked)
except Exception as exc:
logger.exception("Enrichment failed for file=%s: %s", file_id, exc)
if job is not None:
job.state = 'error'
job.message = str(exc)[:500]
try:
job.save()
except Exception:
pass
finally:
# Close only the worker thread's connection, never the request connection.
if close_conn:
connection.close()
@@ -0,0 +1,146 @@
"""Persist results and artifacts produced by the shared analysis context."""
from __future__ import annotations
import json
import logging
import os
from django.conf import settings
from analyzer import AnalysisEngine, extract_urls, extract_hashes
from analyzer.extract_pyshark import build_context_from_pcap
from analyzer.findings import Severity
from IoTAnalyzer.models import FindingReport, UrlRaw, HashRaw
logger = logging.getLogger(__name__)
_CANDIDATE_DIRS = [
str(settings.CAPTURE_UPLOAD_DIR),
]
def resolve_capture_path(filename: str) -> str | None:
"""Resolve a stored capture name against supported upload locations."""
if not filename:
return None
if os.path.isabs(filename) and os.path.exists(filename):
return filename
for base in _CANDIDATE_DIRS:
candidate = os.path.join(base, filename)
if os.path.exists(candidate):
return candidate
return None
def _max_severity(result) -> str:
worst = Severity.INFO
for f in result.findings:
if f.severity.rank > worst.rank:
worst = f.severity
return worst.value
def collect_cloud_results(file_id: int) -> dict:
"""Return deduplicated capture artifacts and any enrichment results."""
from IoTAnalyzer.models import UrlRaw, HashRaw, UrlAnalyzed, HashAnalyzed
# Retain raw IDs so the frontend can request a selected enrichment subset.
raw_urls = list(UrlRaw.objects.filter(raw_dataset__file_id=file_id)
.values_list('id', 'url'))
raw_hashes = list(HashRaw.objects.filter(raw_dataset__file_id=file_id)
.values_list('id', 'hash_value'))
url_map = {}
for ua in (UrlAnalyzed.objects.filter(analyzed_dataset__file_id=file_id)
.prefetch_related('ports', 'tls_versions', 'cipher_suites')):
url_map[ua.url] = {
"ports": sorted({p.port for p in ua.ports.all()}),
"tls_versions": [t.tls_version for t in ua.tls_versions.all()],
"cipher_suites": [c.cipher_suite for c in ua.cipher_suites.all()],
}
hash_map = {}
for ha in (HashAnalyzed.objects.filter(analyzed_dataset__file_id=file_id)
.prefetch_related('hash_type')):
entry = {
"cracked": ha.cracked,
"cracked_value": ha.cracked_value,
"crack_time": ha.crack_time,
"hash_type": [h.hash_type for h in ha.hash_type.all()],
}
existing = hash_map.get(ha.hash_value)
if existing is None or (ha.cracked and not existing.get("cracked")):
hash_map[ha.hash_value] = entry
urls, seen = [], set()
for raw_id, u in raw_urls:
if not u or u in seen:
continue
seen.add(u)
a = url_map.get(u)
urls.append({"id": raw_id, "url": u, "analyzed": a is not None, **(a or {})})
hashes, seen_h = [], set()
for raw_id, h in raw_hashes:
if not h or h in seen_h:
continue
seen_h.add(h)
a = hash_map.get(h)
hashes.append({"id": raw_id, "hash_value": h, "analyzed": a is not None,
**(a or {"cracked": False, "cracked_value": None,
"crack_time": None, "hash_type": []})})
return {"urls": urls, "hashes": hashes}
def _persist_artifacts(ctx, raw_dataset_id: int) -> None:
"""Persist URL and hash artifacts extracted during the analysis pass."""
if raw_dataset_id is None:
return
try:
for u in extract_urls(ctx):
UrlRaw.objects.get_or_create(raw_dataset_id=raw_dataset_id, url=u.value[:200])
except Exception as exc:
logger.exception("URL persistence failed: %s", exc)
try:
for h in extract_hashes(ctx):
HashRaw.objects.get_or_create(raw_dataset_id=raw_dataset_id, hash_value=h.value)
except Exception as exc:
logger.exception("Hash persistence failed: %s", exc)
def analyze_capture(filename: str, file_id: int, raw_dataset_id: int | None = None):
"""Analyze a capture once and persist findings plus extracted artifacts."""
path = resolve_capture_path(filename)
if not path:
logger.warning("Capture file not found: %s", filename)
return None
try:
ctx = build_context_from_pcap(path)
result = AnalysisEngine().analyze_context(ctx)
except Exception as exc:
logger.exception("Security analysis failed for %s: %s", filename, exc)
return None
try:
# Reanalysis replaces the previous report atomically at the file level.
FindingReport.objects.filter(file_id=file_id).delete()
FindingReport.objects.create(
file_id=file_id,
result_json=json.dumps(result.to_dict()),
finding_count=len(result.findings),
max_severity=_max_severity(result),
)
logger.info("Stored finding report: file_id=%s, findings=%s",
file_id, len(result.findings))
except Exception as exc:
logger.exception("Finding report persistence failed: %s", exc)
_persist_artifacts(ctx, raw_dataset_id)
return result
def run_security_analysis(filename: str, file_id: int):
return analyze_capture(filename, file_id, raw_dataset_id=None)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class IotanalyzerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'IoTAnalyzer'
@@ -0,0 +1,13 @@
"""Session authentication adapted to the localhost-only demo API.
State-changing requests remain protected by authenticated session cookies and a
strict CORS allow-list. The separate CSRF check is disabled because the frontend
and API use different localhost ports.
"""
from rest_framework.authentication import SessionAuthentication
class DemoSessionAuthentication(SessionAuthentication):
def enforce_csrf(self, request):
return None
+122
View File
@@ -0,0 +1,122 @@
"""Focused creation services used by the demo API."""
import ipaddress
import logging
import os
from django.core.exceptions import ValidationError
from django.db import transaction
from .models import (
Device,
DeviceType,
File,
Firmware,
FirmwareDevices,
Manufacturer,
Product,
RawDataset,
)
logger = logging.getLogger(__name__)
CAPTURE_EXTENSIONS = {".pcap", ".pcapng"}
def _required_text(value, label: str, max_length: int) -> str:
if not isinstance(value, str) or not value.strip():
raise ValidationError(f"{label} is required.")
value = value.strip()
if len(value) > max_length:
raise ValidationError(f"{label} must not exceed {max_length} characters.")
return value
@transaction.atomic
def create_manufacturer(name: str) -> Manufacturer:
name = _required_text(name, "Manufacturer name", 50)
manufacturer, created = Manufacturer.objects.get_or_create(name=name)
if not created:
raise ValidationError("This manufacturer already exists.")
return manufacturer
@transaction.atomic
def create_device_type(device_type: str) -> DeviceType:
device_type = _required_text(device_type, "Device type", 50)
instance, created = DeviceType.objects.get_or_create(device_type=device_type)
if not created:
raise ValidationError("This device type already exists.")
return instance
@transaction.atomic
def create_new_device(
manufacturer_id: int,
device_type_id: int,
device_name: str,
serial: str,
comment: str = "",
) -> Device:
manufacturer = Manufacturer.objects.get(id=manufacturer_id)
device_type = DeviceType.objects.get(id=device_type_id)
device_name = _required_text(device_name, "Device name", 100)
serial = _required_text(serial, "Serial number", 100)
comment = (comment or "").strip()[:255]
product, _ = Product.objects.get_or_create(
manufacturer=manufacturer,
device_type=device_type,
product_name=device_name,
)
device, created = Device.objects.get_or_create(
product=product,
serial=serial,
defaults={"comment": comment},
)
if not created:
raise ValidationError("A device with this product and serial number already exists.")
return device
def create_capture(
device_id: int,
firmware_version: str,
filename: str,
ip_address: str,
) -> File:
"""Create capture metadata, analyze the stored file, and update its status."""
firmware_version = _required_text(firmware_version, "Firmware version", 100)
filename = os.path.basename(_required_text(filename, "Capture filename", 255))
if os.path.splitext(filename)[1].lower() not in CAPTURE_EXTENSIONS:
raise ValidationError("Only .pcap and .pcapng captures are supported.")
try:
normalized_ip = str(ipaddress.ip_address(ip_address))
except ValueError as exc:
raise ValidationError("A valid device IP address is required.") from exc
with transaction.atomic():
device = Device.objects.select_related("product").get(id=device_id)
firmware, _ = Firmware.objects.get_or_create(
product=device.product,
version=firmware_version,
)
FirmwareDevices.objects.get_or_create(device=device, firmware=firmware)
file_entry, created = File.objects.get_or_create(
device=device,
firmware=firmware,
filename=filename,
ip_address=normalized_ip,
defaults={"status": "analyzing"},
)
if not created:
raise ValidationError("This capture is already registered for the device.")
raw_dataset = RawDataset.objects.create(file=file_entry)
from .analyze.security_analysis import analyze_capture
result = analyze_capture(filename, file_entry.id, raw_dataset.id)
file_entry.status = "analyzed" if result is not None else "analysis_failed"
file_entry.save(update_fields=["status"])
logger.info("Capture %s completed with status %s", file_entry.id, file_entry.status)
return file_entry
+51
View File
@@ -0,0 +1,51 @@
"""Read projections used by the demo API."""
from .models import Device, DeviceType, File, Manufacturer
def get_all_manufacturers() -> list[dict]:
return [item.to_json() for item in Manufacturer.objects.all()]
def get_all_device_types() -> list[dict]:
return [item.to_json() for item in DeviceType.objects.all()]
def get_all_devices() -> list[dict]:
devices = Device.objects.select_related(
"product", "product__manufacturer", "product__device_type"
)
return [
{
"id": device.id,
"manufacturer_name": device.product.manufacturer.name,
"device_type": device.product.device_type.device_type,
"device_name": device.product.product_name,
"serial": device.serial,
"comment": device.comment,
"created": device.created.isoformat(),
}
for device in devices
]
def get_device_by_id(device_id: int) -> dict:
device = Device.objects.select_related(
"product", "product__manufacturer", "product__device_type"
).get(id=device_id)
return device.to_json()
def get_files_by_device(device_id: int) -> list[dict]:
files = File.objects.filter(device_id=device_id).select_related("firmware")
return [
{
"id": file.id,
"filename": file.filename,
"ip": file.ip_address,
"state": file.status,
"upload": file.upload_time.isoformat(),
"firmware_version": file.firmware.version,
}
for file in files
]
@@ -0,0 +1,333 @@
# Generated by Django 4.2.20 on 2026-07-17 12:17
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='AnalyzedDataset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'iot_analyzed_dataset',
},
),
migrations.CreateModel(
name='CipherSuite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cipher_suite', models.CharField(max_length=100, unique=True)),
('created', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'iot_cipher_suites',
},
),
migrations.CreateModel(
name='Device',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('serial', models.CharField(max_length=100)),
('comment', models.CharField(blank=True, default='', max_length=255)),
('created', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'iot_device',
},
),
migrations.CreateModel(
name='DeviceType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('device_type', models.CharField(max_length=50, unique=True)),
('created', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'iot_device_type',
'ordering': ['device_type'],
},
),
migrations.CreateModel(
name='File',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('filename', models.CharField(max_length=255)),
('ip_address', models.GenericIPAddressField()),
('status', models.CharField(default='pending', max_length=50)),
('upload_time', models.DateTimeField(auto_now_add=True)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.device')),
],
options={
'db_table': 'iot_files',
'ordering': ['-upload_time'],
},
),
migrations.CreateModel(
name='Firmware',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version', models.CharField(max_length=100)),
('created', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'iot_firmware',
},
),
migrations.CreateModel(
name='HashType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('hash_type', models.CharField(max_length=100, unique=True)),
('created', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'iot_hash_types',
},
),
migrations.CreateModel(
name='Manufacturer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('created', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'iot_manufacturer',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Port',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('port', models.PositiveIntegerField(unique=True)),
('created', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'iot_ports',
},
),
migrations.CreateModel(
name='RawDataset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True)),
('file', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.file')),
],
options={
'db_table': 'iot_raw_dataset',
},
),
migrations.CreateModel(
name='TlsVersion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tls_version', models.CharField(max_length=50, unique=True)),
('created', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'iot_tls_versions',
},
),
migrations.CreateModel(
name='Tool',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tool_name', models.CharField(max_length=100, unique=True)),
('version', models.CharField(max_length=50)),
('created', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'iot_tool',
},
),
migrations.CreateModel(
name='UrlRaw',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.CharField(max_length=500)),
('created', models.DateTimeField(auto_now_add=True)),
('raw_dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.rawdataset')),
],
options={
'db_table': 'iot_raw_url',
},
),
migrations.CreateModel(
name='UrlAnalyzed',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.CharField(max_length=500)),
('created', models.DateTimeField(auto_now_add=True)),
('analyzed_dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.analyzeddataset')),
('cipher_suites', models.ManyToManyField(to='IoTAnalyzer.ciphersuite')),
('ports', models.ManyToManyField(to='IoTAnalyzer.port')),
('tls_versions', models.ManyToManyField(to='IoTAnalyzer.tlsversion')),
('tool', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.tool')),
],
options={
'db_table': 'iot_analyzed_url',
},
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product_name', models.CharField(max_length=100)),
('created', models.DateTimeField(auto_now_add=True)),
('device_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.devicetype')),
('manufacturer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.manufacturer')),
],
options={
'db_table': 'iot_product',
'ordering': ['product_name'],
},
),
migrations.CreateModel(
name='HashRaw',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('hash_value', models.TextField()),
('created', models.DateTimeField(auto_now_add=True)),
('raw_dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.rawdataset')),
],
options={
'db_table': 'iot_raw_hash',
},
),
migrations.CreateModel(
name='HashAnalyzed',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('hash_value', models.TextField()),
('cracked', models.BooleanField(default=False)),
('cracked_value', models.CharField(blank=True, max_length=255, null=True)),
('crack_time', models.CharField(blank=True, max_length=50, null=True)),
('created', models.DateTimeField(auto_now_add=True)),
('analyzed_dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.analyzeddataset')),
('hash_type', models.ManyToManyField(to='IoTAnalyzer.hashtype')),
('tool', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.tool')),
],
options={
'db_table': 'iot_analyzed_hash',
},
),
migrations.CreateModel(
name='FirmwareDevices',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.device')),
('firmware', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.firmware')),
],
options={
'db_table': 'iot_firmware_devices',
},
),
migrations.AddField(
model_name='firmware',
name='devices',
field=models.ManyToManyField(through='IoTAnalyzer.FirmwareDevices', to='IoTAnalyzer.device'),
),
migrations.AddField(
model_name='firmware',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.product'),
),
migrations.CreateModel(
name='FindingReport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('result_json', models.TextField()),
('finding_count', models.IntegerField(default=0)),
('max_severity', models.CharField(default='info', max_length=20)),
('created', models.DateTimeField(auto_now_add=True)),
('file', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.file')),
],
options={
'db_table': 'iot_finding_report',
'ordering': ['-created'],
},
),
migrations.AddField(
model_name='file',
name='firmware',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.firmware'),
),
migrations.AddField(
model_name='device',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.product'),
),
migrations.CreateModel(
name='DeepAnalysisJob',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('done', 'Done'), ('error', 'Error')], default='pending', max_length=20)),
('url_total', models.IntegerField(default=0)),
('url_done', models.IntegerField(default=0)),
('hash_total', models.IntegerField(default=0)),
('hash_done', models.IntegerField(default=0)),
('cracked', models.IntegerField(default=0)),
('message', models.TextField(blank=True, default='')),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('file', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.file')),
],
options={
'db_table': 'iot_deep_analysis_job',
'ordering': ['-created'],
},
),
migrations.AddField(
model_name='analyzeddataset',
name='file',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.file'),
),
migrations.AddConstraint(
model_name='urlraw',
constraint=models.UniqueConstraint(fields=('raw_dataset', 'url'), name='unique_raw_url'),
),
migrations.AddConstraint(
model_name='urlanalyzed',
constraint=models.UniqueConstraint(fields=('analyzed_dataset', 'url'), name='unique_analyzed_url'),
),
migrations.AddConstraint(
model_name='product',
constraint=models.UniqueConstraint(fields=('manufacturer', 'device_type', 'product_name'), name='unique_demo_product'),
),
migrations.AddConstraint(
model_name='hashraw',
constraint=models.UniqueConstraint(fields=('raw_dataset', 'hash_value'), name='unique_raw_hash'),
),
migrations.AddConstraint(
model_name='hashanalyzed',
constraint=models.UniqueConstraint(fields=('analyzed_dataset', 'hash_value'), name='unique_analyzed_hash'),
),
migrations.AddConstraint(
model_name='firmwaredevices',
constraint=models.UniqueConstraint(fields=('firmware', 'device'), name='unique_device_firmware'),
),
migrations.AddConstraint(
model_name='firmware',
constraint=models.UniqueConstraint(fields=('product', 'version'), name='unique_product_firmware'),
),
migrations.AddConstraint(
model_name='file',
constraint=models.UniqueConstraint(fields=('device', 'firmware', 'filename', 'ip_address'), name='unique_device_capture'),
),
migrations.AddConstraint(
model_name='device',
constraint=models.UniqueConstraint(fields=('product', 'serial'), name='unique_demo_device'),
),
]
+337
View File
@@ -0,0 +1,337 @@
"""Persistence models for IoT PreTester 4.6 Demo."""
import json
from django.db import models
class Manufacturer(models.Model):
name = models.CharField(max_length=50, unique=True)
created = models.DateTimeField(auto_now_add=True)
def to_json(self):
return {"id": self.id, "name": self.name, "created": self.created.isoformat()}
class Meta:
db_table = "iot_manufacturer"
ordering = ["name"]
class DeviceType(models.Model):
device_type = models.CharField(max_length=50, unique=True)
created = models.DateTimeField(auto_now_add=True)
def to_json(self):
return {
"id": self.id,
"device_type": self.device_type,
"created": self.created.isoformat(),
}
class Meta:
db_table = "iot_device_type"
ordering = ["device_type"]
class Product(models.Model):
manufacturer = models.ForeignKey(Manufacturer, on_delete=models.PROTECT)
device_type = models.ForeignKey(DeviceType, on_delete=models.PROTECT)
product_name = models.CharField(max_length=100)
created = models.DateTimeField(auto_now_add=True)
def to_json(self):
return {
"id": self.id,
"manufacturer": self.manufacturer.to_json(),
"product_name": self.product_name,
"device_type": self.device_type.to_json(),
"created": self.created.isoformat(),
}
class Meta:
db_table = "iot_product"
ordering = ["product_name"]
constraints = [
models.UniqueConstraint(
fields=["manufacturer", "device_type", "product_name"],
name="unique_demo_product",
)
]
class Device(models.Model):
product = models.ForeignKey(Product, on_delete=models.PROTECT)
serial = models.CharField(max_length=100)
comment = models.CharField(max_length=255, blank=True, default="")
created = models.DateTimeField(auto_now_add=True)
def to_json(self):
return {
"id": self.id,
"product": self.product.to_json(),
"serial": self.serial,
"comment": self.comment,
"created": self.created.isoformat(),
}
class Meta:
db_table = "iot_device"
constraints = [
models.UniqueConstraint(
fields=["product", "serial"], name="unique_demo_device"
)
]
class Firmware(models.Model):
"""Firmware metadata associated with a capture; binaries are not analyzed."""
version = models.CharField(max_length=100)
product = models.ForeignKey(Product, on_delete=models.PROTECT)
devices = models.ManyToManyField(Device, through="FirmwareDevices")
created = models.DateTimeField(auto_now_add=True)
def to_json(self):
return {
"id": self.id,
"version": self.version,
"product": self.product_id,
"created": self.created.isoformat(),
}
class Meta:
db_table = "iot_firmware"
constraints = [
models.UniqueConstraint(
fields=["product", "version"], name="unique_product_firmware"
)
]
class FirmwareDevices(models.Model):
firmware = models.ForeignKey(Firmware, on_delete=models.CASCADE)
device = models.ForeignKey(Device, on_delete=models.CASCADE)
created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "iot_firmware_devices"
constraints = [
models.UniqueConstraint(
fields=["firmware", "device"], name="unique_device_firmware"
)
]
class File(models.Model):
firmware = models.ForeignKey(Firmware, on_delete=models.PROTECT)
device = models.ForeignKey(Device, on_delete=models.CASCADE)
filename = models.CharField(max_length=255)
ip_address = models.GenericIPAddressField()
status = models.CharField(max_length=50, default="pending")
upload_time = models.DateTimeField(auto_now_add=True)
def to_json(self):
return {
"id": self.id,
"filename": self.filename,
"ip_address": self.ip_address,
"firmware_version": self.firmware.version,
"status": self.status,
"upload_time": self.upload_time.isoformat(),
}
class Meta:
db_table = "iot_files"
ordering = ["-upload_time"]
constraints = [
models.UniqueConstraint(
fields=["device", "firmware", "filename", "ip_address"],
name="unique_device_capture",
)
]
class RawDataset(models.Model):
file = models.OneToOneField(File, on_delete=models.CASCADE)
created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "iot_raw_dataset"
class HashRaw(models.Model):
raw_dataset = models.ForeignKey(RawDataset, on_delete=models.CASCADE)
hash_value = models.TextField()
created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "iot_raw_hash"
constraints = [
models.UniqueConstraint(
fields=["raw_dataset", "hash_value"], name="unique_raw_hash"
)
]
class UrlRaw(models.Model):
raw_dataset = models.ForeignKey(RawDataset, on_delete=models.CASCADE)
url = models.CharField(max_length=500)
created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "iot_raw_url"
constraints = [
models.UniqueConstraint(
fields=["raw_dataset", "url"], name="unique_raw_url"
)
]
class Tool(models.Model):
tool_name = models.CharField(max_length=100, unique=True)
version = models.CharField(max_length=50)
created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "iot_tool"
class Port(models.Model):
port = models.PositiveIntegerField(unique=True)
created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "iot_ports"
class TlsVersion(models.Model):
tls_version = models.CharField(max_length=50, unique=True)
created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "iot_tls_versions"
class CipherSuite(models.Model):
cipher_suite = models.CharField(max_length=100, unique=True)
created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "iot_cipher_suites"
class HashType(models.Model):
hash_type = models.CharField(max_length=100, unique=True)
created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "iot_hash_types"
class AnalyzedDataset(models.Model):
file = models.OneToOneField(File, on_delete=models.CASCADE)
created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "iot_analyzed_dataset"
class UrlAnalyzed(models.Model):
analyzed_dataset = models.ForeignKey(AnalyzedDataset, on_delete=models.CASCADE)
url = models.CharField(max_length=500)
tool = models.ForeignKey(Tool, on_delete=models.PROTECT)
ports = models.ManyToManyField(Port)
tls_versions = models.ManyToManyField(TlsVersion)
cipher_suites = models.ManyToManyField(CipherSuite)
created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "iot_analyzed_url"
constraints = [
models.UniqueConstraint(
fields=["analyzed_dataset", "url"], name="unique_analyzed_url"
)
]
class HashAnalyzed(models.Model):
analyzed_dataset = models.ForeignKey(AnalyzedDataset, on_delete=models.CASCADE)
hash_type = models.ManyToManyField(HashType)
hash_value = models.TextField()
cracked = models.BooleanField(default=False)
cracked_value = models.CharField(max_length=255, blank=True, null=True)
crack_time = models.CharField(max_length=50, blank=True, null=True)
tool = models.ForeignKey(Tool, on_delete=models.PROTECT)
created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "iot_analyzed_hash"
constraints = [
models.UniqueConstraint(
fields=["analyzed_dataset", "hash_value"], name="unique_analyzed_hash"
)
]
class FindingReport(models.Model):
file = models.OneToOneField(File, on_delete=models.CASCADE)
result_json = models.TextField()
finding_count = models.IntegerField(default=0)
max_severity = models.CharField(max_length=20, default="info")
created = models.DateTimeField(auto_now_add=True)
def to_json(self):
return {
"id": self.id,
"file_id": self.file_id,
"finding_count": self.finding_count,
"max_severity": self.max_severity,
"created": self.created.isoformat(),
"result": json.loads(self.result_json),
}
class Meta:
db_table = "iot_finding_report"
ordering = ["-created"]
class DeepAnalysisJob(models.Model):
STATE_CHOICES = [
("pending", "Pending"),
("running", "Running"),
("done", "Done"),
("error", "Error"),
]
file = models.OneToOneField(File, on_delete=models.CASCADE)
state = models.CharField(max_length=20, choices=STATE_CHOICES, default="pending")
url_total = models.IntegerField(default=0)
url_done = models.IntegerField(default=0)
hash_total = models.IntegerField(default=0)
hash_done = models.IntegerField(default=0)
cracked = models.IntegerField(default=0)
message = models.TextField(blank=True, default="")
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
def to_json(self):
done = self.url_done + self.hash_done
total = self.url_total + self.hash_total
return {
"file_id": self.file_id,
"state": self.state,
"url_total": self.url_total,
"url_done": self.url_done,
"hash_total": self.hash_total,
"hash_done": self.hash_done,
"cracked": self.cracked,
"progress_done": done,
"progress_total": total,
"percent": int(done * 100 / total) if total else (100 if self.state == "done" else 0),
"message": self.message,
"updated": self.updated.isoformat(),
}
class Meta:
db_table = "iot_deep_analysis_job"
ordering = ["-created"]
+32
View File
@@ -0,0 +1,32 @@
"""URL routes for the IoT PreTester 4.6 Demo API."""
from django.urls import path
from . import views
urlpatterns = [
path("api/health", views.HealthView.as_view(), name="health"),
path("api/login", views.LoginView.as_view(), name="login"),
path("api/logout", views.LogoutView.as_view(), name="logout"),
path("api/register", views.RegisterUserView.as_view(), name="register_user"),
path("api/list-users", views.ListUsersView.as_view(), name="list_users"),
path("api/delete-user", views.DeleteUserView.as_view(), name="delete_user"),
path("api/reset-password", views.ResetPasswordView.as_view(), name="reset_password"),
path("api/create_manufact", views.CreateManufacturerView.as_view(), name="create_manufacturer"),
path("api/create_device_type", views.CreateDeviceTypeView.as_view(), name="create_device_type"),
path("api/create_device", views.CreateDeviceView.as_view(), name="create_device"),
path("api/captures", views.CreateCaptureView.as_view(), name="create_capture"),
path("api/get_all_manufacturers", views.ManufacturersView.as_view(), name="manufacturers"),
path("api/get_all_devicetypes", views.DeviceTypesView.as_view(), name="device_types"),
path("api/get_all_devices", views.DevicesView.as_view(), name="devices"),
path("api/get_device_by_id/<int:device_id>", views.DeviceDetailView.as_view(), name="device_detail"),
path("api/get_file_by_device/<int:device_id>", views.DeviceCapturesView.as_view(), name="device_captures"),
path("api/delete_device/<int:device_id>", views.DeleteDeviceView.as_view(), name="delete_device"),
path("api/delete_manufacturer/<int:manufacturer_id>", views.DeleteManufacturerView.as_view(), name="delete_manufacturer"),
path("api/delete_device_type/<int:device_type_id>", views.DeleteDeviceTypeView.as_view(), name="delete_device_type"),
path("api/get_findings/<int:file_id>", views.FindingsView.as_view(), name="findings"),
path("api/get_cloud_results/<int:file_id>", views.CloudResultsView.as_view(), name="cloud_results"),
path("api/start_analysis/<int:file_id>", views.StartAnalysisView.as_view(), name="start_analysis"),
path("api/get_analysis_status/<int:file_id>", views.AnalysisStatusView.as_view(), name="analysis_status"),
path("api/get_report/<int:file_id>/<str:fmt>", views.ReportView.as_view(), name="report"),
]
+80
View File
@@ -0,0 +1,80 @@
"""User-account services for the controlled demo environment."""
from django.contrib.auth import authenticate, get_user_model, login, logout
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db import transaction
User = get_user_model()
@transaction.atomic
def register_user(username: str, password: str, role: str) -> dict:
username = (username or "").strip()
if not username:
raise ValidationError("Username is required.")
if role not in {"admin", "user"}:
raise ValidationError("Role must be admin or user.")
if User.objects.filter(username=username).exists():
raise ValidationError("This username already exists.")
validate_password(password)
is_admin = role == "admin"
User.objects.create_user(
username=username,
password=password,
is_superuser=is_admin,
is_staff=is_admin,
)
return {"status": "success", "message": "User created successfully."}
@transaction.atomic
def delete_user(request, user_id: int) -> dict:
if request.user.id == user_id:
raise ValidationError("You cannot delete your own account.")
user = User.objects.get(id=user_id)
user.delete()
return {"status": "success", "message": "User deleted successfully."}
@transaction.atomic
def set_password(user_id: int, new_password: str) -> dict:
user = User.objects.get(id=user_id)
validate_password(new_password, user)
user.set_password(new_password)
user.save(update_fields=["password"])
return {"status": "success", "message": "Password reset successfully."}
def login_user(request, username: str, password: str):
user = authenticate(request, username=(username or "").strip(), password=password)
if user is None:
return False, {"message": "Invalid username or password."}
login(request, user)
role = "admin" if user.is_superuser else "user"
return True, {
"message": "Login successful.",
"user": {"id": user.id, "username": user.username, "role": role},
}
def logout_user(request) -> dict:
logout(request)
return {"status": "success", "message": "Logout successful."}
def list_users() -> dict:
return {
"status": "success",
"data": [
{
"id": user.id,
"username": user.username,
"email": user.email,
"role": "admin" if user.is_superuser else "user",
}
for user in User.objects.order_by("username")
],
}
+382
View File
@@ -0,0 +1,382 @@
"""REST endpoints exposed by IoT PreTester 4.6 Demo."""
import json
import re
from pathlib import Path
from uuid import uuid4
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.files.storage import FileSystemStorage
from django.http import HttpResponse
from django.db.models.deletion import ProtectedError
from django.shortcuts import get_object_or_404
from rest_framework import serializers, status
from rest_framework.permissions import AllowAny, IsAdminUser
from rest_framework.response import Response
from rest_framework.views import APIView
from .analyze.security_analysis import collect_cloud_results
from .db_create import (
create_capture,
create_device_type,
create_manufacturer,
create_new_device,
)
from .db_read import (
get_all_device_types,
get_all_devices,
get_all_manufacturers,
get_device_by_id,
get_files_by_device,
)
from .models import (
DeepAnalysisJob,
Device,
DeviceType,
File,
FindingReport,
Manufacturer,
)
from .user_funcs import (
delete_user,
list_users,
login_user,
logout_user,
register_user,
set_password,
)
def _validation_error(exc: ValidationError) -> Response:
messages = getattr(exc, "messages", [str(exc)])
return Response({"error": messages[0]}, status=status.HTTP_400_BAD_REQUEST)
class HealthView(APIView):
permission_classes = [AllowAny]
def get(self, request):
return Response({"status": "ok", "service": "IoT PreTester 4.6 Demo"})
class LoginView(APIView):
permission_classes = [AllowAny]
def post(self, request):
username = request.data.get("username")
password = request.data.get("password")
if not username or not password:
return Response(
[False, {"message": "Username and password are required."}],
status=status.HTTP_400_BAD_REQUEST,
)
return Response(login_user(request, username, password))
class LogoutView(APIView):
def post(self, request):
return Response(logout_user(request))
class UserSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)
password = serializers.CharField(write_only=True)
role = serializers.ChoiceField(choices=["admin", "user"])
class RegisterUserView(APIView):
permission_classes = [IsAdminUser]
def post(self, request):
serializer = UserSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
result = register_user(**serializer.validated_data)
except ValidationError as exc:
return _validation_error(exc)
return Response(result, status=status.HTTP_201_CREATED)
class ListUsersView(APIView):
permission_classes = [IsAdminUser]
def get(self, request):
return Response(list_users())
class DeleteUserView(APIView):
permission_classes = [IsAdminUser]
def post(self, request):
try:
user_id = int(request.data.get("id"))
return Response(delete_user(request, user_id))
except (TypeError, ValueError):
return Response({"error": "A valid user ID is required."}, status=400)
except ObjectDoesNotExist:
return Response({"error": "The user does not exist."}, status=404)
except ValidationError as exc:
return _validation_error(exc)
class ResetPasswordView(APIView):
permission_classes = [IsAdminUser]
def post(self, request):
user_id = request.data.get("user_id")
password = request.data.get("new_password")
if not isinstance(password, str) or not password:
return Response({"error": "A new password is required."}, status=400)
try:
return Response(set_password(int(user_id), password))
except (TypeError, ValueError):
return Response({"error": "A valid user ID is required."}, status=400)
except ObjectDoesNotExist:
return Response({"error": "The user does not exist."}, status=404)
except ValidationError as exc:
return _validation_error(exc)
class CreateManufacturerView(APIView):
permission_classes = [IsAdminUser]
def post(self, request):
try:
item = create_manufacturer(request.data.get("name"))
return Response(item.to_json(), status=status.HTTP_201_CREATED)
except ValidationError as exc:
return _validation_error(exc)
class CreateDeviceTypeView(APIView):
permission_classes = [IsAdminUser]
def post(self, request):
try:
item = create_device_type(request.data.get("device_type"))
return Response(item.to_json(), status=status.HTTP_201_CREATED)
except ValidationError as exc:
return _validation_error(exc)
class DeviceSerializer(serializers.Serializer):
device_type_id = serializers.IntegerField()
device_name = serializers.CharField(max_length=100)
serial = serializers.CharField(max_length=100)
manufacturer_id = serializers.IntegerField()
comment = serializers.CharField(max_length=255, required=False, allow_blank=True)
class CreateDeviceView(APIView):
permission_classes = [IsAdminUser]
def post(self, request):
serializer = DeviceSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
device = create_new_device(**serializer.validated_data)
return Response(device.to_json(), status=status.HTTP_201_CREATED)
except ObjectDoesNotExist:
return Response({"error": "The manufacturer or device type does not exist."}, status=400)
except ValidationError as exc:
return _validation_error(exc)
class CaptureSerializer(serializers.Serializer):
capture = serializers.FileField(write_only=True)
device_id = serializers.IntegerField()
firmware_version = serializers.CharField(max_length=100)
ip_address = serializers.IPAddressField()
def validate_capture(self, capture):
if capture.size <= 0:
raise serializers.ValidationError("The capture is empty.")
if capture.size > 512 * 1024 * 1024:
raise serializers.ValidationError("The capture exceeds the 512 MB demo limit.")
if Path(capture.name).suffix.lower() not in {".pcap", ".pcapng"}:
raise serializers.ValidationError("Only PCAP and PCAPNG captures are supported.")
return capture
class CreateCaptureView(APIView):
def post(self, request):
serializer = CaptureSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
values = dict(serializer.validated_data)
uploaded_capture = values.pop("capture")
suffix = Path(uploaded_capture.name).suffix.lower()
stem = re.sub(r"[^A-Za-z0-9._-]+", "_", Path(uploaded_capture.name).stem)[:80]
stored_name = f"{stem or 'capture'}-{uuid4().hex[:12]}{suffix}"
storage = FileSystemStorage(location=settings.CAPTURE_UPLOAD_DIR)
stored_name = storage.save(stored_name, uploaded_capture)
try:
capture = create_capture(filename=stored_name, **values)
except ObjectDoesNotExist:
storage.delete(stored_name)
return Response({"error": "The selected device does not exist."}, status=400)
except ValidationError as exc:
storage.delete(stored_name)
return _validation_error(exc)
except Exception:
storage.delete(stored_name)
raise
return Response(
{"status": "success", "data": capture.to_json()},
status=status.HTTP_201_CREATED,
)
class ManufacturersView(APIView):
def get(self, request):
return Response(get_all_manufacturers())
class DeviceTypesView(APIView):
def get(self, request):
return Response(get_all_device_types())
class DevicesView(APIView):
def get(self, request):
return Response(get_all_devices())
class DeviceDetailView(APIView):
def get(self, request, device_id):
get_object_or_404(Device, id=device_id)
return Response(get_device_by_id(device_id))
class DeviceCapturesView(APIView):
def get(self, request, device_id):
get_object_or_404(Device, id=device_id)
return Response(get_files_by_device(device_id))
class DeleteDeviceView(APIView):
permission_classes = [IsAdminUser]
def delete(self, request, device_id):
device = get_object_or_404(Device, id=device_id)
device.delete()
return Response({"status": "success"})
class DeleteManufacturerView(APIView):
permission_classes = [IsAdminUser]
def delete(self, request, manufacturer_id):
item = get_object_or_404(Manufacturer, id=manufacturer_id)
try:
item.delete()
except ProtectedError:
return Response(
{"error": "The manufacturer is still used by a product."},
status=status.HTTP_409_CONFLICT,
)
return Response({"status": "success"})
class DeleteDeviceTypeView(APIView):
permission_classes = [IsAdminUser]
def delete(self, request, device_type_id):
item = get_object_or_404(DeviceType, id=device_type_id)
try:
item.delete()
except ProtectedError:
return Response(
{"error": "The device type is still used by a product."},
status=status.HTTP_409_CONFLICT,
)
return Response({"status": "success"})
class FindingsView(APIView):
def get(self, request, file_id):
get_object_or_404(File, id=file_id)
report = FindingReport.objects.filter(file_id=file_id).first()
if report is None:
return Response(
{
"status": "empty",
"message": "No security analysis is available for this capture.",
"summary": {
"total": 0,
"by_severity": {},
"by_category": {},
"highlights": [],
"top": [],
},
"findings": [],
}
)
return Response(json.loads(report.result_json))
class CloudResultsView(APIView):
def get(self, request, file_id):
get_object_or_404(File, id=file_id)
response = Response(collect_cloud_results(file_id))
response["Cache-Control"] = "no-store"
return response
class StartAnalysisView(APIView):
def post(self, request, file_id):
get_object_or_404(File, id=file_id)
try:
url_ids = [int(value) for value in (request.data.get("url_ids") or [])]
hash_ids = [int(value) for value in (request.data.get("hash_ids") or [])]
except (TypeError, ValueError):
return Response({"error": "Artifact IDs must be integers."}, status=400)
if not url_ids and not hash_ids:
return Response({"error": "Select at least one artifact."}, status=400)
from .analyze.deep_analysis import start_deep_analysis
start_deep_analysis(file_id, url_ids=url_ids, hash_ids=hash_ids)
return Response(
{"status": "started", "url_count": len(url_ids), "hash_count": len(hash_ids)},
status=status.HTTP_202_ACCEPTED,
)
class AnalysisStatusView(APIView):
def get(self, request, file_id):
get_object_or_404(File, id=file_id)
job = DeepAnalysisJob.objects.filter(file_id=file_id).first()
data = job.to_json() if job else {
"state": "pending",
"url_total": 0,
"url_done": 0,
"hash_total": 0,
"hash_done": 0,
"cracked": 0,
"progress_done": 0,
"progress_total": 0,
"percent": 0,
"message": "Not started.",
}
response = Response(data)
response["Cache-Control"] = "no-store"
return response
class ReportView(APIView):
def get(self, request, file_id, fmt):
from analyzer import FORMATS, render, result_from_dict
fmt = fmt.lower()
if fmt not in FORMATS:
return Response({"error": f"Allowed formats: {', '.join(FORMATS)}"}, status=400)
report = get_object_or_404(FindingReport, file_id=file_id)
result = result_from_dict(json.loads(report.result_json))
content = render(result, fmt, collect_cloud_results(file_id))
content_types = {"md": "text/markdown", "txt": "text/plain", "json": "application/json"}
response = HttpResponse(content, content_type=content_types[fmt])
response["Content-Disposition"] = (
f'attachment; filename="iot-pretester-4.6-demo-report-{file_id}.{fmt}"'
)
return response
+9
View File
@@ -0,0 +1,9 @@
"""ASGI entry point for the demo backend."""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "IoTPreTester.settings")
application = get_asgi_application()
+131
View File
@@ -0,0 +1,131 @@
"""Django settings for IoT PreTester 4.6 Demo."""
import os
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
def env_bool(name: str, default: bool = False) -> bool:
return os.environ.get(name, str(default)).strip().lower() in {"1", "true", "yes"}
DEBUG = env_bool("DEBUG", False)
SECRET_KEY = os.environ.get("SECRET_KEY")
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = "django-insecure-iot-pretester-4.6-demo-local-only"
else:
raise ImproperlyConfigured("SECRET_KEY must be configured when DEBUG is disabled.")
ALLOWED_HOSTS = [
host.strip()
for host in os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
if host.strip()
]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"corsheaders",
"rest_framework",
"IoTAnalyzer",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "IoTPreTester.urls"
WSGI_APPLICATION = "IoTPreTester.wsgi.application"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.environ.get("DATABASE_PATH", BASE_DIR / "db.sqlite3"),
"OPTIONS": {"timeout": 30},
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"IoTAnalyzer.authentication.DemoSessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {"user": "20/second"},
}
CORS_ALLOW_ALL_ORIGINS = env_bool("CORS_ALLOW_ALL_ORIGINS", False)
CORS_ALLOWED_ORIGINS = [
origin.strip()
for origin in os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",")
if origin.strip()
]
CORS_ALLOW_CREDENTIALS = True
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
CAPTURE_UPLOAD_DIR = Path(
os.environ.get("CAPTURE_UPLOAD_DIR", BASE_DIR.parent / "uploads")
)
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
APPEND_SLASH = False
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
X_FRAME_OPTIONS = "DENY"
SECURE_CONTENT_TYPE_NOSNIFF = True
if not DEBUG:
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", False)
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", False)
+9
View File
@@ -0,0 +1,9 @@
"""Root routes for the demo backend."""
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("IoTAnalyzer.urls")),
]
+9
View File
@@ -0,0 +1,9 @@
"""WSGI entry point used by Gunicorn."""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "IoTPreTester.settings")
application = get_wsgi_application()
View File
+55
View File
@@ -0,0 +1,55 @@
# IoT PreTester 4.6 Demo analysis core
`analyzer` is the framework-independent network-capture analysis package used by both the Django backend and the command-line interface. It accepts `.pcap` or `.pcapng` files, normalizes protocol data once, runs independent detectors, and renders Markdown, text, or JSON reports.
## Main modules
- `context.py` defines the normalized `CaptureContext` records.
- `extract_pyshark.py` builds the context through tshark/pyshark.
- `detectors/` contains one detection concern per module.
- `engine.py` runs detectors and assembles the final result.
- `scoring.py` deduplicates and prioritizes findings.
- `provenance.py` attaches artifacts and packet/stream references.
- `artifacts.py` extracts URL and hash artifacts from the shared context.
- `enrich/` provides optional URL probing and hash recovery.
- `reporting/` renders `md`, `txt`, and `json` output.
## Command line
From `django_project/`:
```bash
python -m analyzer.cli ../demo_files/demo_capture.pcapng
python -m analyzer.cli capture.pcapng -f json -o report.json
python -m analyzer.cli capture.pcapng -f txt --packet-limit 100000
```
Exit codes are `0` for no high/critical findings, `2` when at least one high/critical finding exists, and `1` for execution errors.
## Python API
```python
from analyzer import AnalysisEngine, render
from analyzer.context import CaptureContext
result = AnalysisEngine().analyze_pcap("capture.pcapng")
print(render(result, "md"))
# Detector logic can also be tested without tshark.
synthetic_result = AnalysisEngine().analyze_context(CaptureContext())
```
## Tests
```bash
python -m unittest analyzer.tests.test_analyzer -v
```
The unit suite uses synthetic contexts and does not require tshark.
## Adding a detector
1. Add a focused module under `detectors/`.
2. Inherit from `BaseDetector`, apply `@register`, and return shared `Finding` objects from `detect()`.
3. Import the module from `detectors/__init__.py`.
4. Add focused tests under `tests/`.
+37
View File
@@ -0,0 +1,37 @@
"""Framework-independent analysis core for IoT PreTester 4.6 Demo.
The package is independent of Django and supports both backend and CLI use.
The pyshark/tshark extractor is loaded only when a capture is analyzed.
Headless example:
from analyzer import AnalysisEngine, render
result = AnalysisEngine().analyze_pcap("capture.pcapng")
print(render(result, "md"))
Synthetic-context example without tshark:
from analyzer import AnalysisEngine
from analyzer.context import CaptureContext
result = AnalysisEngine().analyze_context(CaptureContext(...))
"""
from __future__ import annotations
from .findings import Finding, Category, Severity, Confidence
from .context import CaptureContext
from .engine import AnalysisEngine, AnalysisResult, result_from_dict
from .reporting import render, FORMATS
from .artifacts import (
UrlArtifact, HashArtifact, extract_urls, extract_hashes, identify_hash_mode,
)
# Importing the package registers every detector.
from . import detectors # noqa: F401
__version__ = "4.6.0-demo.0"
__all__ = [
"Finding", "Category", "Severity", "Confidence",
"CaptureContext", "AnalysisEngine", "AnalysisResult", "result_from_dict",
"render", "FORMATS", "__version__",
"UrlArtifact", "HashArtifact", "extract_urls", "extract_hashes", "identify_hash_mode",
]
+237
View File
@@ -0,0 +1,237 @@
"""Extract endpoint and crackable hash artifacts from a capture context.
Extraction is independent of Django, pyshark, and I/O. Active endpoint probing
and hash cracking remain isolated in ``analyzer.enrich``.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Optional
from .context import CaptureContext
# --------------------------------------------------------------------------- #
# Artifact data models
# --------------------------------------------------------------------------- #
@dataclass
class UrlArtifact:
"""A domain, host, or URL observed in the capture."""
value: str
kind: str = "unknown" # "dns" | "sni" | "http_host" | "payload"
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
timestamp: Optional[float] = None
packet_number: Optional[int] = None
# Passive observations are preferred over active probing results.
observed_ports: set[int] = field(default_factory=set)
observed_tls: set[str] = field(default_factory=set)
def to_dict(self) -> dict:
return {
"value": self.value, "kind": self.kind,
"src_ip": self.src_ip, "dst_ip": self.dst_ip,
"timestamp": self.timestamp, "packet_number": self.packet_number,
"observed_ports": sorted(self.observed_ports),
"observed_tls": sorted(self.observed_tls),
}
@dataclass
class HashArtifact:
"""A hash or crackable authentication artifact from the capture."""
value: str # Raw hash or complete hashcat line
hashcat_mode: Optional[int] = None
hash_format: Optional[str] = None # Human-readable format name
kind: str = "raw_hex" # "raw_hex" | "netntlmv2" | "krb5asrep" | "krb5tgs" | "wpa" | "digest"
account: Optional[str] = None
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
timestamp: Optional[float] = None
packet_number: Optional[int] = None
def to_dict(self) -> dict:
return {
"value": self.value, "hashcat_mode": self.hashcat_mode,
"hash_format": self.hash_format, "kind": self.kind,
"account": self.account, "src_ip": self.src_ip, "dst_ip": self.dst_ip,
"timestamp": self.timestamp, "packet_number": self.packet_number,
}
# --------------------------------------------------------------------------- #
# Hashcat mode detection
# --------------------------------------------------------------------------- #
# Hex length to hashcat mode and format name.
_LEN_MODES = {
32: (0, "md5"),
40: (100, "sha1"),
56: (300, "sha224"),
64: (1400, "sha256"),
96: (6100, "sha384"),
128: (1700, "sha512"),
}
# Structured format name to hashcat mode.
_FORMAT_MODES = {
"netntlmv1": 5500,
"netntlmv2": 5600,
"krb5asrep": 18200,
"krb5tgs": 13100,
"wpa": 22000,
"eapol": 22000,
}
_NETNTLMV2_RE = re.compile(r"^[^:]+::[^:]*:[0-9a-fA-F]{16}:[0-9a-fA-F]{32}:[0-9a-fA-F]+$")
_NETNTLMV1_RE = re.compile(r"^[^:]+::[^:]*:[0-9a-fA-F]{48}:[0-9a-fA-F]{48}:[0-9a-fA-F]{16}$")
def identify_hash_mode(value: str, hint_format: Optional[str] = None):
"""Return a hashcat mode and format using structure before hex length."""
v = (value or "").strip()
if not v:
return None, None
if hint_format:
base = str(hint_format).split("(")[0].strip().lower()
if base in _FORMAT_MODES:
return _FORMAT_MODES[base], hint_format
low = v.lower()
if low.startswith("$krb5asrep$"):
return 18200, "krb5asrep"
if low.startswith("$krb5tgs$"):
return 13100, "krb5tgs"
if low.startswith("$wpapsk$") or low.startswith("wpa*") or low.startswith("$wpa$"):
return 22000, "wpa"
if _NETNTLMV2_RE.match(v):
return 5600, "NetNTLMv2"
if _NETNTLMV1_RE.match(v):
return 5500, "NetNTLMv1"
if all(c in "0123456789abcdefABCDEF" for c in v) and len(v) in _LEN_MODES:
return _LEN_MODES[len(v)]
return None, None
def _kind_from_mode(mode: Optional[int]) -> str:
return {
5500: "netntlmv1", 5600: "netntlmv2",
18200: "krb5asrep", 13100: "krb5tgs", 22000: "wpa",
}.get(mode, "raw_hex")
# --------------------------------------------------------------------------- #
# Endpoint extraction
# --------------------------------------------------------------------------- #
_URL_RE = re.compile(
r"\b(?:"
r"(?:https?://|www\.)(?:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})" # URL or www host
r"|"
r"(?:[a-zA-Z0-9-]+\.){2,}[a-zA-Z]{2,6}" # Bare multi-label domain
r"|"
r"https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}" # URL with IPv4 host
r")"
r"(?:/[a-zA-Z0-9._%/-]*)?\b"
)
def _normalize_host(value: Optional[str]) -> Optional[str]:
if not value:
return None
v = str(value).strip().lower()
v = v.rstrip(".")
return v or None
def extract_urls(ctx: CaptureContext) -> list[UrlArtifact]:
"""Collect endpoints and attach passively observed ports and TLS versions."""
by_value: dict[str, UrlArtifact] = {}
def add(value, kind, **kw) -> Optional[UrlArtifact]:
v = _normalize_host(value)
if not v:
return None
art = by_value.get(v)
if art is None:
art = UrlArtifact(value=v, kind=kind, **kw)
by_value[v] = art
return art
for q in ctx.dns_queries:
add(q.query_name, "dns", src_ip=q.src_ip, dst_ip=q.dst_ip,
timestamp=q.timestamp, packet_number=q.packet_number)
for t in ctx.tls_sessions:
if t.sni:
art = add(t.sni, "sni", src_ip=t.src_ip, dst_ip=t.dst_ip,
timestamp=t.timestamp, packet_number=t.packet_number)
if art:
if t.dst_port:
art.observed_ports.add(t.dst_port)
if t.version:
art.observed_tls.add(t.version)
for r in ctx.http_requests:
if r.host:
art = add(r.host, "http_host", src_ip=r.src_ip, dst_ip=r.dst_ip,
timestamp=r.timestamp, packet_number=r.packet_number)
if art and r.dst_port:
art.observed_ports.add(r.dst_port)
for p in ctx.text_payloads:
text = p.get("text") or ""
for m in _URL_RE.findall(text):
add(m, "payload", src_ip=p.get("src_ip"), dst_ip=p.get("dst_ip"),
timestamp=p.get("timestamp"), packet_number=p.get("packet_number"))
return list(by_value.values())
# --------------------------------------------------------------------------- #
# Hash extraction
# --------------------------------------------------------------------------- #
_HASH_HEX_RE = re.compile(r"\b[a-fA-F0-9]{32,64}\b")
def extract_hashes(ctx: CaptureContext) -> list[HashArtifact]:
"""Collect structured authentication material and raw hexadecimal hashes."""
out: list[HashArtifact] = []
seen: set[str] = set()
def add(value, *, mode, fmt, kind, **kw) -> None:
if not value or value in seen:
return
seen.add(value)
out.append(HashArtifact(value=value, hashcat_mode=mode, hash_format=fmt,
kind=kind, **kw))
# Structured material already reconstructed by the packet extractor.
for am in ctx.auth_material:
if getattr(am, "hashcat", None):
mode = am.hashcat_mode
fmt = am.hash_format
if mode is None:
mode, fmt = identify_hash_mode(am.hashcat, hint_format=fmt)
add(am.hashcat, mode=mode, fmt=fmt, kind=_kind_from_mode(mode),
account=am.account, src_ip=am.src_ip, dst_ip=am.dst_ip,
timestamp=am.timestamp, packet_number=am.packet_number)
# Raw 32–64 character hex values use a mode only when the length is known.
for p in ctx.text_payloads:
text = p.get("text") or ""
for m in _HASH_HEX_RE.findall(text):
mode, fmt = identify_hash_mode(m)
add(m.lower(), mode=mode, fmt=fmt, kind="raw_hex",
src_ip=p.get("src_ip"), dst_ip=p.get("dst_ip"),
timestamp=p.get("timestamp"), packet_number=p.get("packet_number"))
return out
+72
View File
@@ -0,0 +1,72 @@
"""Headless CLI for IoT PreTester 4.6 Demo.
Examples:
python -m analyzer.cli capture.pcapng
python -m analyzer.cli capture.pcapng --format json -o report.json
python -m analyzer.cli capture.pcapng --format txt --packet-limit 50000
Exit codes:
0 Analysis completed without high or critical findings
2 Analysis completed with at least one high or critical finding
1 Capture or runtime dependency error
"""
from __future__ import annotations
import argparse
import sys
from . import __version__
from .engine import AnalysisEngine
from .findings import Severity
from .reporting import render, FORMATS
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="analyzer",
description="Analyze the security of a PCAP or PCAPNG network capture.",
)
p.add_argument("capture", help="Path to a .pcap or .pcapng file")
p.add_argument("-f", "--format", choices=FORMATS, default="md",
help="Output format (default: md)")
p.add_argument("-o", "--output", default=None,
help="Output file (default: standard output)")
p.add_argument("--packet-limit", type=int, default=None,
help="Maximum packets to read from a large capture")
p.add_argument(
"--version",
action="version",
version=f"IoT PreTester 4.6 Demo analyzer {__version__}",
)
return p
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
try:
result = AnalysisEngine().analyze_pcap(args.capture, packet_limit=args.packet_limit)
except FileNotFoundError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
except RuntimeError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
report = render(result, args.format)
if args.output:
with open(args.output, "w", encoding="utf-8") as fh:
fh.write(report)
print(f"Report written: {args.output} "
f"({result.meta.get('finding_count', 0)} Findings)", file=sys.stderr)
else:
print(report)
worst = max((f.severity.rank for f in result.findings), default=0)
return 2 if worst >= Severity.HIGH.rank else 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+249
View File
@@ -0,0 +1,249 @@
"""Normalized capture data consumed by every detector.
The module is independent of Django and pyshark so detector logic can be tested
with synthetic data. ``extract_pyshark.py`` performs the actual PCAP extraction.
"""
from __future__ import annotations
import ipaddress
from dataclasses import dataclass, field
from typing import Optional
# --------------------------------------------------------------------------- #
# Normalized records
# --------------------------------------------------------------------------- #
@dataclass
class Host:
ip: str
macs: set[str] = field(default_factory=set)
hostnames: set[str] = field(default_factory=set)
bytes_sent: int = 0
bytes_received: int = 0
ports_offered: set[int] = field(default_factory=set) # Ports on which the host responds
ports_contacted: set[int] = field(default_factory=set) # Ports contacted by the host
@property
def is_private(self) -> bool:
return _is_private(self.ip)
@dataclass
class Conversation:
"""A five-tuple flow aggregated between two endpoints."""
src_ip: str
dst_ip: str
src_port: Optional[int]
dst_port: Optional[int]
protocol: str # For example, "tcp" or "udp"
packets: int = 0
bytes: int = 0
bytes_src_to_dst: int = 0
bytes_dst_to_src: int = 0
first_seen: Optional[float] = None
last_seen: Optional[float] = None
stream_id: Optional[int] = None
timestamps: list[float] = field(default_factory=list) # Used to detect beaconing
packet_numbers: list[int] = field(default_factory=list) # Bounded evidence packet list
@dataclass
class DnsQuery:
query_name: str
qtype: Optional[str] = None
response_ips: list[str] = field(default_factory=list)
rcode: Optional[str] = None # For example, "NOERROR" or "NXDOMAIN"
timestamp: Optional[float] = None
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
resolver: Optional[str] = None
packet_number: Optional[int] = None
@dataclass
class HttpRequest:
method: Optional[str] = None
host: Optional[str] = None
uri: Optional[str] = None
user_agent: Optional[str] = None
authorization: Optional[str] = None # Authorization header value
cookies: Optional[str] = None
headers: dict[str, str] = field(default_factory=dict)
body: Optional[str] = None
request_bytes: int = 0
is_tls: bool = False # True when only TLS metadata is visible
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
dst_port: Optional[int] = None
timestamp: Optional[float] = None
stream_id: Optional[int] = None
packet_number: Optional[int] = None
@dataclass
class HttpResponse:
status_code: Optional[int] = None
content_type: Optional[str] = None
server: Optional[str] = None
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
timestamp: Optional[float] = None
stream_id: Optional[int] = None
packet_number: Optional[int] = None
@dataclass
class TlsSession:
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
dst_port: Optional[int] = 443
version: Optional[str] = None # For example, "TLS 1.2" or "TLS 1.0"
cipher: Optional[str] = None # Cipher-suite name
sni: Optional[str] = None
cert_subject_cn: Optional[str] = None
cert_issuer_cn: Optional[str] = None
cert_not_after: Optional[str] = None
cert_self_signed: Optional[bool] = None
cert_expired: Optional[bool] = None
timestamp: Optional[float] = None
stream_id: Optional[int] = None
packet_number: Optional[int] = None
@dataclass
class AuthMaterial:
"""Hashes, challenge-response material, and authentication tickets."""
kind: str # "ntlmssp", "netntlmv2", "kerberos_asrep", ...
value: Optional[str] = None
account: Optional[str] = None
domain: Optional[str] = None
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
protocol: Optional[str] = None
timestamp: Optional[float] = None
packet_number: Optional[int] = None
# Crackable material, when reconstruction is possible.
hashcat: Optional[str] = None # Complete hashcat input line
hashcat_mode: Optional[int] = None # For example, 5600 (NetNTLMv2)
hash_format: Optional[str] = None # Human-readable format name
server_challenge: Optional[str] = None # NTLMSSP server challenge (hex)
nt_response: Optional[str] = None # NTLMv2 response (hex)
etype: Optional[str] = None # Kerberos encryption type
@dataclass
class ArpEvent:
sender_ip: Optional[str] = None
sender_mac: Optional[str] = None
target_ip: Optional[str] = None
opcode: Optional[int] = None # 1=request, 2=reply
is_gratuitous: bool = False
timestamp: Optional[float] = None
packet_number: Optional[int] = None
@dataclass
class FileTransfer:
filename: Optional[str] = None
extension: Optional[str] = None
content_type: Optional[str] = None
size: Optional[int] = None
protocol: Optional[str] = None # "http", "ftp-data", "smb", "tftp"
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
direction: Optional[str] = None # "download" / "upload"
timestamp: Optional[float] = None
stream_id: Optional[int] = None
packet_number: Optional[int] = None
@dataclass
class PlaintextAuth:
"""Clear-text authentication observed in an application protocol."""
protocol: str
username: Optional[str] = None
password: Optional[str] = None
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
dst_port: Optional[int] = None
timestamp: Optional[float] = None
stream_id: Optional[int] = None
packet_number: Optional[int] = None
# --------------------------------------------------------------------------- #
# Central capture context
# --------------------------------------------------------------------------- #
@dataclass
class CaptureContext:
"""All detector input extracted during a single capture pass."""
path: Optional[str] = None
packet_count: int = 0
start_time: Optional[float] = None
end_time: Optional[float] = None
hosts: dict[str, Host] = field(default_factory=dict)
conversations: list[Conversation] = field(default_factory=list)
dns_queries: list[DnsQuery] = field(default_factory=list)
http_requests: list[HttpRequest] = field(default_factory=list)
http_responses: list[HttpResponse] = field(default_factory=list)
tls_sessions: list[TlsSession] = field(default_factory=list)
auth_material: list[AuthMaterial] = field(default_factory=list)
arp_events: list[ArpEvent] = field(default_factory=list)
file_transfers: list[FileTransfer] = field(default_factory=list)
plaintext_auth: list[PlaintextAuth] = field(default_factory=list)
# protocol_stats: protocol-name -> packet count
protocol_stats: dict[str, int] = field(default_factory=dict)
# Generic text payloads with packet metadata for secret detection.
text_payloads: list[dict] = field(default_factory=list)
@property
def duration(self) -> float:
if self.start_time is None or self.end_time is None:
return 0.0
return max(0.0, self.end_time - self.start_time)
def host(self, ip: str) -> Host:
h = self.hosts.get(ip)
if h is None:
h = Host(ip=ip)
self.hosts[ip] = h
return h
def is_external(self, ip: Optional[str]) -> bool:
"""Return whether an address is globally routable."""
if not ip:
return False
return not _is_private(ip)
def protocol_count(self, *names: str) -> int:
"""Return a case-insensitive packet total for the requested protocols."""
wanted = {n.lower() for n in names}
return sum(
count for proto, count in self.protocol_stats.items()
if str(proto).lower() in wanted
)
def _is_private(ip: str) -> bool:
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
return (
addr.is_private
or addr.is_loopback
or addr.is_link_local
or addr.is_multicast
or addr.is_reserved
or addr.is_unspecified
)
+122
View File
@@ -0,0 +1,122 @@
"""Pure helpers that format captured authentication material for hashcat.
Supported hashcat modes:
5500 NetNTLMv1
5600 NetNTLMv2
18200 Kerberos AS-REP (etype 23)
13100 Kerberos TGS-REP (etype 23)
"""
from __future__ import annotations
import base64
from typing import Optional
def _hex(s: Optional[str]) -> str:
"""Normalize a hexadecimal string and remove separators."""
if not s:
return ""
return "".join(c for c in str(s).lower() if c in "0123456789abcdef")
# --------------------------------------------------------------------------- #
# NTLM / NetNTLM
# --------------------------------------------------------------------------- #
def build_netntlmv2(user, domain, server_challenge, nt_response) -> Optional[dict]:
"""NetNTLMv2 (hashcat -m 5600).
Format: user::domain:serverchallenge:NTproofstr:blob
The NT response contains a 16-byte proof followed by the remaining blob.
"""
chal = _hex(server_challenge)
nt = _hex(nt_response)
if not user or len(chal) != 16 or len(nt) < 32:
return None
ntproof, blob = nt[:32], nt[32:]
line = f"{user}::{domain or ''}:{chal}:{ntproof}:{blob}"
return {"hashcat": line, "hashcat_mode": 5600, "format": "NetNTLMv2"}
def build_netntlmv1(user, host, server_challenge, lm_response, nt_response) -> Optional[dict]:
"""NetNTLMv1 (hashcat -m 5500).
Format: user::host:LMresponse:NTresponse:serverchallenge
"""
chal = _hex(server_challenge)
nt = _hex(nt_response)
lm = _hex(lm_response) or nt
if not user or len(chal) != 16 or len(nt) < 32:
return None
line = f"{user}::{host or ''}:{lm}:{nt}:{chal}"
return {"hashcat": line, "hashcat_mode": 5500, "format": "NetNTLMv1"}
# --------------------------------------------------------------------------- #
# Kerberos
# --------------------------------------------------------------------------- #
def build_krb5_asrep(user, realm, etype, cipher) -> Optional[dict]:
"""Kerberos AS-REP Roasting (hashcat -m 18200, etype 23).
Format: $krb5asrep$<etype>$user@REALM:<checksum>$<edata>
cipher = checksum (16 Byte) + edata.
"""
ci = _hex(cipher)
if not user or len(ci) <= 32:
return None
et = str(etype or 23)
checksum, edata = ci[:32], ci[32:]
realm_u = (realm or "").upper()
line = f"$krb5asrep${et}${user}@{realm_u}:{checksum}${edata}"
mode = 18200 if et == "23" else None
return {"hashcat": line, "hashcat_mode": mode, "format": f"krb5asrep(etype={et})"}
def build_krb5_tgsrep(user, realm, spn, etype, cipher) -> Optional[dict]:
"""Kerberoasting / TGS-REP (hashcat -m 13100, etype 23).
Format: $krb5tgs$<etype>$*user$realm$spn*$<checksum>$<edata>
"""
ci = _hex(cipher)
if not user or len(ci) <= 32:
return None
et = str(etype or 23)
checksum, edata = ci[:32], ci[32:]
realm_u = (realm or "").upper()
line = f"$krb5tgs${et}$*{user}${realm_u}${spn or ''}*${checksum}${edata}"
mode = 13100 if et == "23" else None
return {"hashcat": line, "hashcat_mode": mode, "format": f"krb5tgs(etype={et})"}
# --------------------------------------------------------------------------- #
# SMTP AUTH is clear text with Base64 encoding only.
# --------------------------------------------------------------------------- #
def _b64(token: str) -> Optional[str]:
try:
return base64.b64decode(str(token).strip() + "===").decode("utf-8", "ignore")
except Exception:
return None
def decode_smtp_auth_plain(token: str) -> Optional[tuple[Optional[str], Optional[str]]]:
"""Decode an AUTH PLAIN token into username and password."""
raw = _b64(token)
if raw is None:
return None
parts = raw.split("\x00")
if len(parts) >= 3:
return parts[1] or None, parts[2] or None
if len(parts) == 2:
return parts[0] or None, parts[1] or None
return None
def decode_smtp_auth_login(user_token: Optional[str],
pass_token: Optional[str]) -> tuple[Optional[str], Optional[str]]:
"""Decode the separately encoded AUTH LOGIN username and password."""
user = _b64(user_token) if user_token else None
pw = _b64(pass_token) if pass_token else None
return user, pw
@@ -0,0 +1,17 @@
"""Import and register the ordered default detector set."""
from .base import BaseDetector, register, build_all, all_detector_classes # noqa: F401
# Import order defines the stable detector order in reports.
from . import plaintext_credentials # 1
from . import password_hashes # 2
from . import tokens_secrets # 3
from . import malware_beaconing # 4
from . import exfiltration # 5
from . import mitm # 6
from . import insecure_protocols # 7
from . import tls_weakness # 8
from . import dns_anomaly # 9
from . import bruteforce_scan_filetransfer # 10
__all__ = ["BaseDetector", "register", "build_all", "all_detector_classes"]
+45
View File
@@ -0,0 +1,45 @@
"""Detector base class and registration mechanism."""
from __future__ import annotations
from typing import Iterable
from ..context import CaptureContext
from ..findings import Finding
class BaseDetector:
#: Short unique name referenced by findings and reports.
name: str = "BaseDetector"
#: Human-readable description of the detector scope.
description: str = ""
def detect(self, ctx: CaptureContext) -> Iterable[Finding]: # pragma: no cover
raise NotImplementedError
def _tag(self, findings: Iterable[Finding]) -> list[Finding]:
out = []
for f in findings:
f.detector = self.name
out.append(f)
return out
# ---- Registry ------------------------------------------------------------- #
_REGISTRY: dict[str, type[BaseDetector]] = {}
def register(cls: type[BaseDetector]) -> type[BaseDetector]:
"""Register a detector class."""
_REGISTRY[cls.name] = cls
return cls
def all_detector_classes() -> list[type[BaseDetector]]:
return list(_REGISTRY.values())
def build_all() -> list[BaseDetector]:
"""Instantiate all registered detector classes."""
return [cls() for cls in _REGISTRY.values()]
@@ -0,0 +1,154 @@
"""Detect brute force, scanning, and suspicious file transfers."""
from __future__ import annotations
import os
from collections import defaultdict
from typing import Iterable
from ..context import CaptureContext
from ..findings import Finding, Category, Severity, Confidence
from .base import BaseDetector, register
# File extensions that warrant closer inspection.
_SUSPICIOUS_EXT = {
".exe", ".dll", ".ps1", ".vbs", ".js", ".jar", ".bat", ".cmd", ".scr",
".hta", ".lnk", ".docm", ".xlsm", ".pptm", ".zip", ".rar", ".7z", ".iso", ".elf",
}
_PORTSCAN_PORTS = 15
_HOSTSWEEP_HOSTS = 15
_FAILED_LOGINS = 5
@register
class BruteforceScanFileTransferDetector(BaseDetector):
name = "BruteforceScanFileTransferDetector"
description = "Detects scans, repeated authentication failures, and risky downloads."
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
findings: list[Finding] = []
ports_per_pair: dict[tuple, set[int]] = defaultdict(set)
hosts_per_src: dict[str, set[str]] = defaultdict(set)
packets_per_pair: dict[tuple, list] = defaultdict(list)
packets_per_src: dict[str, list] = defaultdict(list)
for conv in ctx.conversations:
if conv.src_ip and conv.dst_ip:
if conv.dst_port:
ports_per_pair[(conv.src_ip, conv.dst_ip)].add(conv.dst_port)
hosts_per_src[conv.src_ip].add(conv.dst_ip)
if conv.packet_numbers:
packets_per_pair[(conv.src_ip, conv.dst_ip)].append(conv.packet_numbers[0])
packets_per_src[conv.src_ip].append(conv.packet_numbers[0])
for (src, dst), ports in ports_per_pair.items():
if len(ports) >= _PORTSCAN_PORTS:
findings.append(Finding(
title="Port scan detected",
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
source_ip=src, destination_ip=dst,
risk_description=(
"One host contacted many ports on a target, which is characteristic "
"of network reconnaissance."
),
recommendation="Review the source and block unauthorized external scanners.",
evidence={"distinct_ports": len(ports), "dedup_key": f"portscan:{src}:{dst}"},
related_packets=sorted(set(packets_per_pair.get((src, dst), [])))[:8],
))
for src, dsts in hosts_per_src.items():
if len(dsts) >= _HOSTSWEEP_HOSTS:
findings.append(Finding(
title="Host sweep detected",
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
severity=Severity.MEDIUM, confidence=Confidence.LOW,
source_ip=src,
risk_description=(
"One host contacted many different targets, which may indicate a "
"network sweep or worm behavior."
),
recommendation="Review the source and investigate internal hosts for compromise.",
evidence={"distinct_hosts": len(dsts), "dedup_key": f"sweep:{src}"},
related_packets=sorted(set(packets_per_src.get(src, [])))[:8],
))
# Repeated HTTP authorization failures are a brute-force indicator.
http_401: dict[tuple, int] = defaultdict(int)
http_401_pkts: dict[tuple, list] = defaultdict(list)
for resp in ctx.http_responses:
if resp.status_code in (401, 403):
http_401[(resp.dst_ip, resp.src_ip)] += 1
if resp.packet_number:
http_401_pkts[(resp.dst_ip, resp.src_ip)].append(resp.packet_number)
for (client, server), count in http_401.items():
if count >= _FAILED_LOGINS:
findings.append(Finding(
title="Repeated failed HTTP authentication",
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
source_ip=client, destination_ip=server, protocol="http",
risk_description=(
"Many HTTP 401 or 403 responses may indicate a password guessing attack."
),
recommendation="Enable rate limiting or lockout controls and review the source.",
evidence={"failed_responses": count, "dedup_key": f"bruteforce:{client}:{server}"},
related_packets=sorted(set(http_401_pkts.get((client, server), [])))[:8],
))
seen_files: set[str] = set()
for req in ctx.http_requests:
uri = (req.uri or "").split("?")[0]
ext = os.path.splitext(uri)[1].lower()
if ext in _SUSPICIOUS_EXT:
key = f"file:{req.host}:{uri}"
if key in seen_files:
continue
seen_files.add(key)
findings.append(Finding(
title=f"Potentially harmful file download ({ext})",
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
source_ip=req.src_ip, destination_ip=req.dst_ip, protocol="http", port=req.dst_port,
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
risk_description=(
"An executable or script-capable file was transferred over HTTP and "
"may represent a malware download."
),
recommendation="Analyze the file in a sandbox and check its hash against threat intelligence.",
evidence={"host": req.host, "uri": uri, "extension": ext, "dedup_key": key},
related_packets=[p for p in [req.packet_number] if p],
))
# Response metadata can reveal risky files absent from the request URI.
for ft in ctx.file_transfers:
ext = (ft.extension or "").lower()
ct = (ft.content_type or "").lower()
is_executable_ct = any(
m in ct for m in ("x-msdownload", "x-dosexec", "x-executable",
"portable-executable", "x-elf", "x-msi")
)
if ext not in _SUSPICIOUS_EXT and not is_executable_ct:
continue
key = f"ft:{ft.dst_ip}:{ft.filename or ct}"
if key in seen_files:
continue
seen_files.add(key)
label = ext or ct or "binary file"
findings.append(Finding(
title=f"Potentially harmful file download ({label})",
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
source_ip=ft.dst_ip, destination_ip=ft.src_ip, protocol=ft.protocol,
timestamp_first_seen=ft.timestamp, timestamp_last_seen=ft.timestamp,
risk_description=(
"An executable or script-capable file was transferred and may represent "
"a malware download."
),
recommendation="Analyze the file in a sandbox and check its hash against threat intelligence.",
evidence={"filename": ft.filename, "content_type": ft.content_type,
"extension": ext or None, "dedup_key": key},
related_packets=[p for p in [ft.packet_number] if p],
))
return self._tag(findings)
@@ -0,0 +1,154 @@
"""Detect anomalous DNS activity and potential DNS tunneling."""
from __future__ import annotations
import math
from collections import defaultdict
from typing import Iterable
from ..context import CaptureContext
from ..findings import Finding, Category, Severity, Confidence
from .base import BaseDetector, register
_LONG_LABEL = 40
_ENTROPY_MIN_LEN = 12
_HIGH_ENTROPY = 3.2
_NXDOMAIN_RATIO = 0.3
_MANY_SUBDOMAINS = 30
_TUNNEL_QTYPES = {"TXT", "NULL"}
_TXT_TUNNEL_MIN = 20
_TXT_TUNNEL_RATIO = 0.5
@register
class DnsAnomalyDetector(BaseDetector):
name = "DnsAnomalyDetector"
description = "Detects DNS tunneling, DGA-like names, and high NXDOMAIN rates."
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
findings: list[Finding] = []
if not ctx.dns_queries:
return findings
nx = 0
total = 0
txt_like = 0
subdomains_per_domain: dict[str, set[str]] = defaultdict(set)
packets_per_domain: dict[str, list] = defaultdict(list)
nx_packets: list = []
txt_packets: list = []
flagged_random: set[str] = set()
for q in ctx.dns_queries:
name = (q.query_name or "").rstrip(".").lower()
if not name:
continue
total += 1
if (q.rcode or "").upper() == "NXDOMAIN":
nx += 1
if q.packet_number:
nx_packets.append(q.packet_number)
if (q.qtype or "").upper() in _TUNNEL_QTYPES:
txt_like += 1
if q.packet_number:
txt_packets.append(q.packet_number)
labels = name.split(".")
reg_domain = ".".join(labels[-2:]) if len(labels) >= 2 else name
if len(labels) > 2:
subdomains_per_domain[reg_domain].add(".".join(labels[:-2]))
if q.packet_number:
packets_per_domain[reg_domain].append(q.packet_number)
# Long or high-entropy labels may indicate a DGA or tunneling channel.
for label in labels[:-2] or labels:
if len(label) >= _LONG_LABEL or (
len(label) >= _ENTROPY_MIN_LEN and _entropy(label) >= _HIGH_ENTROPY
):
if reg_domain not in flagged_random:
flagged_random.add(reg_domain)
findings.append(Finding(
title="DNS query suspicious for DGA or tunneling activity",
category=Category.DNS_ANOMALY,
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
source_ip=q.src_ip, destination_ip=q.dst_ip, protocol="dns", port=53,
timestamp_first_seen=q.timestamp, timestamp_last_seen=q.timestamp,
risk_description=(
"Long or random-looking subdomains are characteristic of DNS "
"tunneling and malware domain-generation algorithms."
),
recommendation=(
"Check the domain against threat intelligence and enable DNS "
"tunneling detection and egress controls."
),
evidence={"query_name": name, "registered_domain": reg_domain,
"label_entropy": round(_entropy(label), 2),
"dedup_key": f"dga:{reg_domain}"},
related_packets=[p for p in [q.packet_number] if p],
))
break
for domain, subs in subdomains_per_domain.items():
if len(subs) >= _MANY_SUBDOMAINS:
findings.append(Finding(
title=f"Unusually many subdomains under {domain}",
category=Category.DNS_ANOMALY,
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
protocol="dns", port=53,
risk_description=(
"A large number of unique subdomains under one domain is a strong "
"indicator of DNS tunneling or exfiltration."
),
recommendation="Review or block the domain and enable tunneling detection.",
evidence={"registered_domain": domain, "unique_subdomains": len(subs),
"dedup_key": f"subtunnel:{domain}"},
related_packets=sorted(set(packets_per_domain.get(domain, [])))[:8],
))
# TXT and NULL records can carry the large payloads used by DNS tunnels.
if txt_like >= _TXT_TUNNEL_MIN and total and (txt_like / total) >= _TXT_TUNNEL_RATIO:
findings.append(Finding(
title="Unusually many TXT or NULL DNS queries",
category=Category.DNS_ANOMALY,
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
protocol="dns", port=53,
risk_description=(
"TXT and NULL records can carry large payloads and are commonly abused "
"for DNS tunneling or data exfiltration."
),
recommendation=(
"Review the requesting source and destination domain, then enforce DNS "
"tunneling detection and egress controls."
),
evidence={"txt_null_queries": txt_like, "total_queries": total,
"ratio": round(txt_like / total, 2), "dedup_key": "txt_tunnel"},
related_packets=sorted(set(txt_packets))[:8],
))
if total >= 20 and (nx / total) >= _NXDOMAIN_RATIO:
findings.append(Finding(
title="High NXDOMAIN rate",
category=Category.DNS_ANOMALY,
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
protocol="dns", port=53,
risk_description=(
"Repeated NXDOMAIN responses may indicate malware testing generated "
"command-and-control domains."
),
recommendation="Review the requesting source and investigate it for malware.",
evidence={"nxdomain": nx, "total_queries": total,
"ratio": round(nx / total, 2), "dedup_key": "nxdomain_rate"},
related_packets=sorted(set(nx_packets))[:8],
))
return self._tag(findings)
def _entropy(s: str) -> float:
if not s:
return 0.0
counts = defaultdict(int)
for ch in s:
counts[ch] += 1
n = len(s)
return -sum((c / n) * math.log2(c / n) for c in counts.values())
@@ -0,0 +1,125 @@
"""Detect potential data exfiltration."""
from __future__ import annotations
from collections import defaultdict
from typing import Iterable
from ..context import CaptureContext
from ..findings import Finding, Category, Severity, Confidence
from .base import BaseDetector, register
# Conservative heuristic thresholds.
_LARGE_UPLOAD_BYTES = 5 * 1024 * 1024
_AGG_UPLOAD_BYTES = 20 * 1024 * 1024
_MANY_SMALL_TRANSFERS = 50
_DNS_TXT_VOLUME = 100
@register
class ExfiltrationDetector(BaseDetector):
name = "ExfiltrationDetector"
description = "Detects unusual outbound volumes and transfer patterns."
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
findings: list[Finding] = []
per_dest_bytes: dict[str, int] = defaultdict(int)
per_dest_count: dict[str, int] = defaultdict(int)
first_seen: dict[str, float] = {}
last_seen: dict[str, float] = {}
src_for_dest: dict[str, str] = {}
packets_for_dest: dict[str, list] = defaultdict(list)
streams_for_dest: dict[str, list] = defaultdict(list)
for conv in ctx.conversations:
# Evaluate outbound traffic from a private source to an external target.
if not (conv.src_ip and ctx.is_external(conv.dst_ip)
and not ctx.is_external(conv.src_ip)):
continue
up = conv.bytes_src_to_dst or conv.bytes
per_dest_bytes[conv.dst_ip] += up
per_dest_count[conv.dst_ip] += 1
src_for_dest[conv.dst_ip] = conv.src_ip
if conv.packet_numbers:
packets_for_dest[conv.dst_ip].append(conv.packet_numbers[0])
if conv.stream_id is not None:
streams_for_dest[conv.dst_ip].append(conv.stream_id)
if conv.first_seen is not None:
first_seen[conv.dst_ip] = min(first_seen.get(conv.dst_ip, conv.first_seen), conv.first_seen)
if conv.last_seen is not None:
last_seen[conv.dst_ip] = max(last_seen.get(conv.dst_ip, conv.last_seen), conv.last_seen)
if up >= _LARGE_UPLOAD_BYTES:
findings.append(Finding(
title="Large outbound transfer to an external target",
category=Category.EXFILTRATION,
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
source_ip=conv.src_ip, destination_ip=conv.dst_ip,
protocol=conv.protocol, port=conv.dst_port,
timestamp_first_seen=conv.first_seen, timestamp_last_seen=conv.last_seen,
risk_description=(
"A large amount of data left the device for an external target, "
"which may indicate data loss."
),
recommendation="Validate the destination, content, and authorization.",
evidence={"uploaded_bytes": up,
"dedup_key": f"bigup:{conv.src_ip}:{conv.dst_ip}:{conv.dst_port}"},
related_packets=list(conv.packet_numbers),
related_streams=[s for s in [conv.stream_id] if s is not None],
))
for dest, total in per_dest_bytes.items():
if total >= _AGG_UPLOAD_BYTES:
findings.append(Finding(
title="High aggregate upload volume to an external target",
category=Category.EXFILTRATION,
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
source_ip=src_for_dest.get(dest), destination_ip=dest,
timestamp_first_seen=first_seen.get(dest),
timestamp_last_seen=last_seen.get(dest),
risk_description=(
"A high total volume was sent to one external destination across "
"multiple connections, which may indicate gradual exfiltration."
),
recommendation="Review the destination and enforce egress filtering or DLP.",
evidence={"uploaded_bytes_total": total,
"dedup_key": f"aggup:{dest}"},
related_packets=sorted(set(packets_for_dest.get(dest, []))),
related_streams=sorted(set(streams_for_dest.get(dest, []))),
))
elif per_dest_count[dest] >= _MANY_SMALL_TRANSFERS:
findings.append(Finding(
title="Many small outbound transfers to one target",
category=Category.EXFILTRATION,
severity=Severity.MEDIUM, confidence=Confidence.LOW,
source_ip=src_for_dest.get(dest), destination_ip=dest,
risk_description=(
"Many small outbound connections to one target may indicate "
"segmented exfiltration or beaconing."
),
recommendation="Investigate the connection pattern and destination.",
evidence={"transfer_count": per_dest_count[dest],
"dedup_key": f"manyup:{dest}"},
related_packets=sorted(set(packets_for_dest.get(dest, []))),
related_streams=sorted(set(streams_for_dest.get(dest, []))),
))
# DNS volume is a weak signal; the DNS detector provides deeper evidence.
if len(ctx.dns_queries) >= _DNS_TXT_VOLUME:
findings.append(Finding(
title="Very high DNS query volume",
category=Category.EXFILTRATION,
severity=Severity.MEDIUM, confidence=Confidence.LOW,
protocol="dns",
risk_description=(
"Unusually high DNS volume may indicate DNS tunneling or exfiltration."
),
recommendation="Review DNS logs and correlated DNS anomaly findings.",
evidence={"dns_query_count": len(ctx.dns_queries), "dedup_key": "dns_volume"},
related_packets=sorted(
{q.packet_number for q in ctx.dns_queries if q.packet_number}
)[:8],
))
return self._tag(findings)
@@ -0,0 +1,107 @@
"""Detect insecure or unexpected legacy protocols."""
from __future__ import annotations
from collections import defaultdict
from typing import Iterable
from ..context import CaptureContext
from ..findings import Finding, Category, Severity, Confidence
from .base import BaseDetector, register
# Port to protocol name, severity, risk, and recommendation.
_RISKY_PORTS = {
21: ("FTP", Severity.HIGH, "FTP transmits data and credentials in clear text.",
"Replace FTP with FTPS or SFTP."),
23: ("Telnet", Severity.CRITICAL, "Telnet transmits all content in clear text.",
"Disable Telnet and use SSH."),
69: ("TFTP", Severity.HIGH, "TFTP has no authentication or encryption.",
"Avoid TFTP or restrict it to an isolated network."),
80: ("HTTP", Severity.MEDIUM, "HTTP content is unencrypted and observable.",
"Require HTTPS."),
110: ("POP3", Severity.HIGH, "POP3 without TLS exposes messages and passwords.",
"Use POP3S or STARTTLS."),
143: ("IMAP", Severity.HIGH, "IMAP without TLS exposes messages and passwords.",
"Use IMAPS or STARTTLS."),
161: ("SNMP", Severity.MEDIUM, "SNMP v1/v2c uses clear-text community strings.",
"Use SNMPv3 with authentication and privacy."),
389: ("LDAP", Severity.HIGH, "LDAP without TLS exposes directory credentials.",
"Use LDAPS or StartTLS."),
512: ("rexec", Severity.HIGH, "Remote shell services are insecure and unencrypted.",
"Disable the service and use SSH."),
513: ("rlogin", Severity.HIGH, "Remote login services are insecure and unencrypted.",
"Disable the service and use SSH."),
1433: ("MSSQL", Severity.MEDIUM, "The database service may be exposed without TLS.",
"Require TLS and restrict access."),
3306: ("MySQL", Severity.MEDIUM, "The database service may be exposed without TLS.",
"Require TLS and restrict access."),
6379: ("Redis", Severity.HIGH, "Redis may be exposed without authentication or encryption.",
"Enable authentication and restrict network exposure."),
11211: ("Memcached", Severity.HIGH, "Memcached can be abused for amplification.",
"Bind locally and restrict access."),
27017: ("MongoDB", Severity.HIGH, "MongoDB without authentication or TLS exposes data.",
"Enable authentication and TLS, then restrict access."),
}
@register
class InsecureProtocolDetector(BaseDetector):
name = "InsecureProtocolDetector"
description = "Detects clear-text and legacy protocols on known service ports."
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
findings: list[Finding] = []
# Aggregate observed use of known risky service ports.
used: dict[int, dict] = defaultdict(lambda: {"src": None, "dst": None, "proto": None,
"first": None, "last": None, "packets": 0,
"pkts": [], "streams": set()})
for conv in ctx.conversations:
for port in (conv.dst_port, conv.src_port):
if port in _RISKY_PORTS:
e = used[port]
e["src"] = conv.src_ip
e["dst"] = conv.dst_ip
e["proto"] = conv.protocol
e["packets"] += conv.packets
if conv.packet_numbers:
e["pkts"].extend(conv.packet_numbers[:4])
if conv.stream_id is not None:
e["streams"].add(conv.stream_id)
if conv.first_seen is not None:
e["first"] = conv.first_seen if e["first"] is None else min(e["first"], conv.first_seen)
if conv.last_seen is not None:
e["last"] = conv.last_seen if e["last"] is None else max(e["last"], conv.last_seen)
for port, e in used.items():
name, sev, risk, rec = _RISKY_PORTS[port]
findings.append(Finding(
title=f"Insecure protocol: {name} (port {port})",
category=Category.INSECURE_PROTOCOL,
severity=sev, confidence=Confidence.HIGH,
source_ip=e["src"], destination_ip=e["dst"],
protocol=name.lower(), port=port,
timestamp_first_seen=e["first"], timestamp_last_seen=e["last"],
risk_description=risk,
recommendation=rec,
evidence={"port": port, "packets": e["packets"],
"dedup_key": f"insecure:{port}:{e['dst']}"},
related_packets=sorted(set(e["pkts"]))[:8],
related_streams=sorted(e["streams"])[:8],
))
smb1 = ctx.protocol_count("smb")
if smb1 and not ctx.protocol_count("smb2", "smb3"):
findings.append(Finding(
title="SMBv1 in use",
category=Category.INSECURE_PROTOCOL,
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
protocol="smb",
risk_description=(
"SMBv1 is obsolete and vulnerable to well-known attacks such as EternalBlue."
),
recommendation="Disable SMBv1 and require SMBv2 or SMBv3.",
evidence={"smb1_packets": smb1, "dedup_key": "smbv1"},
))
return self._tag(findings)
@@ -0,0 +1,191 @@
"""Detect malware, command-and-control, and beaconing behavior."""
from __future__ import annotations
import statistics
from collections import defaultdict
from typing import Iterable, Optional
from ..context import CaptureContext
from ..findings import Finding, Category, Severity, Confidence
from .base import BaseDetector, register
# Scanner and attack-tool user agents provide a strong signal.
_SCANNER_UA = (
"nikto", "masscan", "sqlmap", "nmap", "hydra", "zgrab", "nuclei",
"dirbuster", "gobuster", "wpscan", "acunetix", "nessus",
)
# Scripting clients are a weak signal because IoT automation may use them legitimately.
_SCRIPTING_UA = (
"curl", "wget", "python-requests", "powershell", "libwww",
)
# Common mobile and embedded clients are intentionally excluded to limit false positives.
_MIN_BEACONS = 5
_MAX_CV = 0.25
@register
class MalwareBeaconingDetector(BaseDetector):
name = "MalwareBeaconingDetector"
description = "Detects periodic external connections and suspicious user agents."
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
findings: list[Finding] = []
findings.extend(self._detect_beaconing(ctx))
findings.extend(self._detect_suspicious_ua(ctx))
return self._tag(findings)
def _detect_beaconing(self, ctx: CaptureContext) -> list[Finding]:
"""Detect periodic traffic in long-lived flows and repeated short flows."""
findings: list[Finding] = []
reported: set[tuple] = set()
# First evaluate multiple timestamps captured within a single flow.
for conv in ctx.conversations:
ext_ip = _external_of(ctx, conv.src_ip, conv.dst_ip)
if not ext_ip:
continue
ts = sorted(t for t in conv.timestamps if t is not None)
if len(ts) < _MIN_BEACONS:
continue
internal = conv.dst_ip if ext_ip == conv.src_ip else conv.src_ip
port = conv.src_port if ext_ip == conv.src_ip else conv.dst_port
f = self._beacon_finding(
ts, src=internal, dst=ext_ip, protocol=conv.protocol, port=port,
streams=[conv.stream_id] if conv.stream_id is not None else [],
packets=list(conv.packet_numbers),
)
if f:
reported.add((internal, ext_ip, port))
findings.append(f)
# Then aggregate repeated short-lived connections to the same target.
events: dict[tuple, list[float]] = defaultdict(list)
meta: dict[tuple, dict] = {}
for conv in ctx.conversations:
if ctx.is_external(conv.dst_ip) and not ctx.is_external(conv.src_ip):
internal, external, port = conv.src_ip, conv.dst_ip, conv.dst_port
elif ctx.is_external(conv.src_ip) and not ctx.is_external(conv.dst_ip):
internal, external, port = conv.dst_ip, conv.src_ip, conv.src_port
else:
continue
if not (internal and external) or conv.first_seen is None:
continue
key = (internal, external, port)
events[key].append(conv.first_seen)
m = meta.setdefault(key, {"protocol": conv.protocol, "streams": set(), "packets": []})
if conv.stream_id is not None:
m["streams"].add(conv.stream_id)
# One representative packet per connection keeps evidence compact.
if conv.packet_numbers:
m["packets"].append(conv.packet_numbers[0])
for key, first_seens in events.items():
if key in reported or len(first_seens) < _MIN_BEACONS:
continue
internal, external, port = key
f = self._beacon_finding(
sorted(first_seens), src=internal, dst=external,
protocol=meta[key]["protocol"], port=port,
streams=sorted(meta[key]["streams"]),
packets=meta[key]["packets"],
connection_based=True,
)
if f:
findings.append(f)
return findings
def _beacon_finding(self, ts: list[float], *, src, dst, protocol, port,
streams, packets=None, connection_based: bool = False) -> Optional[Finding]:
intervals = [b - a for a, b in zip(ts, ts[1:]) if (b - a) > 0]
if len(intervals) < _MIN_BEACONS - 1:
return None
mean = statistics.mean(intervals)
if mean <= 0:
return None
cv = statistics.pstdev(intervals) / mean
if cv > _MAX_CV:
return None
conf = Confidence.MEDIUM if cv > 0.1 else Confidence.HIGH
evidence = {
"beacon_count": len(ts),
"interval_seconds_mean": round(mean, 2),
"interval_cv": round(cv, 3),
"signal": "distinct_connections" if connection_based else "single_flow",
"dedup_key": f"beacon:{src}:{dst}:{port}",
}
return Finding(
title="Periodic beaconing to an external target",
category=Category.MALWARE_BEACONING,
severity=Severity.HIGH, confidence=conf,
source_ip=src, destination_ip=dst,
protocol=protocol, port=port,
timestamp_first_seen=ts[0], timestamp_last_seen=ts[-1],
risk_description=(
"The device contacts an external target at highly regular intervals, "
"which is characteristic of command-and-control beaconing."
),
recommendation=(
"Check the target against threat intelligence, isolate the device, and "
"investigate it for compromise."
),
evidence=evidence,
related_packets=sorted({p for p in (packets or []) if p is not None}),
related_streams=[s for s in streams if s is not None],
)
def _detect_suspicious_ua(self, ctx: CaptureContext) -> list[Finding]:
findings: list[Finding] = []
seen_ua: set[str] = set()
for req in ctx.http_requests:
ua = (req.user_agent or "").lower()
if not ua:
continue
marker = next((m for m in _SCANNER_UA if m in ua), None)
if marker:
tier, sev, conf = "scanner", Severity.HIGH, Confidence.MEDIUM
risk = (
"The user agent belongs to a known scanning or attack tool and may "
"indicate active reconnaissance against the device."
)
else:
marker = next((m for m in _SCRIPTING_UA if m in ua), None)
if not marker:
continue
tier, sev, conf = "scripting", Severity.LOW, Confidence.LOW
risk = (
"An automated command-line client was used. This may be legitimate "
"automation, but it can also indicate scripts or malware."
)
if marker in seen_ua:
continue
seen_ua.add(marker)
findings.append(Finding(
title=f"Suspicious user agent: {marker}",
category=Category.MALWARE_BEACONING,
severity=sev, confidence=conf,
source_ip=req.src_ip, destination_ip=req.dst_ip,
protocol="http", port=req.dst_port,
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
risk_description=risk,
recommendation="Verify that the source and client are authorized.",
evidence={"user_agent": req.user_agent, "host": req.host, "tier": tier,
"dedup_key": f"ua:{marker}:{req.src_ip}"},
related_packets=[p for p in [req.packet_number] if p],
))
return findings
def _external_of(ctx: CaptureContext, src_ip, dst_ip) -> Optional[str]:
"""Return the external address in a pair, if present."""
if ctx.is_external(dst_ip):
return dst_ip
if ctx.is_external(src_ip):
return src_ip
return None
+78
View File
@@ -0,0 +1,78 @@
"""Detect local network manipulation and man-in-the-middle indicators."""
from __future__ import annotations
from collections import defaultdict
from typing import Iterable
from ..context import CaptureContext
from ..findings import Finding, Category, Severity, Confidence
from .base import BaseDetector, register
@register
class MitmDetector(BaseDetector):
name = "MitmDetector"
description = "Detects ARP spoofing, duplicate IP mappings, and ARP floods."
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
findings: list[Finding] = []
# Map each IP address to MAC addresses observed in ARP responses.
ip_to_macs: dict[str, set[str]] = defaultdict(set)
first_seen: dict[str, float] = {}
last_seen: dict[str, float] = {}
packets_for_ip: dict[str, list[int]] = defaultdict(list)
for ev in ctx.arp_events:
if ev.sender_ip and ev.sender_mac:
ip_to_macs[ev.sender_ip].add(ev.sender_mac)
if ev.timestamp is not None:
first_seen[ev.sender_ip] = min(first_seen.get(ev.sender_ip, ev.timestamp), ev.timestamp)
last_seen[ev.sender_ip] = max(last_seen.get(ev.sender_ip, ev.timestamp), ev.timestamp)
if ev.packet_number:
packets_for_ip[ev.sender_ip].append(ev.packet_number)
for ip, host in ctx.hosts.items():
if host.macs:
ip_to_macs[ip].update(host.macs)
for ip, macs in ip_to_macs.items():
if len(macs) > 1:
findings.append(Finding(
title="Multiple MAC addresses associated with one IP",
category=Category.MITM,
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
source_ip=ip,
protocol="arp",
timestamp_first_seen=first_seen.get(ip),
timestamp_last_seen=last_seen.get(ip),
risk_description=(
"One IP address was advertised by multiple MAC addresses, a common "
"indicator of ARP spoofing or traffic interception."
),
recommendation=(
"Review ARP tables, enable Dynamic ARP Inspection and port security, "
"and identify the conflicting host."
),
evidence={"ip": ip, "macs": sorted(macs), "dedup_key": f"arpdup:{ip}"},
related_packets=sorted(packets_for_ip.get(ip, []))[:10],
))
# An ARP reply flood can accompany active cache poisoning.
grat = [e for e in ctx.arp_events if e.opcode == 2]
if len(grat) >= 50:
findings.append(Finding(
title="Unusually high number of ARP replies",
category=Category.MITM,
severity=Severity.MEDIUM, confidence=Confidence.LOW,
protocol="arp",
risk_description=(
"A large number of ARP replies may indicate active ARP cache poisoning."
),
recommendation="Inspect the reply source and investigate possible interception.",
evidence={"arp_reply_count": len(grat), "dedup_key": "arp_reply_flood"},
related_packets=sorted({e.packet_number for e in grat if e.packet_number})[:8],
))
return self._tag(findings)
@@ -0,0 +1,112 @@
"""Detect password hashes, challenge-response material, and Kerberos tickets."""
from __future__ import annotations
import re
from typing import Iterable
from ..context import CaptureContext
from ..findings import Finding, Category, Severity, Confidence
from .base import BaseDetector, register
_DIGEST_RE = re.compile(r"\bDigest\b", re.IGNORECASE)
@register
class PasswordHashDetector(BaseDetector):
name = "PasswordHashDetector"
description = (
"Detects NTLM, Kerberos, HTTP Digest, and wireless handshake material."
)
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
findings: list[Finding] = []
for am in ctx.auth_material:
kind = am.kind.lower()
if kind.startswith("kerberos"):
sev, conf = Severity.HIGH, Confidence.MEDIUM
title = "Kerberos authentication material in capture"
risk = (
"Kerberos AS-REP or TGS material may enable offline password attacks "
"when weak credentials are used."
)
rec = "Enforce pre-authentication, strong passwords, SPN hardening, and AES."
else:
sev, conf = Severity.HIGH, Confidence.MEDIUM
title = "NTLM authentication material in capture"
risk = (
"NTLM or NetNTLM material may enable offline cracking or relay attacks "
"without exposing a clear-text password."
)
rec = "Enable SMB signing and EPA, restrict NTLM, and prefer Kerberos."
evidence = {
"kind": am.kind, "account": am.account, "domain": am.domain,
"dedup_key": f"auth:{am.kind}:{am.src_ip}:{am.dst_ip}:{am.account}",
}
# Fully reconstructed hashcat material raises confidence.
if am.hashcat:
evidence["hashcat"] = am.hashcat
if am.hashcat_mode is not None:
evidence["hashcat_mode"] = am.hashcat_mode
if am.hash_format:
evidence["format"] = am.hash_format
conf = Confidence.HIGH
title += " (crackable)"
rec = (
"Rotate the affected password immediately. " + rec +
" The captured material is available in an offline-crackable format."
)
if am.etype:
evidence["etype"] = am.etype
findings.append(Finding(
title=title,
category=Category.PASSWORD_HASHES,
severity=sev, confidence=conf,
source_ip=am.src_ip, destination_ip=am.dst_ip, protocol=am.protocol,
timestamp_first_seen=am.timestamp, timestamp_last_seen=am.timestamp,
risk_description=risk, recommendation=rec,
evidence=evidence,
related_packets=[p for p in [am.packet_number] if p],
))
for req in ctx.http_requests:
if req.authorization and _DIGEST_RE.search(req.authorization):
findings.append(Finding(
title="HTTP Digest Authentication (Challenge-Response)",
category=Category.PASSWORD_HASHES,
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
source_ip=req.src_ip, destination_ip=req.dst_ip,
protocol="http", port=req.dst_port,
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
risk_description=(
"HTTP Digest transmits a response hash that may allow offline cracking "
"when weak passwords are used."
),
recommendation="Require HTTPS and enforce strong passwords.",
evidence={
"host": req.host, "uri": req.uri,
"dedup_key": f"digest:{req.src_ip}:{req.dst_ip}:{req.uri}",
},
related_packets=[p for p in [req.packet_number] if p],
))
# EAPOL frames indicate potentially crackable wireless handshakes.
eapol = ctx.protocol_count("eapol")
if eapol:
findings.append(Finding(
title="WPA/WPA2 handshake material (EAPOL) detected",
category=Category.PASSWORD_HASHES,
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
protocol="eapol",
risk_description=(
"EAPOL frames from a four-way handshake may enable offline brute-force "
"attacks against the wireless password."
),
recommendation="Use a strong WPA2/WPA3 passphrase and prefer WPA3.",
evidence={"eapol_frames": eapol, "dedup_key": "eapol_handshake"},
))
return self._tag(findings)
@@ -0,0 +1,132 @@
"""Detect clear-text credentials."""
from __future__ import annotations
import base64
import re
from typing import Iterable
from ..context import CaptureContext
from ..findings import Finding, Category, Severity, Confidence
from .base import BaseDetector, register
# Common form fields used for credentials.
_USER_FIELDS = ("user", "username", "uname", "login", "email", "uid")
_PASS_FIELDS = ("pass", "passwd", "password", "pwd", "pw")
_FORM_RE = re.compile(r"([A-Za-z0-9_\-\.]+)=([^&\s]+)")
@register
class PlaintextCredentialDetector(BaseDetector):
name = "PlaintextCredentialDetector"
description = "Detects usernames and passwords transmitted without encryption."
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
findings: list[Finding] = []
for pa in ctx.plaintext_auth:
if pa.username or pa.password:
findings.append(Finding(
title=f"Clear-text credentials over {pa.protocol.upper()}",
category=Category.PLAINTEXT_CREDENTIALS,
severity=Severity.CRITICAL,
confidence=Confidence.HIGH,
source_ip=pa.src_ip, destination_ip=pa.dst_ip,
protocol=pa.protocol, port=pa.dst_port,
timestamp_first_seen=pa.timestamp, timestamp_last_seen=pa.timestamp,
risk_description=(
"A username or password was transmitted without encryption. "
"A network observer could take over the account."
),
recommendation=(
f"Replace {pa.protocol.upper()} with an encrypted alternative "
"such as SFTP, IMAPS, POP3S, or SMTP with STARTTLS, then rotate "
"the affected password."
),
evidence={
"username": pa.username,
"password": _mask(pa.password),
"dedup_key": f"plain:{pa.protocol}:{pa.src_ip}:{pa.dst_ip}:{pa.username}",
},
related_packets=[p for p in [pa.packet_number] if p],
related_streams=[s for s in [pa.stream_id] if s],
))
for req in ctx.http_requests:
if req.is_tls:
continue
auth = (req.authorization or "").strip()
if auth.lower().startswith("basic "):
user, pwd = _decode_basic(auth[6:])
findings.append(Finding(
title="Clear-text HTTP Basic authentication",
category=Category.PLAINTEXT_CREDENTIALS,
severity=Severity.CRITICAL,
confidence=Confidence.HIGH,
source_ip=req.src_ip, destination_ip=req.dst_ip,
protocol="http", port=req.dst_port,
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
risk_description=(
"HTTP Basic authentication sends credentials with Base64 encoding, "
"not encryption, over clear-text HTTP."
),
recommendation="Require HTTPS and rotate the affected password.",
evidence={
"host": req.host, "uri": req.uri,
"username": user, "password": _mask(pwd),
"dedup_key": f"basic:{req.src_ip}:{req.dst_ip}:{user}",
},
related_packets=[p for p in [req.packet_number] if p],
))
# Detect likely login forms in clear-text HTTP request bodies.
if req.method and req.method.upper() == "POST" and req.body:
fields = dict(_FORM_RE.findall(req.body))
lowered = {k.lower(): v for k, v in fields.items()}
has_user = any(k in lowered for k in _USER_FIELDS)
has_pass = any(k in lowered for k in _PASS_FIELDS)
if has_pass and (has_user or len(fields) <= 4):
user_val = next((lowered[k] for k in _USER_FIELDS if k in lowered), None)
pass_val = next((lowered[k] for k in _PASS_FIELDS if k in lowered), None)
findings.append(Finding(
title="Login form submitted over clear-text HTTP",
category=Category.PLAINTEXT_CREDENTIALS,
severity=Severity.CRITICAL,
confidence=Confidence.MEDIUM,
source_ip=req.src_ip, destination_ip=req.dst_ip,
protocol="http", port=req.dst_port,
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
risk_description=(
"A login form was submitted in a clear-text HTTP request. "
"The credentials are visible to network observers."
),
recommendation="Serve the form only over HTTPS and rotate the password.",
evidence={
"host": req.host, "uri": req.uri,
"username": user_val, "password": _mask(pass_val),
"dedup_key": f"form:{req.src_ip}:{req.dst_ip}:{user_val}",
},
related_packets=[p for p in [req.packet_number] if p],
))
return self._tag(findings)
def _decode_basic(b64: str):
try:
raw = base64.b64decode(b64.strip() + "===").decode("utf-8", "ignore")
if ":" in raw:
u, p = raw.split(":", 1)
return u, p
return raw, None
except Exception:
return None, None
def _mask(secret):
if not secret:
return None
s = str(secret)
if len(s) <= 2:
return "*" * len(s)
return s[0] + "*" * (len(s) - 2) + s[-1]
@@ -0,0 +1,93 @@
"""Detect weak, obsolete, or suspicious TLS connections."""
from __future__ import annotations
from typing import Iterable
from ..context import CaptureContext
from ..findings import Finding, Category, Severity, Confidence
from .base import BaseDetector, register
_WEAK_VERSIONS = {"SSL 2.0", "SSL 3.0", "TLS 1.0", "TLS 1.1"}
# Weak cipher markers matched against normalized suite names.
_WEAK_CIPHER_MARKERS = ("RC4", "NULL", "EXPORT", "_DES_", "DES_CBC", "3DES", "MD5", "ANON")
@register
class TlsWeaknessDetector(BaseDetector):
name = "TlsWeaknessDetector"
description = "Detects obsolete TLS versions, weak ciphers, and certificate anomalies."
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
findings: list[Finding] = []
seen: set[str] = set()
for s in ctx.tls_sessions:
target = s.sni or s.dst_ip
base_pkt = [p for p in [s.packet_number] if p]
if s.version and s.version in _WEAK_VERSIONS:
key = f"tlsver:{target}:{s.version}"
if key not in seen:
seen.add(key)
findings.append(Finding(
title=f"Obsolete TLS version: {s.version}",
category=Category.TLS_WEAKNESS,
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="tls", port=s.dst_port,
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
risk_description=(
"An obsolete TLS or SSL version is in use and is vulnerable to "
"known protocol attacks."
),
recommendation="Require at least TLS 1.2 and prefer TLS 1.3.",
evidence={"version": s.version, "target": target, "dedup_key": key},
related_packets=base_pkt,
))
if s.cipher:
up = str(s.cipher).upper()
if any(m in up for m in _WEAK_CIPHER_MARKERS):
key = f"tlscipher:{target}:{s.cipher}"
if key not in seen:
seen.add(key)
findings.append(Finding(
title="Weak TLS cipher suite",
category=Category.TLS_WEAKNESS,
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="tls", port=s.dst_port,
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
risk_description=(
"A weak or obsolete cipher suite reduces transport protection."
),
recommendation="Allow only modern AEAD ciphers such as AES-GCM or ChaCha20.",
evidence={"cipher": s.cipher, "target": target, "dedup_key": key},
related_packets=base_pkt,
))
if s.cert_self_signed or s.cert_expired:
problem = []
if s.cert_self_signed:
problem.append("self-signed")
if s.cert_expired:
problem.append("expired")
key = f"tlscert:{target}:{'-'.join(problem)}"
if key not in seen:
seen.add(key)
findings.append(Finding(
title=f"Suspicious TLS certificate ({', '.join(problem)})",
category=Category.TLS_WEAKNESS,
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="tls", port=s.dst_port,
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
risk_description=(
"A self-signed or expired certificate may indicate endpoint "
"misconfiguration or TLS interception."
),
recommendation="Review the certificate chain and deploy a valid CA certificate.",
evidence={"subject_cn": s.cert_subject_cn, "issuer_cn": s.cert_issuer_cn,
"not_after": s.cert_not_after, "dedup_key": key},
related_packets=base_pkt,
))
return self._tag(findings)
@@ -0,0 +1,93 @@
"""Detect session cookies, bearer tokens, and API keys."""
from __future__ import annotations
import re
from typing import Iterable
from ..context import CaptureContext
from ..findings import Finding, Category, Severity, Confidence
from .base import BaseDetector, register
# JWTs consist of three base64url segments and typically start with ``eyJ``.
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+")
_BEARER_RE = re.compile(r"\bBearer\s+([A-Za-z0-9_\-\.=]+)", re.IGNORECASE)
# Generic API key patterns in URLs, bodies, and headers.
_APIKEY_RE = re.compile(
r"(?i)(api[_\-]?key|apikey|access[_\-]?token|client[_\-]?secret|x[_\-]api[_\-]key)"
r"[=:\"']?\s*([A-Za-z0-9_\-]{16,})"
)
_SESSION_COOKIE_RE = re.compile(
r"(?i)\b(sessionid|session|sid|phpsessid|jsessionid|asp\.net_sessionid|auth|token)="
r"([A-Za-z0-9%_\-\.]{8,})"
)
@register
class TokenSecretDetector(BaseDetector):
name = "TokenSecretDetector"
description = "Detects JWTs, bearer tokens, API keys, and session cookies."
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
findings: list[Finding] = []
seen: set[str] = set()
for payload in ctx.text_payloads:
text = payload.get("text") or ""
over_http = payload.get("protocol") == "http"
for m in _JWT_RE.findall(text):
findings.append(self._mk(
payload, "Clear-text JWT (JSON Web Token)",
"A JWT was transmitted in clear text and may grant passwordless access "
"until it expires.",
"Revoke and rotate the token, shorten its lifetime, and require HTTPS.",
secret=m, conf=Confidence.HIGH, seen=seen, kind="jwt"))
for m in _BEARER_RE.findall(text):
findings.append(self._mk(
payload, "Clear-text bearer or OAuth token",
"A bearer token was transmitted and may grant access without a password.",
"Rotate the token, shorten its lifetime, and require HTTPS.",
secret=m, conf=Confidence.HIGH, seen=seen, kind="bearer"))
for _, m in _APIKEY_RE.findall(text):
findings.append(self._mk(
payload, "Clear-text API key or client secret",
"An API key or client secret was transmitted in clear text.",
"Rotate the secret, store it securely, and require HTTPS.",
secret=m, conf=Confidence.MEDIUM, seen=seen, kind="apikey"))
# Report session cookies only when the transport is unencrypted.
if over_http:
for _, m in _SESSION_COOKIE_RE.findall(text):
findings.append(self._mk(
payload, "Session cookie over clear-text HTTP",
"An unencrypted session cookie may enable session hijacking.",
"Set Secure and HttpOnly cookie flags and require HTTPS.",
secret=m, conf=Confidence.MEDIUM, seen=seen, kind="cookie"))
return self._tag([f for f in findings if f is not None])
def _mk(self, payload, title, risk, rec, *, secret, conf, seen, kind):
key = f"{kind}:{secret[:24]}"
if key in seen:
return None
seen.add(key)
return Finding(
title=title,
category=Category.TOKENS_SECRETS,
severity=Severity.HIGH, confidence=conf,
source_ip=payload.get("src_ip"), destination_ip=payload.get("dst_ip"),
protocol=payload.get("protocol"),
timestamp_first_seen=payload.get("timestamp"),
timestamp_last_seen=payload.get("timestamp"),
risk_description=risk, recommendation=rec,
evidence={"secret_preview": _preview(secret), "type": kind, "dedup_key": key},
related_packets=[p for p in [payload.get("packet_number")] if p],
)
def _preview(secret: str) -> str:
s = str(secret)
return s[:8] + "…" + s[-4:] if len(s) > 16 else s[:4] + "…"
+120
View File
@@ -0,0 +1,120 @@
"""Run all registered detectors and return a prioritized analysis result."""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Optional
from .context import CaptureContext
from .findings import Finding
from . import scoring
from . import provenance
from . import detectors as detectors_pkg
@dataclass
class AnalysisResult:
findings: list[Finding] = field(default_factory=list)
summary: dict = field(default_factory=dict)
meta: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"meta": self.meta,
"summary": _summary_to_dict(self.summary),
"findings": [f.to_dict() for f in self.findings],
}
class AnalysisEngine:
def __init__(self, detector_list=None):
# Use every registered detector unless a test supplies a subset.
self.detectors = detector_list if detector_list is not None else detectors_pkg.build_all()
def analyze_context(self, ctx: CaptureContext) -> AnalysisResult:
started = time.time()
raw: list[Finding] = []
errors: dict[str, str] = {}
for det in self.detectors:
try:
raw.extend(list(det.detect(ctx)))
except Exception as exc: # One detector must not abort the full analysis.
errors[det.name] = repr(exc)
deduped = scoring.dedupe(raw)
ordered = scoring.sort_by_priority(deduped)
# Attach packet-level provenance before the summary is generated.
capture_file = _basename(ctx.path)
for f in ordered:
_enrich_provenance(f, capture_file)
summary = scoring.summarize(ordered)
return AnalysisResult(
findings=ordered,
summary=summary,
meta={
"capture_path": ctx.path,
"packet_count": ctx.packet_count,
"duration_seconds": round(ctx.duration, 3),
"detectors_run": [d.name for d in self.detectors],
"detector_errors": errors,
"analysis_time_seconds": round(time.time() - started, 3),
"raw_finding_count": len(raw),
"finding_count": len(ordered),
},
)
def analyze_pcap(self, path: str, packet_limit: Optional[int] = None) -> AnalysisResult:
# Keep context-only analysis usable without pyshark or tshark.
from .extract_pyshark import build_context_from_pcap
ctx = build_context_from_pcap(path, packet_limit=packet_limit)
return self.analyze_context(ctx)
def result_from_dict(data: dict) -> AnalysisResult:
"""Reconstruct a result and recalculate its summary from stored findings."""
findings = [Finding.from_dict(f) for f in data.get("findings", [])]
summary = scoring.summarize(findings)
meta = data.get("meta", {})
return AnalysisResult(findings=findings, summary=summary, meta=meta)
def _summary_to_dict(summary: dict) -> dict:
out = dict(summary)
if "highlights" in out:
out["highlights"] = [f.to_dict() for f in out["highlights"]]
if "top" in out:
out["top"] = [f.to_dict() for f in out["top"]]
return out
def _basename(path) -> Optional[str]:
if not path:
return None
import os
return os.path.basename(str(path)) or str(path)
def _enrich_provenance(f: Finding, capture_file) -> None:
"""Add capture, artifact, and packet sources without replacing detector data."""
ev = f.evidence
if capture_file and not ev.get("capture_file"):
ev["capture_file"] = capture_file
if "artifact" not in ev:
art = provenance.derive_artifact(ev)
if art is not None:
ev["artifact"] = art
if "sources" not in ev:
ev["sources"] = provenance.build_sources(
related_packets=f.related_packets,
related_streams=f.related_streams,
src_ip=f.source_ip, dst_ip=f.destination_ip,
protocol=f.protocol, port=f.port,
timestamp_first_seen=f.timestamp_first_seen,
capture=capture_file,
)
# Preserve the complete evidence count when the displayed source list is bounded.
if f.related_packets and "source_packet_count" not in ev:
ev["source_packet_count"] = len(f.related_packets)
@@ -0,0 +1,11 @@
"""Optional, lazily loaded hash and endpoint enrichment."""
from .hash_crack import HashCrack, crack_hash
from .url_probe import UrlProbe, probe_url
from .engine import EnrichmentEngine
__all__ = [
"HashCrack", "crack_hash",
"UrlProbe", "probe_url",
"EnrichmentEngine",
]
+39
View File
@@ -0,0 +1,39 @@
"""Coordinate optional enrichment and progress callbacks."""
from __future__ import annotations
from typing import Callable, Optional, Sequence
from ..artifacts import HashArtifact, UrlArtifact
from .hash_crack import HashCrack, crack_hash
from .url_probe import UrlProbe, probe_url
# progress(done, total, item) -> None
ProgressCb = Optional[Callable[[int, int, object], None]]
class EnrichmentEngine:
"""Run hash and endpoint enrichment for artifact lists."""
def crack_hashes(self, artifacts: Sequence[HashArtifact], *,
progress: ProgressCb = None, **crack_kwargs) -> list[HashCrack]:
results: list[HashCrack] = []
total = len(artifacts)
for i, art in enumerate(artifacts, 1):
res = crack_hash(art, **crack_kwargs)
results.append(res)
if progress:
progress(i, total, res)
return results
def probe_urls(self, artifacts: Sequence[UrlArtifact], *,
progress: ProgressCb = None, active: bool = False,
**probe_kwargs) -> list[UrlProbe]:
results: list[UrlProbe] = []
total = len(artifacts)
for i, art in enumerate(artifacts, 1):
res = probe_url(art, active=active, **probe_kwargs)
results.append(res)
if progress:
progress(i, total, res)
return results
@@ -0,0 +1,144 @@
"""Run bounded offline hashcat attacks against supported capture artifacts."""
from __future__ import annotations
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import time
from dataclasses import dataclass, field
from typing import Callable, Optional, Sequence, Union
from ..artifacts import HashArtifact, identify_hash_mode
@dataclass
class HashCrack:
value: str
hashcat_mode: Optional[int] = None
hash_format: Optional[str] = None
cracked: bool = False
plaintext: Optional[str] = None
duration_seconds: Optional[float] = None
# pending | cracked | not_cracked | timeout | error | unsupported
state: str = "pending"
error: Optional[str] = None
def to_dict(self) -> dict:
return {
"value": self.value, "hashcat_mode": self.hashcat_mode,
"hash_format": self.hash_format, "cracked": self.cracked,
"plaintext": self.plaintext, "duration_seconds": self.duration_seconds,
"state": self.state, "error": self.error,
}
# Keep the bundled demo list first; authorized deployments may add a system list.
DEFAULT_WORDLISTS = (
str(Path(__file__).resolve().parents[3] / "demo_files" / "demo_wordlist.txt"),
"/usr/share/wordlists/rockyou.txt",
)
def build_crack_command(hashcat_path: str, mode: int, hash_file: str,
wordlists: Sequence[str], potfile: str) -> list[str]:
"""Build the hashcat dictionary-attack command."""
cmd = [hashcat_path, "-m", str(mode), "-a", "0", "-D", "1,2",
hash_file, *wordlists, "--potfile-path", potfile, "--force"]
return cmd
def build_show_command(hashcat_path: str, mode: int, hash_file: str,
potfile: str) -> list[str]:
"""Build the hashcat command used to read cracked results."""
return [hashcat_path, "-m", str(mode), "--show", "--potfile-path", potfile, hash_file]
def parse_show_output(stdout: str) -> Optional[str]:
"""Extract plaintext from ``hashcat --show`` output."""
if not stdout:
return None
for line in stdout.strip().splitlines():
if ":" in line:
return line.rsplit(":", 1)[1]
return None
def crack_hash(
artifact: Union[HashArtifact, str],
*,
wordlists: Sequence[str] = DEFAULT_WORDLISTS,
hashcat_path: str = "hashcat",
timeout: int = 300,
potfile: Optional[str] = None,
runner: Callable[..., subprocess.CompletedProcess] = subprocess.run,
which: Callable[[str], Optional[str]] = shutil.which,
) -> HashCrack:
"""Crack one artifact and convert runtime failures into a result state."""
if isinstance(artifact, HashArtifact):
value = artifact.value
mode = artifact.hashcat_mode
fmt = artifact.hash_format
else:
value = str(artifact)
mode = None
fmt = None
if mode is None:
mode, fmt = identify_hash_mode(value, hint_format=fmt)
result = HashCrack(value=value, hashcat_mode=mode, hash_format=fmt)
if mode is None:
result.state = "unsupported"
result.error = "No supported hashcat mode was detected"
return result
if which(hashcat_path) is None:
result.state = "error"
result.error = "hashcat is not installed or not available on PATH"
return result
available = [w for w in wordlists if os.path.exists(w)]
if not available:
result.state = "error"
result.error = "No wordlist is available"
return result
tmpdir = tempfile.mkdtemp(prefix="hashcrack_")
hash_file = os.path.join(tmpdir, "hash.txt")
pot = potfile or os.path.join(tmpdir, "hashcat.potfile")
try:
with open(hash_file, "w", encoding="utf-8") as fh:
fh.write(value.strip() + "\n")
start = time.time()
try:
runner(build_crack_command(hashcat_path, mode, hash_file, available, pot),
capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
result.state = "timeout"
result.duration_seconds = round(time.time() - start, 2)
result.error = f"hashcat timed out after {timeout}s"
return result
show = runner(build_show_command(hashcat_path, mode, hash_file, pot),
capture_output=True, text=True, timeout=timeout)
result.duration_seconds = round(time.time() - start, 2)
plaintext = parse_show_output(getattr(show, "stdout", "") or "")
if plaintext is not None:
result.cracked = True
result.plaintext = plaintext
result.state = "cracked"
else:
result.state = "not_cracked"
return result
except Exception as exc: # Enrichment failures must not stop capture analysis.
result.state = "error"
result.error = str(exc)[:300]
return result
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
+143
View File
@@ -0,0 +1,143 @@
"""Optionally enrich observed endpoints with DNS, port, and TLS checks.
Passive capture evidence remains authoritative. Active checks are opt-in and
limited to a small port set because operators must have authorization to scan.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable, Optional, Sequence, Union
from urllib.parse import urlparse
from ..artifacts import UrlArtifact
DEFAULT_PORTS = (22, 80, 443, 8000, 8080)
@dataclass
class UrlProbe:
url: str
host: str
resolved_ips: list[str] = field(default_factory=list)
open_ports: list[int] = field(default_factory=list)
tls_versions: list[str] = field(default_factory=list)
cipher_suites: list[str] = field(default_factory=list)
certificate: Optional[str] = None
active: bool = False
error: Optional[str] = None
def to_dict(self) -> dict:
return {
"url": self.url, "host": self.host,
"resolved_ips": self.resolved_ips, "open_ports": self.open_ports,
"tls_versions": self.tls_versions, "cipher_suites": self.cipher_suites,
"certificate": self.certificate, "active": self.active, "error": self.error,
}
def host_of(url: str) -> str:
"""Reduce a URL or domain value to its hostname."""
s = str(url).strip()
if "://" not in s:
s = "//" + s
netloc = urlparse(s).netloc or ""
host = netloc.split("@")[-1].split(":")[0]
return host or str(url).strip()
def probe_url(
artifact: Union[UrlArtifact, str],
*,
active: bool = False,
ports: Sequence[int] = DEFAULT_PORTS,
dns_resolver: Optional[Callable[[str], list]] = None,
port_scanner: Optional[Callable[[str, Sequence[int]], list]] = None,
tls_scanner: Optional[Callable[[str], dict]] = None,
) -> UrlProbe:
"""Return passive observations and optionally perform authorized active checks."""
if isinstance(artifact, UrlArtifact):
url = artifact.value
probe = UrlProbe(url=url, host=host_of(url))
probe.open_ports = sorted(artifact.observed_ports)
probe.tls_versions = sorted(artifact.observed_tls)
else:
url = str(artifact)
probe = UrlProbe(url=url, host=host_of(url))
if not active:
return probe
probe.active = True
host = probe.host
try:
resolver = dns_resolver or _default_dns_resolver
probe.resolved_ips = resolver(host) or []
except Exception as exc:
probe.error = f"DNS: {exc}"
try:
scanner = port_scanner or _default_port_scanner
found = scanner(host, ports) or []
probe.open_ports = sorted(set(probe.open_ports) | set(found))
except Exception as exc:
probe.error = (probe.error or "") + f" Ports: {exc}"
try:
tls = tls_scanner or _default_tls_scanner
info = tls(host) or {}
if info.get("tls_version"):
probe.tls_versions = sorted(set(probe.tls_versions) | {info["tls_version"]})
if info.get("cipher"):
probe.cipher_suites = sorted(set(probe.cipher_suites) | {info["cipher"]})
probe.certificate = info.get("certificate")
except Exception as exc:
probe.error = (probe.error or "") + f" TLS: {exc}"
return probe
# --------------------------------------------------------------------------- #
# Lazy defaults are imported only when active checks are requested.
# --------------------------------------------------------------------------- #
def _default_dns_resolver(host: str) -> list[str]:
import dns.resolver # lazy
answers = dns.resolver.resolve(host, "A")
return [str(r) for r in answers]
def _default_port_scanner(host: str, ports: Sequence[int]) -> list[int]:
import nmap # lazy
nm = nmap.PortScanner()
port_arg = ",".join(str(p) for p in ports)
# TCP connect scanning works without raw-socket privileges; -Pn skips ICMP discovery.
nm.scan(hosts=host, arguments=f"-sT -Pn -p {port_arg} --max-retries 1 --host-timeout 20s")
open_ports: set[int] = set()
# nmap indexes results by resolved address, so iterate over every returned host.
for h in nm.all_hosts():
try:
for port in nm[h].all_tcp():
if nm[h]["tcp"][port]["state"] == "open":
open_ports.add(int(port))
except Exception:
continue
return sorted(open_ports)
def _default_tls_scanner(host: str) -> dict:
import socket
import ssl
ctx = ssl.create_default_context()
try:
with socket.create_connection((host, 443), timeout=5) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
cert = ssock.getpeercert()
cipher = ssock.cipher()
return {
"tls_version": ssock.version(),
"cipher": cipher[0] if cipher else None,
"certificate": "Present" if cert else "Not Present",
}
except Exception:
return {}
+763
View File
@@ -0,0 +1,763 @@
"""Build a normalized capture context with tshark through pyshark.
pyshark is loaded lazily, and individual packet handlers are isolated so one
unexpected packet cannot abort the complete analysis.
"""
from __future__ import annotations
import base64
import os
from typing import Optional
from .context import (
CaptureContext,
Conversation,
DnsQuery,
HttpRequest,
HttpResponse,
TlsSession,
ArpEvent,
PlaintextAuth,
AuthMaterial,
FileTransfer,
)
from . import crackformats
# Known clear-text protocols used by extraction heuristics.
CLEARTEXT_PORTS = {
21: "ftp",
23: "telnet",
25: "smtp",
80: "http",
110: "pop3",
143: "imap",
389: "ldap",
161: "snmp",
69: "tftp",
}
def build_context_from_pcap(path: str, packet_limit: Optional[int] = None) -> CaptureContext:
"""Read a capture and return its normalized context."""
if not os.path.exists(path):
raise FileNotFoundError(f"Capture not found: {path}")
try:
import pyshark # lazy import
except ImportError as exc: # pragma: no cover
raise RuntimeError(
"pyshark or tshark is unavailable. Install tshark or use the backend container."
) from exc
ctx = CaptureContext(path=path)
cap = pyshark.FileCapture(path, keep_packets=False)
conv_index: dict[tuple, Conversation] = {}
# Transient state correlates NTLM and SMTP authentication across packets.
state: dict = {"ntlm_chal": {}, "smtp_auth": {}}
n = 0
try:
for pkt in cap:
n += 1
if packet_limit and n > packet_limit:
break
try:
_handle_packet(ctx, pkt, conv_index, state)
except Exception:
# Skip isolated malformed or unexpected packets.
continue
except Exception as exc:
if exc.__class__.__name__ == "TSharkNotFoundException":
raise RuntimeError(
"tshark was not found. Install Wireshark/tshark or use the backend container."
) from exc
raise
finally:
try:
cap.close()
except Exception:
pass
ctx.packet_count = n
ctx.conversations = list(conv_index.values())
return ctx
# --------------------------------------------------------------------------- #
# Packet handlers
# --------------------------------------------------------------------------- #
def _ts(pkt) -> Optional[float]:
try:
return float(pkt.sniff_timestamp)
except Exception:
return None
def _num(pkt) -> Optional[int]:
try:
return int(pkt.number)
except Exception:
return None
def _handle_packet(ctx: CaptureContext, pkt, conv_index: dict, state: Optional[dict] = None) -> None:
if state is None:
state = {"ntlm_chal": {}, "smtp_auth": {}}
ts = _ts(pkt)
if ts is not None:
ctx.start_time = ts if ctx.start_time is None else min(ctx.start_time, ts)
ctx.end_time = ts if ctx.end_time is None else max(ctx.end_time, ts)
# Count packets by the highest decoded protocol layer.
try:
top = pkt.highest_layer
ctx.protocol_stats[top] = ctx.protocol_stats.get(top, 0) + 1
except Exception:
pass
layers = {l.layer_name for l in pkt.layers}
src_ip = dst_ip = None
length = 0
try:
length = int(pkt.length)
except Exception:
pass
if "ip" in layers:
try:
src_ip = pkt.ip.src
dst_ip = pkt.ip.dst
except Exception:
pass
elif "ipv6" in layers:
try:
src_ip = pkt.ipv6.src
dst_ip = pkt.ipv6.dst
except Exception:
pass
# MAC-Adressen / Hosts
src_mac = dst_mac = None
if "eth" in layers:
try:
src_mac, dst_mac = pkt.eth.src, pkt.eth.dst
except Exception:
pass
src_port = dst_port = None
transport = None
if "tcp" in layers:
transport = "tcp"
try:
src_port = int(pkt.tcp.srcport)
dst_port = int(pkt.tcp.dstport)
except Exception:
pass
elif "udp" in layers:
transport = "udp"
try:
src_port = int(pkt.udp.srcport)
dst_port = int(pkt.udp.dstport)
except Exception:
pass
if src_ip:
h = ctx.host(src_ip)
h.bytes_sent += length
if src_mac:
h.macs.add(src_mac)
if dst_port:
h.ports_contacted.add(dst_port)
if dst_ip:
h = ctx.host(dst_ip)
h.bytes_received += length
if dst_port:
h.ports_offered.add(dst_port)
# Conversation aggregieren
if src_ip and dst_ip and transport:
key = tuple(sorted([(src_ip, src_port), (dst_ip, dst_port)]) + [transport])
conv = conv_index.get(key)
if conv is None:
conv = Conversation(
src_ip=src_ip, dst_ip=dst_ip, src_port=src_port, dst_port=dst_port,
protocol=transport, first_seen=ts, last_seen=ts,
)
try:
conv.stream_id = int(pkt.tcp.stream) if transport == "tcp" else None
except Exception:
conv.stream_id = None
conv_index[key] = conv
conv.packets += 1
conv.bytes += length
pnum = _num(pkt)
if pnum is not None and len(conv.packet_numbers) < 20:
conv.packet_numbers.append(pnum)
if ts is not None:
conv.timestamps.append(ts)
conv.last_seen = ts if conv.last_seen is None else max(conv.last_seen, ts)
conv.first_seen = ts if conv.first_seen is None else min(conv.first_seen, ts)
if src_ip == conv.src_ip:
conv.bytes_src_to_dst += length
else:
conv.bytes_dst_to_src += length
# TCP stream IDs support cross-packet authentication correlation.
stream_id = None
if transport == "tcp":
try:
stream_id = int(pkt.tcp.stream)
except Exception:
stream_id = None
# Protocol-specific extraction.
if "arp" in layers:
_handle_arp(ctx, pkt, ts)
if "dns" in layers:
_handle_dns(ctx, pkt, src_ip, dst_ip, ts)
if "http" in layers:
_handle_http(ctx, pkt, src_ip, dst_ip, dst_port, ts)
if "tls" in layers or "ssl" in layers:
_handle_tls(ctx, pkt, src_ip, dst_ip, dst_port, ts)
if "ftp" in layers:
_handle_ftp(ctx, pkt, src_ip, dst_ip, dst_port, ts)
if "telnet" in layers:
_handle_telnet(ctx, pkt, src_ip, dst_ip, dst_port, ts)
if "pop" in layers:
_handle_pop3(ctx, pkt, src_ip, dst_ip, dst_port, ts)
if "imap" in layers:
_handle_imap(ctx, pkt, src_ip, dst_ip, dst_port, ts)
if "smtp" in layers:
_handle_smtp(ctx, pkt, src_ip, dst_ip, dst_port, ts, stream_id, state)
if "ntlmssp" in layers:
_handle_ntlm(ctx, pkt, src_ip, dst_ip, ts, stream_id, state)
if "kerberos" in layers:
_handle_kerberos(ctx, pkt, src_ip, dst_ip, ts)
# Generic payload extraction finds artifacts that protocol fields may omit.
_handle_raw_payload(ctx, pkt, layers, src_ip, dst_ip, ts)
def _handle_arp(ctx, pkt, ts):
try:
a = pkt.arp
ctx.arp_events.append(ArpEvent(
sender_ip=getattr(a, "src_proto_ipv4", None),
sender_mac=getattr(a, "src_hw_mac", None),
target_ip=getattr(a, "dst_proto_ipv4", None),
opcode=int(getattr(a, "opcode", 0) or 0),
timestamp=ts,
packet_number=_num(pkt),
))
except Exception:
pass
def _handle_dns(ctx, pkt, src_ip, dst_ip, ts):
try:
d = pkt.dns
# Ignore DNS records without a query name.
qname = getattr(d, "qry_name", None)
if not qname:
return
rcode = getattr(d, "flags_rcode", None)
rcode_map = {"0": "NOERROR", "3": "NXDOMAIN"}
response_ips = []
for attr in ("a", "aaaa"):
response_ips.extend(_all_field_values(d, attr))
ctx.dns_queries.append(DnsQuery(
query_name=qname,
qtype=_dns_qtype_name(getattr(d, "qry_type", None)),
response_ips=response_ips,
rcode=rcode_map.get(str(rcode), rcode),
timestamp=ts,
src_ip=src_ip,
dst_ip=dst_ip,
packet_number=_num(pkt),
))
except Exception:
pass
def _handle_http(ctx, pkt, src_ip, dst_ip, dst_port, ts):
try:
h = pkt.http
if hasattr(h, "request_method") or hasattr(h, "request_uri"):
req = HttpRequest(
method=getattr(h, "request_method", None),
host=getattr(h, "host", None),
uri=getattr(h, "request_uri", None),
user_agent=getattr(h, "user_agent", None),
authorization=getattr(h, "authorization", None),
cookies=getattr(h, "cookie", None),
body=getattr(h, "file_data", None),
src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port, timestamp=ts,
packet_number=_num(pkt),
)
ctx.http_requests.append(req)
# Preserve visible HTTP text for artifact detection.
for piece in (req.uri, req.cookies, req.authorization, req.body):
if piece:
ctx.text_payloads.append({
"text": str(piece), "protocol": "http",
"src_ip": src_ip, "dst_ip": dst_ip,
"packet_number": _num(pkt), "timestamp": ts,
})
if hasattr(h, "response_code"):
ct = getattr(h, "content_type", None)
ctx.http_responses.append(HttpResponse(
status_code=_safe_int(getattr(h, "response_code", None)),
content_type=ct,
server=getattr(h, "server", None),
src_ip=src_ip, dst_ip=dst_ip, timestamp=ts, packet_number=_num(pkt),
))
# Record only responses that look like file downloads.
disposition = getattr(h, "content_disposition", None)
filename = _filename_from_disposition(disposition)
if filename or _is_download_content_type(ct):
ext = os.path.splitext(filename or "")[1].lower() or None
ctx.file_transfers.append(FileTransfer(
filename=filename, extension=ext, content_type=ct,
protocol="http", src_ip=src_ip, dst_ip=dst_ip,
direction="download", timestamp=ts, packet_number=_num(pkt),
))
except Exception:
pass
def _handle_tls(ctx, pkt, src_ip, dst_ip, dst_port, ts):
try:
layer = getattr(pkt, "tls", None) or getattr(pkt, "ssl", None)
if layer is None:
return
# TLS 1.3 reports its real version in the supported-versions extension.
version = (
getattr(layer, "handshake_extensions_supported_version", None)
or getattr(layer, "handshake_version", None)
or getattr(layer, "record_version", None)
)
subject_cn = _first(
getattr(layer, "x509sat_printablestring", None),
getattr(layer, "x509ce_dnsname", None),
)
issuer_cn = getattr(layer, "x509sat_printablestring_1", None)
not_after = (
getattr(layer, "x509af_utctime", None)
or getattr(layer, "x509af_notafter", None)
or getattr(layer, "x509af_utctime_1", None)
)
ctx.tls_sessions.append(TlsSession(
src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port or 443,
version=_tls_version_name(version),
cipher=getattr(layer, "handshake_ciphersuite", None),
sni=getattr(layer, "handshake_extensions_server_name", None),
cert_subject_cn=subject_cn,
cert_issuer_cn=issuer_cn,
cert_not_after=not_after,
cert_self_signed=_cert_is_self_signed(subject_cn, issuer_cn),
cert_expired=_cert_is_expired(not_after, ctx.end_time or ts),
timestamp=ts, packet_number=_num(pkt),
))
except Exception:
pass
def _handle_ftp(ctx, pkt, src_ip, dst_ip, dst_port, ts):
try:
f = pkt.ftp
cmd = getattr(f, "request_command", None)
arg = getattr(f, "request_arg", None)
if cmd and cmd.upper() in ("USER", "PASS"):
pa = _last_plaintext(ctx, "ftp", src_ip, dst_ip, dst_port)
if cmd.upper() == "USER":
pa.username = arg
else:
pa.password = arg
pa.timestamp = ts
pa.packet_number = _num(pkt)
except Exception:
pass
def _handle_telnet(ctx, pkt, src_ip, dst_ip, dst_port, ts):
try:
data = getattr(pkt.telnet, "data", None)
if data:
ctx.text_payloads.append({
"text": str(data), "protocol": "telnet",
"src_ip": src_ip, "dst_ip": dst_ip,
"packet_number": _num(pkt), "timestamp": ts,
})
except Exception:
pass
def _handle_pop3(ctx, pkt, src_ip, dst_ip, dst_port, ts):
"""POP3 USER/PASS im Klartext."""
try:
p = pkt.pop
cmd = (getattr(p, "request_command", None) or "").upper()
param = getattr(p, "request_parameter", None)
if cmd in ("USER", "PASS") and param:
pa = _last_plaintext(ctx, "pop3", src_ip, dst_ip, dst_port)
if cmd == "USER":
pa.username = param
else:
pa.password = param
pa.timestamp = ts
pa.packet_number = _num(pkt)
except Exception:
pass
def _handle_imap(ctx, pkt, src_ip, dst_ip, dst_port, ts):
"""Extract clear-text IMAP LOGIN credentials."""
try:
i = pkt.imap
line = getattr(i, "request", None) or getattr(i, "request_command", None)
if not line:
return
parts = str(line).split()
# Accept both tagged and untagged IMAP LOGIN forms.
idx = next((k for k, tok in enumerate(parts) if tok.upper() == "LOGIN"), None)
if idx is not None and len(parts) >= idx + 3:
user = parts[idx + 1].strip('"')
pw = parts[idx + 2].strip('"')
pa = _last_plaintext(ctx, "imap", src_ip, dst_ip, dst_port)
pa.username, pa.password = user, pw
pa.timestamp = ts
pa.packet_number = _num(pkt)
except Exception:
pass
def _handle_smtp(ctx, pkt, src_ip, dst_ip, dst_port, ts, stream_id, state):
"""Extract Base64-encoded SMTP AUTH LOGIN or PLAIN credentials."""
try:
s = pkt.smtp
param = getattr(s, "req_parameter", None) or getattr(s, "request_parameter", None)
cmd = (getattr(s, "req_command", None) or getattr(s, "request_command", None) or "").upper()
if not param and not cmd:
return
pending = state["smtp_auth"].setdefault(stream_id, {"mech": None, "tokens": []})
text = f"{cmd} {param}".strip()
upper = text.upper()
if "AUTH LOGIN" in upper:
pending["mech"] = "login"
return
if "AUTH PLAIN" in upper:
pending["mech"] = "plain"
# AUTH PLAIN may carry its token on the command line.
tail = text.split()
if len(tail) >= 3:
creds = crackformats.decode_smtp_auth_plain(tail[2])
_emit_smtp(ctx, creds, src_ip, dst_ip, dst_port, ts, pkt)
state["smtp_auth"].pop(stream_id, None)
return
# AUTH LOGIN sends username and password in subsequent lines.
if pending["mech"] == "login" and param:
pending["tokens"].append(param)
if len(pending["tokens"]) >= 2:
user, pw = crackformats.decode_smtp_auth_login(
pending["tokens"][0], pending["tokens"][1])
_emit_smtp(ctx, (user, pw), src_ip, dst_ip, dst_port, ts, pkt)
state["smtp_auth"].pop(stream_id, None)
elif pending["mech"] == "plain" and param:
creds = crackformats.decode_smtp_auth_plain(param)
_emit_smtp(ctx, creds, src_ip, dst_ip, dst_port, ts, pkt)
state["smtp_auth"].pop(stream_id, None)
except Exception:
pass
def _emit_smtp(ctx, creds, src_ip, dst_ip, dst_port, ts, pkt):
if not creds:
return
user, pw = creds
if not (user or pw):
return
ctx.plaintext_auth.append(PlaintextAuth(
protocol="smtp", username=user, password=pw,
src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port,
timestamp=ts, packet_number=_num(pkt),
))
def _handle_ntlm(ctx, pkt, src_ip, dst_ip, ts, stream_id, state):
"""Correlate NTLM challenge and response packets into hashcat material."""
try:
n = pkt.ntlmssp
msg_type = str(getattr(n, "messagetype", "") or "").lower()
# Retain the type-2 server challenge for the matching type-3 response.
server_chal = getattr(n, "ntlmserverchallenge", None)
if server_chal and ("0x00000002" in msg_type or msg_type.endswith("2")):
state["ntlm_chal"][stream_id] = str(server_chal)
# A type-3 message provides the account and NT response.
account = getattr(n, "auth_username", None)
domain = getattr(n, "auth_domain", None)
nt_resp = (getattr(n, "ntlmv2_response", None)
or getattr(n, "auth_ntresponse", None))
if account or nt_resp:
chal = state["ntlm_chal"].get(stream_id)
am = AuthMaterial(
kind="ntlmssp", account=account, domain=domain,
src_ip=src_ip, dst_ip=dst_ip, protocol="ntlm",
timestamp=ts, packet_number=_num(pkt),
server_challenge=chal, nt_response=str(nt_resp) if nt_resp else None,
)
built = crackformats.build_netntlmv2(account, domain, chal, nt_resp)
if built:
am.kind = "netntlmv2"
am.hashcat = built["hashcat"]
am.hashcat_mode = built["hashcat_mode"]
am.hash_format = built["format"]
ctx.auth_material.append(am)
except Exception:
pass
# Bound retained payload text for large captures.
_MAX_TEXT_PAYLOADS = 20000
def _handle_raw_payload(ctx, pkt, layers, src_ip, dst_ip, ts):
"""Retain bounded TCP or UDP text for generic artifact detection."""
if len(ctx.text_payloads) >= _MAX_TEXT_PAYLOADS:
return
raw = None
try:
if "tcp" in layers and hasattr(pkt.tcp, "payload"):
raw = pkt.tcp.payload
elif "udp" in layers and hasattr(pkt.udp, "payload"):
raw = pkt.udp.payload
elif "data" in layers:
raw = getattr(pkt.data, "data", None)
except Exception:
raw = None
if not raw:
return
text = _bytes_from_hexish(str(raw))
if not text or not text.strip():
return
try:
proto = (pkt.highest_layer or "raw").lower()
except Exception:
proto = "raw"
ctx.text_payloads.append({
"text": text[:16384], "protocol": proto,
"src_ip": src_ip, "dst_ip": dst_ip,
"packet_number": _num(pkt), "timestamp": ts,
})
def _bytes_from_hexish(raw: str) -> Optional[str]:
"""Convert a hex-formatted pyshark payload to text when possible."""
s = str(raw).strip()
compact = s.replace(":", "").replace(".", "").replace(" ", "")
if compact and len(compact) % 2 == 0 and all(c in "0123456789abcdefABCDEF" for c in compact):
try:
return bytes.fromhex(compact).decode("utf-8", "ignore")
except Exception:
return None
# Some dissectors already expose decoded text.
return s
def _handle_kerberos(ctx, pkt, src_ip, dst_ip, ts):
"""Extract Kerberos AS-REP and TGS-REP material for offline analysis."""
try:
k = pkt.kerberos
msg_type = str(getattr(k, "msg_type", "") or "")
account = getattr(k, "cnamestring", None) or getattr(k, "cnamestring_1", None)
realm = getattr(k, "realm", None) or getattr(k, "crealm", None)
etype = getattr(k, "etype", None)
cipher = getattr(k, "cipher", None) or getattr(k, "enc_part_cipher", None)
am = AuthMaterial(
kind=f"kerberos_{msg_type}" if msg_type else "kerberos",
account=account, domain=realm,
src_ip=src_ip, dst_ip=dst_ip, protocol="kerberos",
timestamp=ts, packet_number=_num(pkt),
etype=str(etype) if etype is not None else None,
)
# Kerberos message types 11 and 13 are AS-REP and TGS-REP.
built = None
if msg_type == "11":
built = crackformats.build_krb5_asrep(account, realm, etype, cipher)
elif msg_type == "13":
spn = getattr(k, "snamestring", None)
built = crackformats.build_krb5_tgsrep(account, realm, spn, etype, cipher)
if built:
am.hashcat = built["hashcat"]
am.hashcat_mode = built["hashcat_mode"]
am.hash_format = built["format"]
ctx.auth_material.append(am)
except Exception:
pass
# --------------------------------------------------------------------------- #
# kleine Helfer
# --------------------------------------------------------------------------- #
def _last_plaintext(ctx, protocol, src_ip, dst_ip, dst_port) -> PlaintextAuth:
"""Find or create the authentication record that combines USER and PASS."""
for pa in reversed(ctx.plaintext_auth):
if pa.protocol == protocol and pa.src_ip == src_ip and pa.dst_ip == dst_ip:
if pa.password is None or pa.username is None:
return pa
pa = PlaintextAuth(protocol=protocol, src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port)
ctx.plaintext_auth.append(pa)
return pa
def _safe_int(v):
try:
return int(v)
except Exception:
return None
def _tls_version_name(raw) -> Optional[str]:
mapping = {
"0x0300": "SSL 3.0", "0x0301": "TLS 1.0", "0x0302": "TLS 1.1",
"0x0303": "TLS 1.2", "0x0304": "TLS 1.3",
"768": "SSL 3.0", "769": "TLS 1.0", "770": "TLS 1.1",
"771": "TLS 1.2", "772": "TLS 1.3",
}
if raw is None:
return None
return mapping.get(str(raw).lower(), str(raw))
def _first(*vals):
"""Return the first non-empty dissector field value."""
for v in vals:
if v:
return v
return None
def _all_field_values(layer, name) -> list[str]:
"""Return every occurrence of a repeated pyshark field."""
out: list[str] = []
try:
field = getattr(layer, name, None)
if field is None:
return out
allf = getattr(field, "all_fields", None)
if allf:
for f in allf:
v = getattr(f, "show", None) or getattr(f, "showname_value", None)
if v:
out.append(str(v))
else:
out.append(str(field))
except Exception:
pass
return out
# tshark often exposes DNS query types as numeric values.
_DNS_QTYPES = {
"1": "A", "2": "NS", "5": "CNAME", "6": "SOA", "12": "PTR", "15": "MX",
"16": "TXT", "28": "AAAA", "33": "SRV", "255": "ANY", "10": "NULL",
"43": "DS", "48": "DNSKEY", "257": "CAA", "65": "HTTPS", "64": "SVCB",
}
def _dns_qtype_name(raw) -> Optional[str]:
if raw is None:
return None
s = str(raw).strip()
return _DNS_QTYPES.get(s, s.upper())
# Content types that indicate a file rather than ordinary web content.
_DOWNLOAD_CT_MARKERS = (
"application/octet-stream", "application/x-msdownload", "application/x-dosexec",
"application/x-executable", "application/vnd.microsoft.portable-executable",
"application/zip", "application/x-rar", "application/x-7z", "application/x-tar",
"application/gzip", "application/x-msi", "application/x-sh",
"application/java-archive", "application/x-elf",
)
def _is_download_content_type(ct) -> bool:
if not ct:
return False
low = str(ct).lower()
return any(m in low for m in _DOWNLOAD_CT_MARKERS)
def _filename_from_disposition(disposition) -> Optional[str]:
"""Extract a filename from a Content-Disposition header."""
if not disposition:
return None
import re as _re
m = _re.search(r'filename\*?=(?:"([^"]+)"|([^;]+))', str(disposition), _re.IGNORECASE)
if not m:
return None
name = (m.group(1) or m.group(2) or "").strip().strip('"')
# Remove an optional RFC 5987 character-set prefix.
if "''" in name:
name = name.split("''", 1)[1]
return name or None
def _cert_is_self_signed(subject_cn, issuer_cn) -> Optional[bool]:
"""Return whether subject and issuer match, or None if data is incomplete."""
if not subject_cn or not issuer_cn:
return None
return str(subject_cn).strip().lower() == str(issuer_cn).strip().lower()
def _cert_is_expired(not_after, now_epoch) -> Optional[bool]:
"""Compare a parsed certificate expiry with the capture timestamp."""
if not not_after or now_epoch is None:
return None
exp = _parse_cert_time(not_after)
if exp is None:
return None
try:
return float(now_epoch) > exp
except (TypeError, ValueError):
return None
def _parse_cert_time(raw) -> Optional[float]:
"""Convert common X.509 time formats to a UTC epoch timestamp."""
import datetime as _dt
s = str(raw).strip()
# tshark may already provide an epoch value.
try:
return float(s)
except ValueError:
pass
fmts = (
"%Y-%m-%d %H:%M:%S", # 2020-01-01 00:00:00
"%b %d %H:%M:%S %Y GMT", # Jan 1 00:00:00 2020 GMT
"%y%m%d%H%M%SZ", # UTCTime 200101000000Z
"%Y%m%d%H%M%SZ", # GeneralizedTime
"%Y-%m-%dT%H:%M:%SZ", # ISO 8601
)
for fmt in fmts:
try:
dt = _dt.datetime.strptime(s, fmt).replace(tzinfo=_dt.timezone.utc)
return dt.timestamp()
except ValueError:
continue
return None
+166
View File
@@ -0,0 +1,166 @@
"""Shared finding taxonomy for IoT PreTester 4.6 Demo.
The module deliberately has no Django or third-party dependencies so the same
model works in the web backend, CLI, and unit tests.
"""
from __future__ import annotations
import enum
import hashlib
from dataclasses import dataclass, field, asdict
from typing import Any, Optional
class Severity(enum.Enum):
"""Potential impact if the finding is valid."""
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
INFO = "info"
@property
def rank(self) -> int:
return _SEVERITY_RANK[self]
class Confidence(enum.Enum):
"""Confidence in the evidence extracted from the capture."""
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
@property
def rank(self) -> int:
return _CONFIDENCE_RANK[self]
class Category(enum.Enum):
"""Security finding categories covered by the detector set."""
PLAINTEXT_CREDENTIALS = "plaintext_credentials"
PASSWORD_HASHES = "password_hashes"
TOKENS_SECRETS = "tokens_secrets"
MALWARE_BEACONING = "malware_beaconing"
EXFILTRATION = "exfiltration"
MITM = "mitm"
INSECURE_PROTOCOL = "insecure_protocol"
TLS_WEAKNESS = "tls_weakness"
DNS_ANOMALY = "dns_anomaly"
BRUTEFORCE_SCAN_FILETRANSFER = "bruteforce_scan_filetransfer"
_SEVERITY_RANK = {
Severity.CRITICAL: 4,
Severity.HIGH: 3,
Severity.MEDIUM: 2,
Severity.LOW: 1,
Severity.INFO: 0,
}
_CONFIDENCE_RANK = {
Confidence.HIGH: 2,
Confidence.MEDIUM: 1,
Confidence.LOW: 0,
}
def _coerce(value, enum_cls):
"""Accept enum members and their serialized string values."""
if isinstance(value, enum_cls):
return value
if isinstance(value, str):
return enum_cls(value)
raise TypeError(f"{value!r} is not a valid {enum_cls.__name__}")
@dataclass
class Finding:
"""Shared data model for detector output and packet evidence."""
title: str
category: Category
severity: Severity
confidence: Confidence
risk_description: str
recommendation: str
affected_device: Optional[str] = None
source_ip: Optional[str] = None
destination_ip: Optional[str] = None
protocol: Optional[str] = None
port: Optional[int] = None
timestamp_first_seen: Optional[float] = None
timestamp_last_seen: Optional[float] = None
# Structured evidence captured by the detector.
evidence: dict[str, Any] = field(default_factory=dict)
related_packets: list[int] = field(default_factory=list)
related_streams: list[int] = field(default_factory=list)
# Filled by the engine with the detector class name.
detector: Optional[str] = None
finding_id: Optional[str] = None
def __post_init__(self) -> None:
self.category = _coerce(self.category, Category)
self.severity = _coerce(self.severity, Severity)
self.confidence = _coerce(self.confidence, Confidence)
if self.finding_id is None:
self.finding_id = self.compute_id()
def compute_id(self) -> str:
"""Build a stable identifier from the finding's identity fields."""
basis = "|".join(
str(x)
for x in (
self.category.value,
self.title,
self.source_ip,
self.destination_ip,
self.protocol,
self.port,
_evidence_fingerprint(self.evidence),
)
)
return hashlib.sha1(basis.encode("utf-8")).hexdigest()[:16]
@property
def priority(self) -> tuple[int, int]:
"""Sort by severity first, then confidence."""
return (self.severity.rank, self.confidence.rank)
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["category"] = self.category.value
data["severity"] = self.severity.value
data["confidence"] = self.confidence.value
return data
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Finding":
"""Reconstruct a finding from serialized data."""
fields = {
"title", "category", "severity", "confidence", "risk_description",
"recommendation", "affected_device", "source_ip", "destination_ip",
"protocol", "port", "timestamp_first_seen", "timestamp_last_seen",
"evidence", "related_packets", "related_streams", "detector", "finding_id",
}
kwargs = {k: v for k, v in data.items() if k in fields}
return cls(**kwargs)
def _evidence_fingerprint(evidence: dict[str, Any]) -> str:
"""Prefer an explicit deduplication key, then stable sorted evidence."""
if not evidence:
return ""
if "dedup_key" in evidence:
return str(evidence["dedup_key"])
try:
items = sorted((str(k), str(v)) for k, v in evidence.items())
except Exception:
return str(evidence)
return ";".join(f"{k}={v}" for k, v in items)
+110
View File
@@ -0,0 +1,110 @@
"""Build packet-level provenance without coupling findings to PCAP I/O."""
from __future__ import annotations
from typing import Any, Optional
# Keep reports compact while preserving the complete packet count separately.
MAX_SOURCE_PACKETS = 8
def source_ref(*, packet: Optional[int] = None, stream: Optional[int] = None,
timestamp: Optional[float] = None, src_ip: Optional[str] = None,
dst_ip: Optional[str] = None, protocol: Optional[str] = None,
port: Optional[int] = None, capture: Optional[str] = None,
note: Optional[str] = None) -> dict:
"""Build a normalized source reference while omitting empty fields."""
ref: dict[str, Any] = {}
if packet is not None:
ref["packet"] = packet
if stream is not None:
ref["stream"] = stream
if timestamp is not None:
ref["timestamp"] = timestamp
if src_ip:
ref["src_ip"] = src_ip
if dst_ip:
ref["dst_ip"] = dst_ip
if protocol:
ref["protocol"] = protocol
if port:
ref["port"] = port
if capture:
ref["capture"] = capture
if note:
ref["note"] = note
return ref
# Priority-ordered evidence keys used to select the primary artifact.
_ARTIFACT_KEYS = [
("hashcat", "Hash (hashcat)"),
("secret_preview", "Secret/Token"),
("query_name", "DNS query"),
("cipher", "TLS-Cipher"),
("version", "TLS-Version"),
("user_agent", "User-Agent"),
("filename", "File"),
("uri", "URI"),
("subject_cn", "Certificate CN"),
("registered_domain", "Domain"),
("macs", "MAC addresses"),
]
def derive_artifact(evidence: dict) -> Optional[dict]:
"""Derive the most representative artifact from structured evidence."""
if not evidence:
return None
# Treat a username and masked password as one artifact.
user = evidence.get("username")
pw = evidence.get("password")
if user or pw:
return {"label": "Credentials", "value": f"{user or '?'} : {pw or '?'}"}
for key, label in _ARTIFACT_KEYS:
val = evidence.get(key)
if val:
if isinstance(val, (list, tuple)):
val = ", ".join(str(x) for x in val)
return {"label": label, "value": str(val)}
return None
def build_sources(*, related_packets: list[int], related_streams: list[int],
src_ip: Optional[str], dst_ip: Optional[str],
protocol: Optional[str], port: Optional[int],
timestamp_first_seen: Optional[float],
capture: Optional[str],
max_packets: int = MAX_SOURCE_PACKETS) -> list[dict]:
"""Build a bounded source list from packet, stream, and endpoint fields."""
packets = list(dict.fromkeys(p for p in (related_packets or []) if p is not None))
streams = list(dict.fromkeys(s for s in (related_streams or []) if s is not None))
single_stream = streams[0] if len(streams) == 1 else None
sources: list[dict] = []
if packets:
single_ts = timestamp_first_seen if len(packets) == 1 else None
for p in packets[:max_packets]:
sources.append(source_ref(
packet=p, stream=single_stream, timestamp=single_ts,
src_ip=src_ip, dst_ip=dst_ip, protocol=protocol, port=port,
capture=capture,
))
elif streams:
for s in streams[:max_packets]:
sources.append(source_ref(
stream=s, src_ip=src_ip, dst_ip=dst_ip, protocol=protocol,
port=port, capture=capture,
))
elif src_ip or dst_ip or timestamp_first_seen is not None:
sources.append(source_ref(
src_ip=src_ip, dst_ip=dst_ip, protocol=protocol, port=port,
timestamp=timestamp_first_seen, capture=capture,
note="aggregated evidence; no single packet is available",
))
elif capture:
sources.append(source_ref(capture=capture,
note="aggregated evidence; no single packet is available"))
return sources
@@ -0,0 +1,25 @@
"""Report renderers for Markdown, plain text, and JSON."""
from __future__ import annotations
from ..engine import AnalysisResult
from . import markdown as _md
from . import text as _txt
from . import json_report as _json
FORMATS = ("md", "txt", "json")
def render(result: AnalysisResult, fmt: str, extra: dict | None = None) -> str:
"""Render a report with optional endpoint and hash enrichment sections."""
fmt = (fmt or "md").lower()
if fmt in ("md", "markdown"):
return _md.render(result, extra)
if fmt in ("txt", "text"):
return _txt.render(result, extra)
if fmt == "json":
return _json.render(result, extra)
raise ValueError(f"Unknown report format: {fmt!r} (allowed: {', '.join(FORMATS)})")
__all__ = ["render", "FORMATS"]
@@ -0,0 +1,107 @@
"""Shared report-rendering helpers."""
from __future__ import annotations
import datetime as _dt
from ..findings import Finding, Severity
SEVERITY_LABEL = {
Severity.CRITICAL: "CRITICAL",
Severity.HIGH: "HIGH",
Severity.MEDIUM: "MEDIUM",
Severity.LOW: "LOW",
Severity.INFO: "INFO",
}
def fmt_ts(ts) -> str:
if ts is None:
return "—"
try:
return _dt.datetime.fromtimestamp(float(ts), _dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
except Exception:
return str(ts)
def affected_line(f: Finding) -> str:
"""Format affected devices, endpoints, and ports as one compact line."""
parts = []
if f.affected_device:
parts.append(f"Device {f.affected_device}")
if f.source_ip:
parts.append(f"Source {f.source_ip}")
if f.destination_ip:
parts.append(f"Destination {f.destination_ip}")
if f.protocol:
proto = f.protocol + (f":{f.port}" if f.port else "")
parts.append(proto)
for k in ("host", "registered_domain", "query_name", "target"):
if f.evidence.get(k):
parts.append(str(f.evidence[k]))
break
return ", ".join(parts) if parts else "—"
# Evidence rendered in dedicated sections is excluded from generic details.
_EVIDENCE_SKIP = {
"dedup_key", "sources", "artifact", "capture_file", "source_packet_count",
"occurrences",
}
def artifact_line(f: Finding) -> str:
"""Format the primary artifact as one compact line."""
art = f.evidence.get("artifact")
if isinstance(art, dict) and art.get("value"):
label = art.get("label")
return f"{label}: {art['value']}" if label else str(art["value"])
if art:
return str(art)
return "—"
def fmt_source(ref: dict) -> str:
"""Format one packet-level source reference."""
parts = []
if ref.get("packet") is not None:
parts.append(f"Packet #{ref['packet']}")
if ref.get("stream") is not None:
parts.append(f"Stream {ref['stream']}")
if ref.get("timestamp") is not None:
parts.append(fmt_ts(ref["timestamp"]))
if ref.get("src_ip") or ref.get("dst_ip"):
parts.append(f"{ref.get('src_ip', '?')} → {ref.get('dst_ip', '?')}")
if ref.get("protocol"):
proto = ref["protocol"] + (f":{ref['port']}" if ref.get("port") else "")
parts.append(proto)
if ref.get("capture"):
parts.append(f"Capture {ref['capture']}")
if ref.get("note"):
parts.append(str(ref["note"]))
return " · ".join(parts) if parts else "—"
def sources_list(f: Finding) -> list[str]:
"""Return formatted packet-level source references for a finding."""
refs = f.evidence.get("sources") or []
lines = [fmt_source(r) for r in refs]
total = f.evidence.get("source_packet_count")
if total and len(f.related_packets or []) and total > len(refs):
lines.append(f"… and {total - len(refs)} more packets")
return lines
def evidence_line(f: Finding) -> str:
"""Format packet, stream, time, and detector-specific evidence."""
parts = []
if f.related_packets:
pk = ", ".join(map(str, f.related_packets[:8]))
parts.append(f"Packets: {pk}")
if f.related_streams:
parts.append(f"Streams: {', '.join(map(str, f.related_streams[:8]))}")
parts.append(f"Time: {fmt_ts(f.timestamp_first_seen)} – {fmt_ts(f.timestamp_last_seen)}")
extras = {k: v for k, v in f.evidence.items() if k not in _EVIDENCE_SKIP and v is not None}
if extras:
kv = ", ".join(f"{k}={v}" for k, v in list(extras.items())[:6])
parts.append(kv)
return " | ".join(parts)
@@ -0,0 +1,14 @@
"""Machine-readable JSON report renderer."""
from __future__ import annotations
import json
from ..engine import AnalysisResult
def render(result: AnalysisResult, extra: dict | None = None) -> str:
data = result.to_dict()
if extra:
data["cloud_results"] = extra
return json.dumps(data, indent=2, ensure_ascii=False, default=str)
@@ -0,0 +1,111 @@
"""Markdown report renderer."""
from __future__ import annotations
from ..engine import AnalysisResult
from ..findings import Severity
from ._common import (
SEVERITY_LABEL, fmt_ts, affected_line, evidence_line, artifact_line, sources_list,
)
def render(result: AnalysisResult, extra: dict | None = None) -> str:
m = result.meta
s = result.summary
out: list[str] = []
out.append("# IoT PreTester 4.6 Demo — Network Capture Security Analysis")
out.append("")
out.append(f"- **Capture:** `{m.get('capture_path', '—')}`")
out.append(f"- **Packets:** {m.get('packet_count', 0)} · "
f"**Duration:** {m.get('duration_seconds', 0)} s · "
f"**Findings:** {m.get('finding_count', 0)}")
out.append("")
bysev = s.get("by_severity", {})
out.append("## Summary")
out.append("")
out.append("| Severity | Count |")
out.append("| ----------- | ------ |")
for sev in Severity:
out.append(f"| {SEVERITY_LABEL[sev]} | {bysev.get(sev.value, 0)} |")
out.append("")
highlights = s.get("highlights", [])
if highlights:
out.append("## ⚠️ Prioritized Highlights")
out.append("")
out.append("Highest-impact credential, token, C2, and exfiltration findings:")
out.append("")
for f in highlights:
out.append(f"- **[{SEVERITY_LABEL[f.severity]}/{f.confidence.value}]** "
f"{f.title} — {affected_line(f)}")
out.append("")
out.append("## Findings")
out.append("")
if not result.findings:
out.append("_No security-relevant findings detected._")
for i, f in enumerate(result.findings, 1):
out.append(f"### {i}. {f.title}")
out.append("")
out.append(f"- **Severity:** {SEVERITY_LABEL[f.severity]} "
f"· **Confidence:** {f.confidence.value} "
f"· **Category:** {f.category.value} "
f"· **Detector:** {f.detector}")
art = artifact_line(f)
if art and art != "—":
out.append(f"- **🔎 Observed artifact:** `{art}`")
srcs = sources_list(f)
if srcs:
out.append("- **📍 Sources:**")
for source in srcs:
out.append(f" - {source}")
out.append(f"- **Risk:** {f.risk_description}")
out.append(f"- **Affected:** {affected_line(f)}")
out.append(f"- **Additional evidence:** {evidence_line(f)}")
out.append(f"- **Recommendation:** {f.recommendation}")
out.append(f"- **Finding ID:** `{f.finding_id}`")
out.append("")
# Optional URL and hash enrichment results.
if extra:
hashes = extra.get("hashes") or []
urls = extra.get("urls") or []
if hashes:
out.append("")
out.append(f"## Hashes ({len(hashes)})")
out.append("")
for h in hashes:
if h.get("cracked"):
state = f"**CRACKED** → `{h.get('cracked_value')}`"
if h.get("crack_time"):
state += f" ({h['crack_time']})"
elif h.get("analyzed"):
state = "not cracked"
else:
state = "analysis pending"
htype = ", ".join(h.get("hash_type") or [])
out.append(f"- `{h.get('hash_value')}`"
f"{f' [{htype}]' if htype else ''} — {state}")
if urls:
out.append("")
out.append(f"## Cloud URLs and Endpoints ({len(urls)})")
out.append("")
for u in urls:
if u.get("analyzed"):
ports = ", ".join(map(str, u.get("ports") or [])) or "—"
details = f"Ports: {ports}"
if u.get("tls_versions"):
details += f"; TLS: {', '.join(u['tls_versions'])}"
if u.get("cipher_suites"):
details += f"; Cipher: {', '.join(u['cipher_suites'])}"
else:
details = "analysis pending"
out.append(f"- `{u.get('url')}` — {details}")
out.append("")
out.append("---")
out.append(f"_Generated by IoT PreTester 4.6 Demo · Detectors: "
f"{', '.join(m.get('detectors_run', []))}_")
return "\n".join(out)
+100
View File
@@ -0,0 +1,100 @@
"""Plain-text report renderer."""
from __future__ import annotations
from ..engine import AnalysisResult
from ..findings import Severity
from ._common import (
SEVERITY_LABEL, affected_line, evidence_line, artifact_line, sources_list,
)
def render(result: AnalysisResult, extra: dict | None = None) -> str:
m = result.meta
s = result.summary
out: list[str] = []
line = "=" * 70
out.append(line)
out.append("IoT PreTester 4.6 Demo - Network Capture Security Analysis")
out.append(line)
out.append(f"Capture : {m.get('capture_path', '-')}")
out.append(f"Packets : {m.get('packet_count', 0)} Duration: {m.get('duration_seconds', 0)}s")
out.append(f"Findings: {m.get('finding_count', 0)}")
out.append("")
bysev = s.get("by_severity", {})
out.append("SUMMARY")
out.append("-" * 70)
for sev in Severity:
out.append(f" {SEVERITY_LABEL[sev]:<8}: {bysev.get(sev.value, 0)}")
out.append("")
highlights = s.get("highlights", [])
if highlights:
out.append("PRIORITIZED HIGHLIGHTS")
out.append("-" * 70)
for f in highlights:
out.append(f" [{SEVERITY_LABEL[f.severity]}/{f.confidence.value}] "
f"{f.title} -- {affected_line(f)}")
out.append("")
out.append("FINDINGS")
out.append("-" * 70)
if not result.findings:
out.append(" No security-relevant findings detected.")
for i, f in enumerate(result.findings, 1):
out.append(f"[{i}] {f.title}")
out.append(f" Severity : {SEVERITY_LABEL[f.severity]} / Confidence: {f.confidence.value}")
out.append(f" Category : {f.category.value} (Detector: {f.detector})")
art = artifact_line(f)
if art and art != "—":
out.append(f" ARTIFACT : {art}")
srcs = sources_list(f)
if srcs:
out.append(" Sources :")
for source in srcs:
out.append(f" - {source}")
out.append(f" Risk : {f.risk_description}")
out.append(f" Affected : {affected_line(f)}")
out.append(f" Evidence : {evidence_line(f)}")
out.append(f" Recommendation : {f.recommendation}")
out.append(f" Finding ID : {f.finding_id}")
out.append("")
if extra:
hashes = extra.get("hashes") or []
urls = extra.get("urls") or []
if hashes:
out.append(f"HASHES ({len(hashes)})")
out.append("-" * 70)
for h in hashes:
if h.get("cracked"):
state = f"CRACKED -> {h.get('cracked_value')}"
if h.get("crack_time"):
state += f" ({h['crack_time']})"
elif h.get("analyzed"):
state = "not cracked"
else:
state = "analysis pending"
htype = ", ".join(h.get("hash_type") or [])
out.append(f" {h.get('hash_value')}{f' [{htype}]' if htype else ''}")
out.append(f" {state}")
out.append("")
if urls:
out.append(f"CLOUD URLS & ENDPOINTS ({len(urls)})")
out.append("-" * 70)
for u in urls:
out.append(f" {u.get('url')}")
if u.get("analyzed"):
ports = ", ".join(map(str, u.get("ports") or [])) or "-"
out.append(f" Ports: {ports}")
if u.get("tls_versions"):
out.append(f" TLS: {', '.join(u['tls_versions'])}")
if u.get("cipher_suites"):
out.append(f" Cipher: {', '.join(u['cipher_suites'])}")
else:
out.append(" analysis pending")
out.append("")
return "\n".join(out)
+80
View File
@@ -0,0 +1,80 @@
"""Central finding deduplication, prioritization, and summary logic."""
from __future__ import annotations
from .findings import Finding, Category, Severity, Confidence
# Categories highlighted in the report summary.
HIGHLIGHT_CATEGORIES = {
Category.PLAINTEXT_CREDENTIALS,
Category.PASSWORD_HASHES,
Category.TOKENS_SECRETS,
Category.MALWARE_BEACONING,
Category.EXFILTRATION,
}
def dedupe(findings: list[Finding]) -> list[Finding]:
"""Merge findings with the same ID while retaining their strongest evidence."""
by_id: dict[str, Finding] = {}
for f in findings:
existing = by_id.get(f.finding_id)
if existing is None:
by_id[f.finding_id] = f
continue
existing.timestamp_first_seen = _min(existing.timestamp_first_seen, f.timestamp_first_seen)
existing.timestamp_last_seen = _max(existing.timestamp_last_seen, f.timestamp_last_seen)
existing.related_packets = sorted(set(existing.related_packets) | set(f.related_packets))
existing.related_streams = sorted(set(existing.related_streams) | set(f.related_streams))
if f.confidence.rank > existing.confidence.rank:
existing.confidence = f.confidence
existing.evidence.setdefault("occurrences", 1)
existing.evidence["occurrences"] += 1
return list(by_id.values())
def sort_by_priority(findings: list[Finding]) -> list[Finding]:
"""Sort by severity, confidence, and stable category order."""
return sorted(
findings,
key=lambda f: (f.severity.rank, f.confidence.rank, -_category_order(f.category)),
reverse=True,
)
def summarize(findings: list[Finding]) -> dict:
"""Build the prioritized summary consumed by report renderers."""
by_sev = {s.value: 0 for s in Severity}
by_cat: dict[str, int] = {}
for f in findings:
by_sev[f.severity.value] += 1
by_cat[f.category.value] = by_cat.get(f.category.value, 0) + 1
highlights = [
f for f in sort_by_priority(findings)
if f.category in HIGHLIGHT_CATEGORIES
and f.severity.rank >= Severity.HIGH.rank
]
return {
"total": len(findings),
"by_severity": by_sev,
"by_category": by_cat,
"highlights": highlights,
"top": sort_by_priority(findings)[:10],
}
def _category_order(cat: Category) -> int:
order = list(Category)
return order.index(cat)
def _min(a, b):
vals = [x for x in (a, b) if x is not None]
return min(vals) if vals else None
def _max(a, b):
vals = [x for x in (a, b) if x is not None]
return max(vals) if vals else None
@@ -0,0 +1,879 @@
"""Unit tests for the framework-independent analysis core.
The tests use synthetic capture contexts and therefore require NO
tshark or pyshark. Run with:
cd django_project
python -m unittest analyzer.tests.test_analyzer -v
"""
import base64
import unittest
from analyzer import AnalysisEngine, render, FORMATS
from analyzer.context import (
CaptureContext, Conversation, DnsQuery, HttpRequest, HttpResponse,
TlsSession, ArpEvent, PlaintextAuth, AuthMaterial, Host,
)
from analyzer.findings import Finding, Category, Severity, Confidence
from analyzer import scoring
def run(ctx):
return AnalysisEngine().analyze_context(ctx)
def categories(result):
return {f.category for f in result.findings}
class PlaintextCredentialTests(unittest.TestCase):
def test_ftp_plaintext(self):
ctx = CaptureContext()
ctx.plaintext_auth.append(PlaintextAuth(
protocol="ftp", username="admin", password="secret",
src_ip="192.168.1.10", dst_ip="192.168.1.1", dst_port=21))
res = run(ctx)
self.assertIn(Category.PLAINTEXT_CREDENTIALS, categories(res))
f = next(f for f in res.findings if f.category == Category.PLAINTEXT_CREDENTIALS)
self.assertEqual(f.severity, Severity.CRITICAL)
self.assertEqual(f.evidence["username"], "admin")
self.assertNotEqual(f.evidence["password"], "secret") # Masked in evidence.
def test_http_basic_auth(self):
token = base64.b64encode(b"bob:hunter2").decode()
ctx = CaptureContext()
ctx.http_requests.append(HttpRequest(
method="GET", host="example.com", uri="/admin",
authorization=f"Basic {token}", src_ip="10.0.0.5", dst_ip="93.184.216.34",
dst_port=80))
res = run(ctx)
f = next(f for f in res.findings if "HTTP Basic" in f.title)
self.assertEqual(f.evidence["username"], "bob")
class TokenSecretTests(unittest.TestCase):
def test_jwt_detected(self):
jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.abcDEF123_-xyz"
ctx = CaptureContext()
ctx.text_payloads.append({"text": f"Authorization: Bearer {jwt}",
"protocol": "http", "src_ip": "10.0.0.1",
"dst_ip": "1.2.3.4", "packet_number": 7})
res = run(ctx)
self.assertIn(Category.TOKENS_SECRETS, categories(res))
def test_session_cookie_over_http(self):
ctx = CaptureContext()
ctx.text_payloads.append({"text": "Cookie: sessionid=ABCDEF1234567890",
"protocol": "http", "src_ip": "10.0.0.1",
"dst_ip": "1.2.3.4"})
res = run(ctx)
self.assertTrue(any("Session cookie" in f.title for f in res.findings))
class InsecureProtocolTests(unittest.TestCase):
def test_telnet_flagged_critical(self):
ctx = CaptureContext()
ctx.conversations.append(Conversation(
src_ip="192.168.0.2", dst_ip="192.168.0.3", src_port=50000, dst_port=23,
protocol="tcp", packets=10, bytes=500))
res = run(ctx)
f = next(f for f in res.findings if f.category == Category.INSECURE_PROTOCOL)
self.assertEqual(f.severity, Severity.CRITICAL)
self.assertEqual(f.port, 23)
class TlsWeaknessTests(unittest.TestCase):
def test_old_tls_version(self):
ctx = CaptureContext()
ctx.tls_sessions.append(TlsSession(
src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=443,
version="TLS 1.0", cipher="TLS_RSA_WITH_RC4_128_SHA", sni="legacy.example"))
res = run(ctx)
titles = [f.title for f in res.findings if f.category == Category.TLS_WEAKNESS]
self.assertTrue(any("Obsolete TLS" in t for t in titles))
self.assertTrue(any("cipher" in t.lower() for t in titles))
class DnsAnomalyTests(unittest.TestCase):
def test_dns_tunneling_many_subdomains(self):
ctx = CaptureContext()
for i in range(40):
ctx.dns_queries.append(DnsQuery(
query_name=f"data{i}chunk.tunnel.example.com", rcode="NOERROR",
src_ip="10.0.0.9", dst_ip="8.8.8.8"))
res = run(ctx)
self.assertIn(Category.DNS_ANOMALY, categories(res))
def test_high_entropy_domain(self):
ctx = CaptureContext()
ctx.dns_queries.append(DnsQuery(
query_name="a8f3kd92jfh3kd0fj2k3.malware-c2.net", rcode="NOERROR"))
res = run(ctx)
self.assertIn(Category.DNS_ANOMALY, categories(res))
class MitmTests(unittest.TestCase):
def test_arp_spoof_multiple_macs(self):
ctx = CaptureContext()
ctx.arp_events.append(ArpEvent(sender_ip="192.168.1.1", sender_mac="aa:aa:aa:aa:aa:aa", opcode=2))
ctx.arp_events.append(ArpEvent(sender_ip="192.168.1.1", sender_mac="bb:bb:bb:bb:bb:bb", opcode=2))
res = run(ctx)
self.assertIn(Category.MITM, categories(res))
class BeaconingTests(unittest.TestCase):
def test_regular_beaconing(self):
ctx = CaptureContext()
conv = Conversation(src_ip="192.168.1.50", dst_ip="8.8.8.8", src_port=44000,
dst_port=443, protocol="tcp")
# Exact 60-second intervals produce a strong periodicity signal.
conv.timestamps = [float(t * 60) for t in range(8)]
conv.first_seen, conv.last_seen = conv.timestamps[0], conv.timestamps[-1]
ctx.conversations.append(conv)
res = run(ctx)
self.assertIn(Category.MALWARE_BEACONING, categories(res))
class ExfiltrationTests(unittest.TestCase):
def test_large_upload(self):
ctx = CaptureContext()
ctx.conversations.append(Conversation(
src_ip="192.168.1.20", dst_ip="8.8.8.8", src_port=40000, dst_port=443,
protocol="tcp", packets=100, bytes=10 * 1024 * 1024,
bytes_src_to_dst=10 * 1024 * 1024))
res = run(ctx)
self.assertIn(Category.EXFILTRATION, categories(res))
class BruteforceScanTests(unittest.TestCase):
def test_portscan(self):
ctx = CaptureContext()
for port in range(1000, 1030):
ctx.conversations.append(Conversation(
src_ip="10.0.0.66", dst_ip="10.0.0.5", src_port=50000 + port,
dst_port=port, protocol="tcp", packets=1, bytes=60))
res = run(ctx)
self.assertTrue(any("Port scan" in f.title for f in res.findings))
def test_malware_download(self):
ctx = CaptureContext()
ctx.http_requests.append(HttpRequest(
method="GET", host="evil.example", uri="/payload.exe",
src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=80))
res = run(ctx)
self.assertTrue(any(".exe" in f.title for f in res.findings))
class PasswordHashTests(unittest.TestCase):
def test_ntlm_material(self):
ctx = CaptureContext()
ctx.auth_material.append(AuthMaterial(
kind="ntlmssp", account="user1", domain="CORP",
src_ip="10.0.0.1", dst_ip="10.0.0.2", protocol="ntlm"))
res = run(ctx)
self.assertIn(Category.PASSWORD_HASHES, categories(res))
class ScoringTests(unittest.TestCase):
def _f(self, sev, conf=Confidence.HIGH, key="x"):
return Finding(title="t", category=Category.EXFILTRATION, severity=sev,
confidence=conf, risk_description="r", recommendation="rec",
evidence={"dedup_key": key})
def test_dedupe_merges_identical(self):
a = self._f(Severity.HIGH, Confidence.LOW, key="same")
b = self._f(Severity.HIGH, Confidence.HIGH, key="same")
a.related_packets = [1]
b.related_packets = [2]
merged = scoring.dedupe([a, b])
self.assertEqual(len(merged), 1)
self.assertEqual(merged[0].confidence, Confidence.HIGH)
self.assertEqual(merged[0].related_packets, [1, 2])
def test_sort_by_priority(self):
low = self._f(Severity.LOW, key="a")
crit = self._f(Severity.CRITICAL, key="b")
med = self._f(Severity.MEDIUM, key="c")
ordered = scoring.sort_by_priority([low, crit, med])
self.assertEqual([f.severity for f in ordered],
[Severity.CRITICAL, Severity.MEDIUM, Severity.LOW])
class ReportRenderingTests(unittest.TestCase):
def _result(self):
ctx = CaptureContext(path="x.pcapng", packet_count=10)
ctx.plaintext_auth.append(PlaintextAuth(
protocol="ftp", username="admin", password="secret",
src_ip="192.168.1.10", dst_ip="192.168.1.1", dst_port=21))
return run(ctx)
def test_all_formats_render(self):
res = self._result()
for fmt in FORMATS:
out = render(res, fmt)
self.assertIsInstance(out, str)
self.assertTrue(len(out) > 0)
def test_json_is_valid(self):
import json
res = self._result()
data = json.loads(render(res, "json"))
self.assertIn("findings", data)
self.assertIn("summary", data)
self.assertGreaterEqual(len(data["findings"]), 1)
def test_extra_cloud_results_in_report(self):
import json
res = self._result()
extra = {
"urls": [{"url": "api.example.com", "analyzed": True, "ports": [443],
"tls_versions": ["TLS 1.2"], "cipher_suites": ["AES_256_GCM"]}],
"hashes": [{"hash_value": "5f4dcc3b5aa765d61d8327deb882cf99", "analyzed": True,
"cracked": True, "cracked_value": "password", "crack_time": "0.10s",
"hash_type": ["md5"]}],
}
md = render(res, "md", extra)
self.assertIn("api.example.com", md)
self.assertIn("password", md)
txt = render(res, "txt", extra)
self.assertIn("CRACKED", txt)
data = json.loads(render(res, "json", extra))
self.assertIn("cloud_results", data)
self.assertEqual(data["cloud_results"]["hashes"][0]["cracked_value"], "password")
class ReconstructionTests(unittest.TestCase):
def test_roundtrip_from_dict_renders(self):
import json
from analyzer import result_from_dict, render
ctx = CaptureContext(path="x.pcapng", packet_count=5)
ctx.plaintext_auth.append(PlaintextAuth(
protocol="ftp", username="admin", password="secret",
src_ip="192.168.1.10", dst_ip="192.168.1.1", dst_port=21))
res = run(ctx)
# Serialize and reconstruct a stored result.
stored = json.dumps(res.to_dict())
rebuilt = result_from_dict(json.loads(stored))
self.assertEqual(len(rebuilt.findings), len(res.findings))
self.assertEqual(rebuilt.findings[0].finding_id, res.findings[0].finding_id)
# Every renderer must work with the reconstructed result.
for fmt in FORMATS:
out = render(rebuilt, fmt)
self.assertTrue(len(out) > 0)
class EngineRobustnessTests(unittest.TestCase):
def test_empty_capture(self):
res = run(CaptureContext(path="empty.pcap"))
self.assertEqual(res.findings, [])
self.assertEqual(res.summary["total"], 0)
def test_all_ten_detectors_registered(self):
res = run(CaptureContext())
self.assertEqual(len(res.meta["detectors_run"]), 10)
self.assertEqual(res.meta["detector_errors"], {})
# --------------------------------------------------------------------------- #
# Detector regression tests
# --------------------------------------------------------------------------- #
class BeaconingConnectionTests(unittest.TestCase):
def test_beaconing_via_distinct_connections(self):
"""Detect TCP beaconing spread across multiple short connections."""
ctx = CaptureContext()
for i in range(8):
t = float(i * 30) # One new connection every 30 seconds.
ctx.conversations.append(Conversation(
src_ip="192.168.1.77", dst_ip="8.8.8.8",
src_port=40000 + i, dst_port=443, protocol="tcp",
packets=2, bytes=200, first_seen=t, last_seen=t + 1,
stream_id=i, timestamps=[t, t + 1],
))
res = run(ctx)
beacons = [f for f in res.findings if f.category == Category.MALWARE_BEACONING]
self.assertTrue(beacons, "Beaconing across separate connections was not detected")
self.assertEqual(beacons[0].evidence.get("signal"), "distinct_connections")
self.assertEqual(beacons[0].destination_ip, "8.8.8.8")
def test_irregular_connections_not_flagged(self):
ctx = CaptureContext()
for i, t in enumerate([0.0, 5.0, 90.0, 91.0, 400.0, 402.0]):
ctx.conversations.append(Conversation(
src_ip="192.168.1.77", dst_ip="8.8.8.8",
src_port=40000 + i, dst_port=443, protocol="tcp",
first_seen=t, last_seen=t, timestamps=[t]))
res = run(ctx)
self.assertNotIn(Category.MALWARE_BEACONING, categories(res))
class ProtocolStatsCaseTests(unittest.TestCase):
def test_eapol_handshake_real_casing(self):
"""Protocol statistics remain case-insensitive for tshark layer names."""
ctx = CaptureContext()
ctx.protocol_stats["EAPOL"] = 4
res = run(ctx)
self.assertTrue(any("EAPOL" in f.title for f in res.findings))
def test_smbv1_real_casing(self):
ctx = CaptureContext()
ctx.protocol_stats["SMB"] = 20
res = run(ctx)
self.assertTrue(any("SMBv1" in f.title for f in res.findings))
def test_smbv1_suppressed_when_smb2_present(self):
ctx = CaptureContext()
ctx.protocol_stats["SMB"] = 20
ctx.protocol_stats["SMB2"] = 100
res = run(ctx)
self.assertFalse(any("SMBv1" in f.title for f in res.findings))
class TlsCertTests(unittest.TestCase):
def test_self_signed_certificate_flagged(self):
ctx = CaptureContext()
ctx.tls_sessions.append(TlsSession(
src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=443,
version="TLS 1.2", sni="iot.local",
cert_subject_cn="iot.local", cert_issuer_cn="iot.local",
cert_self_signed=True))
res = run(ctx)
self.assertTrue(any("self-signed" in f.title for f in res.findings))
def test_expired_certificate_flagged(self):
ctx = CaptureContext()
ctx.tls_sessions.append(TlsSession(
src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=443,
version="TLS 1.2", cert_expired=True))
res = run(ctx)
self.assertTrue(any("expired" in f.title for f in res.findings))
class CertHelperTests(unittest.TestCase):
def test_self_signed_helper(self):
from analyzer.extract_pyshark import _cert_is_self_signed
self.assertTrue(_cert_is_self_signed("device.local", "device.local"))
self.assertFalse(_cert_is_self_signed("device.local", "DigiCert CA"))
self.assertIsNone(_cert_is_self_signed("device.local", None))
def test_expired_helper(self):
from analyzer.extract_pyshark import _cert_is_expired
# The certificate expired before the 2025 capture timestamp.
now = 1735689600.0 # 2025-01-01 UTC
self.assertTrue(_cert_is_expired("2020-01-01 00:00:00", now))
self.assertFalse(_cert_is_expired("2030-01-01 00:00:00", now))
self.assertIsNone(_cert_is_expired(None, now))
def test_parse_cert_time_formats(self):
from analyzer.extract_pyshark import _parse_cert_time
self.assertIsNotNone(_parse_cert_time("2020-01-01 00:00:00"))
self.assertIsNotNone(_parse_cert_time("Jan 1 00:00:00 2020 GMT"))
self.assertIsNotNone(_parse_cert_time("200101000000Z"))
self.assertIsNone(_parse_cert_time("nonsense"))
# --------------------------------------------------------------------------- #
# Authentication material reconstruction
# --------------------------------------------------------------------------- #
class CrackFormatTests(unittest.TestCase):
def test_netntlmv2(self):
from analyzer.crackformats import build_netntlmv2
nt = "a" * 32 + "b" * 40 # NTProof(16B) + Blob
out = build_netntlmv2("alice", "CORP", "1122334455667788", nt)
self.assertIsNotNone(out)
self.assertEqual(out["hashcat_mode"], 5600)
self.assertEqual(out["hashcat"],
"alice::CORP:1122334455667788:" + "a" * 32 + ":" + "b" * 40)
def test_netntlmv2_rejects_bad_challenge(self):
from analyzer.crackformats import build_netntlmv2
self.assertIsNone(build_netntlmv2("alice", "CORP", "1122", "a" * 40))
def test_krb5_asrep(self):
from analyzer.crackformats import build_krb5_asrep
cipher = "c" * 32 + "d" * 64
out = build_krb5_asrep("bob", "corp.local", 23, cipher)
self.assertEqual(out["hashcat_mode"], 18200)
self.assertTrue(out["hashcat"].startswith("$krb5asrep$23$bob@CORP.LOCAL:"))
def test_krb5_tgsrep(self):
from analyzer.crackformats import build_krb5_tgsrep
cipher = "e" * 32 + "f" * 64
out = build_krb5_tgsrep("svc", "corp.local", "MSSQL/db", 23, cipher)
self.assertEqual(out["hashcat_mode"], 13100)
self.assertIn("$krb5tgs$23$*svc$CORP.LOCAL$MSSQL/db*$", out["hashcat"])
def test_smtp_auth_plain(self):
import base64
from analyzer.crackformats import decode_smtp_auth_plain
token = base64.b64encode(b"\x00joe\x00s3cret").decode()
self.assertEqual(decode_smtp_auth_plain(token), ("joe", "s3cret"))
def test_smtp_auth_login(self):
import base64
from analyzer.crackformats import decode_smtp_auth_login
u = base64.b64encode(b"joe").decode()
p = base64.b64encode(b"s3cret").decode()
self.assertEqual(decode_smtp_auth_login(u, p), ("joe", "s3cret"))
class CrackableAuthMaterialTests(unittest.TestCase):
def test_hashcat_material_surfaced_and_high_confidence(self):
ctx = CaptureContext()
ctx.auth_material.append(AuthMaterial(
kind="netntlmv2", account="alice", domain="CORP",
src_ip="10.0.0.5", dst_ip="10.0.0.1", protocol="ntlm",
hashcat="alice::CORP:1122334455667788:" + "a" * 32 + ":" + "b" * 40,
hashcat_mode=5600, hash_format="NetNTLMv2"))
res = run(ctx)
ph = [f for f in res.findings if f.category == Category.PASSWORD_HASHES]
self.assertTrue(ph)
f = ph[0]
self.assertEqual(f.confidence, Confidence.HIGH)
self.assertIn("crackable", f.title)
self.assertEqual(f.evidence.get("hashcat_mode"), 5600)
self.assertTrue(f.evidence.get("hashcat", "").startswith("alice::CORP:"))
class MailPlaintextAuthTests(unittest.TestCase):
def test_pop3_plaintext(self):
ctx = CaptureContext()
ctx.plaintext_auth.append(PlaintextAuth(
protocol="pop3", username="joe", password="pw123",
src_ip="10.0.0.5", dst_ip="10.0.0.2", dst_port=110))
res = run(ctx)
self.assertTrue(any("POP3" in f.title for f in res.findings))
def test_smtp_plaintext(self):
ctx = CaptureContext()
ctx.plaintext_auth.append(PlaintextAuth(
protocol="smtp", username="joe", password="pw123",
src_ip="10.0.0.5", dst_ip="10.0.0.2", dst_port=25))
res = run(ctx)
self.assertTrue(any("SMTP" in f.title for f in res.findings))
# --------------------------------------------------------------------------- #
# False-positive regression tests
# --------------------------------------------------------------------------- #
class UserAgentTests(unittest.TestCase):
def _ua(self, ua):
ctx = CaptureContext()
ctx.http_requests.append(HttpRequest(
method="GET", host="x.example", uri="/", user_agent=ua,
src_ip="10.0.0.5", dst_ip="1.2.3.4", dst_port=80))
return run(ctx)
def test_benign_iot_ua_not_flagged(self):
for ua in ("okhttp/4.9.0", "Java/1.8.0", "Go-http-client/1.1"):
res = self._ua(ua)
self.assertFalse(
any(f.category == Category.MALWARE_BEACONING for f in res.findings),
f"{ua} sollte kein Finding erzeugen")
def test_scanner_ua_flagged_high(self):
res = self._ua("Mozilla/5.00 (Nikto/2.1.6)")
ua = [f for f in res.findings if f.category == Category.MALWARE_BEACONING]
self.assertTrue(ua)
self.assertEqual(ua[0].severity, Severity.HIGH)
self.assertEqual(ua[0].evidence.get("tier"), "scanner")
def test_scripting_ua_flagged_low(self):
res = self._ua("curl/8.1.2")
ua = [f for f in res.findings if f.category == Category.MALWARE_BEACONING]
self.assertTrue(ua)
self.assertEqual(ua[0].severity, Severity.LOW)
class DnsQtypeTests(unittest.TestCase):
def test_qtype_name_mapping(self):
from analyzer.extract_pyshark import _dns_qtype_name
self.assertEqual(_dns_qtype_name("16"), "TXT")
self.assertEqual(_dns_qtype_name("1"), "A")
self.assertEqual(_dns_qtype_name("txt"), "TXT")
self.assertIsNone(_dns_qtype_name(None))
def test_txt_tunneling_flagged(self):
ctx = CaptureContext()
for i in range(25):
ctx.dns_queries.append(DnsQuery(
query_name=f"chunk{i}.exfil.example.com", qtype="TXT",
rcode="NOERROR", src_ip="10.0.0.9", dst_ip="8.8.8.8"))
res = run(ctx)
self.assertTrue(any("TXT or NULL" in f.title for f in res.findings))
def test_normal_a_queries_no_txt_finding(self):
ctx = CaptureContext()
for i in range(25):
ctx.dns_queries.append(DnsQuery(
query_name=f"host{i}.example.com", qtype="A", rcode="NOERROR"))
res = run(ctx)
self.assertFalse(any("TXT or NULL" in f.title for f in res.findings))
class DownloadHelperTests(unittest.TestCase):
def test_is_download_content_type(self):
from analyzer.extract_pyshark import _is_download_content_type
self.assertTrue(_is_download_content_type("application/octet-stream"))
self.assertTrue(_is_download_content_type("application/x-msdownload"))
self.assertFalse(_is_download_content_type("text/html; charset=utf-8"))
self.assertFalse(_is_download_content_type(None))
def test_filename_from_disposition(self):
from analyzer.extract_pyshark import _filename_from_disposition
self.assertEqual(_filename_from_disposition('attachment; filename="update.exe"'), "update.exe")
self.assertEqual(_filename_from_disposition("attachment; filename=payload.bin"), "payload.bin")
self.assertIsNone(_filename_from_disposition("inline"))
class FileTransferDownloadTests(unittest.TestCase):
def test_executable_content_type_flagged(self):
from analyzer.context import FileTransfer
ctx = CaptureContext()
ctx.file_transfers.append(FileTransfer(
filename=None, extension=None, content_type="application/x-msdownload",
protocol="http", src_ip="1.2.3.4", dst_ip="10.0.0.5", direction="download"))
res = run(ctx)
self.assertTrue(any("harmful file" in f.title for f in res.findings))
def test_disposition_filename_extension_flagged(self):
from analyzer.context import FileTransfer
ctx = CaptureContext()
ctx.file_transfers.append(FileTransfer(
filename="update.exe", extension=".exe", content_type="application/octet-stream",
protocol="http", src_ip="1.2.3.4", dst_ip="10.0.0.5", direction="download"))
res = run(ctx)
self.assertTrue(any(".exe" in f.title for f in res.findings))
def test_benign_download_not_flagged(self):
from analyzer.context import FileTransfer
ctx = CaptureContext()
ctx.file_transfers.append(FileTransfer(
filename="report.pdf", extension=".pdf", content_type="application/pdf",
protocol="http", src_ip="1.2.3.4", dst_ip="10.0.0.5", direction="download"))
res = run(ctx)
self.assertFalse(any(f.category == Category.BRUTEFORCE_SCAN_FILETRANSFER
for f in res.findings))
# --------------------------------------------------------------------------- #
# Packet-level artifact provenance
# --------------------------------------------------------------------------- #
class ProvenanceHelperTests(unittest.TestCase):
def test_derive_artifact_credentials(self):
from analyzer.provenance import derive_artifact
art = derive_artifact({"username": "joe", "password": "pw"})
self.assertEqual(art["label"], "Credentials")
self.assertIn("joe", art["value"])
def test_derive_artifact_hashcat(self):
from analyzer.provenance import derive_artifact
art = derive_artifact({"hashcat": "alice::CORP:...:...:..."})
self.assertEqual(art["label"], "Hash (hashcat)")
def test_derive_artifact_none_for_behavioral(self):
from analyzer.provenance import derive_artifact
self.assertIsNone(derive_artifact({"distinct_ports": 30}))
def test_build_sources_single_packet_has_timestamp(self):
from analyzer.provenance import build_sources
srcs = build_sources(related_packets=[42], related_streams=[3],
src_ip="10.0.0.5", dst_ip="10.0.0.2", protocol="ftp",
port=21, timestamp_first_seen=1577880000.0, capture="c.pcapng")
self.assertEqual(len(srcs), 1)
self.assertEqual(srcs[0]["packet"], 42)
self.assertEqual(srcs[0]["stream"], 3)
self.assertEqual(srcs[0]["timestamp"], 1577880000.0)
self.assertEqual(srcs[0]["capture"], "c.pcapng")
def test_build_sources_multi_packet_capped_no_per_packet_ts(self):
from analyzer.provenance import build_sources, MAX_SOURCE_PACKETS
srcs = build_sources(related_packets=list(range(1, 20)), related_streams=[1, 2],
src_ip="10.0.0.5", dst_ip="8.8.8.8", protocol="tcp",
port=443, timestamp_first_seen=100.0, capture="c.pcapng")
self.assertEqual(len(srcs), MAX_SOURCE_PACKETS)
self.assertNotIn("timestamp", srcs[0]) # Never infer per-packet timestamps.
self.assertNotIn("stream", srcs[0]) # Omit an ambiguous stream reference.
def test_build_sources_no_packet_still_has_reference(self):
from analyzer.provenance import build_sources
srcs = build_sources(related_packets=[], related_streams=[],
src_ip="10.0.0.9", dst_ip=None, protocol="dns", port=53,
timestamp_first_seen=None, capture="c.pcapng")
self.assertEqual(len(srcs), 1)
self.assertIn("note", srcs[0])
class EngineProvenanceTests(unittest.TestCase):
def test_finding_gets_capture_artifact_sources(self):
ctx = CaptureContext(path="/data/demo_capture.pcapng")
ctx.plaintext_auth.append(PlaintextAuth(
protocol="ftp", username="admin", password="s3cret",
src_ip="10.0.0.5", dst_ip="10.0.0.2", dst_port=21,
timestamp=1577880000.0, packet_number=42, stream_id=3))
res = run(ctx)
f = next(f for f in res.findings if f.category == Category.PLAINTEXT_CREDENTIALS)
# Store only the capture basename.
self.assertEqual(f.evidence["capture_file"], "demo_capture.pcapng")
self.assertEqual(f.evidence["artifact"]["label"], "Credentials")
self.assertTrue(f.evidence["sources"])
self.assertEqual(f.evidence["sources"][0]["packet"], 42)
self.assertEqual(f.evidence["sources"][0]["capture"], "demo_capture.pcapng")
def test_aggregated_findings_have_packets(self):
# Aggregated scan findings retain representative packet numbers.
ctx = CaptureContext(path="cap.pcapng")
for p in range(1000, 1020):
ctx.conversations.append(Conversation(
src_ip="10.0.0.66", dst_ip="10.0.0.5", src_port=50000 + p,
dst_port=p, protocol="tcp", packets=1, bytes=60,
packet_numbers=[p]))
res = run(ctx)
scan = next(f for f in res.findings if "Port scan" in f.title)
self.assertTrue(scan.related_packets, "Port scan has no packet evidence")
self.assertTrue(scan.evidence["sources"])
self.assertIn("packet", scan.evidence["sources"][0])
def test_beaconing_distinct_connections_have_packets(self):
ctx = CaptureContext(path="cap.pcapng")
for i in range(8):
t = float(i * 30)
ctx.conversations.append(Conversation(
src_ip="192.168.1.77", dst_ip="8.8.8.8", src_port=40000 + i,
dst_port=443, protocol="tcp", first_seen=t, last_seen=t,
timestamps=[t], stream_id=i, packet_numbers=[500 + i]))
res = run(ctx)
beacon = next(f for f in res.findings if f.category == Category.MALWARE_BEACONING)
self.assertTrue(beacon.related_packets)
class ReportProvenanceTests(unittest.TestCase):
def test_report_shows_artifact_and_packet(self):
ctx = CaptureContext(path="/data/evidence.pcapng")
ctx.plaintext_auth.append(PlaintextAuth(
protocol="ftp", username="admin", password="s3cret",
src_ip="10.0.0.5", dst_ip="10.0.0.2", dst_port=21,
timestamp=1577880000.0, packet_number=42, stream_id=3))
res = run(ctx)
md = render(res, "md")
self.assertIn("Observed artifact", md)
self.assertIn("Packet #42", md)
self.assertIn("evidence.pcapng", md)
txt = render(res, "txt")
self.assertIn("ARTIFACT", txt)
self.assertIn("Packet #42", txt)
# --------------------------------------------------------------------------- #
# Artifact extraction and enrichment integration
# --------------------------------------------------------------------------- #
class HashModeTests(unittest.TestCase):
def test_length_based_modes(self):
from analyzer.artifacts import identify_hash_mode
self.assertEqual(identify_hash_mode("a" * 32)[0], 0) # md5
self.assertEqual(identify_hash_mode("a" * 40)[0], 100) # sha1
self.assertEqual(identify_hash_mode("a" * 64)[0], 1400) # sha256
self.assertEqual(identify_hash_mode("a" * 128)[0], 1700) # sha512
def test_structural_modes(self):
from analyzer.artifacts import identify_hash_mode
netntlmv2 = "alice::CORP:" + "1" * 16 + ":" + "a" * 32 + ":" + "b" * 40
self.assertEqual(identify_hash_mode(netntlmv2)[0], 5600)
self.assertEqual(identify_hash_mode("$krb5asrep$23$bob@CORP:aaa$bbb")[0], 18200)
self.assertEqual(identify_hash_mode("$krb5tgs$23$*svc$CORP$spn*$a$b")[0], 13100)
self.assertEqual(identify_hash_mode("wpa*01*...")[0], 22000)
def test_hint_format_wins(self):
from analyzer.artifacts import identify_hash_mode
self.assertEqual(identify_hash_mode("whatever", hint_format="NetNTLMv2")[0], 5600)
self.assertEqual(identify_hash_mode("x", hint_format="krb5asrep(etype=23)")[0], 18200)
def test_unknown_returns_none(self):
from analyzer.artifacts import identify_hash_mode
self.assertEqual(identify_hash_mode("nothash"), (None, None))
self.assertEqual(identify_hash_mode("a" * 33), (None, None))
class ArtifactExtractionTests(unittest.TestCase):
def test_extract_urls_dedupes_and_observes(self):
from analyzer import extract_urls
ctx = CaptureContext()
ctx.dns_queries.append(DnsQuery(query_name="api.example.com.", src_ip="10.0.0.5"))
ctx.tls_sessions.append(TlsSession(src_ip="10.0.0.5", dst_ip="1.2.3.4",
dst_port=443, version="TLS 1.2", sni="api.example.com"))
ctx.http_requests.append(HttpRequest(method="GET", host="cdn.example.net",
uri="/x", src_ip="10.0.0.5", dst_ip="1.2.3.4", dst_port=80))
urls = extract_urls(ctx)
vals = {u.value for u in urls}
self.assertIn("api.example.com", vals)
self.assertIn("cdn.example.net", vals)
# DNS and SNI observations merge into one endpoint artifact.
self.assertEqual(len([u for u in urls if u.value == "api.example.com"]), 1)
api = next(u for u in urls if u.value == "api.example.com")
self.assertIn(443, api.observed_ports)
self.assertIn("TLS 1.2", api.observed_tls)
def test_extract_hashes_from_auth_material_and_payload(self):
from analyzer import extract_hashes
ctx = CaptureContext()
ntlm = "alice::CORP:" + "1" * 16 + ":" + "a" * 32 + ":" + "b" * 40
ctx.auth_material.append(AuthMaterial(
kind="netntlmv2", account="alice", hashcat=ntlm, hashcat_mode=5600,
hash_format="NetNTLMv2", src_ip="10.0.0.5", packet_number=7))
ctx.text_payloads.append({"text": "token=" + "d" * 64, "protocol": "http",
"src_ip": "10.0.0.5", "packet_number": 9})
hashes = extract_hashes(ctx)
modes = {h.hashcat_mode for h in hashes}
self.assertIn(5600, modes) # NTLM
self.assertIn(1400, modes) # SHA-256 extracted from payload text.
ntlm_art = next(h for h in hashes if h.hashcat_mode == 5600)
self.assertEqual(ntlm_art.account, "alice")
self.assertEqual(ntlm_art.kind, "netntlmv2")
class DemoPayloadRegressionTests(unittest.TestCase):
"""Extract hashes and URLs from generic HTTP body payloads."""
def test_hashes_and_urls_from_body_payload(self):
from analyzer import extract_urls, extract_hashes
ctx = CaptureContext()
ctx.text_payloads.append({"text": (
"POST /hashes HTTP/1.1\r\nHost: testserver\r\n\r\n"
"5f4dcc3b5aa765d61d8327deb882cf99\n" # md5
"e27f400cc2fe611bbb129ae0b4b4be6b32debf12\n" # sha1
"0e2cfcd348361a44a6f3e646552cb56d8d4d9555b4bcd89770f96983d8f321ad" # sha256
), "protocol": "http", "packet_number": 1})
ctx.text_payloads.append({"text": (
"GET /allowed_urls HTTP/1.1\r\nHost: testserver\r\n\r\n"
"eu.iot.dreame.tech\n"
"https://app.mt.eu.iot.dreame.tech\n"
"http://40.101.70.178/\n"
), "protocol": "http", "packet_number": 2})
hashes = extract_hashes(ctx)
self.assertEqual(len(hashes), 3)
self.assertEqual({h.hashcat_mode for h in hashes}, {0, 100, 1400})
urls = {u.value for u in extract_urls(ctx)}
self.assertIn("eu.iot.dreame.tech", urls)
self.assertIn("https://app.mt.eu.iot.dreame.tech", urls)
self.assertTrue(any("40.101.70.178" in u for u in urls))
def test_hexish_payload_decoder(self):
from analyzer.extract_pyshark import _bytes_from_hexish
# pyshark commonly exposes payload bytes as colon-separated hex.
self.assertEqual(_bytes_from_hexish("47:45:54"), "GET")
self.assertEqual(_bytes_from_hexish("already text"), "already text")
class HashCrackTests(unittest.TestCase):
def _runner_factory(self, show_stdout):
import types
calls = {"n": 0}
def runner(cmd, **kw):
calls["n"] += 1
# The second invocation reads the cracked result.
out = show_stdout if "--show" in cmd else ""
return types.SimpleNamespace(stdout=out, stderr="", returncode=0)
return runner, calls
def test_command_builders_and_parser(self):
from analyzer.enrich.hash_crack import build_crack_command, build_show_command, parse_show_output
cmd = build_crack_command("hashcat", 5600, "/tmp/h.txt", ["/wl/rockyou.txt"], "/tmp/pot")
self.assertEqual(cmd[:3], ["hashcat", "-m", "5600"])
self.assertIn("--potfile-path", cmd)
self.assertIn("--show", build_show_command("hashcat", 0, "/tmp/h.txt", "/tmp/pot"))
self.assertEqual(parse_show_output("abc123:hunter2"), "hunter2")
self.assertIsNone(parse_show_output(""))
def test_crack_success(self):
from analyzer.artifacts import HashArtifact
from analyzer.enrich.hash_crack import DEFAULT_WORDLISTS, crack_hash
runner, _ = self._runner_factory(show_stdout="deadbeef" * 8 + ":hunter2\n")
art = HashArtifact(value="d" * 64, hashcat_mode=1400, hash_format="sha256")
res = crack_hash(art, wordlists=DEFAULT_WORDLISTS, hashcat_path="hashcat",
runner=runner, which=lambda p: "/usr/bin/hashcat")
self.assertTrue(res.cracked)
self.assertEqual(res.plaintext, "hunter2")
self.assertEqual(res.state, "cracked")
def test_crack_not_found(self):
from analyzer.artifacts import HashArtifact
from analyzer.enrich.hash_crack import DEFAULT_WORDLISTS, crack_hash
runner, _ = self._runner_factory(show_stdout="")
art = HashArtifact(value="d" * 64, hashcat_mode=1400)
res = crack_hash(art, wordlists=DEFAULT_WORDLISTS, runner=runner,
which=lambda p: "/usr/bin/hashcat")
self.assertFalse(res.cracked)
self.assertEqual(res.state, "not_cracked")
def test_crack_unsupported_mode(self):
from analyzer.enrich.hash_crack import crack_hash
res = crack_hash("not-a-hash", which=lambda p: "/usr/bin/hashcat")
self.assertEqual(res.state, "unsupported")
def test_crack_hashcat_missing(self):
from analyzer.artifacts import HashArtifact
from analyzer.enrich.hash_crack import crack_hash
art = HashArtifact(value="d" * 64, hashcat_mode=1400)
res = crack_hash(art, wordlists=["/etc/hostname"], which=lambda p: None)
self.assertEqual(res.state, "error")
class UrlProbeTests(unittest.TestCase):
def test_host_of(self):
from analyzer.enrich.url_probe import host_of
self.assertEqual(host_of("https://api.example.com/x?y=1"), "api.example.com")
self.assertEqual(host_of("example.net:8080/path"), "example.net")
self.assertEqual(host_of("plain.host"), "plain.host")
def test_passive_returns_observed(self):
from analyzer.artifacts import UrlArtifact
from analyzer.enrich.url_probe import probe_url
art = UrlArtifact(value="api.example.com", kind="sni",
observed_ports={443}, observed_tls={"TLS 1.2"})
probe = probe_url(art, active=False)
self.assertFalse(probe.active)
self.assertEqual(probe.open_ports, [443])
self.assertEqual(probe.tls_versions, ["TLS 1.2"])
def test_active_merges_injected_scanners(self):
from analyzer.artifacts import UrlArtifact
from analyzer.enrich.url_probe import probe_url
art = UrlArtifact(value="api.example.com", observed_ports={443})
probe = probe_url(
art, active=True,
dns_resolver=lambda h: ["93.184.216.34"],
port_scanner=lambda h, ports: [80, 443],
tls_scanner=lambda h: {"tls_version": "TLS 1.3", "cipher": "TLS_AES_128_GCM_SHA256",
"certificate": "Present"},
)
self.assertTrue(probe.active)
self.assertEqual(probe.resolved_ips, ["93.184.216.34"])
self.assertEqual(probe.open_ports, [80, 443])
self.assertIn("TLS 1.3", probe.tls_versions)
self.assertEqual(probe.certificate, "Present")
class EnrichmentEngineTests(unittest.TestCase):
def test_engine_progress_and_results(self):
from analyzer.artifacts import HashArtifact
from analyzer.enrich import EnrichmentEngine
seen = []
arts = [HashArtifact(value="d" * 64, hashcat_mode=1400),
HashArtifact(value="nope", hashcat_mode=None)]
res = EnrichmentEngine().crack_hashes(
arts, progress=lambda i, t, item: seen.append((i, t)),
which=lambda p: None) # Avoid a real subprocess in this unit test.
self.assertEqual(len(res), 2)
self.assertEqual(seen, [(1, 2), (2, 2)])
self.assertEqual(res[1].state, "unsupported")
if __name__ == "__main__":
unittest.main(verbosity=2)
+65
View File
@@ -0,0 +1,65 @@
"""Seed the IoT PreTester 4.6 Demo reference data."""
import os
import django
from django.db import transaction
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "IoTPreTester.settings")
django.setup()
from IoTAnalyzer.models import Device, DeviceType, Manufacturer, Product # noqa: E402
DEVICE_TYPES = (
"Camera",
"Smart Plug",
"Smart Bulb",
"Smart Switch",
"Robot Vacuum Cleaner",
"Smart Lock",
"Smart Controller",
"Sensor",
)
MANUFACTURERS = (
"Aqara",
"Dreame",
"DJI",
"IKEA",
"Nuki",
"Philips Hue",
"Roborock",
"Shelly",
"Sonoff",
"TP-Link",
"X-Sense",
)
@transaction.atomic
def populate_database() -> None:
"""Create stable reference rows and one sample device without duplicates."""
for name in DEVICE_TYPES:
DeviceType.objects.get_or_create(device_type=name)
for name in MANUFACTURERS:
Manufacturer.objects.get_or_create(name=name)
manufacturer = Manufacturer.objects.get(name="Shelly")
device_type = DeviceType.objects.get(device_type="Smart Controller")
product, _ = Product.objects.get_or_create(
manufacturer=manufacturer,
device_type=device_type,
product_name="1PM Gen3",
)
Device.objects.get_or_create(
product=product,
serial="DEMO-001",
defaults={"comment": "IoT PreTester 4.6 Demo sample device"},
)
print("Demo reference data synchronized")
if __name__ == "__main__":
populate_database()
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "IoTPreTester.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
+78
View File
@@ -0,0 +1,78 @@
name: iot-pretester-46-demo
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: iot-pretester-46-demo-backend
restart: unless-stopped
user: "1000:1000"
ports:
- "${BACKEND_PORT:-8000}:8000"
volumes:
- backend_data:/app/data
- backend_logs:/app/logs
- capture_uploads:/app/files/uploads
- backend_staticfiles:/app/django_project/staticfiles
environment:
DEBUG: ${DEBUG:-False}
SECRET_KEY: ${SECRET_KEY}
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
DJANGO_SETTINGS_MODULE: IoTPreTester.settings
DATABASE_PATH: /app/data/db.sqlite3
CAPTURE_UPLOAD_DIR: /app/files/uploads
CORS_ALLOW_ALL_ORIGINS: ${CORS_ALLOW_ALL_ORIGINS:-False}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
DJANGO_SUPERUSER_USERNAME: ${DJANGO_SUPERUSER_USERNAME:-admin}
DJANGO_SUPERUSER_EMAIL: ${DJANGO_SUPERUSER_EMAIL:-admin@example.com}
DJANGO_SUPERUSER_PASSWORD: ${DJANGO_SUPERUSER_PASSWORD}
INIT_DATABASE: ${INIT_DATABASE:-true}
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-600}
LOG_LEVEL: ${LOG_LEVEL:-info}
networks:
- demo-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000/api}
container_name: iot-pretester-46-demo-frontend
restart: unless-stopped
user: "1000:1000"
ports:
- "${FRONTEND_PORT:-3000}:3000"
environment:
NODE_ENV: ${NODE_ENV:-production}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000/api}
HOSTNAME: 0.0.0.0
depends_on:
backend:
condition: service_healthy
networks:
- demo-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/login"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
demo-network:
driver: bridge
volumes:
backend_data:
backend_logs:
backend_staticfiles:
capture_uploads:
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
set -eu
echo "Starting IoT PreTester 4.6 Demo backend"
mkdir -p /app/data /app/files/uploads /app/logs /app/django_project/staticfiles
cd /app/django_project
python manage.py migrate --noinput
python manage.py collectstatic --noinput
# Keep the configured demo administrator reproducible across container restarts.
python manage.py shell <<'PY'
import os
from django.contrib.auth import get_user_model
from django.utils.timezone import now
User = get_user_model()
username = os.environ.get("DJANGO_SUPERUSER_USERNAME", "admin")
email = os.environ.get("DJANGO_SUPERUSER_EMAIL", "admin@example.com")
password = os.environ["DJANGO_SUPERUSER_PASSWORD"]
user, created = User.objects.get_or_create(username=username, defaults={"email": email})
user.email = email
user.is_superuser = True
user.is_staff = True
user.is_active = True
user.set_password(password)
user.last_login = now()
user.save()
print("Demo administrator created" if created else "Demo administrator synchronized")
PY
if [ "${INIT_DATABASE:-true}" = "true" ]; then
python create_models.py
fi
exec gunicorn IoTPreTester.wsgi:application \
--bind 0.0.0.0:8000 \
--workers "${GUNICORN_WORKERS:-4}" \
--timeout "${GUNICORN_TIMEOUT:-120}" \
--access-logfile /app/logs/access.log \
--error-logfile /app/logs/error.log \
--log-level "${LOG_LEVEL:-info}"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
echo "Starting IoT PreTester 4.6 Demo frontend"
exec "$@"
+4
View File
@@ -0,0 +1,4 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"ignorePatterns": ["next-env.d.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
output: "standalone",
}
export default nextConfig
+6395
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "iot-pre-tester-4-6-demo-frontend",
"version": "4.6.0-demo.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint . --max-warnings=0",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-toast": "^1.2.5",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"next": "15.5.20",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.5",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^20.17.16",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"eslint": "^8",
"eslint-config-next": "15.5.20",
"postcss": "^8.5.10",
"tailwindcss": "^3.4.1",
"typescript": "^5"
},
"overrides": {
"brace-expansion@<2": "1.1.13",
"brace-expansion@>=2 <3": "2.0.3",
"postcss": "$postcss"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+196
View File
@@ -0,0 +1,196 @@
"use client"
import { getApiUrl } from "@/config/api"
import { useEffect, useState } from "react"
import { useRouter, useParams } from "next/navigation"
import { ArrowLeft, FileIcon, Upload, LogOut } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { ProtectedRoute } from "@/components/protected-route"
import { FileUpload } from "@/components/file-upload"
import type { Device, DeviceFile } from "@/types/device"
import { ScrollArea } from "@/components/ui/scroll-area"
import { getDeviceImage } from "@/components/device-images"
import axios from "axios"
import Image from "next/image"
interface ApiCapture {
id: number
filename: string
upload: string
firmware_version: string
ip: string
state: string
}
const getDeviceById = async (deviceId: string) => {
try {
const deviceResponse = await axios.get(getApiUrl(`get_device_by_id/${deviceId}`))
const deviceData = deviceResponse.data
const filesResponse = await axios.get(getApiUrl(`get_file_by_device/${deviceId}`))
const filesData = filesResponse.data
const files: DeviceFile[] = filesData.map((file: ApiCapture) => ({
id: String(file.id),
name: file.filename,
uploadedAt: file.upload,
firmwareVersion: file.firmware_version,
ipAddress: file.ip,
status: file.state === "pending" ? "pending" : "scanned",
}))
return {
id: String(deviceData.id),
name: deviceData.product.product_name,
type: deviceData.product.device_type.device_type,
manufacturer: deviceData.product.manufacturer.name,
serialNumber: deviceData.serial,
comment: deviceData.comment,
lastScanned: new Date(deviceData.created).toLocaleDateString("en-GB"),
files,
}
} catch (error) {
console.error("Failed to fetch device details:", error)
return null
}
}
export default function DeviceDetailPage() {
const params = useParams()
const id = params?.id as string
const router = useRouter()
const [device, setDevice] = useState<Device | null>(null)
const [showUpload, setShowUpload] = useState(false)
useEffect(() => {
const fetchDeviceDetails = async () => {
const deviceDetails = await getDeviceById(id)
if (!deviceDetails) {
router.push("/devices")
} else {
setDevice(deviceDetails)
}
}
fetchDeviceDetails()
}, [id, router])
const handleViewResults = (fileId: string) => {
router.push(`/scan-results?fileId=${fileId}`)
}
if (!device) {
return <div className="min-h-screen bg-background" />
}
return (
<ProtectedRoute>
{({ logout }) => (
<div className="min-h-screen bg-background p-4 flex items-start">
<div className="flex gap-4 max-w-[1400px] mx-auto justify-center w-full h-[calc(100vh-32px)]">
<div className="flex-1 space-y-8">
<Card className="w-full h-full flex flex-col">
<CardHeader>
<div className="flex justify-end items-center mb-8">
<Button
variant="ghost"
size="icon"
onClick={() => {
logout()
router.push("/login")
}}
>
<LogOut className="h-4 w-4" />
<span className="sr-only">Logout</span>
</Button>
</div>
{!showUpload && (
<div className="flex items-start gap-4">
<div className="flex-1 space-y-2">
<CardTitle className="text-2xl">{device.name}</CardTitle>
<CardDescription className="space-y-1">
<span className="block">Type: {device.type}</span>
<span className="block">Manufacturer: {device.manufacturer}</span>
<span className="block">Serial Number: {device.serialNumber}</span>
{device.comment && <span className="block">Comment: {device.comment}</span>}
{device.lastScanned && (
<span className="block">Last Scanned: {device.lastScanned}</span>
)}
</CardDescription>
</div>
<div className="w-40 h-40 rounded-full overflow-hidden flex-shrink-0 border">
<Image
src={getDeviceImage(device.type) || "/images/logos/Placeholder.png"}
alt={device.name}
width={160}
height={160}
className="w-full h-full object-cover"
/>
</div>
</div>
)}
</CardHeader>
<CardContent className="flex-grow overflow-hidden relative">
{showUpload ? (
<FileUpload onBack={() => setShowUpload(false)} deviceId={id} />
) : (
<div className="space-y-6 h-full flex flex-col">
<Button onClick={() => setShowUpload(true)} className="w-auto">
<Upload className="mr-2 h-4 w-4" />
Upload New File
</Button>
<div className="space-y-4 flex-grow overflow-hidden">
<h3 className="text-lg font-semibold">Device Files</h3>
<ScrollArea className="h-[calc(100%-60px)] pr-4">
{device.files && device.files.length > 0 ? (
<div className="space-y-2">
{device.files.map((file) => (
<div key={file.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center space-x-4">
<FileIcon className="h-6 w-6 text-muted-foreground" />
<div className="space-y-1">
<p className="font-medium">{file.name}</p>
<div className="text-sm text-muted-foreground space-y-0.5">
<p>Uploaded: {new Date(file.uploadedAt).toLocaleDateString("de-DE")}</p>
{file.firmwareVersion && <p>Firmware: {file.firmwareVersion}</p>}
{file.ipAddress && <p>IP Address: {file.ipAddress}</p>}
</div>
</div>
</div>
<Button
variant="default"
className="bg-emerald-600 hover:bg-emerald-700"
onClick={() => handleViewResults(file.id)}
>
View Results
</Button>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
No files uploaded yet. Upload a file to start scanning.
</div>
)}
</ScrollArea>
</div>
<div className="flex justify-end mt-auto pt-4">
<Button onClick={() => router.push("/devices")}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Devices
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
)}
</ProtectedRoute>
)
}
+382
View File
@@ -0,0 +1,382 @@
"use client"
import { getApiUrl } from "@/config/api"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Plus, Trash2, LogOut, ChevronRight, Users, Settings } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { ProtectedRoute } from "@/components/protected-route"
import { useAuth } from "@/lib/auth-context"
import { ScrollArea } from "@/components/ui/scroll-area"
import type { Device } from "@/types/device"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import Image from "next/image"
import { getAllDevices, getManufacturers, getDeviceTypes, addDevice, deleteDevice } from "@/services/apiIOT"
interface SelectOption {
value: string
label: string
}
export default function DevicesPage() {
const router = useRouter()
const { user, logout } = useAuth()
const [devices, setDevices] = useState<Device[]>([])
const [newDeviceName, setNewDeviceName] = useState("")
const [newDeviceType, setNewDeviceType] = useState("")
const [newManufacturer, setNewManufacturer] = useState("")
const [newSerialNumber, setNewSerialNumber] = useState("")
const [newComment, setNewComment] = useState("")
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deviceToDelete, setDeviceToDelete] = useState<string | null>(null)
const [deviceTypes, setDeviceTypes] = useState<SelectOption[]>([])
const [manufacturers, setManufacturers] = useState<SelectOption[]>([])
useEffect(() => {
const fetchDeviceTypes = async () => {
const types = await getDeviceTypes()
setDeviceTypes(types)
}
fetchDeviceTypes()
}, [])
useEffect(() => {
const fetchManufacturers = async () => {
const types = await getManufacturers()
setManufacturers(types)
}
fetchManufacturers()
}, [])
useEffect(() => {
const fetchDevices = async () => {
const fetchedDevices = await getAllDevices()
setDevices(fetchedDevices)
}
fetchDevices()
}, [])
useEffect(() => {
if (!user) {
router.push("/login")
}
}, [user, router])
const handleLogout = async () => {
try {
await fetch(getApiUrl("logout"), {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
})
} catch (error) {
console.error("Error during logout:", error)
} finally {
logout()
router.push("/login")
}
}
const handleAddDevice = async () => {
if (newDeviceName && newDeviceType && newManufacturer && newSerialNumber) {
const newDevice = {
device_name: newDeviceName,
device_type_id: newDeviceType,
manufacturer_id: newManufacturer,
serial: newSerialNumber,
comment: newComment,
}
try {
await addDevice(newDevice)
setDialogOpen(false)
const updatedDevices = await getAllDevices()
setDevices(updatedDevices)
} catch (error) {
console.error("Failed to add device:", error)
}
}
}
const handleDeleteClick = (id: string) => {
setDeviceToDelete(id)
setDeleteDialogOpen(true)
}
const confirmDelete = async () => {
if (deviceToDelete) {
try {
await deleteDevice(deviceToDelete)
const updatedDevices = await getAllDevices()
setDevices(updatedDevices)
setDeleteDialogOpen(false)
setDeviceToDelete(null)
} catch (error) {
console.error("Failed to delete device:", error)
}
}
}
const navigateToDevice = (deviceId: string) => {
router.push(`/devices/${deviceId}`)
}
if (!user) {
return null
}
return (
<ProtectedRoute>
<div className="min-h-screen bg-background p-4">
<div className="flex items-start max-w-[1400px] mx-auto">
<div className="flex-1 flex justify-center">
<div className="w-full max-w-full space-y-8">
<Card>
<CardHeader className="space-y-4">
<div className="w-64">
<Image
src="/images/logos/Logo.png"
alt="IoT PreTester 4.6 Demo logo"
width={128}
height={128}
/>
</div>
<div className="flex justify-between items-center">
<Button variant="ghost" size="icon" onClick={handleLogout} className="ml-auto">
<LogOut className="h-4 w-4" />
<span className="sr-only">Logout</span>
</Button>
</div>
<div>
<CardTitle className="text-2xl">My Devices</CardTitle>
<CardDescription>Manage and monitor your IoT devices</CardDescription>
</div>
<div className="flex gap-2">
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" />
Add New Device
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-xl font-bold text-foreground">Add New Device</DialogTitle>
<DialogDescription>Enter the details of your new IoT device.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-foreground">
Device Name
</Label>
<Input
id="name"
value={newDeviceName}
onChange={(e) => setNewDeviceName(e.target.value)}
placeholder="Enter device name"
className="text-foreground"
/>
</div>
<div className="space-y-2">
<Label htmlFor="manufacturer" className="text-foreground">
Manufacturer
</Label>
<Select value={newManufacturer} onValueChange={setNewManufacturer}>
<SelectTrigger className="text-foreground">
<SelectValue placeholder="Select manufacturer" />
</SelectTrigger>
<SelectContent>
{manufacturers.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="type" className="text-foreground">
Device Type
</Label>
<Select value={newDeviceType} onValueChange={setNewDeviceType}>
<SelectTrigger className="text-foreground">
<SelectValue placeholder="Select device type" />
</SelectTrigger>
<SelectContent>
{deviceTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="serialNumber" className="text-foreground">
Serial Number
</Label>
<Input
id="serialNumber"
value={newSerialNumber}
onChange={(e) => setNewSerialNumber(e.target.value)}
placeholder="Enter serial number"
className="text-foreground"
/>
</div>
<div className="space-y-2">
<Label htmlFor="comment" className="text-foreground">
Comment
</Label>
<Input
id="comment"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Enter optional comment"
className="text-foreground"
/>
</div>
</div>
<DialogFooter>
<Button variant="destructive" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleAddDevice}
disabled={!newDeviceName || !newDeviceType || !newManufacturer || !newSerialNumber}
>
Add Device
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{(user.role === "admin" || user.role === "superuser") && (
<>
<Button className="w-full sm:w-auto" onClick={() => router.push("/manage-users")}>
<Users className="mr-2 h-4 w-4" />
Manage Users
</Button>
<Button className="w-full sm:w-auto" onClick={() => router.push("/manage-device-types")}>
<Settings className="mr-2 h-4 w-4" />
Manage Device Types
</Button>
<Button className="w-full sm:w-auto" onClick={() => router.push("/manage-manufacturers")}>
<Settings className="mr-2 h-4 w-4" />
Manage Manufacturers
</Button>
<Button className="w-full sm:w-auto"
onClick={() => window.open("http://127.0.0.1:8000", "_blank")}
>
<Settings className="mr-2 h-4 w-4" />
Manage Database
</Button>
</>
)}
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[calc(100vh-19rem)]">
<div className="space-y-4">
{devices.map((device) => (
<Card key={device.id} className="relative overflow-hidden bg-card">
<div className="absolute inset-0 z-0">
<div className="relative w-3/5 h-full ml-auto">
{/*<Image*/}
{/* src={getDeviceImage(device.type) || "/placeholder.svg"}*/}
{/* alt={`${device.name} image`}*/}
{/* layout="fill"*/}
{/* className="object-cover opacity-75 mix-blend-multiply"*/}
{/* priority={false}*/}
{/*/>*/}
</div>
</div>
<CardHeader className="pb-2 relative z-10">
<div className="flex justify-between items-start">
<div className="space-y-1">
<CardTitle>{device.name}</CardTitle>
<CardDescription className="space-y-1">
<span className="block">
{deviceTypes.find((t) => t.value === device.type)?.label || device.type}
</span>
<span className="block">
Manufacturer:{" "}
{manufacturers.find((m) => m.value === device.manufacturer)?.label ||
device.manufacturer}
</span>
<span className="block">Serial Number: {device.serialNumber}</span>
{device.comment && <span className="block text-sm">Comment: {device.comment}</span>}
</CardDescription>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteClick(device.id)}
className="absolute top-2 right-2"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete device</span>
</Button>
</div>
</CardHeader>
<CardContent className="pb-4 relative z-10">
<div className="flex justify-between items-center">
<div className="text-sm text-muted-foreground">
{device.files?.length || 0} files
{device.lastScanned && ` • Last scan: ${device.lastScanned}`}
</div>
<Button size="sm" onClick={() => navigateToDevice(device.id)} className="ml-2">
View Device
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
{devices.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No devices added yet. Add your first device to get started.
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-foreground">Delete Device</DialogTitle>
<DialogDescription>
Are you sure you want to delete this device? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={confirmDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ProtectedRoute>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+614
View File
@@ -0,0 +1,614 @@
"use client"
import { getApiUrl } from "@/config/api"
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { ArrowLeft, FileJson, FileText, FileCode, ShieldAlert, Globe, ChevronRight } from "lucide-react"
import { ProtectedRoute } from "@/components/protected-route"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
// API types mirror the backend finding schema.
interface Finding {
finding_id: string
title: string
category: string
severity: "critical" | "high" | "medium" | "low" | "info"
confidence: "high" | "medium" | "low"
detector?: string
affected_device?: string | null
source_ip?: string | null
destination_ip?: string | null
protocol?: string | null
port?: number | null
risk_description: string
recommendation: string
evidence: Record<string, unknown>
related_packets: number[]
related_streams: number[]
timestamp_first_seen?: number | null
timestamp_last_seen?: number | null
}
interface Summary {
total: number
by_severity: Record<string, number>
by_category: Record<string, number>
highlights: Finding[]
top: Finding[]
}
interface AnalysisResult {
meta?: Record<string, unknown>
summary: Summary
findings: Finding[]
}
interface DeepStatus {
state: "pending" | "running" | "done" | "error"
url_total: number
url_done: number
hash_total: number
hash_done: number
cracked: number
progress_done: number
progress_total: number
percent: number
message?: string
}
interface CloudUrl {
id: number
url: string
analyzed: boolean
ports?: number[]
tls_versions?: string[]
cipher_suites?: string[]
}
interface CloudHash {
id: number
hash_value: string
analyzed: boolean
cracked: boolean
cracked_value?: string | null
crack_time?: string | null
hash_type?: string[]
}
interface CloudResults {
urls: CloudUrl[]
hashes: CloudHash[]
}
const SEV_ORDER = ["critical", "high", "medium", "low", "info"] as const
const SEV_STYLE: Record<string, string> = {
critical: "bg-red-600 text-white",
high: "bg-orange-500 text-white",
medium: "bg-yellow-400 text-black",
low: "bg-blue-400 text-white",
info: "bg-gray-400 text-white",
}
const SEV_LABEL: Record<string, string> = {
critical: "CRITICAL",
high: "HIGH",
medium: "MEDIUM",
low: "LOW",
info: "INFO",
}
function Pill({ severity }: { severity: string }) {
return (
<span className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${SEV_STYLE[severity] || SEV_STYLE.info}`}>
{SEV_LABEL[severity] || severity.toUpperCase()}
</span>
)
}
function affectedLine(f: Finding): string {
const parts: string[] = []
if (f.source_ip) parts.push(`Source ${f.source_ip}`)
if (f.destination_ip) parts.push(`Destination ${f.destination_ip}`)
if (f.protocol) parts.push(f.protocol + (f.port ? `:${f.port}` : ""))
const ev = f.evidence || {}
for (const k of ["host", "registered_domain", "query_name", "target"]) {
if (ev[k]) { parts.push(String(ev[k])); break }
}
return parts.join(", ") || "—"
}
// Evidence rendered in dedicated sections is excluded from raw details.
const EVIDENCE_SKIP = new Set([
"dedup_key", "sources", "artifact", "capture_file", "source_packet_count", "occurrences",
])
type SourceRef = {
packet?: number; stream?: number; timestamp?: number
src_ip?: string; dst_ip?: string; protocol?: string; port?: number
capture?: string; note?: string
}
function fmtTs(ts?: number): string {
if (ts === undefined || ts === null) return ""
try { return new Date(ts * 1000).toISOString().replace("T", " ").replace(".000Z", " UTC") }
catch { return String(ts) }
}
function artifactOf(f: Finding): string | null {
const art = (f.evidence || {})["artifact"] as { label?: string; value?: string } | string | undefined
if (!art) return null
if (typeof art === "string") return art
if (art.value) return art.label ? `${art.label}: ${art.value}` : art.value
return null
}
function sourcesOf(f: Finding): SourceRef[] {
const s = (f.evidence || {})["sources"]
return Array.isArray(s) ? (s as SourceRef[]) : []
}
function fmtSource(ref: SourceRef): string {
const parts: string[] = []
if (ref.packet !== undefined) parts.push(`Packet #${ref.packet}`)
if (ref.stream !== undefined) parts.push(`Stream ${ref.stream}`)
if (ref.timestamp !== undefined) parts.push(fmtTs(ref.timestamp))
if (ref.src_ip || ref.dst_ip) parts.push(`${ref.src_ip ?? "?"} → ${ref.dst_ip ?? "?"}`)
if (ref.protocol) parts.push(ref.protocol + (ref.port ? `:${ref.port}` : ""))
if (ref.capture) parts.push(`Capture ${ref.capture}`)
if (ref.note) parts.push(ref.note)
return parts.join(" · ") || "—"
}
function FindingsContent() {
const router = useRouter()
const searchParams = useSearchParams()
const fileId = searchParams.get("fileId")
const [data, setData] = useState<AnalysisResult | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<DeepStatus | null>(null)
const [cloud, setCloud] = useState<CloudResults | null>(null)
const [analyzing, setAnalyzing] = useState(false)
const [selUrls, setSelUrls] = useState<Set<number>>(new Set())
const [selHashes, setSelHashes] = useState<Set<number>>(new Set())
const fetchCloud = async () => {
if (!fileId) return
try {
const res = await fetch(getApiUrl(`get_cloud_results/${fileId}`), {
cache: "no-store",
credentials: "include",
})
if (res.ok) setCloud(await res.json())
} catch { /* Optional enrichment data does not block the finding view. */ }
}
useEffect(() => {
if (!fileId) { setError("No capture ID was provided."); setLoading(false); return }
const load = async () => {
try {
const res = await fetch(getApiUrl(`get_findings/${fileId}`), {
cache: "no-store",
credentials: "include",
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setData(await res.json())
} catch (e) {
setError(`Findings could not be loaded: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setLoading(false)
}
await fetchCloud()
try {
const sres = await fetch(getApiUrl(`get_analysis_status/${fileId}`), {
cache: "no-store",
credentials: "include",
})
if (sres.ok) {
const s: DeepStatus = await sres.json()
setStatus(s)
if (s.state === "running") setAnalyzing(true)
}
} catch { /* Status is optional until enrichment starts. */ }
}
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileId])
// Refresh enrichment results after the user returns to this tab.
useEffect(() => {
if (!fileId) return
const onFocus = () => { fetchCloud() }
window.addEventListener("focus", onFocus)
document.addEventListener("visibilitychange", onFocus)
return () => {
window.removeEventListener("focus", onFocus)
document.removeEventListener("visibilitychange", onFocus)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileId])
// Poll only while enrichment is running.
useEffect(() => {
if (!fileId || !analyzing) return
let timer: ReturnType<typeof setInterval> | null = null
const poll = async () => {
try {
const res = await fetch(getApiUrl(`get_analysis_status/${fileId}`), {
cache: "no-store",
credentials: "include",
})
if (res.ok) {
const s: DeepStatus = await res.json()
setStatus(s)
if (s.state === "done" || s.state === "error") setAnalyzing(false)
}
} catch { /* Retry on the next polling interval. */ }
await fetchCloud()
}
poll()
timer = setInterval(poll, 3000)
return () => { if (timer) clearInterval(timer) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileId, analyzing])
const startAnalysis = async () => {
if (!fileId || (selUrls.size + selHashes.size) === 0) return
try {
const response = await fetch(getApiUrl(`start_analysis/${fileId}`), {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url_ids: Array.from(selUrls), hash_ids: Array.from(selHashes) }),
cache: "no-store",
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
setAnalyzing(true)
} catch (e) {
setError(`Enrichment could not be started: ${e instanceof Error ? e.message : String(e)}`)
}
}
const toggleId = (set: Set<number>, setter: (s: Set<number>) => void, id: number) => {
const next = new Set(set)
if (next.has(id)) next.delete(id); else next.add(id)
setter(next)
}
const setAll = (ids: number[], setter: (s: Set<number>) => void, on: boolean) =>
setter(on ? new Set(ids) : new Set())
const download = (fmt: "md" | "txt" | "json") => {
if (!fileId) return
window.open(getApiUrl(`get_report/${fileId}/${fmt}`), "_blank")
}
if (loading) {
return <CenterCard title="Loading analysis…" desc="Retrieving findings." />
}
if (error) {
return <CenterCard title="Error" desc={error} />
}
const summary = data?.summary
const findings = data?.findings ?? []
const isEmpty = findings.length === 0
return (
<div className="min-h-screen p-4 bg-background">
<div className="max-w-7xl mx-auto space-y-6">
<Card>
<CardHeader>
<div className="flex justify-between items-start mb-4 flex-wrap gap-2">
<Button variant="outline" onClick={() => router.push("/devices")}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to devices
</Button>
<div className="flex gap-2">
<Button
onClick={startAnalysis}
disabled={analyzing || (selUrls.size + selHashes.size) === 0}
>
{analyzing
? "Enrichment running…"
: `Start enrichment (${selUrls.size + selHashes.size})`}
</Button>
<Button variant="outline" onClick={() => download("md")}>
<FileText className="mr-2 h-4 w-4" /> .md
</Button>
<Button variant="outline" onClick={() => download("txt")}>
<FileCode className="mr-2 h-4 w-4" /> .txt
</Button>
<Button variant="outline" onClick={() => download("json")}>
<FileJson className="mr-2 h-4 w-4" /> .json
</Button>
</div>
</div>
<CardTitle className="text-3xl flex items-center gap-2">
<ShieldAlert className="h-7 w-7" /> Security findings
</CardTitle>
<CardDescription>
Network-capture evaluation by IoT PreTester 4.6 Demo.
</CardDescription>
</CardHeader>
<CardContent>
{/* Status of optional URL and hash enrichment. */}
{status && status.state !== "pending" && (
<div className={`mb-6 rounded-lg border p-4 ${
status.state === "done" ? "border-green-300 bg-green-50"
: status.state === "error" ? "border-red-300 bg-red-50"
: "border-blue-300 bg-blue-50"}`}>
<div className="flex items-center justify-between gap-2 flex-wrap">
<span className="font-medium">
{status.state === "running" && "Artifact enrichment is running…"}
{status.state === "done" && "✅ Artifact enrichment completed"}
{status.state === "error" && "⚠️ Artifact enrichment failed"}
</span>
<span className="text-sm text-muted-foreground">
URLs {status.url_done}/{status.url_total} · Hashes {status.hash_done}/{status.hash_total}
{status.cracked > 0 && ` · ${status.cracked} cracked`}
</span>
</div>
{status.state === "running" && status.progress_total > 0 && (
<div className="mt-2 h-2 w-full rounded bg-blue-100 overflow-hidden">
<div className="h-full bg-blue-500 transition-all" style={{ width: `${status.percent}%` }} />
</div>
)}
{(status.state === "done" || status.state === "running") && (
<p className="mt-2 text-sm text-muted-foreground">
Details are available under “Cloud URLs and hashes”.
</p>
)}
</div>
)}
{/* Severity summary */}
{summary && (
<div className="flex flex-wrap gap-3 mb-6">
{SEV_ORDER.map((sev) => (
<div key={sev} className="flex items-center gap-2">
<Pill severity={sev} />
<span className="text-sm text-muted-foreground">
{summary.by_severity?.[sev] ?? 0}
</span>
</div>
))}
<div className="text-sm text-muted-foreground ml-auto">
Total: <span className="font-semibold">{summary.total}</span>
</div>
</div>
)}
{/* Prioritized highlights */}
{summary && summary.highlights?.length > 0 && (
<div className="mb-6 rounded-lg border border-red-300 bg-red-50 p-4">
<h3 className="font-semibold mb-2 text-red-800">⚠️ Prioritized highlights</h3>
<ul className="space-y-1">
{summary.highlights.map((f) => (
<li key={f.finding_id} className="text-sm">
<Pill severity={f.severity} /> <span className="ml-1">{f.title}</span>
<span className="text-muted-foreground"> — {affectedLine(f)}</span>
</li>
))}
</ul>
</div>
)}
{isEmpty ? (
<div className="text-center py-12 text-muted-foreground">
No security-relevant findings were detected, or analysis is still running.
<br />Reload the page if the capture has just been submitted.
</div>
) : (
<div className="space-y-4">
{findings.map((f, i) => {
const artifact = artifactOf(f)
const sources = sourcesOf(f)
const pktTotal = typeof f.evidence?.source_packet_count === "number"
? (f.evidence.source_packet_count as number) : 0
const captureFile = typeof f.evidence?.capture_file === "string"
? (f.evidence.capture_file as string) : null
return (
<Card key={f.finding_id} className="border-l-4" style={{ borderLeftColor: sevBorder(f.severity) }}>
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h4 className="font-semibold">{i + 1}. {f.title}</h4>
<div className="flex items-center gap-2">
<Pill severity={f.severity} />
<span className="text-xs text-muted-foreground">Confidence: {f.confidence}</span>
</div>
</div>
{/* Primary artifact */}
{artifact && (
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-2">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400">🔎 Observed artifact</div>
<code className="text-sm break-all">{artifact}</code>
</div>
)}
{/* Packet-level sources */}
{sources.length > 0 && (
<div className="rounded-md border bg-muted/40 p-2">
<div className="text-xs font-medium mb-1">📍 Capture sources</div>
<ul className="space-y-0.5">
{sources.map((ref, idx) => (
<li key={idx} className="text-xs font-mono text-muted-foreground break-all">
{fmtSource(ref)}
</li>
))}
{pktTotal > sources.length && (
<li className="text-xs text-muted-foreground">
… and {pktTotal - sources.length} more packets
</li>
)}
</ul>
</div>
)}
<p className="text-sm"><span className="font-medium">Risk:</span> {f.risk_description}</p>
<p className="text-sm"><span className="font-medium">Affected:</span> {affectedLine(f)}</p>
<p className="text-sm"><span className="font-medium">Recommendation:</span> {f.recommendation}</p>
<div className="text-xs text-muted-foreground">
Category: {f.category} · Detector: {f.detector}
{captureFile && <> · Capture: {captureFile}</>}
</div>
{f.evidence && Object.keys(f.evidence).filter((k) => !EVIDENCE_SKIP.has(k)).length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">Additional evidence</summary>
<pre className="mt-1 whitespace-pre-wrap bg-muted p-2 rounded">
{JSON.stringify(
Object.fromEntries(Object.entries(f.evidence).filter(([k]) => !EVIDENCE_SKIP.has(k))),
null, 2)}
</pre>
</details>
)}
</CardContent>
</Card>
)
})}
</div>
)}
{cloud && (cloud.hashes.length > 0 || cloud.urls.length > 0) && (
<h2 className="text-2xl font-bold mt-10 mb-3">Extracted artifacts</h2>
)}
{/* Hash artifacts */}
{cloud && cloud.hashes.length > 0 && (
<details className="mt-2 group">
<summary className="flex items-center justify-between gap-2 mb-3 cursor-pointer list-none">
<h3 className="text-xl font-semibold flex items-center gap-2">
<ChevronRight className="h-5 w-5 transition-transform group-open:rotate-90" />
Hashes ({cloud.hashes.length})
</h3>
<label className="text-sm flex items-center gap-1 cursor-pointer text-muted-foreground"
onClick={(e) => e.stopPropagation()}>
<input type="checkbox" disabled={analyzing}
checked={cloud.hashes.every((h) => selHashes.has(h.id))}
onChange={(e) => setAll(cloud.hashes.map((h) => h.id), setSelHashes, e.target.checked)} />
select all
</label>
</summary>
<div className="space-y-3">
{cloud.hashes.map((h) => (
<Card key={h.id} className="border-l-4"
style={{ borderLeftColor: h.cracked ? "#dc2626" : "#9ca3af" }}>
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h4 className="font-semibold flex items-center gap-2 min-w-0">
<input type="checkbox" disabled={analyzing}
checked={selHashes.has(h.id)}
onChange={() => toggleId(selHashes, setSelHashes, h.id)} />
<code className="font-mono text-sm break-all">{h.hash_value}</code>
</h4>
<div className="shrink-0">
{h.cracked
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-red-600 text-white">CRACKED</span>
: h.analyzed
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-gray-400 text-white">not cracked</span>
: (analyzing && selHashes.has(h.id))
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-blue-400 text-white">running…</span>
: <span className="rounded px-2 py-0.5 text-xs font-semibold bg-gray-200 text-gray-700">not analyzed</span>}
</div>
</div>
{h.hash_type && h.hash_type.length > 0 && (
<p className="text-sm"><span className="font-medium">Type:</span> {h.hash_type.join(", ")}</p>
)}
{h.cracked && (
<p className="text-sm">
<span className="font-medium">Plaintext:</span>{" "}
<code className="bg-red-100 px-1 rounded">{h.cracked_value}</code>
{h.crack_time ? ` (${h.crack_time})` : ""}
</p>
)}
</CardContent>
</Card>
))}
</div>
</details>
)}
{/* Endpoint artifacts */}
{cloud && cloud.urls.length > 0 && (
<details className="mt-3 group">
<summary className="flex items-center justify-between gap-2 mb-3 cursor-pointer list-none">
<h3 className="text-xl font-semibold flex items-center gap-2">
<ChevronRight className="h-5 w-5 transition-transform group-open:rotate-90" />
<Globe className="h-5 w-5" /> Cloud URLs and endpoints ({cloud.urls.length})
</h3>
<label className="text-sm flex items-center gap-1 cursor-pointer text-muted-foreground"
onClick={(e) => e.stopPropagation()}>
<input type="checkbox" disabled={analyzing}
checked={cloud.urls.every((u) => selUrls.has(u.id))}
onChange={(e) => setAll(cloud.urls.map((u) => u.id), setSelUrls, e.target.checked)} />
select all
</label>
</summary>
<div className="space-y-3">
{cloud.urls.map((u) => (
<Card key={u.id} className="border-l-4" style={{ borderLeftColor: "#60a5fa" }}>
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h4 className="font-semibold flex items-center gap-2 min-w-0">
<input type="checkbox" disabled={analyzing}
checked={selUrls.has(u.id)}
onChange={() => toggleId(selUrls, setSelUrls, u.id)} />
<code className="font-mono text-sm break-all">{u.url}</code>
</h4>
<div className="shrink-0">
{u.analyzed
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-green-600 text-white">analyzed</span>
: (analyzing && selUrls.has(u.id))
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-blue-400 text-white">running…</span>
: <span className="rounded px-2 py-0.5 text-xs font-semibold bg-gray-200 text-gray-700">not analyzed</span>}
</div>
</div>
{u.analyzed && (
<>
<p className="text-sm"><span className="font-medium">Open ports:</span> {u.ports && u.ports.length > 0 ? u.ports.join(", ") : "—"}</p>
{u.tls_versions && u.tls_versions.length > 0 && <p className="text-sm"><span className="font-medium">TLS:</span> {u.tls_versions.join(", ")}</p>}
{u.cipher_suites && u.cipher_suites.length > 0 && <p className="text-sm"><span className="font-medium">Cipher:</span> {u.cipher_suites.join(", ")}</p>}
</>
)}
</CardContent>
</Card>
))}
</div>
</details>
)}
</CardContent>
</Card>
</div>
</div>
)
}
function sevBorder(sev: string): string {
return ({
critical: "#dc2626", high: "#f97316", medium: "#facc15", low: "#60a5fa", info: "#9ca3af",
} as Record<string, string>)[sev] || "#9ca3af"
}
function CenterCard({ title, desc }: { title: string; desc: string }) {
return (
<div className="min-h-screen bg-background p-4 flex items-center justify-center">
<Card className="w-96">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{desc}</CardDescription>
</CardHeader>
</Card>
</div>
)
}
export default function FindingsPage() {
return (
<ProtectedRoute>
<Suspense fallback={<CenterCard title="Loading…" desc="Please wait." />}>
<FindingsContent />
</Suspense>
</ProtectedRoute>
)
}
Binary file not shown.
+72
View File
@@ -0,0 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
}
@layer base {
:root {
--background: 210 40% 98%;
--foreground: 222.2 47.4% 11.2%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--primary: 221 83% 53%;
--primary-foreground: 0 0% 100%;
--secondary: 214 32% 91%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 217 91% 95%;
--accent-foreground: 221 83% 45%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 214.3 31.8% 88%;
--input: 214.3 31.8% 86%;
--ring: 221 83% 53%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3%;
--foreground: 0 0% 0%;
--card: 0 0% 5%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+25
View File
@@ -0,0 +1,25 @@
import "./globals.css"
import localFont from "next/font/local"
import { Providers } from "@/components/providers"
import { Toaster } from "@/components/ui/toaster"
const geist = localFont({ src: "./fonts/GeistVF.woff" })
export const metadata = {
title: "IoT PreTester 4.6 Demo",
description: "Partner demo for IoT network-capture security analysis",
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={geist.className}>
<Providers>{children}</Providers>
<Toaster />
<div className="fixed bottom-2 right-3 z-50 pointer-events-none select-none text-xs text-muted-foreground/80">
4.6 Demo
</div>
</body>
</html>
)
}
+114
View File
@@ -0,0 +1,114 @@
"use client"
import { getApiUrl } from "@/config/api"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useAuth } from "@/lib/auth-context"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { AnimatedBackground } from "@/components/ui/animated-background"
import Image from "next/image"
const LoginPage = () => {
const router = useRouter()
const { login } = useAuth()
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setError(null)
setIsLoading(true)
try {
const response = await fetch(getApiUrl("login"), {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
})
const [success, data] = await response.json()
if (success) {
login(data.user)
router.push("/devices")
} else {
setError(data.message || "Invalid username or password")
}
} catch {
setError("An error occurred during login")
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-background relative overflow-hidden">
<AnimatedBackground />
<div className="w-full max-w-sm mb-4 relative z-10 transition-all duration-500 hover:scale-105">
<Image
src="/images/logos/Logo.png"
alt="IoT PreTester 4.6 Demo logo"
width={224}
height={224}
className="block mx-auto drop-shadow-2xl"
/>
</div>
<Card className="w-full max-w-sm relative z-10 backdrop-blur-sm bg-card/80 border-primary/20 shadow-xl">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold tracking-tight">Login</CardTitle>
<CardDescription>Enter your credentials to access the system</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
placeholder="Enter your username"
className="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="Enter your password"
className="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
<p className="mt-4 text-sm text-muted-foreground relative z-10">4.6 Demo</p>
</div>
)
}
export default LoginPage
@@ -0,0 +1,184 @@
"use client"
import { getApiUrl } from "@/config/api"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { ProtectedRoute } from "@/components/protected-route"
import { useAuth } from "@/lib/auth-context"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import axios from "axios"
interface DeviceType {
value: number
label: string
}
export default function ManageDeviceTypesPage() {
const router = useRouter()
const { user } = useAuth()
const [deviceTypes, setDeviceTypes] = useState<DeviceType[]>([])
const [newTypeLabel, setNewTypeLabel] = useState("")
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [typeToDelete, setTypeToDelete] = useState<number | null>(null)
useEffect(() => {
if (user?.role !== "admin" && user?.role !== "superuser") {
router.push("/")
}
}, [user, router])
const fetchDeviceTypes = async () => {
try {
const response = await axios.get(getApiUrl("get_all_devicetypes"))
const formattedDeviceTypes = response.data.map((item: { id: number; device_type: string }) => ({
value: item.id,
label: item.device_type,
}))
setDeviceTypes(formattedDeviceTypes)
} catch (error) {
console.error("Error fetching device types:", error)
}
}
useEffect(() => {
fetchDeviceTypes()
}, [])
const addDeviceType = async () => {
if (newTypeLabel.trim()) {
try {
await axios.post(getApiUrl("create_device_type"), { device_type: newTypeLabel.trim() })
setNewTypeLabel("")
fetchDeviceTypes()
} catch (error) {
console.error("Error adding device type:", error)
}
}
}
const handleDeleteClick = (value: number) => {
setTypeToDelete(value)
setDeleteDialogOpen(true)
}
const confirmDelete = async () => {
if (typeToDelete !== null) {
try {
await axios.delete(getApiUrl(`delete_device_type/${typeToDelete}`))
fetchDeviceTypes()
setDeleteDialogOpen(false)
setTypeToDelete(null)
} catch (error) {
console.error("Error deleting device type:", error)
}
}
}
return (
<ProtectedRoute>
<div className="min-h-screen p-4 bg-background">
<div className="max-w-4xl mx-auto space-y-8">
<Card>
<CardHeader>
<CardTitle className="text-3xl">Manage Device Types</CardTitle>
<CardDescription>
Add or remove device types that users can select when adding new devices
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="space-y-4">
<div className="flex space-x-4">
<div className="flex-grow">
<Label htmlFor="typeLabel">Type Name</Label>
<Input
id="typeLabel"
value={newTypeLabel}
onChange={(e) => setNewTypeLabel(e.target.value)}
placeholder="Enter device type name"
/>
</div>
<div className="flex items-end">
<Button onClick={addDeviceType}>
<Plus className="mr-2 h-4 w-4" />
Add Type
</Button>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold">Available Device Types</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Display Name</TableHead>
<TableHead>Value</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deviceTypes.map((type) => (
<TableRow key={type.value}>
<TableCell>{type.label}</TableCell>
<TableCell className="font-mono text-sm">{type.value}</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteClick(type.value)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex justify-end mt-6">
<Button onClick={() => router.push("/devices")}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Devices
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-foreground">Delete Device Type</DialogTitle>
<DialogDescription>
Are you sure you want to delete this device type? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={confirmDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ProtectedRoute>
)
}
@@ -0,0 +1,184 @@
"use client"
import { getApiUrl } from "@/config/api"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { ProtectedRoute } from "@/components/protected-route"
import { useAuth } from "@/lib/auth-context"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import axios from "axios"
interface Manufacturer {
value: number
label: string
}
export default function ManageManufacturersPage() {
const router = useRouter()
const { user } = useAuth()
const [manufacturers, setManufacturers] = useState<Manufacturer[]>([])
const [newManufacturerLabel, setNewManufacturerLabel] = useState("")
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [manufacturerToDelete, setManufacturerToDelete] = useState<number | null>(null)
useEffect(() => {
if (user?.role !== "superuser" && user?.role !== "admin") {
router.push("/")
}
}, [user, router])
const fetchManufacturers = async () => {
try {
const response = await axios.get(getApiUrl("get_all_manufacturers"))
const formattedManufacturers = response.data.map((item: { id: number; name: string }) => ({
value: item.id,
label: item.name,
}))
setManufacturers(formattedManufacturers)
} catch (error) {
console.error("Error fetching manufacturers:", error)
}
}
useEffect(() => {
fetchManufacturers()
}, [])
const addManufacturer = async () => {
if (newManufacturerLabel.trim()) {
try {
await axios.post(getApiUrl("create_manufact"), { name: newManufacturerLabel.trim() })
setNewManufacturerLabel("")
fetchManufacturers()
} catch (error) {
console.error("Error adding manufacturer:", error)
}
}
}
const handleDeleteClick = (value: number) => {
setManufacturerToDelete(value)
setDeleteDialogOpen(true)
}
const confirmDelete = async () => {
if (manufacturerToDelete !== null) {
try {
await axios.delete(getApiUrl(`delete_manufacturer/${manufacturerToDelete}`))
fetchManufacturers()
setDeleteDialogOpen(false)
setManufacturerToDelete(null)
} catch (error) {
console.error("Error deleting manufacturer:", error)
}
}
}
return (
<ProtectedRoute>
<div className="min-h-screen p-4 bg-background">
<div className="max-w-4xl mx-auto space-y-8">
<Card>
<CardHeader>
<CardTitle className="text-3xl">Manage Manufacturers</CardTitle>
<CardDescription>
Add or remove manufacturers that users can select when adding new devices
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="space-y-4">
<div className="flex space-x-4">
<div className="flex-grow">
<Label htmlFor="manufacturerLabel">Manufacturer Name</Label>
<Input
id="manufacturerLabel"
value={newManufacturerLabel}
onChange={(e) => setNewManufacturerLabel(e.target.value)}
placeholder="Enter manufacturer name"
/>
</div>
<div className="flex items-end">
<Button onClick={addManufacturer}>
<Plus className="mr-2 h-4 w-4" />
Add Manufacturer
</Button>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold">Available Manufacturers</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Display Name</TableHead>
<TableHead>Value</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{manufacturers.map((manufacturer) => (
<TableRow key={manufacturer.value}>
<TableCell>{manufacturer.label}</TableCell>
<TableCell className="font-mono text-sm">{manufacturer.value}</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteClick(manufacturer.value)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex justify-end mt-6">
<Button onClick={() => router.push("/devices")}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Devices
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-foreground">Delete Manufacturer</DialogTitle>
<DialogDescription>
Are you sure you want to delete this manufacturer? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={confirmDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ProtectedRoute>
)
}
+390
View File
@@ -0,0 +1,390 @@
"use client"
import { getApiUrl } from "@/config/api"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import axios from "axios"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { ProtectedRoute } from "@/components/protected-route"
import { useAuth } from "@/lib/auth-context"
import { ArrowLeft, Copy } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { useToast } from "@/components/ui/use-toast"
interface User {
id: number
username: string
role: string
}
function generatePassword() {
const length = 12
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
const values = crypto.getRandomValues(new Uint32Array(length))
return Array.from(values, (value) => charset[value % charset.length]).join("")
}
export default function ManageUsersPage() {
const router = useRouter()
const { user } = useAuth()
const [users, setUsers] = useState<User[]>([])
const [newUsername, setNewUsername] = useState("")
const [newRole, setNewRole] = useState("user")
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [userToDelete, setUserToDelete] = useState<number | null>(null)
const [generatedPassword, setGeneratedPassword] = useState<string | null>(null)
const [showPasswordAlert, setShowPasswordAlert] = useState(false)
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
const [userToResetPassword, setUserToResetPassword] = useState<number | null>(null)
const { toast } = useToast()
useEffect(() => {
if (user?.role !== "admin" && user?.role !== "superuser") {
router.push("/")
}
}, [user, router])
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await axios.get(getApiUrl("list-users"))
if (response.status === 200 && response.data) {
setUsers(response.data.data)
} else {
console.error("Failed to fetch users")
}
} catch (error) {
console.error("Error fetching users:", error)
}
}
fetchUsers()
}, [])
const addUser = async () => {
if (newUsername) {
const password = generatePassword()
try {
const response = await axios.post(getApiUrl("register"), {
username: newUsername,
role: newRole,
password: password,
})
if (response.data.status === "success") {
const fetchUsers = async () => {
try {
const response = await axios.get(getApiUrl("list-users"))
if (response.status === 200 && response.data) {
setUsers(response.data.data)
} else {
console.error("Failed to fetch users")
}
} catch (error) {
console.error("Error fetching users:", error)
}
}
await fetchUsers()
setNewUsername("")
setNewRole("user")
setGeneratedPassword(password)
setShowPasswordAlert(true)
toast({
title: "Success",
description: "User created successfully",
duration: 2000,
})
} else {
console.error("Failed to create user")
toast({
title: "Error",
description: "Failed to create user",
variant: "destructive",
duration: 2000,
})
}
} catch (error) {
console.error("Error creating user:", error)
toast({
title: "Error",
description: "An error occurred while creating the user",
variant: "destructive",
duration: 2000,
})
}
}
setTimeout(() => setShowPasswordAlert(false), 30000)
}
const handleDeleteClick = (id: number) => {
setUserToDelete(id)
setDeleteDialogOpen(true)
}
const confirmDelete = async () => {
if (userToDelete) {
try {
const response = await axios.post(getApiUrl("delete-user"), { id: userToDelete })
if (response.data.status === "success") {
setUsers(users.filter((user) => user.id !== userToDelete))
setDeleteDialogOpen(false)
setUserToDelete(null)
toast({
title: "Success",
description: "User deleted successfully",
duration: 2000,
})
} else {
console.error("Failed to delete user")
toast({
title: "Error",
description: "Failed to delete user",
variant: "destructive",
duration: 2000,
})
}
} catch (error) {
console.error("Error deleting user:", error)
toast({
title: "Error",
description: "An error occurred while deleting the user",
variant: "destructive",
duration: 2000,
})
}
}
}
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
toast({
title: "Copied!",
description: "Password copied to clipboard",
duration: 2000,
})
} catch (err) {
console.error("Failed to copy text: ", err)
toast({
title: "Error",
description: "Failed to copy password",
variant: "destructive",
duration: 2000,
})
}
}
const handleResetPasswordClick = (userId: number) => {
setUserToResetPassword(userId)
setResetPasswordDialogOpen(true)
}
const confirmResetPassword = async () => {
if (userToResetPassword) {
const newPassword = generatePassword()
try {
const response = await axios.post(getApiUrl("reset-password"), {
user_id: userToResetPassword,
new_password: newPassword,
})
if (response.data.status === "success") {
const fetchUsers = async () => {
try {
const response = await axios.get(getApiUrl("list-users"))
if (response.status === 200 && response.data) {
setUsers(response.data.data)
} else {
console.error("Failed to fetch users")
}
} catch (error) {
console.error("Error fetching users:", error)
}
}
await fetchUsers()
setGeneratedPassword(newPassword)
setShowPasswordAlert(true)
setResetPasswordDialogOpen(false)
setUserToResetPassword(null)
toast({
title: "Success",
description: "Password reset successfully",
duration: 2000,
})
} else {
console.error("Failed to reset password")
toast({
title: "Error",
description: "Failed to reset password",
variant: "destructive",
duration: 2000,
})
}
} catch (error) {
console.error("Error resetting password:", error)
toast({
title: "Error",
description: "An error occurred while resetting the password",
variant: "destructive",
duration: 2000,
})
}
}
setTimeout(() => setShowPasswordAlert(false), 30000)
}
return (
<ProtectedRoute>
<div className="min-h-screen p-4 bg-background">
<div className="max-w-4xl mx-auto space-y-8">
<Card>
<CardHeader>
<CardTitle className="text-3xl">Manage Users</CardTitle>
<CardDescription>Add, view, or remove users from the system</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">Add New User</h3>
<div className="grid grid-cols-1 md:grid-cols-[2fr,1fr,auto] gap-4">
<div>
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
placeholder="Enter username"
/>
</div>
<div>
<Label htmlFor="role">Role</Label>
<select
id="role"
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
className="w-full p-2 rounded-md border border-input bg-background"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="flex items-end">
<Button onClick={addUser}>Add User</Button>
</div>
</div>
</div>
{showPasswordAlert && generatedPassword && (
<Alert className="bg-yellow-500/20 text-yellow-200 border-yellow-500">
<AlertDescription className="flex items-center justify-between">
<div>
<span className="font-semibold">Initial password: </span>
<code className="bg-yellow-500/20 px-2 py-1 rounded">{generatedPassword}</code>
</div>
<Button variant="outline" size="sm" onClick={() => copyToClipboard(generatedPassword)}>
<Copy className="h-4 w-4" />
</Button>
</AlertDescription>
</Alert>
)}
<div>
<h3 className="text-lg font-semibold mb-4">Existing Users</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Username</TableHead>
<TableHead>Role</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.id}</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.role}</TableCell>
<TableCell>
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={() => handleResetPasswordClick(user.id)}
className="w-full sm:w-auto"
>
Reset Password
</Button>
<Button
variant="destructive"
onClick={() => handleDeleteClick(user.id)}
className="w-full sm:w-auto"
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex justify-end mt-6">
<Button onClick={() => router.push("/devices")}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Devices
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-foreground">Delete User</DialogTitle>
<DialogDescription>
Are you sure you want to delete this user? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={confirmDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reset Password Confirmation Dialog */}
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-foreground">Reset Password</DialogTitle>
<DialogDescription>
Are you sure you want to reset this user&apos;s password? Share the new initial password securely.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setResetPasswordDialogOpen(false)}>Cancel</Button>
<Button variant="default" onClick={confirmResetPassword}>
Reset Password
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ProtectedRoute>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function Home() {
redirect('/devices')
}
+65
View File
@@ -0,0 +1,65 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { ProtectedRoute } from "@/components/protected-route"
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
// The legacy route forwards directly to the consolidated findings view.
const ROUTE_FINDINGS = "/findings"
function SmartResultsRedirectContent() {
const router = useRouter()
const searchParams = useSearchParams()
const [status, setStatus] = useState<"idle" | "checking" | "redirected" | "error">("idle")
useEffect(() => {
const fileId = searchParams.get("fileId")
if (!fileId) {
setStatus("error")
return
}
setStatus("checking")
router.replace(`${ROUTE_FINDINGS}?fileId=${encodeURIComponent(fileId)}`)
setStatus("redirected")
}, [router, searchParams])
return (
<ProtectedRoute>
<div className="min-h-screen bg-background p-4 flex items-center justify-center">
<Card className="w-96">
<CardHeader>
<CardTitle>
{status === "error" ? "Error" : "Loading..."}
</CardTitle>
<CardDescription>
{status === "error"
? "Couldn't determine result page. Please try again."
: "Determining the correct result page…"}
</CardDescription>
</CardHeader>
</Card>
</div>
</ProtectedRoute>
)
}
export default function SmartResultsRedirectPage() {
return (
<Suspense fallback={
<ProtectedRoute>
<div className="min-h-screen bg-background p-4 flex items-center justify-center">
<Card className="w-96">
<CardHeader>
<CardTitle>Loading...</CardTitle>
<CardDescription>Please wait...</CardDescription>
</CardHeader>
</Card>
</div>
</ProtectedRoute>
}>
<SmartResultsRedirectContent />
</Suspense>
)
}
+23
View File
@@ -0,0 +1,23 @@
export const getDeviceImage = (deviceType: string) => {
switch (deviceType.toLowerCase()) {
case 'camera':
return '/images/devices/camera.png';
case 'smart-plug':
return '/images/devices/smart_plug.png';
case 'smart-bulb':
return '/images/devices/smart_bulb.png';
case 'smart-switch':
return '/images/devices/smart_switch.png';
case 'robot-vacuum':
case 'robot vacuum cleaner':
return '/images/devices/robot_vacuum_cleaner.png';
case 'smart-lock':
return '/images/devices/smart_lock.png';
case 'smart-controller':
return '/images/devices/smart_controller.png';
case 'sensor':
return '/images/devices/sensor.png';
default:
return '/images/logos/Placeholder.png';
}
};
+237
View File
@@ -0,0 +1,237 @@
"use client"
import { getApiUrl } from "@/config/api"
import * as React from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import axios from "axios"
import { FileIcon, UploadCloudIcon, XIcon, ScanIcon, ArrowLeft } from "lucide-react"
import { useDropzone } from "react-dropzone"
import type { FileRejection } from "react-dropzone"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
interface FileUploadProps {
onBack?: () => void
deviceId?: string
}
interface FileDetails {
firmware: string
ipAddress: string
}
export function FileUpload({ onBack, deviceId }: FileUploadProps) {
const router = useRouter()
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState<string | null>(null)
const [uploadProgress, setUploadProgress] = useState(0)
const [isScanning, setIsScanning] = useState(false)
const [fileDetails, setFileDetails] = useState<FileDetails>({
firmware: "",
ipAddress: "",
})
const onDrop = React.useCallback((acceptedFiles: File[], fileRejections: FileRejection[]) => {
if (fileRejections.length > 0) {
setError("Invalid file type. Please upload a .pcap or .pcapng capture.")
return
}
if (acceptedFiles.length > 0) {
const selectedFile = acceptedFiles[0]
if (selectedFile.size > 512 * 1024 * 1024) {
setError("File size exceeds the 512 MB demo limit.")
return
}
setFile(selectedFile)
setError(null)
setUploadProgress(0)
}
}, [])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"application/vnd.tcpdump.pcap": [".pcap"],
"application/x-pcapng": [".pcapng"],
},
maxFiles: 1,
multiple: false,
})
const removeFile = () => {
setFile(null)
setUploadProgress(0)
setError(null)
setFileDetails({
firmware: "",
ipAddress: "",
})
}
const handleScan = async () => {
if (!file || !fileDetails.firmware || !fileDetails.ipAddress) return
try {
setIsScanning(true)
setError(null)
const formData = new FormData()
formData.append("capture", file)
formData.append("device_id", deviceId || "")
formData.append("firmware_version", fileDetails.firmware)
formData.append("ip_address", fileDetails.ipAddress)
const response = await axios.post(getApiUrl("captures"), formData, {
onUploadProgress: (event) => {
if (event.total) setUploadProgress(Math.round((event.loaded / event.total) * 100))
},
})
const fileId = response.data.data.id
router.push(`/scan-results?fileId=${fileId}`)
} catch (err) {
console.error("Scan error:", err)
const apiError = axios.isAxiosError(err)
? err.response?.data?.error || err.response?.data?.capture?.[0] || err.message
: err instanceof Error
? err.message
: String(err)
setError(`Failed to scan file: ${apiError}`)
} finally {
setIsScanning(false)
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setFileDetails((prev) => ({
...prev,
[name]: value,
}))
}
return (
<Card className="w-full">
<CardHeader className="space-y-4">
<div className="flex items-center justify-between">
{onBack && (
<Button onClick={onBack} className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
)}
</div>
<div className="text-center">
<CardTitle className="text-2xl font-bold">Upload New File</CardTitle>
<CardDescription className="mt-2">
Upload a PCAP/PCAPNG capture for IoT network security analysis.
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-6">
{!file && (
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors
${isDragActive ? "border-primary bg-primary/5" : "border-border"}
${error ? "border-destructive bg-destructive/5" : ""}
`}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center text-center space-y-4">
<UploadCloudIcon className="w-12 h-12 text-muted-foreground" />
<div className="space-y-2">
<p className="font-medium">Drag & drop your file here</p>
<p className="text-sm text-muted-foreground">
or click to select a file from your computer
</p>
<p className="text-xs text-muted-foreground mt-2">
Supported formats: .pcap and .pcapng · maximum 512 MB
</p>
</div>
</div>
</div>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{file && (
<div className="space-y-6">
<div className="bg-muted p-4 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<FileIcon className="w-5 h-5 text-primary" />
<div className="flex flex-col">
<span className="font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground">
Network capture
</span>
</div>
</div>
<Button variant="ghost" size="sm" onClick={removeFile}>
<XIcon className="w-4 h-4" />
</Button>
</div>
{isScanning && <Progress value={uploadProgress} className="mt-4" />}
</div>
<div className="space-y-4">
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="firmware">Firmware Version</Label>
<Input
id="firmware"
name="firmware"
value={fileDetails.firmware}
onChange={handleInputChange}
placeholder="Enter firmware version"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ipAddress">IP Address</Label>
<Input
id="ipAddress"
name="ipAddress"
value={fileDetails.ipAddress}
onChange={handleInputChange}
placeholder="Enter IP address"
/>
</div>
</div>
<Button
className="w-full py-6 text-lg"
onClick={handleScan}
disabled={isScanning || !fileDetails.firmware || !fileDetails.ipAddress}
>
{isScanning ? (
<>
<ScanIcon className="mr-2 h-5 w-5 animate-spin" />
Scanning capture...
</>
) : (
<>
<ScanIcon className="mr-2 h-5 w-5" />
Analyze Capture
</>
)}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,21 @@
"use client"
import { ReactNode, useEffect } from "react"
import { useRouter } from "next/navigation"
import { AuthUser, useAuth } from "@/lib/auth-context"
interface ProtectedRouteProps {
children: ReactNode | ((props: { user: AuthUser; logout: () => void }) => ReactNode)
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { user, logout } = useAuth()
const router = useRouter()
useEffect(() => {
if (!user) router.replace("/login")
}, [user, router])
if (!user) return null
return typeof children === "function" ? children({ user, logout }) : children
}

Some files were not shown because too many files have changed in this diff Show More