From 551fdf5cbb35680ff001b7ca37621eec0cbafb56 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 16:41:14 +1100 Subject: [PATCH] Add network scan --- NETSCAN_PLAN.md | 429 ++++++++++++++++++ core/settings.py | 1 + core/urls.py | 3 + data/db.sqlite3 | Bin 479232 -> 479232 bytes links/mini_apps_views.py | 2 +- memory/MEMORY.md | 18 + netscan/__init__.py | 0 netscan/admin.py | 21 + netscan/apps.py | 20 + netscan/checks/__init__.py | 0 netscan/checks/base.py | 10 + netscan/checks/cameras.py | 117 +++++ netscan/checks/dns.py | 86 ++++ netscan/checks/ingress.py | 93 ++++ netscan/checks/ports.py | 58 +++ netscan/checks/router.py | 117 +++++ netscan/checks/tls.py | 131 ++++++ netscan/fields.py | 39 ++ netscan/forms.py | 73 +++ netscan/migrations/0001_initial.py | 63 +++ .../0002_encrypt_telegram_fields.py | 22 + netscan/migrations/__init__.py | 0 netscan/models.py | 84 ++++ netscan/notifications.py | 60 +++ netscan/scanner.py | 74 +++ netscan/signals.py | 17 + netscan/tasks.py | 25 + netscan/templates/netscan/dashboard.html | 133 ++++++ .../netscan/profile_confirm_delete.html | 29 ++ netscan/templates/netscan/profile_form.html | 234 ++++++++++ netscan/templates/netscan/run_detail.html | 147 ++++++ netscan/templates/netscan/run_list.html | 98 ++++ netscan/urls.py | 14 + netscan/views.py | 215 +++++++++ pyproject.toml | 1 + templates/base.html | 10 + uv.lock | 121 +++++ 37 files changed, 2564 insertions(+), 1 deletion(-) create mode 100644 NETSCAN_PLAN.md create mode 100644 memory/MEMORY.md create mode 100644 netscan/__init__.py create mode 100644 netscan/admin.py create mode 100644 netscan/apps.py create mode 100644 netscan/checks/__init__.py create mode 100644 netscan/checks/base.py create mode 100644 netscan/checks/cameras.py create mode 100644 netscan/checks/dns.py create mode 100644 netscan/checks/ingress.py create mode 100644 netscan/checks/ports.py create mode 100644 netscan/checks/router.py create mode 100644 netscan/checks/tls.py create mode 100644 netscan/fields.py create mode 100644 netscan/forms.py create mode 100644 netscan/migrations/0001_initial.py create mode 100644 netscan/migrations/0002_encrypt_telegram_fields.py create mode 100644 netscan/migrations/__init__.py create mode 100644 netscan/models.py create mode 100644 netscan/notifications.py create mode 100644 netscan/scanner.py create mode 100644 netscan/signals.py create mode 100644 netscan/tasks.py create mode 100644 netscan/templates/netscan/dashboard.html create mode 100644 netscan/templates/netscan/profile_confirm_delete.html create mode 100644 netscan/templates/netscan/profile_form.html create mode 100644 netscan/templates/netscan/run_detail.html create mode 100644 netscan/templates/netscan/run_list.html create mode 100644 netscan/urls.py create mode 100644 netscan/views.py diff --git a/NETSCAN_PLAN.md b/NETSCAN_PLAN.md new file mode 100644 index 0000000..bfe2219 --- /dev/null +++ b/NETSCAN_PLAN.md @@ -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//edit/` | ModelForm | +| `ProfileDeleteView` | `/ui/netscan/profile//delete/` | Confirm page | +| `ScanRunListView` | `/ui/netscan/profile//runs/` | Paginated history, status + severity count cols | +| `ScanRunDetailView` | `/ui/netscan/run//` | Findings grouped by severity, collapsible raw JSON | +| `TriggerScanView` | `/ui/netscan/profile//trigger/` | POST-only; spawns `Thread(target=run_scan, args=[pk])`, redirects to run list | +| `TestTelegramView` | `/ui/netscan/profile//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//edit/', ProfileUpdateView.as_view(), name='netscan-profile-edit'), + path('profile//delete/', ProfileDeleteView.as_view(), name='netscan-profile-delete'), + path('profile//runs/', ScanRunListView.as_view(), name='netscan-run-list'), + path('profile//trigger/', TriggerScanView.as_view(), name='netscan-trigger'), + path('profile//test-telegram/', TestTelegramView.as_view(), name='netscan-test-telegram'), + path('run//', 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.70–250 (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 diff --git a/core/settings.py b/core/settings.py index c1867ac..7504c3e 100644 --- a/core/settings.py +++ b/core/settings.py @@ -20,6 +20,7 @@ INSTALLED_APPS = [ 'new_theme', 'simplemde', 'markdown', # 只需要基本的markdown包 + 'netscan', ] ROOT_URLCONF = 'core.urls' diff --git a/core/urls.py b/core/urls.py index 3b938e4..013b2f5 100644 --- a/core/urls.py +++ b/core/urls.py @@ -26,6 +26,9 @@ urlpatterns = [ path('custom//', CustomLinkView.as_view(), name='custom_link'), path('custom//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')), ] diff --git a/data/db.sqlite3 b/data/db.sqlite3 index a5abcb49e7911fdc78f637af56bab063362ff8cf..663889aa32775ef931cee6e486a47917b3611150 100644 GIT binary patch delta 7005 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 z 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 diff --git a/netscan/checks/dns.py b/netscan/checks/dns.py new file mode 100644 index 0000000..3e59956 --- /dev/null +++ b/netscan/checks/dns.py @@ -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)}, + )] diff --git a/netscan/checks/ingress.py b/netscan/checks/ingress.py new file mode 100644 index 0000000..4225579 --- /dev/null +++ b/netscan/checks/ingress.py @@ -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 diff --git a/netscan/checks/ports.py b/netscan/checks/ports.py new file mode 100644 index 0000000..2b2218a --- /dev/null +++ b/netscan/checks/ports.py @@ -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 diff --git a/netscan/checks/router.py b/netscan/checks/router.py new file mode 100644 index 0000000..c401d83 --- /dev/null +++ b/netscan/checks/router.py @@ -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 diff --git a/netscan/checks/tls.py b/netscan/checks/tls.py new file mode 100644 index 0000000..658fce8 --- /dev/null +++ b/netscan/checks/tls.py @@ -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 diff --git a/netscan/fields.py b/netscan/fields.py new file mode 100644 index 0000000..8491d8c --- /dev/null +++ b/netscan/fields.py @@ -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() diff --git a/netscan/forms.py b/netscan/forms.py new file mode 100644 index 0000000..c1df12b --- /dev/null +++ b/netscan/forms.py @@ -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 diff --git a/netscan/migrations/0001_initial.py b/netscan/migrations/0001_initial.py new file mode 100644 index 0000000..c2e572f --- /dev/null +++ b/netscan/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/netscan/migrations/0002_encrypt_telegram_fields.py b/netscan/migrations/0002_encrypt_telegram_fields.py new file mode 100644 index 0000000..60a0ed5 --- /dev/null +++ b/netscan/migrations/0002_encrypt_telegram_fields.py @@ -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), + ), + ] diff --git a/netscan/migrations/__init__.py b/netscan/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netscan/models.py b/netscan/models.py new file mode 100644 index 0000000..7a30afc --- /dev/null +++ b/netscan/models.py @@ -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}' diff --git a/netscan/notifications.py b/netscan/notifications.py new file mode 100644 index 0000000..7e5b683 --- /dev/null +++ b/netscan/notifications.py @@ -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)} diff --git a/netscan/scanner.py b/netscan/scanner.py new file mode 100644 index 0000000..1740e74 --- /dev/null +++ b/netscan/scanner.py @@ -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 diff --git a/netscan/signals.py b/netscan/signals.py new file mode 100644 index 0000000..449d2ec --- /dev/null +++ b/netscan/signals.py @@ -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) diff --git a/netscan/tasks.py b/netscan/tasks.py new file mode 100644 index 0000000..ecd12bf --- /dev/null +++ b/netscan/tasks.py @@ -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}') diff --git a/netscan/templates/netscan/dashboard.html b/netscan/templates/netscan/dashboard.html new file mode 100644 index 0000000..5bf137e --- /dev/null +++ b/netscan/templates/netscan/dashboard.html @@ -0,0 +1,133 @@ +{% extends 'base.html' %} + +{% block content %} +
+ +
+ + +
+
+

+ + NetScan +

+

Home network security scanner

+
+ + + New Profile + +
+ + {% if not profile_data %} + +
+
+ +
+

No scan profiles yet

+

Create your first scan profile to start monitoring your home network.

+ + + Create your first scan profile + +
+ + {% else %} + + + + + + + + + + + + + {% for item in profile_data %} + {% with p=item.profile run=item.last_run worst=item.worst_severity %} + + + + + + + + + + + + + + + + + + + {% endwith %} + {% endfor %} + +
ProfileActions
+
+ {% if worst %} + {% if worst == 'critical' %}🔴{% elif worst == 'warning' %}🟡{% else %}🟢{% endif %} + {% endif %} + {{ p.name }} +
+
+ + {% if p.enabled %}Enabled{% else %}Disabled{% endif %} + + + Every {{ p.schedule_interval }} day{{ p.schedule_interval|pluralize }} + +
+
+
+
+ {% csrf_token %} + +
+ + History + + + Edit + + + + +
+
+ {% endif %} + +
+
+{% endblock %} diff --git a/netscan/templates/netscan/profile_confirm_delete.html b/netscan/templates/netscan/profile_confirm_delete.html new file mode 100644 index 0000000..d4a8ab2 --- /dev/null +++ b/netscan/templates/netscan/profile_confirm_delete.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ +
+

Delete Scan Profile

+

+ Are you sure you want to delete {{ object.name }}? + All scan runs and findings for this profile will be permanently deleted. +

+
+ {% csrf_token %} +
+ + + Cancel + +
+
+
+
+{% endblock %} diff --git a/netscan/templates/netscan/profile_form.html b/netscan/templates/netscan/profile_form.html new file mode 100644 index 0000000..0366bea --- /dev/null +++ b/netscan/templates/netscan/profile_form.html @@ -0,0 +1,234 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+ + +
+

+ + {{ form_title }} +

+ + Back + +
+ +
+ {% csrf_token %} + + +
+

General

+
+ + +
+ + {{ form.name }} + {% for error in form.name.errors %}

{{ error }}

{% endfor %} +
+ + +
+ {{ form.enabled }} + +
+ + +
+ + {{ form.schedule_interval }} + {% for error in form.schedule_interval.errors %}

{{ error }}

{% endfor %} +
+ +
+
+ +
+ + +
+
+

Network

+ +
+
+ +
+ + {{ form.gateway_ip }} + {% for error in form.gateway_ip.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.public_ip }} + {% for error in form.public_ip.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.network_cidr }} + {% if form.network_cidr.help_text %}

{{ form.network_cidr.help_text }}

{% endif %} + {% for error in form.network_cidr.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.auth_provider_host }} + {% if form.auth_provider_host.help_text %}

{{ form.auth_provider_host.help_text }}

{% endif %} + {% for error in form.auth_provider_host.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.domains_text }} + {% if form.domains_text.help_text %}

{{ form.domains_text.help_text }}

{% endif %} + {% for error in form.domains_text.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.cameras_text }} + {% if form.cameras_text.help_text %}

{{ form.cameras_text.help_text }}

{% endif %} + {% for error in form.cameras_text.errors %}

{{ error }}

{% endfor %} +
+ +
+
+ +
+ + +
+

Telegram Notifications

+
+ +
+ + {{ form.telegram_bot_token }} + {% for error in form.telegram_bot_token.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.telegram_chat_id }} + {% for error in form.telegram_chat_id.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.notify_on_severity }} + {% for error in form.notify_on_severity.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {% if object.pk %} +
+ + +
+ {% endif %} +
+ + +
+ + Cancel + + +
+ +
+
+
+{% endblock %} + +{% block extra_js %} + + +{% if object.pk %} + +{% endif %} +{% endblock %} diff --git a/netscan/templates/netscan/run_detail.html b/netscan/templates/netscan/run_detail.html new file mode 100644 index 0000000..44024e6 --- /dev/null +++ b/netscan/templates/netscan/run_detail.html @@ -0,0 +1,147 @@ +{% extends 'base.html' %} + +{% block content %} +
+ +
+ NetScan + / + {{ run.profile.name }} + / + Run #{{ run.pk }} +
+ + +
+
+
+

{{ run.profile.name }} — Run #{{ run.pk }}

+

+ 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 {{ run.triggered_by }} +

+
+
+ + {{ run.status }} + +
+ {% csrf_token %} + +
+
+
+ + +
+ {% if run.summary.critical %} +
+ 🔴 {{ run.summary.critical }} Critical +
+ {% endif %} + {% if run.summary.warning %} +
+ 🟡 {{ run.summary.warning }} Warning +
+ {% endif %} + {% if run.summary.ok %} +
+ 🟢 {{ run.summary.ok }} OK +
+ {% endif %} + {% if run.summary.info %} +
+ ℹ️ {{ run.summary.info }} Info +
+ {% endif %} +
+
+ + + {% if critical_findings %} +
+ + 🔴 Critical Findings ({{ critical_findings|length }}) + +
+ {% for f in critical_findings %} +
+
{{ f.title }}
+

{{ f.detail }}

+ {% if f.raw %} +
+ View raw data +
{{ f.raw|pprint }}
+
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if warning_findings %} +
+ + 🟡 Warnings ({{ warning_findings|length }}) + +
+ {% for f in warning_findings %} +
+
{{ f.title }}
+

{{ f.detail }}

+ {% if f.raw %} +
+ View raw data +
{{ f.raw|pprint }}
+
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if ok_findings %} +
+ + 🟢 OK / Info ({{ ok_findings|length }}) + +
+ {% for f in ok_findings %} +
+
+ {% if f.severity == 'info' %}ℹ️{% else %}🟢{% endif %} + {{ f.title }} +
+

{{ f.detail }}

+ {% if f.raw %} +
+ View raw data +
{{ f.raw|pprint }}
+
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + {% if not critical_findings and not warning_findings and not ok_findings %} +
+ +

No findings recorded yet — the scan may still be running.

+
+ {% endif %} +
+{% endblock %} diff --git a/netscan/templates/netscan/run_list.html b/netscan/templates/netscan/run_list.html new file mode 100644 index 0000000..12e404c --- /dev/null +++ b/netscan/templates/netscan/run_list.html @@ -0,0 +1,98 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ + NetScan + +

{{ profile.name }} — Scan History

+
+
+ {% csrf_token %} + +
+
+ + {% if runs %} +
+ + + + + + + + + + + + + {% for run in runs %} + + + + + + + + + {% endfor %} + +
StartedDurationTriggered byStatusFindings
{{ run.started_at|date:"M d, H:i:s" }} + {% if run.duration_seconds is not None %}{{ run.duration_seconds }}s{% else %}—{% endif %} + + + {{ run.triggered_by }} + + + + {{ run.status }} + + + + {% if run.summary.critical %}🔴{{ run.summary.critical }}{% endif %} + {% if run.summary.warning %}🟡{{ run.summary.warning }}{% endif %} + {% if run.summary.ok %}🟢{{ run.summary.ok }}{% endif %} + {% if run.summary.info %}ℹ️{{ run.summary.info }}{% endif %} + + + View → +
+
+ + + {% if is_paginated %} +
+ {% if page_obj.has_previous %} + ← Prev + {% endif %} + + Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} + + {% if page_obj.has_next %} + Next → + {% endif %} +
+ {% endif %} + + {% else %} +
+ +

No scan runs yet. Click Run Now to start.

+
+ {% endif %} +
+{% endblock %} diff --git a/netscan/urls.py b/netscan/urls.py new file mode 100644 index 0000000..0e1fe1b --- /dev/null +++ b/netscan/urls.py @@ -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//edit/', views.ProfileUpdateView.as_view(), name='netscan-profile-edit'), + path('profile//delete/', views.ProfileDeleteView.as_view(), name='netscan-profile-delete'), + path('profile//runs/', views.ScanRunListView.as_view(), name='netscan-run-list'), + path('profile//trigger/', views.TriggerScanView.as_view(), name='netscan-trigger'), + path('profile//test-telegram/', views.TestTelegramView.as_view(), name='netscan-test-telegram'), + path('detect-network/', views.DetectNetworkView.as_view(), name='netscan-detect-network'), + path('run//', views.ScanRunDetailView.as_view(), name='netscan-run-detail'), +] diff --git a/netscan/views.py b/netscan/views.py new file mode 100644 index 0000000..d5806d5 --- /dev/null +++ b/netscan/views.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index abd2978..9133546 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "pillow~=12.1.1", "whoosh==2.7.4", "qdrant-client>=1.13.2", + "cryptography>=42.0.0", ] [build-system] diff --git a/templates/base.html b/templates/base.html index 13900fa..87c3a73 100644 --- a/templates/base.html +++ b/templates/base.html @@ -129,6 +129,16 @@ + +
+ + + + {% trans "NetScan" %} +
+
+
diff --git a/uv.lock b/uv.lock index 055cc3d..9283076 100644 --- a/uv.lock +++ b/uv.lock @@ -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"