build 4.6 Demo
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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")
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user