Add network scan

This commit is contained in:
2026-03-21 16:41:14 +11:00
parent 4d73ed9ac9
commit 551fdf5cbb
37 changed files with 2564 additions and 1 deletions
+429
View File
@@ -0,0 +1,429 @@
# NetScan — Home Network Security Scanner
A new Django sub-app (`netscan`) added to the Links project that runs configurable, scheduled security checks against your home network, stores results in SQLite, sends Telegram alerts for critical findings, and surfaces everything through a Tailwind UI inside the existing mini-apps section.
---
## Context & Constraints
- **Codebase**: Django + APScheduler + Tailwind + SQLite (PostgreSQL in prod via env var)
- **Deployment**: Same Docker image as the Links app, deployed in K3s at 192.168.1.2 (server-3)
- **Why in-cluster**: The scanner must run **inside the LAN** to probe 192.168.1.x addresses (router, cameras). Running on the cluster node satisfies this automatically.
- **No new dependencies except `requests`** (already present). All checks use stdlib `socket`, `ssl`, `subprocess`, `struct`.
- **No new JS framework** — pure Django templates + Tailwind, matching the rest of the app.
---
## Database Models (`netscan/models.py`)
### `ScanProfile`
| Field | Type | Notes |
|---|---|---|
| `name` | CharField | Human label e.g. "Home Network" |
| `enabled` | BooleanField | Controls scheduler |
| `schedule_interval` | IntegerField | 1 / 3 / 7 / 30 days (choices) |
| `gateway_ip` | GenericIPAddressField | e.g. 192.168.1.1 |
| `public_ip` | GenericIPAddressField | e.g. 14.137.198.99 |
| `network_cidr` | CharField | e.g. 192.168.1.0/24 |
| `auth_provider_host` | CharField | e.g. pass.junv.cc (for ingress check) |
| `domains` | JSONField | List of public hostnames to check |
| `cameras` | JSONField | List of camera IPs to probe |
| `telegram_bot_token` | CharField | blank/null; stored encrypted in env preferred |
| `telegram_chat_id` | CharField | blank/null |
| `notify_on_severity` | CharField | "warning" or "critical" (default "critical") |
| `last_run_at` | DateTimeField | null |
| `created_at` | DateTimeField | auto |
### `ScanRun`
| Field | Type | Notes |
|---|---|---|
| `profile` | FK → ScanProfile | cascade delete |
| `started_at` | DateTimeField | |
| `finished_at` | DateTimeField | null |
| `status` | CharField | pending / running / success / failed |
| `summary` | JSONField | `{ok:N, info:N, warning:N, critical:N}` |
| `triggered_by` | CharField | "scheduler" or "manual" |
### `ScanFinding`
| Field | Type | Notes |
|---|---|---|
| `run` | FK → ScanRun | cascade delete |
| `check_name` | CharField | e.g. "router_ports", "tls_expiry" |
| `severity` | CharField | ok / info / warning / critical |
| `title` | CharField | Short human summary |
| `detail` | TextField | Full explanation |
| `raw` | JSONField | Raw probe output for debugging |
---
## Check Modules (`netscan/checks/`)
Each module exposes a single function `run(profile) -> list[Finding]`. All use only stdlib + `requests`.
### `checks/base.py`
```python
@dataclass
class Finding:
check_name: str
severity: str # ok | info | warning | critical
title: str
detail: str
raw: dict
```
### `checks/router.py` — Gateway security
- TCP connect-probe gateway IP on ports: 22, 23, 53, 80, 139, 443, 445, 8080, 8443
- Fetch HTTP headers from port 80 to identify plain-HTTP admin (`Server: httpd`)
- Flag: SMB open (139/445) → **warning**
- Flag: SSH closed → **ok**; SSH open from WAN → **warning**
- Flag: plain-HTTP admin (no HTTPS on 443) → **warning**
- Flag: unknown port 8080 open → **info**
### `checks/dns.py` — Open resolver (WAN DNS exposure)
- Send raw DNS query for `google.com A` to `profile.public_ip:53` over UDP using `socket` + `struct`
- Parse the response: if `NOERROR` + answer section returned → recursion available
- Flag: open recursive resolver → **critical**
- Note in detail: "Verify from off-LAN; NAT hairpin may cause false positive"
### `checks/ingress.py` — Public domain auth verification
- For each domain in `profile.domains`:
- `requests.get(f"https://{domain}/", allow_redirects=True, timeout=8)`
- Check: does the redirect chain pass through `profile.auth_provider_host`?
- Check: does the final URL (after all redirects) contain the auth provider host?
- If final URL is the app itself (not the auth host) → **critical** (auth bypassed)
- If redirect goes through auth provider → **ok**
- If connection refused / DNS fails → **warning**
### `checks/cameras.py` — RTSP unauthenticated access
- For each IP in `profile.cameras`:
1. TCP connect to port 554 — if closed → **info** (skip)
2. Send `OPTIONS rtsp://{ip}/ RTSP/1.0\r\nCSeq: 1\r\n\r\n` over raw socket
3. Parse response status line
4. If OPTIONS returns 200, send `DESCRIBE rtsp://{ip}/ RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\n\r\n`
5. If DESCRIBE returns 200 (stream accessible with no creds) → **critical**
6. If DESCRIBE returns 401/403 → **ok** (auth required)
7. If OPTIONS returns 404 / no common path accessible → **ok**
### `checks/tls.py` — TLS certificate validity
- For each domain in `profile.domains`:
- `ssl.get_server_certificate((domain, 443))` + parse `notAfter`
- Days to expiry < 0 → **critical** (expired)
- Days to expiry < 14 → **critical**
- Days to expiry < 30 → **warning**
- Otherwise → **ok**
- Also flag if cert `CN`/`SAN` doesn't match the domain → **warning**
### `checks/ports.py` — Public IP port exposure
- TCP connect-probe `profile.public_ip` on: 22, 23, 25, 53, 80, 443, 3306, 5432, 6379, 8080, 8443
- Flag presence of each open port with canned risk descriptions:
- 80/443 → **info** (expected for web services)
- 22 → **warning** (SSH exposed to internet)
- 23 → **critical** (Telnet exposed)
- 53 → **warning** (DNS — run dns check to confirm resolver)
- Database ports (3306/5432/6379) → **critical**
- 8443/8080 → **warning** (alt web ports)
---
## Scanner Orchestrator (`netscan/scanner.py`)
```python
def run_scan(profile_id: int) -> int:
"""
Runs all checks for the given profile.
Returns the ScanRun PK.
Called by APScheduler jobs and TriggerScanView.
"""
profile = ScanProfile.objects.get(pk=profile_id)
run = ScanRun.objects.create(profile=profile, status='running', triggered_by=...)
all_findings = []
check_modules = [router, dns, ingress, cameras, tls, ports]
for mod in check_modules:
try:
findings = mod.run(profile)
all_findings.extend(findings)
except Exception as e:
# Wrap uncaught errors as a warning finding so run still completes
all_findings.append(Finding(check_name=mod.__name__, severity='warning',
title='Check errored', detail=str(e), raw={}))
# Persist findings
ScanFinding.objects.bulk_create([...])
# Update run summary
summary = Counter(f.severity for f in all_findings)
run.summary = dict(summary)
run.status = 'success'
run.finished_at = now()
run.save()
# Telegram notification
if profile.telegram_bot_token and profile.telegram_chat_id:
notify_telegram(profile, run, all_findings)
# Update profile.last_run_at
profile.last_run_at = now()
profile.save(update_fields=['last_run_at'])
return run.pk
```
---
## Telegram Notifications (`netscan/notifications.py`)
```python
def notify_telegram(profile, run, findings):
"""
Sends a Telegram message if any finding meets or exceeds notify_on_severity.
Uses the Bot API sendMessage endpoint directly via requests (no library needed).
"""
threshold_order = ['ok', 'info', 'warning', 'critical']
threshold_idx = threshold_order.index(profile.notify_on_severity)
flagged = [f for f in findings
if threshold_order.index(f.severity) >= threshold_idx]
if not flagged:
return
lines = [f"🔒 *NetScan Alert* — {profile.name}",
f"Run #{run.pk} finished at {run.finished_at:%Y-%m-%d %H:%M}",
f"Summary: {run.summary}",
""]
for f in flagged[:10]: # cap at 10 to stay under TG message limit
icon = {'critical': '🔴', 'warning': '🟡', 'ok': '🟢', 'info': ''}[f.severity]
lines.append(f"{icon} *{f.title}*\n {f.detail[:120]}")
if len(flagged) > 10:
lines.append(f"_...and {len(flagged)-10} more findings_")
text = "\n".join(lines)
url = f"https://api.telegram.org/bot{profile.telegram_bot_token}/sendMessage"
requests.post(url, json={
"chat_id": profile.telegram_chat_id,
"text": text,
"parse_mode": "Markdown"
}, timeout=10)
```
---
## APScheduler Integration (`netscan/tasks.py` + `netscan/apps.py`)
### `tasks.py`
```python
from core.scheduler import scheduler
from apscheduler.triggers.interval import IntervalTrigger
def schedule_profile(profile):
job_id = f'netscan_profile_{profile.pk}'
scheduler.add_job(
run_scan,
trigger=IntervalTrigger(days=profile.schedule_interval),
id=job_id,
args=[profile.pk],
replace_existing=True
)
def unschedule_profile(profile):
job_id = f'netscan_profile_{profile.pk}'
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
```
### `apps.py`
```python
class NetscanConfig(AppConfig):
name = 'netscan'
def ready(self):
from netscan.tasks import schedule_profile
from netscan.models import ScanProfile
for profile in ScanProfile.objects.filter(enabled=True):
schedule_profile(profile)
```
### `signals.py`
```python
@receiver(post_save, sender=ScanProfile)
def reschedule_on_save(sender, instance, **kwargs):
if instance.enabled:
schedule_profile(instance)
else:
unschedule_profile(instance)
```
---
## Views (`netscan/views.py`)
All views use `LoginRequiredMixin`.
| View | URL | Notes |
|---|---|---|
| `DashboardView` | `/ui/netscan/` | List profiles, worst-severity badge per profile, last run time, Run Now + Edit buttons |
| `ProfileCreateView` | `/ui/netscan/profile/new/` | ModelForm |
| `ProfileUpdateView` | `/ui/netscan/profile/<pk>/edit/` | ModelForm |
| `ProfileDeleteView` | `/ui/netscan/profile/<pk>/delete/` | Confirm page |
| `ScanRunListView` | `/ui/netscan/profile/<pk>/runs/` | Paginated history, status + severity count cols |
| `ScanRunDetailView` | `/ui/netscan/run/<pk>/` | Findings grouped by severity, collapsible raw JSON |
| `TriggerScanView` | `/ui/netscan/profile/<pk>/trigger/` | POST-only; spawns `Thread(target=run_scan, args=[pk])`, redirects to run list |
| `TestTelegramView` | `/ui/netscan/profile/<pk>/test-telegram/` | POST-only; sends a test message, returns JSON |
---
## Templates (`netscan/templates/netscan/`)
All extend `base.html`, use Tailwind classes matching the existing app.
### `dashboard.html`
- Grid of profile cards (matches mini_apps card style)
- Each card: name, schedule chip (e.g. "Every 7 days"), last run timestamp, worst-severity badge (🔴/🟡/🟢), finding count breakdown
- "Run Now" button (POST to trigger URL), "Edit" link, "History" link
- Empty state with "Create your first scan profile" CTA
### `profile_form.html`
- Fields: Name, Schedule (dropdown: 1/3/7/30 days), Gateway IP, Public IP, Network CIDR, Auth Provider Host, Domains (textarea, one per line), Camera IPs (textarea, one per line), Telegram Bot Token, Telegram Chat ID, Notify on Severity (dropdown: warning/critical), Enabled checkbox
- "Test Telegram" button (JS fetch to TestTelegramView, shows inline success/error)
### `run_list.html`
- Table: Started, Duration, Triggered by, Status badge, 🔴 Critical, 🟡 Warning, 🟢 OK counts, View link
- Pagination
### `run_detail.html`
- Header: profile name, run timestamp, status, summary badges, "Re-run" button
- Three collapsible sections: Critical findings, Warnings, OK/Info
- Each finding: title, detail text; expandable "Raw" disclosure showing JSON
- Back to history link
---
## URL Wiring
### `core/urls.py` — add:
```python
path('ui/netscan/', include('netscan.urls')),
```
### `netscan/urls.py`:
```python
urlpatterns = [
path('', DashboardView.as_view(), name='netscan-dashboard'),
path('profile/new/', ProfileCreateView.as_view(), name='netscan-profile-create'),
path('profile/<int:pk>/edit/', ProfileUpdateView.as_view(), name='netscan-profile-edit'),
path('profile/<int:pk>/delete/', ProfileDeleteView.as_view(), name='netscan-profile-delete'),
path('profile/<int:pk>/runs/', ScanRunListView.as_view(), name='netscan-run-list'),
path('profile/<int:pk>/trigger/', TriggerScanView.as_view(), name='netscan-trigger'),
path('profile/<int:pk>/test-telegram/', TestTelegramView.as_view(), name='netscan-test-telegram'),
path('run/<int:pk>/', ScanRunDetailView.as_view(), name='netscan-run-detail'),
]
```
### `core/settings.py` — add to INSTALLED_APPS:
```python
'netscan',
```
### `links/mini_apps_views.py` — add to `mini_apps` list:
```python
{
'name': 'NetScan',
'description': 'Scheduled home network security scanner. Checks router exposure, DNS, TLS certs, public ingress auth, and camera access.',
'url': 'netscan-dashboard',
'thumbnail': 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=400&h=300&fit=crop',
'icon': 'fas fa-shield-alt',
'color': '#e74c3c'
},
```
---
## K3s Deployment Changes (`k8s/manifest.yaml`)
The scanner runs **inside the same Pod** as the Links app — no new container or service needed. The existing Deployment only needs two new env vars (or they can be set per-profile in the DB):
```yaml
# Optional: global fallback Telegram credentials
- name: NETSCAN_TELEGRAM_BOT_TOKEN
valueFrom:
secretKeyRef:
name: netscan-credentials
key: telegram_bot_token
- name: NETSCAN_TELEGRAM_CHAT_ID
valueFrom:
secretKeyRef:
name: netscan-credentials
key: telegram_chat_id
```
Create the secret:
```bash
kubectl create secret generic netscan-credentials \
--from-literal=telegram_bot_token=YOUR_TOKEN \
--from-literal=telegram_chat_id=YOUR_CHAT_ID \
-n home-apps
```
The Pod already runs inside the LAN (on server-3 at 192.168.1.2), so it can reach 192.168.1.1 (router), 192.168.1.70250 (cameras), and the public IP.
---
## File Checklist
| File | Action |
|---|---|
| `netscan/__init__.py` | create (empty) |
| `netscan/apps.py` | create |
| `netscan/models.py` | create |
| `netscan/admin.py` | create (register all 3 models) |
| `netscan/checks/__init__.py` | create (empty) |
| `netscan/checks/base.py` | create |
| `netscan/checks/router.py` | create |
| `netscan/checks/dns.py` | create |
| `netscan/checks/ingress.py` | create |
| `netscan/checks/cameras.py` | create |
| `netscan/checks/tls.py` | create |
| `netscan/checks/ports.py` | create |
| `netscan/scanner.py` | create |
| `netscan/notifications.py` | create |
| `netscan/tasks.py` | create |
| `netscan/signals.py` | create |
| `netscan/forms.py` | create |
| `netscan/views.py` | create |
| `netscan/urls.py` | create |
| `netscan/migrations/0001_initial.py` | create (via makemigrations) |
| `netscan/templates/netscan/dashboard.html` | create |
| `netscan/templates/netscan/profile_form.html` | create |
| `netscan/templates/netscan/run_list.html` | create |
| `netscan/templates/netscan/run_detail.html` | create |
| `core/settings.py` | add `'netscan'` to INSTALLED_APPS |
| `core/urls.py` | add `path('ui/netscan/', include('netscan.urls'))` |
| `links/mini_apps_views.py` | add NetScan entry to mini_apps list |
| `k8s/manifest.yaml` | add env vars for Telegram credentials |
---
## Verification Steps
1. `python manage.py makemigrations netscan && python manage.py migrate` — no errors
2. `python manage.py runserver` — navigate to `/ui/netscan/` — dashboard loads
3. Create a ScanProfile via the form, ensure all fields save
4. Click "Run Now" — `ScanRun` + `ScanFinding` rows appear in DB; run_detail page shows them
5. Set schedule to 1 day, save — `scheduler.get_jobs()` shows a `netscan_profile_N` job
6. Toggle profile disabled — job is removed from scheduler
7. If Telegram configured, click "Test Telegram" — message appears in chat
8. After a scheduled run: Telegram alert arrives for any critical/warning findings
9. All views return 302 to login when accessed without auth
10. `kubectl apply -f k8s/manifest.yaml` — Pod starts cleanly with new env vars
---
## Implementation Order (for LLM session in links workspace)
1. **Phase 1** (parallel): models + migration + app registration + all check modules (no inter-dependencies)
2. **Phase 2**: scanner.py + notifications.py (depends on models + checks)
3. **Phase 3**: tasks.py + apps.py + signals.py (depends on scanner)
4. **Phase 4**: views.py + urls.py + forms.py (depends on models)
5. **Phase 5**: all 4 templates (depends on views)
6. **Phase 6**: wire into core/settings, core/urls, mini_apps_views, k8s/manifest
+1
View File
@@ -20,6 +20,7 @@ INSTALLED_APPS = [
'new_theme',
'simplemde',
'markdown', # 只需要基本的markdown包
'netscan',
]
ROOT_URLCONF = 'core.urls'
+3
View File
@@ -26,6 +26,9 @@ urlpatterns = [
path('custom/<slug:alias>/', CustomLinkView.as_view(), name='custom_link'),
path('custom/<slug:alias>/edit/', LinkUpdateView.as_view(), name='custom_link_update'),
# Include netscan BEFORE links.urls to prevent the alias catch-all from intercepting it
path('ui/netscan/', include('netscan.urls')),
# Include main app URLs with locale
path('', include('links.urls')),
]
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -41,7 +41,7 @@ class MiniAppsListView(TemplateView):
'thumbnail': 'https://images.unsplash.com/photo-1504608524841-42fe6f032b4b?w=400&h=300&fit=crop',
'icon': 'fas fa-cloud-sun',
'color': '#e74c3c'
}
},
]
context['mini_apps'] = mini_apps
+18
View File
@@ -0,0 +1,18 @@
# Links Project Memory
## Project: links (GoLinks + mini-apps)
- Django + APScheduler + Tailwind + SQLite (local) / PostgreSQL (prod via DB_HOST env var)
- Deployment: Docker → K3s at 192.168.1.2, namespace `home-apps`
- Use `uv run manage.py` (not bare `python manage.py`) — greenlet/playwright has macOS code-signing issues locally
- Templates extend `base.html` from `templates/base.html` (not app-level)
- Scheduler singleton: `core.scheduler.scheduler` (BackgroundScheduler, already running from CoreConfig.ready)
- App scheduler wiring pattern: import signals in AppConfig.ready(), wrap ORM calls in try/except
## NetScan sub-app (implemented 2026-03-21)
- Located at `netscan/`
- 3 models: ScanProfile, ScanRun, ScanFinding
- 6 check modules in `netscan/checks/`: router, dns, ingress, cameras, tls, ports
- Scanner orchestrator: `netscan/scanner.run_scan(profile_id, triggered_by)`
- Telegram notifications: `netscan/notifications.py` (raw requests, no library)
- URL prefix: `/ui/netscan/`
- Migration written manually (0001_initial.py) — local playwright/greenlet prevents running makemigrations
View File
+21
View File
@@ -0,0 +1,21 @@
from django.contrib import admin
from .models import ScanProfile, ScanRun, ScanFinding
@admin.register(ScanProfile)
class ScanProfileAdmin(admin.ModelAdmin):
list_display = ['name', 'enabled', 'schedule_interval', 'gateway_ip', 'public_ip', 'last_run_at', 'created_at']
list_filter = ['enabled', 'schedule_interval']
@admin.register(ScanRun)
class ScanRunAdmin(admin.ModelAdmin):
list_display = ['profile', 'status', 'triggered_by', 'started_at', 'finished_at']
list_filter = ['status', 'triggered_by', 'profile']
readonly_fields = ['started_at', 'finished_at', 'summary']
@admin.register(ScanFinding)
class ScanFindingAdmin(admin.ModelAdmin):
list_display = ['run', 'check_name', 'severity', 'title']
list_filter = ['severity', 'check_name']
+20
View File
@@ -0,0 +1,20 @@
from django.apps import AppConfig
import logging
logger = logging.getLogger(__name__)
class NetscanConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'netscan'
def ready(self):
import netscan.signals # noqa: F401
try:
from netscan.tasks import schedule_profile
from netscan.models import ScanProfile
for profile in ScanProfile.objects.filter(enabled=True):
schedule_profile(profile)
logger.info(f'Scheduled netscan profile: {profile.name}')
except Exception as e:
logger.warning(f'Could not schedule netscan profiles on startup: {e}')
View File
+10
View File
@@ -0,0 +1,10 @@
from dataclasses import dataclass, field
@dataclass
class Finding:
check_name: str
severity: str # ok | info | warning | critical
title: str
detail: str
raw: dict = field(default_factory=dict)
+117
View File
@@ -0,0 +1,117 @@
import socket
import logging
from .base import Finding
logger = logging.getLogger(__name__)
CHECK_NAME = 'camera_rtsp'
TIMEOUT = 5
RTSP_PORT = 554
def _tcp_open(host: str, port: int) -> bool:
try:
with socket.create_connection((host, port), timeout=TIMEOUT):
return True
except (OSError, socket.timeout):
return False
def _rtsp_request(host: str, method: str, seq: int, extra_headers: str = '') -> str:
try:
with socket.create_connection((host, RTSP_PORT), timeout=TIMEOUT) as s:
request = (
f'{method} rtsp://{host}/ RTSP/1.0\r\n'
f'CSeq: {seq}\r\n'
f'{extra_headers}'
'\r\n'
)
s.sendall(request.encode())
response = s.recv(4096).decode('utf-8', errors='replace')
return response
except Exception as e:
return f'ERROR: {e}'
def _parse_rtsp_status(response: str) -> int:
"""Extract HTTP-style status code from RTSP response."""
try:
first_line = response.splitlines()[0]
return int(first_line.split()[1])
except (IndexError, ValueError):
return 0
def run(profile) -> list:
findings = []
cameras = profile.cameras or []
if not cameras:
return [Finding(
check_name=CHECK_NAME,
severity='info',
title='No camera IPs configured',
detail='Add camera IPs to the scan profile to enable RTSP unauthenticated access check.',
raw={},
)]
for ip in cameras:
raw = {'camera_ip': ip}
if not _tcp_open(ip, RTSP_PORT):
findings.append(Finding(
check_name=CHECK_NAME,
severity='info',
title=f'{ip}: RTSP port 554 closed',
detail=f'Port 554 is not open on {ip}. Camera may be offline or not using RTSP.',
raw=raw,
))
continue
options_resp = _rtsp_request(ip, 'OPTIONS', 1)
raw['options_response'] = options_resp[:500]
options_status = _parse_rtsp_status(options_resp)
if options_status == 0:
findings.append(Finding(
check_name=CHECK_NAME,
severity='info',
title=f'{ip}: RTSP OPTIONS failed',
detail=f'Got unexpected RTSP OPTIONS response from {ip}.',
raw=raw,
))
continue
describe_resp = _rtsp_request(ip, 'DESCRIBE', 2, 'Accept: application/sdp\r\n')
raw['describe_response'] = describe_resp[:500]
describe_status = _parse_rtsp_status(describe_resp)
if describe_status == 200:
findings.append(Finding(
check_name=CHECK_NAME,
severity='critical',
title=f'{ip}: RTSP stream accessible without credentials',
detail=(
f'Camera at {ip} returned 200 to DESCRIBE without authentication. '
'Live stream may be publicly accessible on the LAN.'
),
raw=raw,
))
elif describe_status in (401, 403):
findings.append(Finding(
check_name=CHECK_NAME,
severity='ok',
title=f'{ip}: RTSP requires authentication',
detail=f'Camera at {ip} returned {describe_status} to DESCRIBE — auth is enforced.',
raw=raw,
))
else:
findings.append(Finding(
check_name=CHECK_NAME,
severity='ok',
title=f'{ip}: RTSP DESCRIBE returned {describe_status}',
detail=f'Camera at {ip} responded with status {describe_status} — no unauthenticated stream detected.',
raw=raw,
))
return findings
+86
View File
@@ -0,0 +1,86 @@
import socket
import struct
import logging
from .base import Finding
logger = logging.getLogger(__name__)
CHECK_NAME = 'dns_resolver'
TIMEOUT = 5
def _build_dns_query(domain: str) -> bytes:
"""Build a minimal DNS A query packet."""
header = struct.pack('>HHHHHH', 0xAAAA, 0x0100, 1, 0, 0, 0)
parts = domain.encode().split(b'.')
question = b''.join(bytes([len(p)]) + p for p in parts) + b'\x00'
question += struct.pack('>HH', 1, 1) # type A, class IN
return header + question
def _parse_dns_response(data: bytes) -> dict:
"""Return basic info from a DNS response header."""
if len(data) < 12:
return {'error': 'response too short'}
txid, flags, qdcount, ancount, nscount, arcount = struct.unpack('>HHHHHH', data[:12])
rcode = flags & 0x000F
return {
'txid': txid,
'flags': flags,
'rcode': rcode,
'ancount': ancount,
}
def run(profile) -> list:
host = profile.public_ip
raw = {'public_ip': host}
query = _build_dns_query('google.com')
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(TIMEOUT)
sock.sendto(query, (host, 53))
data, _ = sock.recvfrom(512)
sock.close()
parsed = _parse_dns_response(data)
raw['response'] = parsed
if parsed.get('rcode') == 0 and parsed.get('ancount', 0) > 0:
return [Finding(
check_name=CHECK_NAME,
severity='critical',
title=f'Open DNS resolver detected on {host}:53',
detail=(
'Your public IP responds to recursive DNS queries from external hosts. '
'This can be abused for DNS amplification attacks. '
'Note: NAT hairpin may cause false positive — verify from off-LAN.'
),
raw=raw,
)]
else:
return [Finding(
check_name=CHECK_NAME,
severity='ok',
title='DNS port 53 does not appear to be an open resolver',
detail=f'DNS query to {host}:53 returned rcode={parsed.get("rcode")} with {parsed.get("ancount", 0)} answers.',
raw=raw,
)]
except socket.timeout:
return [Finding(
check_name=CHECK_NAME,
severity='ok',
title='DNS port 53 timed out (not an open resolver)',
detail=f'No response from {host}:53 within {TIMEOUT}s. Port is likely closed or filtered.',
raw={**raw, 'error': 'timeout'},
)]
except Exception as e:
logger.warning(f'DNS check error: {e}')
return [Finding(
check_name=CHECK_NAME,
severity='info',
title='DNS resolver check failed',
detail=f'Could not complete DNS probe to {host}:53 — {e}',
raw={**raw, 'error': str(e)},
)]
+93
View File
@@ -0,0 +1,93 @@
import logging
import requests
from .base import Finding
logger = logging.getLogger(__name__)
CHECK_NAME = 'ingress_auth'
TIMEOUT = 10
def run(profile) -> list:
findings = []
domains = profile.domains or []
auth_host = profile.auth_provider_host
if not domains:
return [Finding(
check_name=CHECK_NAME,
severity='info',
title='No domains configured for ingress check',
detail='Add domains to the scan profile to enable ingress auth verification.',
raw={},
)]
for domain in domains:
raw = {'domain': domain, 'auth_provider_host': auth_host}
try:
resp = requests.get(f'https://{domain}/', allow_redirects=True, timeout=TIMEOUT,
headers={'User-Agent': 'NetScan/1.0'})
redirect_chain = [r.url for r in resp.history] + [resp.url]
raw['redirect_chain'] = redirect_chain
raw['final_url'] = resp.url
raw['status_code'] = resp.status_code
if auth_host:
passed_through_auth = any(auth_host in url for url in redirect_chain[:-1])
final_is_auth = auth_host in resp.url
if passed_through_auth or final_is_auth:
findings.append(Finding(
check_name=CHECK_NAME,
severity='ok',
title=f'{domain}: auth provider in redirect chain',
detail=f'Request passed through {auth_host} as expected.',
raw=raw,
))
else:
findings.append(Finding(
check_name=CHECK_NAME,
severity='critical',
title=f'{domain}: auth provider NOT in redirect chain',
detail=(
f'Expected redirect through {auth_host} but final URL is {resp.url}. '
'Authentication may be bypassed.'
),
raw=raw,
))
else:
findings.append(Finding(
check_name=CHECK_NAME,
severity='info',
title=f'{domain}: reachable (no auth provider configured)',
detail=f'Domain reached with status {resp.status_code}. Set auth_provider_host to verify auth.',
raw=raw,
))
except requests.exceptions.ConnectionError as e:
findings.append(Finding(
check_name=CHECK_NAME,
severity='warning',
title=f'{domain}: connection refused',
detail=f'Could not connect to https://{domain}/ — {e}',
raw={**raw, 'error': str(e)},
))
except requests.exceptions.Timeout:
findings.append(Finding(
check_name=CHECK_NAME,
severity='warning',
title=f'{domain}: request timed out',
detail=f'Request to https://{domain}/ timed out after {TIMEOUT}s.',
raw={**raw, 'error': 'timeout'},
))
except Exception as e:
logger.warning(f'Ingress check error for {domain}: {e}')
findings.append(Finding(
check_name=CHECK_NAME,
severity='warning',
title=f'{domain}: check error',
detail=str(e),
raw={**raw, 'error': str(e)},
))
return findings
+58
View File
@@ -0,0 +1,58 @@
import socket
import logging
from .base import Finding
logger = logging.getLogger(__name__)
CHECK_NAME = 'public_ports'
TIMEOUT = 3
PORT_INFO = {
22: ('warning', 'SSH', 'SSH exposed to the internet. Ensure key-only auth and restrict access.'),
23: ('critical', 'Telnet', 'Telnet (cleartext) is exposed to the internet. Disable immediately.'),
25: ('warning', 'SMTP', 'SMTP port exposed. Could be used for spam relay if misconfigured.'),
53: ('warning', 'DNS', 'DNS port open. Run the DNS resolver check to confirm if recursive queries are allowed.'),
80: ('info', 'HTTP', 'HTTP port open. Expected for public web services.'),
443: ('info', 'HTTPS', 'HTTPS port open. Expected for public web services.'),
3306: ('critical', 'MySQL', 'MySQL database port exposed to the internet. Restrict access immediately.'),
5432: ('critical', 'Postgres','PostgreSQL database port exposed to the internet. Restrict access immediately.'),
6379: ('critical', 'Redis', 'Redis port exposed to the internet. Redis has no auth by default — critical risk.'),
8080: ('warning', 'HTTP-alt','Alternate HTTP port 8080 is open. Verify this is intentional.'),
8443: ('warning', 'HTTPS-alt','Alternate HTTPS port 8443 is open. Verify this is intentional.'),
}
def _tcp_open(host: str, port: int) -> bool:
try:
with socket.create_connection((host, port), timeout=TIMEOUT):
return True
except (OSError, socket.timeout):
return False
def run(profile) -> list:
findings = []
host = profile.public_ip
raw = {'public_ip': host, 'open_ports': []}
for port, (severity, label, detail) in PORT_INFO.items():
if _tcp_open(host, port):
raw['open_ports'].append(port)
findings.append(Finding(
check_name=CHECK_NAME,
severity=severity,
title=f'Port {port} ({label}) open on public IP {host}',
detail=detail,
raw={'public_ip': host, 'port': port},
))
if not findings:
findings.append(Finding(
check_name=CHECK_NAME,
severity='ok',
title=f'No high-risk ports open on public IP {host}',
detail='All probed ports are closed or filtered.',
raw=raw,
))
return findings
+117
View File
@@ -0,0 +1,117 @@
import socket
import logging
from .base import Finding
logger = logging.getLogger(__name__)
CHECK_NAME = 'router_ports'
PORTS_TO_PROBE = [22, 23, 53, 80, 139, 443, 445, 8080, 8443]
TIMEOUT = 3
def _tcp_open(host: str, port: int) -> bool:
try:
with socket.create_connection((host, port), timeout=TIMEOUT):
return True
except (OSError, socket.timeout):
return False
def _fetch_http_headers(host: str, port: int = 80) -> dict:
try:
with socket.create_connection((host, port), timeout=TIMEOUT) as s:
s.sendall(f'HEAD / HTTP/1.0\r\nHost: {host}\r\n\r\n'.encode())
resp = s.recv(4096).decode('utf-8', errors='replace')
headers = {}
for line in resp.splitlines()[1:]:
if ':' in line:
k, _, v = line.partition(':')
headers[k.strip().lower()] = v.strip()
return headers
except Exception:
return {}
def run(profile) -> list:
findings = []
host = profile.gateway_ip
open_ports = {}
for port in PORTS_TO_PROBE:
open_ports[port] = _tcp_open(host, port)
raw = {'gateway_ip': host, 'open_ports': {str(p): v for p, v in open_ports.items()}}
# SMB exposure
if open_ports.get(139) or open_ports.get(445):
smb_ports = [p for p in [139, 445] if open_ports.get(p)]
findings.append(Finding(
check_name=CHECK_NAME,
severity='warning',
title=f'SMB ports open on gateway ({", ".join(str(p) for p in smb_ports)})',
detail='Windows file sharing (SMB) is accessible on the gateway. This could expose network shares.',
raw=raw,
))
# Telnet
if open_ports.get(23):
findings.append(Finding(
check_name=CHECK_NAME,
severity='critical',
title='Telnet port 23 open on gateway',
detail='Telnet transmits credentials in plaintext. Disable telnet and use SSH instead.',
raw=raw,
))
# SSH open on gateway
if open_ports.get(22):
findings.append(Finding(
check_name=CHECK_NAME,
severity='warning',
title='SSH port 22 open on gateway',
detail='SSH is accessible on the gateway. Ensure key-only auth is enforced and access is restricted.',
raw=raw,
))
# Plain HTTP admin (port 80 open, port 443 closed)
if open_ports.get(80) and not open_ports.get(443):
headers = _fetch_http_headers(host, 80)
findings.append(Finding(
check_name=CHECK_NAME,
severity='warning',
title='Gateway admin over plain HTTP (no HTTPS)',
detail=f'Port 80 is open but 443 is closed. Admin interface may be served unencrypted. Server header: {headers.get("server", "unknown")}',
raw={**raw, 'http_headers': headers},
))
# Unknown port 8080
if open_ports.get(8080):
findings.append(Finding(
check_name=CHECK_NAME,
severity='info',
title='Port 8080 open on gateway',
detail='An alternate HTTP service is running on port 8080. Verify this is intentional.',
raw=raw,
))
# Port 8443 open
if open_ports.get(8443):
findings.append(Finding(
check_name=CHECK_NAME,
severity='info',
title='Port 8443 open on gateway',
detail='An alternate HTTPS service is running on port 8443. Verify this is intentional.',
raw=raw,
))
if not findings:
findings.append(Finding(
check_name=CHECK_NAME,
severity='ok',
title='Gateway port scan looks clean',
detail=f'No high-risk ports found open on {host}.',
raw=raw,
))
return findings
+131
View File
@@ -0,0 +1,131 @@
import ssl
import socket
import logging
from datetime import datetime, timezone
from .base import Finding
logger = logging.getLogger(__name__)
CHECK_NAME = 'tls_expiry'
TIMEOUT = 10
def _get_cert_info(domain: str) -> dict:
ctx = ssl.create_default_context()
try:
with ctx.wrap_socket(socket.create_connection((domain, 443), timeout=TIMEOUT),
server_hostname=domain) as s:
cert = s.getpeercert()
return {'cert': cert, 'error': None}
except ssl.SSLCertVerificationError as e:
return {'cert': None, 'error': f'SSL verification failed: {e}'}
except Exception as e:
return {'cert': None, 'error': str(e)}
def _days_until_expiry(not_after: str) -> int:
expiry = datetime.strptime(not_after, '%b %d %H:%M:%S %Y %Z').replace(tzinfo=timezone.utc)
now = datetime.now(tz=timezone.utc)
return (expiry - now).days
def _cert_covers_domain(cert: dict, domain: str) -> bool:
san_list = [v for t, v in cert.get('subjectAltName', []) if t == 'DNS']
for san in san_list:
if san == domain:
return True
if san.startswith('*.') and domain.endswith(san[1:]):
return True
if not san_list:
cn = dict(x[0] for x in cert.get('subject', [])).get('commonName', '')
return cn == domain
return False
def run(profile) -> list:
findings = []
domains = profile.domains or []
if not domains:
return [Finding(
check_name=CHECK_NAME,
severity='info',
title='No domains configured for TLS check',
detail='Add domains to the scan profile to enable TLS certificate checks.',
raw={},
)]
for domain in domains:
raw = {'domain': domain}
info = _get_cert_info(domain)
if info['error']:
findings.append(Finding(
check_name=CHECK_NAME,
severity='warning',
title=f'{domain}: TLS check failed',
detail=info['error'],
raw={**raw, 'error': info['error']},
))
continue
cert = info['cert']
not_after = cert.get('notAfter', '')
raw['not_after'] = not_after
try:
days = _days_until_expiry(not_after)
raw['days_to_expiry'] = days
if days < 0:
findings.append(Finding(
check_name=CHECK_NAME,
severity='critical',
title=f'{domain}: TLS certificate EXPIRED {abs(days)} days ago',
detail=f'Certificate expired on {not_after}. Renew immediately.',
raw=raw,
))
elif days < 14:
findings.append(Finding(
check_name=CHECK_NAME,
severity='critical',
title=f'{domain}: TLS certificate expires in {days} days',
detail=f'Certificate will expire on {not_after}. Renew urgently.',
raw=raw,
))
elif days < 30:
findings.append(Finding(
check_name=CHECK_NAME,
severity='warning',
title=f'{domain}: TLS certificate expires in {days} days',
detail=f'Certificate expires on {not_after}. Plan renewal soon.',
raw=raw,
))
else:
findings.append(Finding(
check_name=CHECK_NAME,
severity='ok',
title=f'{domain}: TLS certificate valid for {days} more days',
detail=f'Certificate expires on {not_after}.',
raw=raw,
))
except ValueError as e:
findings.append(Finding(
check_name=CHECK_NAME,
severity='warning',
title=f'{domain}: could not parse certificate expiry',
detail=str(e),
raw=raw,
))
continue
if not _cert_covers_domain(cert, domain):
findings.append(Finding(
check_name=CHECK_NAME,
severity='warning',
title=f'{domain}: certificate CN/SAN does not match domain',
detail=f'The TLS certificate does not include {domain} in its names.',
raw=raw,
))
return findings
+39
View File
@@ -0,0 +1,39 @@
import base64
import hashlib
from cryptography.fernet import Fernet
from django.conf import settings
from django.db import models
def _get_fernet():
"""Derive a Fernet key from Django's SECRET_KEY."""
raw_key = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
return Fernet(base64.urlsafe_b64encode(raw_key))
class EncryptedCharField(models.TextField):
"""
Stores values encrypted at rest using Fernet symmetric encryption.
The encryption key is derived from Django's SECRET_KEY so no extra
secrets management is required — if the SECRET_KEY is set, values
are protected.
From the application's perspective this behaves like a plain text field:
you read/write plaintext; encryption/decryption happens transparently.
"""
def from_db_value(self, value, expression, connection):
if not value:
return value
try:
return _get_fernet().decrypt(value.encode()).decode()
except Exception:
# Gracefully return raw value if decryption fails
# (e.g. migrating plaintext rows that were saved before encryption)
return value
def get_prep_value(self, value):
if not value:
return value
return _get_fernet().encrypt(value.encode()).decode()
+73
View File
@@ -0,0 +1,73 @@
from django import forms
from .models import ScanProfile
_INPUT = (
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-700 '
'focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent'
)
_TEXTAREA = _INPUT + ' resize-none'
class ScanProfileForm(forms.ModelForm):
domains_text = forms.CharField(
widget=forms.Textarea(attrs={
'rows': 4,
'placeholder': 'to.junv.cc\ngo.junv.cc',
'class': _TEXTAREA,
}),
required=False,
label='Domains (one per line)',
help_text='Public hostnames to check for TLS and auth.',
)
cameras_text = forms.CharField(
widget=forms.Textarea(attrs={
'rows': 3,
'placeholder': '192.168.1.70\n192.168.1.71',
'class': _TEXTAREA,
}),
required=False,
label='Camera IPs (one per line)',
help_text='Local IP addresses of cameras to probe for unauthenticated RTSP.',
)
class Meta:
model = ScanProfile
fields = [
'name', 'enabled', 'schedule_interval',
'gateway_ip', 'public_ip', 'network_cidr',
'auth_provider_host',
'telegram_bot_token', 'telegram_chat_id', 'notify_on_severity',
]
widgets = {
'name': forms.TextInput(attrs={'class': _INPUT}),
'schedule_interval': forms.Select(attrs={'class': _INPUT}),
'gateway_ip': forms.TextInput(attrs={'class': _INPUT}),
'public_ip': forms.TextInput(attrs={'class': _INPUT}),
'network_cidr': forms.TextInput(attrs={'class': _INPUT}),
'auth_provider_host': forms.TextInput(attrs={'class': _INPUT}),
'telegram_bot_token': forms.PasswordInput(render_value=True, attrs={'class': _INPUT}),
'telegram_chat_id': forms.TextInput(attrs={'class': _INPUT}),
'notify_on_severity': forms.Select(attrs={'class': _INPUT}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
self.fields['domains_text'].initial = '\n'.join(self.instance.domains or [])
self.fields['cameras_text'].initial = '\n'.join(self.instance.cameras or [])
def clean_domains_text(self):
raw = self.cleaned_data.get('domains_text', '')
return [line.strip() for line in raw.splitlines() if line.strip()]
def clean_cameras_text(self):
raw = self.cleaned_data.get('cameras_text', '')
return [line.strip() for line in raw.splitlines() if line.strip()]
def save(self, commit=True):
instance = super().save(commit=False)
instance.domains = self.cleaned_data['domains_text']
instance.cameras = self.cleaned_data['cameras_text']
if commit:
instance.save()
return instance
+63
View File
@@ -0,0 +1,63 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ScanProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('enabled', models.BooleanField(default=True)),
('schedule_interval', models.IntegerField(choices=[(1, 'Every 1 day'), (3, 'Every 3 days'), (7, 'Every 7 days'), (30, 'Every 30 days')], default=7)),
('gateway_ip', models.GenericIPAddressField(help_text='e.g. 192.168.1.1')),
('public_ip', models.GenericIPAddressField(help_text='Your public/WAN IP address')),
('network_cidr', models.CharField(blank=True, help_text='e.g. 192.168.1.0/24', max_length=50)),
('auth_provider_host', models.CharField(blank=True, help_text='e.g. pass.junv.cc', max_length=255)),
('domains', models.JSONField(blank=True, default=list, help_text='List of public hostnames to check')),
('cameras', models.JSONField(blank=True, default=list, help_text='List of camera IPs to probe')),
('telegram_bot_token', models.CharField(blank=True, max_length=255)),
('telegram_chat_id', models.CharField(blank=True, max_length=100)),
('notify_on_severity', models.CharField(choices=[('warning', 'Warning and above'), ('critical', 'Critical only')], default='critical', max_length=20)),
('last_run_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='ScanRun',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('started_at', models.DateTimeField(auto_now_add=True)),
('finished_at', models.DateTimeField(blank=True, null=True)),
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('success', 'Success'), ('failed', 'Failed')], default='pending', max_length=20)),
('summary', models.JSONField(default=dict)),
('triggered_by', models.CharField(default='manual', max_length=20)),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='runs', to='netscan.scanprofile')),
],
options={
'ordering': ['-started_at'],
},
),
migrations.CreateModel(
name='ScanFinding',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('check_name', models.CharField(max_length=100)),
('severity', models.CharField(choices=[('ok', 'OK'), ('info', 'Info'), ('warning', 'Warning'), ('critical', 'Critical')], max_length=20)),
('title', models.CharField(max_length=255)),
('detail', models.TextField()),
('raw', models.JSONField(default=dict)),
('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='findings', to='netscan.scanrun')),
],
options={
'ordering': ['severity', 'check_name'],
},
),
]
@@ -0,0 +1,22 @@
from django.db import migrations
import netscan.fields
class Migration(migrations.Migration):
dependencies = [
('netscan', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='scanprofile',
name='telegram_bot_token',
field=netscan.fields.EncryptedCharField(blank=True),
),
migrations.AlterField(
model_name='scanprofile',
name='telegram_chat_id',
field=netscan.fields.EncryptedCharField(blank=True),
),
]
View File
+84
View File
@@ -0,0 +1,84 @@
from django.db import models
from .fields import EncryptedCharField
class ScanProfile(models.Model):
INTERVAL_CHOICES = [
(1, 'Every 1 day'),
(3, 'Every 3 days'),
(7, 'Every 7 days'),
(30, 'Every 30 days'),
]
SEVERITY_CHOICES = [
('warning', 'Warning and above'),
('critical', 'Critical only'),
]
name = models.CharField(max_length=200)
enabled = models.BooleanField(default=True)
schedule_interval = models.IntegerField(choices=INTERVAL_CHOICES, default=7)
gateway_ip = models.GenericIPAddressField(help_text='e.g. 192.168.1.1')
public_ip = models.GenericIPAddressField(help_text='Your public/WAN IP address')
network_cidr = models.CharField(max_length=50, blank=True, help_text='e.g. 192.168.1.0/24')
auth_provider_host = models.CharField(max_length=255, blank=True, help_text='e.g. pass.junv.cc')
domains = models.JSONField(default=list, blank=True, help_text='List of public hostnames to check')
cameras = models.JSONField(default=list, blank=True, help_text='List of camera IPs to probe')
telegram_bot_token = EncryptedCharField(blank=True)
telegram_chat_id = EncryptedCharField(blank=True)
notify_on_severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES, default='critical')
last_run_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class ScanRun(models.Model):
STATUS_CHOICES = [
('pending', 'Pending'),
('running', 'Running'),
('success', 'Success'),
('failed', 'Failed'),
]
profile = models.ForeignKey(ScanProfile, on_delete=models.CASCADE, related_name='runs')
started_at = models.DateTimeField(auto_now_add=True)
finished_at = models.DateTimeField(null=True, blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
summary = models.JSONField(default=dict)
triggered_by = models.CharField(max_length=20, default='manual')
class Meta:
ordering = ['-started_at']
def __str__(self):
return f'{self.profile.name} run #{self.pk} ({self.status})'
@property
def duration_seconds(self):
if self.finished_at and self.started_at:
return int((self.finished_at - self.started_at).total_seconds())
return None
class ScanFinding(models.Model):
SEVERITY_CHOICES = [
('ok', 'OK'),
('info', 'Info'),
('warning', 'Warning'),
('critical', 'Critical'),
]
run = models.ForeignKey(ScanRun, on_delete=models.CASCADE, related_name='findings')
check_name = models.CharField(max_length=100)
severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES)
title = models.CharField(max_length=255)
detail = models.TextField()
raw = models.JSONField(default=dict)
class Meta:
ordering = ['severity', 'check_name']
def __str__(self):
return f'[{self.severity.upper()}] {self.title}'
+60
View File
@@ -0,0 +1,60 @@
import logging
import requests
logger = logging.getLogger(__name__)
SEVERITY_ORDER = ['ok', 'info', 'warning', 'critical']
SEVERITY_ICONS = {'critical': '🔴', 'warning': '🟡', 'ok': '🟢', 'info': ''}
def notify_telegram(profile, run, findings):
"""
Send a Telegram message if any finding meets or exceeds notify_on_severity.
"""
threshold_idx = SEVERITY_ORDER.index(profile.notify_on_severity)
flagged = [f for f in findings if SEVERITY_ORDER.index(f.severity) >= threshold_idx]
if not flagged:
return
finished_str = run.finished_at.strftime('%Y-%m-%d %H:%M') if run.finished_at else 'unknown'
lines = [
f'🔒 *NetScan Alert* — {profile.name}',
f'Run \\#{run.pk} finished at {finished_str}',
f'Summary: {run.summary}',
'',
]
for f in flagged[:10]:
icon = SEVERITY_ICONS.get(f.severity, '')
lines.append(f'{icon} *{f.title}*\n {f.detail[:120]}')
if len(flagged) > 10:
lines.append(f'_...and {len(flagged) - 10} more findings_')
text = '\n'.join(lines)
url = f'https://api.telegram.org/bot{profile.telegram_bot_token}/sendMessage'
resp = requests.post(url, json={
'chat_id': profile.telegram_chat_id,
'text': text,
'parse_mode': 'Markdown',
}, timeout=10)
resp.raise_for_status()
logger.info(f'Telegram notification sent for run #{run.pk}')
def send_test_telegram(profile) -> dict:
"""Send a test message. Returns {'ok': True} or {'ok': False, 'error': str}."""
if not profile.telegram_bot_token or not profile.telegram_chat_id:
return {'ok': False, 'error': 'Telegram bot token or chat ID not configured.'}
try:
url = f'https://api.telegram.org/bot{profile.telegram_bot_token}/sendMessage'
resp = requests.post(url, json={
'chat_id': profile.telegram_chat_id,
'text': f'✅ *NetScan test message* from profile _{profile.name}_. Notifications are working.',
'parse_mode': 'Markdown',
}, timeout=10)
resp.raise_for_status()
return {'ok': True}
except Exception as e:
return {'ok': False, 'error': str(e)}
+74
View File
@@ -0,0 +1,74 @@
import logging
from collections import Counter
from django.utils.timezone import now
from .models import ScanProfile, ScanRun, ScanFinding
from .checks import router, dns, ingress, cameras, tls, ports
from .checks.base import Finding
from .notifications import notify_telegram
logger = logging.getLogger(__name__)
CHECK_MODULES = [router, dns, ingress, cameras, tls, ports]
def run_scan(profile_id: int, triggered_by: str = 'scheduler') -> int:
"""
Run all checks for the given profile. Returns the ScanRun PK.
Called by APScheduler jobs and TriggerScanView.
"""
profile = ScanProfile.objects.get(pk=profile_id)
run = ScanRun.objects.create(
profile=profile,
status='running',
triggered_by=triggered_by,
)
logger.info(f'Starting netscan run #{run.pk} for profile "{profile.name}" (triggered_by={triggered_by})')
all_findings: list[Finding] = []
for mod in CHECK_MODULES:
mod_name = mod.__name__.split('.')[-1]
try:
findings = mod.run(profile)
all_findings.extend(findings)
logger.debug(f' {mod_name}: {len(findings)} findings')
except Exception as e:
logger.exception(f' {mod_name}: uncaught exception')
all_findings.append(Finding(
check_name=mod_name,
severity='warning',
title=f'{mod_name}: check errored',
detail=str(e),
raw={'exception': str(e)},
))
ScanFinding.objects.bulk_create([
ScanFinding(
run=run,
check_name=f.check_name,
severity=f.severity,
title=f.title,
detail=f.detail,
raw=f.raw,
)
for f in all_findings
])
summary = dict(Counter(f.severity for f in all_findings))
run.summary = summary
run.status = 'success'
run.finished_at = now()
run.save()
profile.last_run_at = now()
profile.save(update_fields=['last_run_at'])
if profile.telegram_bot_token and profile.telegram_chat_id:
try:
notify_telegram(profile, run, all_findings)
except Exception as e:
logger.warning(f'Telegram notification failed: {e}')
logger.info(f'Finished netscan run #{run.pk}: {summary}')
return run.pk
+17
View File
@@ -0,0 +1,17 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
@receiver(post_save, sender='netscan.ScanProfile')
def reschedule_on_save(sender, instance, **kwargs):
from netscan.tasks import schedule_profile, unschedule_profile
if instance.enabled:
schedule_profile(instance)
else:
unschedule_profile(instance)
@receiver(post_delete, sender='netscan.ScanProfile')
def unschedule_on_delete(sender, instance, **kwargs):
from netscan.tasks import unschedule_profile
unschedule_profile(instance)
+25
View File
@@ -0,0 +1,25 @@
import logging
from apscheduler.triggers.interval import IntervalTrigger
from core.scheduler import scheduler
from netscan.scanner import run_scan
logger = logging.getLogger(__name__)
def schedule_profile(profile):
job_id = f'netscan_profile_{profile.pk}'
scheduler.add_job(
run_scan,
trigger=IntervalTrigger(days=profile.schedule_interval),
id=job_id,
args=[profile.pk, 'scheduler'],
replace_existing=True,
)
logger.info(f'Scheduled netscan job {job_id} every {profile.schedule_interval} day(s)')
def unschedule_profile(profile):
job_id = f'netscan_profile_{profile.pk}'
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
logger.info(f'Removed netscan job {job_id}')
+133
View File
@@ -0,0 +1,133 @@
{% extends 'base.html' %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="bg-white shadow-md rounded-lg overflow-hidden">
<!-- Header -->
<div class="px-4 py-5 sm:px-6 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-shield-alt mr-3 text-red-500"></i>
NetScan
</h1>
<p class="text-sm text-gray-500 mt-0.5">Home network security scanner</p>
</div>
<a href="{% url 'netscan-profile-create' %}"
class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
<i class="fas fa-plus mr-2"></i>
New Profile
</a>
</div>
{% if not profile_data %}
<!-- Empty state -->
<div class="text-center py-20">
<div class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-shield-alt text-red-500 text-2xl"></i>
</div>
<h2 class="text-lg font-semibold text-gray-700 mb-2">No scan profiles yet</h2>
<p class="text-gray-500 text-sm mb-6">Create your first scan profile to start monitoring your home network.</p>
<a href="{% url 'netscan-profile-create' %}"
class="inline-flex items-center px-5 py-2.5 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700">
<i class="fas fa-plus mr-2"></i>
Create your first scan profile
</a>
</div>
{% else %}
<!-- Profiles table -->
<table class="min-w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Profile</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide hidden sm:table-cell">Network</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide hidden md:table-cell">Last Run</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide hidden sm:table-cell">Findings</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wide">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for item in profile_data %}
{% with p=item.profile run=item.last_run worst=item.worst_severity %}
<tr class="hover:bg-gray-50 transition-colors">
<!-- Profile name + badges -->
<td class="px-4 py-4">
<div class="font-semibold text-gray-900 flex items-center gap-2">
{% if worst %}
{% if worst == 'critical' %}🔴{% elif worst == 'warning' %}🟡{% else %}🟢{% endif %}
{% endif %}
{{ p.name }}
</div>
<div class="flex flex-wrap gap-1 mt-1">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{% if p.enabled %}bg-green-100 text-green-700{% else %}bg-gray-100 text-gray-500{% endif %}">
{% if p.enabled %}Enabled{% else %}Disabled{% endif %}
</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-600">
Every {{ p.schedule_interval }} day{{ p.schedule_interval|pluralize }}
</span>
</div>
</td>
<!-- Network info -->
<td class="px-4 py-4 hidden sm:table-cell">
<div class="text-gray-700 font-mono text-xs">{{ p.gateway_ip }}</div>
<div class="text-gray-400 font-mono text-xs">{{ p.public_ip }}</div>
</td>
<!-- Last run -->
<td class="px-4 py-4 hidden md:table-cell text-gray-500 text-xs">
{% if p.last_run_at %}{{ p.last_run_at|date:"M d, H:i" }}{% else %}<span class="text-gray-400">Never</span>{% endif %}
</td>
<!-- Findings -->
<td class="px-4 py-4 hidden sm:table-cell">
{% if run %}
<div class="flex items-center gap-2">
{% if run.summary.critical %}<span class="text-red-600 font-medium text-xs">🔴{{ run.summary.critical }}</span>{% endif %}
{% if run.summary.warning %}<span class="text-yellow-600 font-medium text-xs">🟡{{ run.summary.warning }}</span>{% endif %}
{% if run.summary.ok %}<span class="text-green-600 font-medium text-xs">🟢{{ run.summary.ok }}</span>{% endif %}
</div>
{% else %}
<span class="text-gray-300 text-xs"></span>
{% endif %}
</td>
<!-- Actions -->
<td class="px-4 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<form method="post" action="{% url 'netscan-trigger' p.pk %}">
{% csrf_token %}
<button type="submit"
class="inline-flex items-center px-3 py-1.5 bg-red-600 text-white text-xs rounded-md hover:bg-red-700 font-medium">
<i class="fas fa-play mr-1.5"></i> Run
</button>
</form>
<a href="{% url 'netscan-run-list' p.pk %}"
class="inline-flex items-center px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded-md hover:bg-gray-200">
<i class="fas fa-history mr-1.5"></i> History
</a>
<a href="{% url 'netscan-profile-edit' p.pk %}"
class="inline-flex items-center px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded-md hover:bg-gray-200">
<i class="fas fa-edit mr-1.5"></i> Edit
</a>
<a href="{% url 'netscan-profile-delete' p.pk %}"
class="inline-flex items-center px-2 py-1.5 bg-red-50 text-red-500 text-xs rounded-md hover:bg-red-100">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,29 @@
{% extends 'base.html' %}
{% block content %}
<div class="max-w-lg mx-auto px-4 py-12">
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 text-center">
<div class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-trash text-red-600 text-xl"></i>
</div>
<h1 class="text-xl font-bold text-gray-900 mb-2">Delete Scan Profile</h1>
<p class="text-gray-600 mb-6">
Are you sure you want to delete <strong>{{ object.name }}</strong>?
All scan runs and findings for this profile will be permanently deleted.
</p>
<form method="post">
{% csrf_token %}
<div class="flex justify-center gap-3">
<button type="submit"
class="px-6 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium">
Delete
</button>
<a href="{% url 'netscan-dashboard' %}"
class="px-6 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 font-medium">
Cancel
</a>
</div>
</form>
</div>
</div>
{% endblock %}
+234
View File
@@ -0,0 +1,234 @@
{% extends 'base.html' %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="bg-white shadow-md rounded-lg overflow-hidden">
<!-- Page header -->
<div class="px-4 py-5 sm:px-6 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-shield-alt mr-3 text-red-500"></i>
{{ form_title }}
</h1>
<a href="{% url 'netscan-dashboard' %}" class="text-sm text-gray-500 hover:text-gray-700">
<i class="fas fa-arrow-left mr-1"></i> Back
</a>
</div>
<form method="post" id="profile-form" class="px-4 py-5 sm:p-6 space-y-8">
{% csrf_token %}
<!-- General -->
<div>
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">General</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Name (full width) -->
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_name">Name</label>
{{ form.name }}
{% for error in form.name.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
</div>
<!-- Enabled -->
<div class="flex items-center gap-2">
{{ form.enabled }}
<label class="text-sm font-medium text-gray-700" for="id_enabled">Enabled</label>
</div>
<!-- Schedule interval -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_schedule_interval">Scan interval</label>
{{ form.schedule_interval }}
{% for error in form.schedule_interval.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
</div>
</div>
</div>
<hr class="border-gray-100">
<!-- Network -->
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Network</h2>
<button type="button" id="auto-detect-btn"
class="inline-flex items-center px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 text-sm font-medium border border-blue-200">
<i class="fas fa-magic mr-2"></i>
Auto-detect
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_gateway_ip">Gateway IP</label>
{{ form.gateway_ip }}
{% for error in form.gateway_ip.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_public_ip">Public IP</label>
{{ form.public_ip }}
{% for error in form.public_ip.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_network_cidr">Network CIDR</label>
{{ form.network_cidr }}
{% if form.network_cidr.help_text %}<p class="text-xs text-gray-400 mt-1">{{ form.network_cidr.help_text }}</p>{% endif %}
{% for error in form.network_cidr.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_auth_provider_host">Auth provider host</label>
{{ form.auth_provider_host }}
{% if form.auth_provider_host.help_text %}<p class="text-xs text-gray-400 mt-1">{{ form.auth_provider_host.help_text }}</p>{% endif %}
{% for error in form.auth_provider_host.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_domains_text">Domains</label>
{{ form.domains_text }}
{% if form.domains_text.help_text %}<p class="text-xs text-gray-400 mt-1">{{ form.domains_text.help_text }}</p>{% endif %}
{% for error in form.domains_text.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_cameras_text">Camera IPs</label>
{{ form.cameras_text }}
{% if form.cameras_text.help_text %}<p class="text-xs text-gray-400 mt-1">{{ form.cameras_text.help_text }}</p>{% endif %}
{% for error in form.cameras_text.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
</div>
</div>
</div>
<hr class="border-gray-100">
<!-- Telegram Notifications -->
<div>
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Telegram Notifications</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_telegram_bot_token">Bot token</label>
{{ form.telegram_bot_token }}
{% for error in form.telegram_bot_token.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_telegram_chat_id">Chat ID</label>
{{ form.telegram_chat_id }}
{% for error in form.telegram_chat_id.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_notify_on_severity">Notify on severity</label>
{{ form.notify_on_severity }}
{% for error in form.notify_on_severity.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
</div>
</div>
{% if object.pk %}
<div class="mt-4">
<button type="button" id="test-telegram-btn"
class="inline-flex items-center px-4 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 text-sm font-medium border border-blue-200">
<i class="fas fa-paper-plane mr-2"></i>
Test Telegram
</button>
<span id="test-telegram-result" class="ml-3 text-sm hidden"></span>
</div>
{% endif %}
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2 border-t border-gray-100">
<a href="{% url 'netscan-dashboard' %}"
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-gray-700 bg-gray-200 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
Cancel
</a>
<button type="submit"
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
Save Profile
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.getElementById('auto-detect-btn').addEventListener('click', function () {
const btn = this;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Detecting...';
fetch("{% url 'netscan-detect-network' %}")
.then(r => r.json())
.then(data => {
if (data.gateway_ip) document.getElementById('id_gateway_ip').value = data.gateway_ip;
if (data.public_ip) document.getElementById('id_public_ip').value = data.public_ip;
if (data.network_cidr) document.getElementById('id_network_cidr').value = data.network_cidr;
btn.innerHTML = '<i class="fas fa-check mr-2"></i>Detected!';
btn.classList.replace('text-blue-700', 'text-green-700');
btn.classList.replace('bg-blue-50', 'bg-green-50');
btn.classList.replace('border-blue-200', 'border-green-200');
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-magic mr-2"></i>Auto-detect';
btn.classList.replace('text-green-700', 'text-blue-700');
btn.classList.replace('bg-green-50', 'bg-blue-50');
btn.classList.replace('border-green-200', 'border-blue-200');
}, 3000);
})
.catch(() => {
btn.innerHTML = '<i class="fas fa-exclamation-triangle mr-2"></i>Failed';
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-magic mr-2"></i>Auto-detect';
}, 3000);
});
});
</script>
{% if object.pk %}
<script>
document.getElementById('test-telegram-btn').addEventListener('click', function () {
const btn = this;
const result = document.getElementById('test-telegram-result');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Sending...';
result.className = 'ml-3 text-sm hidden';
fetch("{% url 'netscan-test-telegram' object.pk %}", {
method: 'POST',
headers: {'X-CSRFToken': '{{ csrf_token }}'},
})
.then(r => r.json())
.then(data => {
result.classList.remove('hidden');
if (data.ok) {
result.className = 'ml-3 text-sm text-green-600';
result.textContent = '✓ Test message sent!';
} else {
result.className = 'ml-3 text-sm text-red-600';
result.textContent = '✗ ' + (data.error || 'Failed');
}
})
.catch(() => {
result.classList.remove('hidden');
result.className = 'ml-3 text-sm text-red-600';
result.textContent = '✗ Network error';
})
.finally(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>Test Telegram';
});
});
</script>
{% endif %}
{% endblock %}
+147
View File
@@ -0,0 +1,147 @@
{% extends 'base.html' %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- Breadcrumb -->
<div class="flex items-center gap-2 text-sm text-gray-400 mb-4">
<a href="{% url 'netscan-dashboard' %}" class="hover:text-gray-600">NetScan</a>
<span>/</span>
<a href="{% url 'netscan-run-list' run.profile.pk %}" class="hover:text-gray-600">{{ run.profile.name }}</a>
<span>/</span>
<span class="text-gray-600">Run #{{ run.pk }}</span>
</div>
<!-- Header -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 mb-6">
<div class="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 class="text-xl font-bold text-gray-900">{{ run.profile.name }} — Run #{{ run.pk }}</h1>
<p class="text-gray-500 text-sm mt-1">
Started {{ run.started_at|date:"N j, Y H:i:s" }}
{% if run.duration_seconds is not None %}· {{ run.duration_seconds }}s{% endif %}
· Triggered by <strong>{{ run.triggered_by }}</strong>
</p>
</div>
<div class="flex items-center gap-3">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
{% if run.status == 'success' %}bg-green-100 text-green-700
{% elif run.status == 'failed' %}bg-red-100 text-red-700
{% elif run.status == 'running' %}bg-blue-100 text-blue-700
{% else %}bg-gray-100 text-gray-600{% endif %}">
{{ run.status }}
</span>
<form method="post" action="{% url 'netscan-trigger' run.profile.pk %}">
{% csrf_token %}
<button type="submit"
class="inline-flex items-center px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium text-sm">
<i class="fas fa-redo mr-1.5"></i> Re-run
</button>
</form>
</div>
</div>
<!-- Summary badges -->
<div class="flex flex-wrap gap-3 mt-4 pt-4 border-t border-gray-100">
{% if run.summary.critical %}
<div class="flex items-center gap-1.5 bg-red-50 text-red-700 px-3 py-1.5 rounded-lg text-sm font-semibold">
🔴 {{ run.summary.critical }} Critical
</div>
{% endif %}
{% if run.summary.warning %}
<div class="flex items-center gap-1.5 bg-yellow-50 text-yellow-700 px-3 py-1.5 rounded-lg text-sm font-semibold">
🟡 {{ run.summary.warning }} Warning
</div>
{% endif %}
{% if run.summary.ok %}
<div class="flex items-center gap-1.5 bg-green-50 text-green-700 px-3 py-1.5 rounded-lg text-sm font-semibold">
🟢 {{ run.summary.ok }} OK
</div>
{% endif %}
{% if run.summary.info %}
<div class="flex items-center gap-1.5 bg-blue-50 text-blue-700 px-3 py-1.5 rounded-lg text-sm font-semibold">
️ {{ run.summary.info }} Info
</div>
{% endif %}
</div>
</div>
<!-- Critical -->
{% if critical_findings %}
<details class="mb-4 open" open>
<summary class="flex items-center gap-2 cursor-pointer bg-red-50 border border-red-200 rounded-xl px-5 py-3 font-semibold text-red-800 select-none">
🔴 Critical Findings ({{ critical_findings|length }})
</summary>
<div class="mt-2 space-y-3">
{% for f in critical_findings %}
<div class="bg-white border border-red-200 rounded-xl p-4">
<div class="font-medium text-gray-900 mb-1">{{ f.title }}</div>
<p class="text-sm text-gray-600">{{ f.detail }}</p>
{% if f.raw %}
<details class="mt-2">
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-600">View raw data</summary>
<pre class="mt-2 bg-gray-50 rounded-lg p-3 text-xs overflow-auto text-gray-700">{{ f.raw|pprint }}</pre>
</details>
{% endif %}
</div>
{% endfor %}
</div>
</details>
{% endif %}
<!-- Warnings -->
{% if warning_findings %}
<details class="mb-4">
<summary class="flex items-center gap-2 cursor-pointer bg-yellow-50 border border-yellow-200 rounded-xl px-5 py-3 font-semibold text-yellow-800 select-none">
🟡 Warnings ({{ warning_findings|length }})
</summary>
<div class="mt-2 space-y-3">
{% for f in warning_findings %}
<div class="bg-white border border-yellow-200 rounded-xl p-4">
<div class="font-medium text-gray-900 mb-1">{{ f.title }}</div>
<p class="text-sm text-gray-600">{{ f.detail }}</p>
{% if f.raw %}
<details class="mt-2">
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-600">View raw data</summary>
<pre class="mt-2 bg-gray-50 rounded-lg p-3 text-xs overflow-auto text-gray-700">{{ f.raw|pprint }}</pre>
</details>
{% endif %}
</div>
{% endfor %}
</div>
</details>
{% endif %}
<!-- OK / Info -->
{% if ok_findings %}
<details class="mb-4">
<summary class="flex items-center gap-2 cursor-pointer bg-green-50 border border-green-200 rounded-xl px-5 py-3 font-semibold text-green-800 select-none">
🟢 OK / Info ({{ ok_findings|length }})
</summary>
<div class="mt-2 space-y-3">
{% for f in ok_findings %}
<div class="bg-white border border-gray-200 rounded-xl p-4">
<div class="flex items-center gap-2 font-medium text-gray-900 mb-1">
{% if f.severity == 'info' %}️{% else %}🟢{% endif %}
{{ f.title }}
</div>
<p class="text-sm text-gray-600">{{ f.detail }}</p>
{% if f.raw %}
<details class="mt-2">
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-600">View raw data</summary>
<pre class="mt-2 bg-gray-50 rounded-lg p-3 text-xs overflow-auto text-gray-700">{{ f.raw|pprint }}</pre>
</details>
{% endif %}
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if not critical_findings and not warning_findings and not ok_findings %}
<div class="text-center py-12 text-gray-400">
<i class="fas fa-hourglass-half text-3xl mb-2"></i>
<p>No findings recorded yet — the scan may still be running.</p>
</div>
{% endif %}
</div>
{% endblock %}
+98
View File
@@ -0,0 +1,98 @@
{% extends 'base.html' %}
{% block content %}
<div class="max-w-5xl mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-6">
<div>
<a href="{% url 'netscan-dashboard' %}" class="text-gray-400 hover:text-gray-600 text-sm">
<i class="fas fa-arrow-left mr-1"></i> NetScan
</a>
<h1 class="text-2xl font-bold text-gray-900 mt-1">{{ profile.name }} — Scan History</h1>
</div>
<form method="post" action="{% url 'netscan-trigger' profile.pk %}">
{% csrf_token %}
<button type="submit"
class="inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium text-sm">
<i class="fas fa-play mr-2"></i> Run Now
</button>
</form>
</div>
{% if runs %}
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<table class="min-w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-600">Started</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600">Duration</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600">Triggered by</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600">Status</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600">Findings</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{% for run in runs %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-gray-800 font-mono text-xs">{{ run.started_at|date:"M d, H:i:s" }}</td>
<td class="px-4 py-3 text-gray-500">
{% if run.duration_seconds is not None %}{{ run.duration_seconds }}s{% else %}—{% endif %}
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{% if run.triggered_by == 'manual' %}bg-blue-50 text-blue-700{% else %}bg-gray-100 text-gray-600{% endif %}">
{{ run.triggered_by }}
</span>
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{% if run.status == 'success' %}bg-green-100 text-green-700
{% elif run.status == 'failed' %}bg-red-100 text-red-700
{% elif run.status == 'running' %}bg-blue-100 text-blue-700
{% else %}bg-gray-100 text-gray-600{% endif %}">
{{ run.status }}
</span>
</td>
<td class="px-4 py-3">
<span class="flex items-center gap-2">
{% if run.summary.critical %}<span class="text-red-600 font-medium">🔴{{ run.summary.critical }}</span>{% endif %}
{% if run.summary.warning %}<span class="text-yellow-600 font-medium">🟡{{ run.summary.warning }}</span>{% endif %}
{% if run.summary.ok %}<span class="text-green-600 font-medium">🟢{{ run.summary.ok }}</span>{% endif %}
{% if run.summary.info %}<span class="text-blue-600 font-medium">️{{ run.summary.info }}</span>{% endif %}
</span>
</td>
<td class="px-4 py-3">
<a href="{% url 'netscan-run-detail' run.pk %}"
class="text-blue-600 hover:text-blue-800 text-xs font-medium">View →</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if is_paginated %}
<div class="flex justify-center mt-6 gap-2">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}"
class="px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm hover:bg-gray-50">← Prev</a>
{% endif %}
<span class="px-3 py-1.5 text-sm text-gray-600">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}"
class="px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm hover:bg-gray-50">Next →</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="text-center py-16 text-gray-400">
<i class="fas fa-history text-4xl mb-3"></i>
<p>No scan runs yet. Click <strong>Run Now</strong> to start.</p>
</div>
{% endif %}
</div>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.DashboardView.as_view(), name='netscan-dashboard'),
path('profile/new/', views.ProfileCreateView.as_view(), name='netscan-profile-create'),
path('profile/<int:pk>/edit/', views.ProfileUpdateView.as_view(), name='netscan-profile-edit'),
path('profile/<int:pk>/delete/', views.ProfileDeleteView.as_view(), name='netscan-profile-delete'),
path('profile/<int:pk>/runs/', views.ScanRunListView.as_view(), name='netscan-run-list'),
path('profile/<int:pk>/trigger/', views.TriggerScanView.as_view(), name='netscan-trigger'),
path('profile/<int:pk>/test-telegram/', views.TestTelegramView.as_view(), name='netscan-test-telegram'),
path('detect-network/', views.DetectNetworkView.as_view(), name='netscan-detect-network'),
path('run/<int:pk>/', views.ScanRunDetailView.as_view(), name='netscan-run-detail'),
]
+215
View File
@@ -0,0 +1,215 @@
import json
import socket
import platform
import subprocess
import ipaddress
import threading
import logging
import requests as http_requests
from django.views.generic import TemplateView, CreateView, UpdateView, DeleteView, ListView, DetailView, View
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy, reverse
from django.http import JsonResponse
from .models import ScanProfile, ScanRun, ScanFinding
from .forms import ScanProfileForm
from .scanner import run_scan
from .notifications import send_test_telegram
logger = logging.getLogger(__name__)
SEVERITY_ORDER = ['critical', 'warning', 'info', 'ok']
def _worst_severity(summary: dict) -> str:
for s in SEVERITY_ORDER:
if summary.get(s, 0) > 0:
return s
return 'ok'
def _detect_gateway() -> str | None:
try:
if platform.system() == 'Linux':
r = subprocess.run(['ip', 'route', 'show', 'default'], capture_output=True, text=True, timeout=5)
for line in r.stdout.splitlines():
if 'default' in line and 'via' in line:
parts = line.split()
return parts[parts.index('via') + 1]
else:
r = subprocess.run(['netstat', '-rn'], capture_output=True, text=True, timeout=5)
for line in r.stdout.splitlines():
if line.startswith('default') or line.startswith('0.0.0.0'):
parts = line.split()
if len(parts) >= 2:
return parts[1]
except Exception:
pass
return None
def _detect_local_ip() -> str | None:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return None
def _detect_subnet_mask(local_ip: str) -> str | None:
"""Try to get the real subnet mask from the OS, fallback to /24."""
try:
if platform.system() == 'Linux':
r = subprocess.run(['ip', 'addr', 'show'], capture_output=True, text=True, timeout=5)
for line in r.stdout.splitlines():
line = line.strip()
if line.startswith('inet ') and local_ip in line:
cidr_part = line.split()[1]
net = ipaddress.IPv4Network(cidr_part, strict=False)
return str(net)
else:
r = subprocess.run(['ifconfig'], capture_output=True, text=True, timeout=5)
lines = r.stdout.splitlines()
for i, line in enumerate(lines):
if local_ip in line:
for detail in lines[i:i + 3]:
if 'netmask' in detail.lower():
parts = detail.split()
try:
mask_idx = [p.lower() for p in parts].index('netmask')
mask = parts[mask_idx + 1]
# macOS outputs hex netmask like 0xffffff00
if mask.startswith('0x'):
mask = socket.inet_ntoa(int(mask, 16).to_bytes(4, 'big'))
net = ipaddress.IPv4Network(f'{local_ip}/{mask}', strict=False)
return str(net)
except (ValueError, IndexError):
pass
except Exception:
pass
# fallback to /24
try:
net = ipaddress.IPv4Network(f'{local_ip}/24', strict=False)
return str(net)
except Exception:
return None
def _detect_public_ip() -> str | None:
for url in ['https://api.ipify.org', 'https://icanhazip.com', 'https://checkip.amazonaws.com']:
try:
resp = http_requests.get(url, timeout=5)
ip = resp.text.strip()
ipaddress.ip_address(ip) # validate
return ip
except Exception:
continue
return None
class DashboardView(TemplateView):
template_name = 'netscan/dashboard.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profiles = ScanProfile.objects.all()
profile_data = []
for p in profiles:
last_run = p.runs.first()
worst = _worst_severity(last_run.summary) if last_run else None
profile_data.append({
'profile': p,
'last_run': last_run,
'worst_severity': worst,
})
ctx['profile_data'] = profile_data
return ctx
class ProfileCreateView(CreateView):
model = ScanProfile
form_class = ScanProfileForm
template_name = 'netscan/profile_form.html'
success_url = reverse_lazy('netscan-dashboard')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['form_title'] = 'Create Scan Profile'
return ctx
class ProfileUpdateView(UpdateView):
model = ScanProfile
form_class = ScanProfileForm
template_name = 'netscan/profile_form.html'
success_url = reverse_lazy('netscan-dashboard')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['form_title'] = f'Edit: {self.object.name}'
return ctx
class ProfileDeleteView(DeleteView):
model = ScanProfile
template_name = 'netscan/profile_confirm_delete.html'
success_url = reverse_lazy('netscan-dashboard')
class ScanRunListView(ListView):
template_name = 'netscan/run_list.html'
context_object_name = 'runs'
paginate_by = 20
def get_queryset(self):
self.profile = get_object_or_404(ScanProfile, pk=self.kwargs['pk'])
return ScanRun.objects.filter(profile=self.profile)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['profile'] = self.profile
return ctx
class ScanRunDetailView(DetailView):
model = ScanRun
template_name = 'netscan/run_detail.html'
context_object_name = 'run'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
findings = self.object.findings.all()
ctx['critical_findings'] = findings.filter(severity='critical')
ctx['warning_findings'] = findings.filter(severity='warning')
ctx['ok_findings'] = findings.filter(severity__in=['ok', 'info'])
return ctx
class TriggerScanView(View):
def post(self, request, pk):
profile = get_object_or_404(ScanProfile, pk=pk)
t = threading.Thread(target=run_scan, args=[profile.pk, 'manual'], daemon=True)
t.start()
return redirect(reverse('netscan-run-list', kwargs={'pk': profile.pk}))
class TestTelegramView(View):
def post(self, request, pk):
profile = get_object_or_404(ScanProfile, pk=pk)
result = send_test_telegram(profile)
return JsonResponse(result)
class DetectNetworkView(View):
def get(self, request):
local_ip = _detect_local_ip()
data = {
'gateway_ip': _detect_gateway(),
'local_ip': local_ip,
'network_cidr': _detect_subnet_mask(local_ip) if local_ip else None,
'public_ip': _detect_public_ip(),
}
return JsonResponse(data)
+1
View File
@@ -24,6 +24,7 @@ dependencies = [
"pillow~=12.1.1",
"whoosh==2.7.4",
"qdrant-client>=1.13.2",
"cryptography>=42.0.0",
]
[build-system]
+10
View File
@@ -129,6 +129,16 @@
</div>
</a>
<a href="{% url 'netscan-dashboard' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
{% trans "NetScan" %}
</div>
</a>
<a href="{% url 'mini-apps-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Generated
+121
View File
@@ -123,6 +123,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.1"
@@ -179,6 +236,59 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
]
[[package]]
name = "django"
version = "5.2.11"
@@ -467,6 +577,7 @@ dependencies = [
{ name = "asgiref" },
{ name = "beautifulsoup4" },
{ name = "boto3" },
{ name = "cryptography" },
{ name = "django" },
{ name = "django-simplemde" },
{ name = "django-tailwind" },
@@ -501,6 +612,7 @@ requires-dist = [
{ name = "beautifulsoup4", specifier = "==4.12.3" },
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0" },
{ name = "boto3", specifier = ">=1.35.0" },
{ name = "cryptography", specifier = ">=42.0.0" },
{ name = "django", specifier = ">=5.2.9" },
{ name = "django-simplemde", specifier = "==0.1.4" },
{ name = "django-tailwind", specifier = "==3.8.0" },
@@ -772,6 +884,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284, upload-time = "2024-08-04T20:26:53.173Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"