mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Add network scan
This commit is contained in:
+429
@@ -0,0 +1,429 @@
|
||||
# NetScan — Home Network Security Scanner
|
||||
|
||||
A new Django sub-app (`netscan`) added to the Links project that runs configurable, scheduled security checks against your home network, stores results in SQLite, sends Telegram alerts for critical findings, and surfaces everything through a Tailwind UI inside the existing mini-apps section.
|
||||
|
||||
---
|
||||
|
||||
## Context & Constraints
|
||||
|
||||
- **Codebase**: Django + APScheduler + Tailwind + SQLite (PostgreSQL in prod via env var)
|
||||
- **Deployment**: Same Docker image as the Links app, deployed in K3s at 192.168.1.2 (server-3)
|
||||
- **Why in-cluster**: The scanner must run **inside the LAN** to probe 192.168.1.x addresses (router, cameras). Running on the cluster node satisfies this automatically.
|
||||
- **No new dependencies except `requests`** (already present). All checks use stdlib `socket`, `ssl`, `subprocess`, `struct`.
|
||||
- **No new JS framework** — pure Django templates + Tailwind, matching the rest of the app.
|
||||
|
||||
---
|
||||
|
||||
## Database Models (`netscan/models.py`)
|
||||
|
||||
### `ScanProfile`
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | CharField | Human label e.g. "Home Network" |
|
||||
| `enabled` | BooleanField | Controls scheduler |
|
||||
| `schedule_interval` | IntegerField | 1 / 3 / 7 / 30 days (choices) |
|
||||
| `gateway_ip` | GenericIPAddressField | e.g. 192.168.1.1 |
|
||||
| `public_ip` | GenericIPAddressField | e.g. 14.137.198.99 |
|
||||
| `network_cidr` | CharField | e.g. 192.168.1.0/24 |
|
||||
| `auth_provider_host` | CharField | e.g. pass.junv.cc (for ingress check) |
|
||||
| `domains` | JSONField | List of public hostnames to check |
|
||||
| `cameras` | JSONField | List of camera IPs to probe |
|
||||
| `telegram_bot_token` | CharField | blank/null; stored encrypted in env preferred |
|
||||
| `telegram_chat_id` | CharField | blank/null |
|
||||
| `notify_on_severity` | CharField | "warning" or "critical" (default "critical") |
|
||||
| `last_run_at` | DateTimeField | null |
|
||||
| `created_at` | DateTimeField | auto |
|
||||
|
||||
### `ScanRun`
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `profile` | FK → ScanProfile | cascade delete |
|
||||
| `started_at` | DateTimeField | |
|
||||
| `finished_at` | DateTimeField | null |
|
||||
| `status` | CharField | pending / running / success / failed |
|
||||
| `summary` | JSONField | `{ok:N, info:N, warning:N, critical:N}` |
|
||||
| `triggered_by` | CharField | "scheduler" or "manual" |
|
||||
|
||||
### `ScanFinding`
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `run` | FK → ScanRun | cascade delete |
|
||||
| `check_name` | CharField | e.g. "router_ports", "tls_expiry" |
|
||||
| `severity` | CharField | ok / info / warning / critical |
|
||||
| `title` | CharField | Short human summary |
|
||||
| `detail` | TextField | Full explanation |
|
||||
| `raw` | JSONField | Raw probe output for debugging |
|
||||
|
||||
---
|
||||
|
||||
## Check Modules (`netscan/checks/`)
|
||||
|
||||
Each module exposes a single function `run(profile) -> list[Finding]`. All use only stdlib + `requests`.
|
||||
|
||||
### `checks/base.py`
|
||||
```python
|
||||
@dataclass
|
||||
class Finding:
|
||||
check_name: str
|
||||
severity: str # ok | info | warning | critical
|
||||
title: str
|
||||
detail: str
|
||||
raw: dict
|
||||
```
|
||||
|
||||
### `checks/router.py` — Gateway security
|
||||
- TCP connect-probe gateway IP on ports: 22, 23, 53, 80, 139, 443, 445, 8080, 8443
|
||||
- Fetch HTTP headers from port 80 to identify plain-HTTP admin (`Server: httpd`)
|
||||
- Flag: SMB open (139/445) → **warning**
|
||||
- Flag: SSH closed → **ok**; SSH open from WAN → **warning**
|
||||
- Flag: plain-HTTP admin (no HTTPS on 443) → **warning**
|
||||
- Flag: unknown port 8080 open → **info**
|
||||
|
||||
### `checks/dns.py` — Open resolver (WAN DNS exposure)
|
||||
- Send raw DNS query for `google.com A` to `profile.public_ip:53` over UDP using `socket` + `struct`
|
||||
- Parse the response: if `NOERROR` + answer section returned → recursion available
|
||||
- Flag: open recursive resolver → **critical**
|
||||
- Note in detail: "Verify from off-LAN; NAT hairpin may cause false positive"
|
||||
|
||||
### `checks/ingress.py` — Public domain auth verification
|
||||
- For each domain in `profile.domains`:
|
||||
- `requests.get(f"https://{domain}/", allow_redirects=True, timeout=8)`
|
||||
- Check: does the redirect chain pass through `profile.auth_provider_host`?
|
||||
- Check: does the final URL (after all redirects) contain the auth provider host?
|
||||
- If final URL is the app itself (not the auth host) → **critical** (auth bypassed)
|
||||
- If redirect goes through auth provider → **ok**
|
||||
- If connection refused / DNS fails → **warning**
|
||||
|
||||
### `checks/cameras.py` — RTSP unauthenticated access
|
||||
- For each IP in `profile.cameras`:
|
||||
1. TCP connect to port 554 — if closed → **info** (skip)
|
||||
2. Send `OPTIONS rtsp://{ip}/ RTSP/1.0\r\nCSeq: 1\r\n\r\n` over raw socket
|
||||
3. Parse response status line
|
||||
4. If OPTIONS returns 200, send `DESCRIBE rtsp://{ip}/ RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\n\r\n`
|
||||
5. If DESCRIBE returns 200 (stream accessible with no creds) → **critical**
|
||||
6. If DESCRIBE returns 401/403 → **ok** (auth required)
|
||||
7. If OPTIONS returns 404 / no common path accessible → **ok**
|
||||
|
||||
### `checks/tls.py` — TLS certificate validity
|
||||
- For each domain in `profile.domains`:
|
||||
- `ssl.get_server_certificate((domain, 443))` + parse `notAfter`
|
||||
- Days to expiry < 0 → **critical** (expired)
|
||||
- Days to expiry < 14 → **critical**
|
||||
- Days to expiry < 30 → **warning**
|
||||
- Otherwise → **ok**
|
||||
- Also flag if cert `CN`/`SAN` doesn't match the domain → **warning**
|
||||
|
||||
### `checks/ports.py` — Public IP port exposure
|
||||
- TCP connect-probe `profile.public_ip` on: 22, 23, 25, 53, 80, 443, 3306, 5432, 6379, 8080, 8443
|
||||
- Flag presence of each open port with canned risk descriptions:
|
||||
- 80/443 → **info** (expected for web services)
|
||||
- 22 → **warning** (SSH exposed to internet)
|
||||
- 23 → **critical** (Telnet exposed)
|
||||
- 53 → **warning** (DNS — run dns check to confirm resolver)
|
||||
- Database ports (3306/5432/6379) → **critical**
|
||||
- 8443/8080 → **warning** (alt web ports)
|
||||
|
||||
---
|
||||
|
||||
## Scanner Orchestrator (`netscan/scanner.py`)
|
||||
|
||||
```python
|
||||
def run_scan(profile_id: int) -> int:
|
||||
"""
|
||||
Runs all checks for the given profile.
|
||||
Returns the ScanRun PK.
|
||||
Called by APScheduler jobs and TriggerScanView.
|
||||
"""
|
||||
profile = ScanProfile.objects.get(pk=profile_id)
|
||||
run = ScanRun.objects.create(profile=profile, status='running', triggered_by=...)
|
||||
|
||||
all_findings = []
|
||||
check_modules = [router, dns, ingress, cameras, tls, ports]
|
||||
for mod in check_modules:
|
||||
try:
|
||||
findings = mod.run(profile)
|
||||
all_findings.extend(findings)
|
||||
except Exception as e:
|
||||
# Wrap uncaught errors as a warning finding so run still completes
|
||||
all_findings.append(Finding(check_name=mod.__name__, severity='warning',
|
||||
title='Check errored', detail=str(e), raw={}))
|
||||
|
||||
# Persist findings
|
||||
ScanFinding.objects.bulk_create([...])
|
||||
|
||||
# Update run summary
|
||||
summary = Counter(f.severity for f in all_findings)
|
||||
run.summary = dict(summary)
|
||||
run.status = 'success'
|
||||
run.finished_at = now()
|
||||
run.save()
|
||||
|
||||
# Telegram notification
|
||||
if profile.telegram_bot_token and profile.telegram_chat_id:
|
||||
notify_telegram(profile, run, all_findings)
|
||||
|
||||
# Update profile.last_run_at
|
||||
profile.last_run_at = now()
|
||||
profile.save(update_fields=['last_run_at'])
|
||||
|
||||
return run.pk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Telegram Notifications (`netscan/notifications.py`)
|
||||
|
||||
```python
|
||||
def notify_telegram(profile, run, findings):
|
||||
"""
|
||||
Sends a Telegram message if any finding meets or exceeds notify_on_severity.
|
||||
Uses the Bot API sendMessage endpoint directly via requests (no library needed).
|
||||
"""
|
||||
threshold_order = ['ok', 'info', 'warning', 'critical']
|
||||
threshold_idx = threshold_order.index(profile.notify_on_severity)
|
||||
|
||||
flagged = [f for f in findings
|
||||
if threshold_order.index(f.severity) >= threshold_idx]
|
||||
if not flagged:
|
||||
return
|
||||
|
||||
lines = [f"🔒 *NetScan Alert* — {profile.name}",
|
||||
f"Run #{run.pk} finished at {run.finished_at:%Y-%m-%d %H:%M}",
|
||||
f"Summary: {run.summary}",
|
||||
""]
|
||||
for f in flagged[:10]: # cap at 10 to stay under TG message limit
|
||||
icon = {'critical': '🔴', 'warning': '🟡', 'ok': '🟢', 'info': 'ℹ️'}[f.severity]
|
||||
lines.append(f"{icon} *{f.title}*\n {f.detail[:120]}")
|
||||
|
||||
if len(flagged) > 10:
|
||||
lines.append(f"_...and {len(flagged)-10} more findings_")
|
||||
|
||||
text = "\n".join(lines)
|
||||
url = f"https://api.telegram.org/bot{profile.telegram_bot_token}/sendMessage"
|
||||
requests.post(url, json={
|
||||
"chat_id": profile.telegram_chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown"
|
||||
}, timeout=10)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## APScheduler Integration (`netscan/tasks.py` + `netscan/apps.py`)
|
||||
|
||||
### `tasks.py`
|
||||
```python
|
||||
from core.scheduler import scheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
def schedule_profile(profile):
|
||||
job_id = f'netscan_profile_{profile.pk}'
|
||||
scheduler.add_job(
|
||||
run_scan,
|
||||
trigger=IntervalTrigger(days=profile.schedule_interval),
|
||||
id=job_id,
|
||||
args=[profile.pk],
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
def unschedule_profile(profile):
|
||||
job_id = f'netscan_profile_{profile.pk}'
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
```
|
||||
|
||||
### `apps.py`
|
||||
```python
|
||||
class NetscanConfig(AppConfig):
|
||||
name = 'netscan'
|
||||
|
||||
def ready(self):
|
||||
from netscan.tasks import schedule_profile
|
||||
from netscan.models import ScanProfile
|
||||
for profile in ScanProfile.objects.filter(enabled=True):
|
||||
schedule_profile(profile)
|
||||
```
|
||||
|
||||
### `signals.py`
|
||||
```python
|
||||
@receiver(post_save, sender=ScanProfile)
|
||||
def reschedule_on_save(sender, instance, **kwargs):
|
||||
if instance.enabled:
|
||||
schedule_profile(instance)
|
||||
else:
|
||||
unschedule_profile(instance)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Views (`netscan/views.py`)
|
||||
|
||||
All views use `LoginRequiredMixin`.
|
||||
|
||||
| View | URL | Notes |
|
||||
|---|---|---|
|
||||
| `DashboardView` | `/ui/netscan/` | List profiles, worst-severity badge per profile, last run time, Run Now + Edit buttons |
|
||||
| `ProfileCreateView` | `/ui/netscan/profile/new/` | ModelForm |
|
||||
| `ProfileUpdateView` | `/ui/netscan/profile/<pk>/edit/` | ModelForm |
|
||||
| `ProfileDeleteView` | `/ui/netscan/profile/<pk>/delete/` | Confirm page |
|
||||
| `ScanRunListView` | `/ui/netscan/profile/<pk>/runs/` | Paginated history, status + severity count cols |
|
||||
| `ScanRunDetailView` | `/ui/netscan/run/<pk>/` | Findings grouped by severity, collapsible raw JSON |
|
||||
| `TriggerScanView` | `/ui/netscan/profile/<pk>/trigger/` | POST-only; spawns `Thread(target=run_scan, args=[pk])`, redirects to run list |
|
||||
| `TestTelegramView` | `/ui/netscan/profile/<pk>/test-telegram/` | POST-only; sends a test message, returns JSON |
|
||||
|
||||
---
|
||||
|
||||
## Templates (`netscan/templates/netscan/`)
|
||||
|
||||
All extend `base.html`, use Tailwind classes matching the existing app.
|
||||
|
||||
### `dashboard.html`
|
||||
- Grid of profile cards (matches mini_apps card style)
|
||||
- Each card: name, schedule chip (e.g. "Every 7 days"), last run timestamp, worst-severity badge (🔴/🟡/🟢), finding count breakdown
|
||||
- "Run Now" button (POST to trigger URL), "Edit" link, "History" link
|
||||
- Empty state with "Create your first scan profile" CTA
|
||||
|
||||
### `profile_form.html`
|
||||
- Fields: Name, Schedule (dropdown: 1/3/7/30 days), Gateway IP, Public IP, Network CIDR, Auth Provider Host, Domains (textarea, one per line), Camera IPs (textarea, one per line), Telegram Bot Token, Telegram Chat ID, Notify on Severity (dropdown: warning/critical), Enabled checkbox
|
||||
- "Test Telegram" button (JS fetch to TestTelegramView, shows inline success/error)
|
||||
|
||||
### `run_list.html`
|
||||
- Table: Started, Duration, Triggered by, Status badge, 🔴 Critical, 🟡 Warning, 🟢 OK counts, View link
|
||||
- Pagination
|
||||
|
||||
### `run_detail.html`
|
||||
- Header: profile name, run timestamp, status, summary badges, "Re-run" button
|
||||
- Three collapsible sections: Critical findings, Warnings, OK/Info
|
||||
- Each finding: title, detail text; expandable "Raw" disclosure showing JSON
|
||||
- Back to history link
|
||||
|
||||
---
|
||||
|
||||
## URL Wiring
|
||||
|
||||
### `core/urls.py` — add:
|
||||
```python
|
||||
path('ui/netscan/', include('netscan.urls')),
|
||||
```
|
||||
|
||||
### `netscan/urls.py`:
|
||||
```python
|
||||
urlpatterns = [
|
||||
path('', DashboardView.as_view(), name='netscan-dashboard'),
|
||||
path('profile/new/', ProfileCreateView.as_view(), name='netscan-profile-create'),
|
||||
path('profile/<int:pk>/edit/', ProfileUpdateView.as_view(), name='netscan-profile-edit'),
|
||||
path('profile/<int:pk>/delete/', ProfileDeleteView.as_view(), name='netscan-profile-delete'),
|
||||
path('profile/<int:pk>/runs/', ScanRunListView.as_view(), name='netscan-run-list'),
|
||||
path('profile/<int:pk>/trigger/', TriggerScanView.as_view(), name='netscan-trigger'),
|
||||
path('profile/<int:pk>/test-telegram/', TestTelegramView.as_view(), name='netscan-test-telegram'),
|
||||
path('run/<int:pk>/', ScanRunDetailView.as_view(), name='netscan-run-detail'),
|
||||
]
|
||||
```
|
||||
|
||||
### `core/settings.py` — add to INSTALLED_APPS:
|
||||
```python
|
||||
'netscan',
|
||||
```
|
||||
|
||||
### `links/mini_apps_views.py` — add to `mini_apps` list:
|
||||
```python
|
||||
{
|
||||
'name': 'NetScan',
|
||||
'description': 'Scheduled home network security scanner. Checks router exposure, DNS, TLS certs, public ingress auth, and camera access.',
|
||||
'url': 'netscan-dashboard',
|
||||
'thumbnail': 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=400&h=300&fit=crop',
|
||||
'icon': 'fas fa-shield-alt',
|
||||
'color': '#e74c3c'
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## K3s Deployment Changes (`k8s/manifest.yaml`)
|
||||
|
||||
The scanner runs **inside the same Pod** as the Links app — no new container or service needed. The existing Deployment only needs two new env vars (or they can be set per-profile in the DB):
|
||||
|
||||
```yaml
|
||||
# Optional: global fallback Telegram credentials
|
||||
- name: NETSCAN_TELEGRAM_BOT_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: netscan-credentials
|
||||
key: telegram_bot_token
|
||||
- name: NETSCAN_TELEGRAM_CHAT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: netscan-credentials
|
||||
key: telegram_chat_id
|
||||
```
|
||||
|
||||
Create the secret:
|
||||
```bash
|
||||
kubectl create secret generic netscan-credentials \
|
||||
--from-literal=telegram_bot_token=YOUR_TOKEN \
|
||||
--from-literal=telegram_chat_id=YOUR_CHAT_ID \
|
||||
-n home-apps
|
||||
```
|
||||
|
||||
The Pod already runs inside the LAN (on server-3 at 192.168.1.2), so it can reach 192.168.1.1 (router), 192.168.1.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
|
||||
@@ -20,6 +20,7 @@ INSTALLED_APPS = [
|
||||
'new_theme',
|
||||
'simplemde',
|
||||
'markdown', # 只需要基本的markdown包
|
||||
'netscan',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'core.urls'
|
||||
|
||||
@@ -26,6 +26,9 @@ urlpatterns = [
|
||||
path('custom/<slug:alias>/', CustomLinkView.as_view(), name='custom_link'),
|
||||
path('custom/<slug:alias>/edit/', LinkUpdateView.as_view(), name='custom_link_update'),
|
||||
|
||||
# Include netscan BEFORE links.urls to prevent the alias catch-all from intercepting it
|
||||
path('ui/netscan/', include('netscan.urls')),
|
||||
|
||||
# Include main app URLs with locale
|
||||
path('', include('links.urls')),
|
||||
]
|
||||
|
||||
Binary file not shown.
@@ -41,7 +41,7 @@ class MiniAppsListView(TemplateView):
|
||||
'thumbnail': 'https://images.unsplash.com/photo-1504608524841-42fe6f032b4b?w=400&h=300&fit=crop',
|
||||
'icon': 'fas fa-cloud-sun',
|
||||
'color': '#e74c3c'
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
context['mini_apps'] = mini_apps
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Links Project Memory
|
||||
|
||||
## Project: links (GoLinks + mini-apps)
|
||||
- Django + APScheduler + Tailwind + SQLite (local) / PostgreSQL (prod via DB_HOST env var)
|
||||
- Deployment: Docker → K3s at 192.168.1.2, namespace `home-apps`
|
||||
- Use `uv run manage.py` (not bare `python manage.py`) — greenlet/playwright has macOS code-signing issues locally
|
||||
- Templates extend `base.html` from `templates/base.html` (not app-level)
|
||||
- Scheduler singleton: `core.scheduler.scheduler` (BackgroundScheduler, already running from CoreConfig.ready)
|
||||
- App scheduler wiring pattern: import signals in AppConfig.ready(), wrap ORM calls in try/except
|
||||
|
||||
## NetScan sub-app (implemented 2026-03-21)
|
||||
- Located at `netscan/`
|
||||
- 3 models: ScanProfile, ScanRun, ScanFinding
|
||||
- 6 check modules in `netscan/checks/`: router, dns, ingress, cameras, tls, ports
|
||||
- Scanner orchestrator: `netscan/scanner.run_scan(profile_id, triggered_by)`
|
||||
- Telegram notifications: `netscan/notifications.py` (raw requests, no library)
|
||||
- URL prefix: `/ui/netscan/`
|
||||
- Migration written manually (0001_initial.py) — local playwright/greenlet prevents running makemigrations
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.contrib import admin
|
||||
from .models import ScanProfile, ScanRun, ScanFinding
|
||||
|
||||
|
||||
@admin.register(ScanProfile)
|
||||
class ScanProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'enabled', 'schedule_interval', 'gateway_ip', 'public_ip', 'last_run_at', 'created_at']
|
||||
list_filter = ['enabled', 'schedule_interval']
|
||||
|
||||
|
||||
@admin.register(ScanRun)
|
||||
class ScanRunAdmin(admin.ModelAdmin):
|
||||
list_display = ['profile', 'status', 'triggered_by', 'started_at', 'finished_at']
|
||||
list_filter = ['status', 'triggered_by', 'profile']
|
||||
readonly_fields = ['started_at', 'finished_at', 'summary']
|
||||
|
||||
|
||||
@admin.register(ScanFinding)
|
||||
class ScanFindingAdmin(admin.ModelAdmin):
|
||||
list_display = ['run', 'check_name', 'severity', 'title']
|
||||
list_filter = ['severity', 'check_name']
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.apps import AppConfig
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NetscanConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'netscan'
|
||||
|
||||
def ready(self):
|
||||
import netscan.signals # noqa: F401
|
||||
try:
|
||||
from netscan.tasks import schedule_profile
|
||||
from netscan.models import ScanProfile
|
||||
for profile in ScanProfile.objects.filter(enabled=True):
|
||||
schedule_profile(profile)
|
||||
logger.info(f'Scheduled netscan profile: {profile.name}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Could not schedule netscan profiles on startup: {e}')
|
||||
@@ -0,0 +1,10 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
check_name: str
|
||||
severity: str # ok | info | warning | critical
|
||||
title: str
|
||||
detail: str
|
||||
raw: dict = field(default_factory=dict)
|
||||
@@ -0,0 +1,117 @@
|
||||
import socket
|
||||
import logging
|
||||
from .base import Finding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_NAME = 'camera_rtsp'
|
||||
TIMEOUT = 5
|
||||
RTSP_PORT = 554
|
||||
|
||||
|
||||
def _tcp_open(host: str, port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=TIMEOUT):
|
||||
return True
|
||||
except (OSError, socket.timeout):
|
||||
return False
|
||||
|
||||
|
||||
def _rtsp_request(host: str, method: str, seq: int, extra_headers: str = '') -> str:
|
||||
try:
|
||||
with socket.create_connection((host, RTSP_PORT), timeout=TIMEOUT) as s:
|
||||
request = (
|
||||
f'{method} rtsp://{host}/ RTSP/1.0\r\n'
|
||||
f'CSeq: {seq}\r\n'
|
||||
f'{extra_headers}'
|
||||
'\r\n'
|
||||
)
|
||||
s.sendall(request.encode())
|
||||
response = s.recv(4096).decode('utf-8', errors='replace')
|
||||
return response
|
||||
except Exception as e:
|
||||
return f'ERROR: {e}'
|
||||
|
||||
|
||||
def _parse_rtsp_status(response: str) -> int:
|
||||
"""Extract HTTP-style status code from RTSP response."""
|
||||
try:
|
||||
first_line = response.splitlines()[0]
|
||||
return int(first_line.split()[1])
|
||||
except (IndexError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def run(profile) -> list:
|
||||
findings = []
|
||||
cameras = profile.cameras or []
|
||||
|
||||
if not cameras:
|
||||
return [Finding(
|
||||
check_name=CHECK_NAME,
|
||||
severity='info',
|
||||
title='No camera IPs configured',
|
||||
detail='Add camera IPs to the scan profile to enable RTSP unauthenticated access check.',
|
||||
raw={},
|
||||
)]
|
||||
|
||||
for ip in cameras:
|
||||
raw = {'camera_ip': ip}
|
||||
|
||||
if not _tcp_open(ip, RTSP_PORT):
|
||||
findings.append(Finding(
|
||||
check_name=CHECK_NAME,
|
||||
severity='info',
|
||||
title=f'{ip}: RTSP port 554 closed',
|
||||
detail=f'Port 554 is not open on {ip}. Camera may be offline or not using RTSP.',
|
||||
raw=raw,
|
||||
))
|
||||
continue
|
||||
|
||||
options_resp = _rtsp_request(ip, 'OPTIONS', 1)
|
||||
raw['options_response'] = options_resp[:500]
|
||||
options_status = _parse_rtsp_status(options_resp)
|
||||
|
||||
if options_status == 0:
|
||||
findings.append(Finding(
|
||||
check_name=CHECK_NAME,
|
||||
severity='info',
|
||||
title=f'{ip}: RTSP OPTIONS failed',
|
||||
detail=f'Got unexpected RTSP OPTIONS response from {ip}.',
|
||||
raw=raw,
|
||||
))
|
||||
continue
|
||||
|
||||
describe_resp = _rtsp_request(ip, 'DESCRIBE', 2, 'Accept: application/sdp\r\n')
|
||||
raw['describe_response'] = describe_resp[:500]
|
||||
describe_status = _parse_rtsp_status(describe_resp)
|
||||
|
||||
if describe_status == 200:
|
||||
findings.append(Finding(
|
||||
check_name=CHECK_NAME,
|
||||
severity='critical',
|
||||
title=f'{ip}: RTSP stream accessible without credentials',
|
||||
detail=(
|
||||
f'Camera at {ip} returned 200 to DESCRIBE without authentication. '
|
||||
'Live stream may be publicly accessible on the LAN.'
|
||||
),
|
||||
raw=raw,
|
||||
))
|
||||
elif describe_status in (401, 403):
|
||||
findings.append(Finding(
|
||||
check_name=CHECK_NAME,
|
||||
severity='ok',
|
||||
title=f'{ip}: RTSP requires authentication',
|
||||
detail=f'Camera at {ip} returned {describe_status} to DESCRIBE — auth is enforced.',
|
||||
raw=raw,
|
||||
))
|
||||
else:
|
||||
findings.append(Finding(
|
||||
check_name=CHECK_NAME,
|
||||
severity='ok',
|
||||
title=f'{ip}: RTSP DESCRIBE returned {describe_status}',
|
||||
detail=f'Camera at {ip} responded with status {describe_status} — no unauthenticated stream detected.',
|
||||
raw=raw,
|
||||
))
|
||||
|
||||
return findings
|
||||
@@ -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)},
|
||||
)]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,63 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ScanProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('schedule_interval', models.IntegerField(choices=[(1, 'Every 1 day'), (3, 'Every 3 days'), (7, 'Every 7 days'), (30, 'Every 30 days')], default=7)),
|
||||
('gateway_ip', models.GenericIPAddressField(help_text='e.g. 192.168.1.1')),
|
||||
('public_ip', models.GenericIPAddressField(help_text='Your public/WAN IP address')),
|
||||
('network_cidr', models.CharField(blank=True, help_text='e.g. 192.168.1.0/24', max_length=50)),
|
||||
('auth_provider_host', models.CharField(blank=True, help_text='e.g. pass.junv.cc', max_length=255)),
|
||||
('domains', models.JSONField(blank=True, default=list, help_text='List of public hostnames to check')),
|
||||
('cameras', models.JSONField(blank=True, default=list, help_text='List of camera IPs to probe')),
|
||||
('telegram_bot_token', models.CharField(blank=True, max_length=255)),
|
||||
('telegram_chat_id', models.CharField(blank=True, max_length=100)),
|
||||
('notify_on_severity', models.CharField(choices=[('warning', 'Warning and above'), ('critical', 'Critical only')], default='critical', max_length=20)),
|
||||
('last_run_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScanRun',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('started_at', models.DateTimeField(auto_now_add=True)),
|
||||
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('success', 'Success'), ('failed', 'Failed')], default='pending', max_length=20)),
|
||||
('summary', models.JSONField(default=dict)),
|
||||
('triggered_by', models.CharField(default='manual', max_length=20)),
|
||||
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='runs', to='netscan.scanprofile')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-started_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScanFinding',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('check_name', models.CharField(max_length=100)),
|
||||
('severity', models.CharField(choices=[('ok', 'OK'), ('info', 'Info'), ('warning', 'Warning'), ('critical', 'Critical')], max_length=20)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('detail', models.TextField()),
|
||||
('raw', models.JSONField(default=dict)),
|
||||
('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='findings', to='netscan.scanrun')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['severity', 'check_name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.db import migrations
|
||||
import netscan.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('netscan', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='scanprofile',
|
||||
name='telegram_bot_token',
|
||||
field=netscan.fields.EncryptedCharField(blank=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scanprofile',
|
||||
name='telegram_chat_id',
|
||||
field=netscan.fields.EncryptedCharField(blank=True),
|
||||
),
|
||||
]
|
||||
@@ -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}'
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}')
|
||||
@@ -0,0 +1,133 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="px-4 py-5 sm:px-6 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-shield-alt mr-3 text-red-500"></i>
|
||||
NetScan
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Home network security scanner</p>
|
||||
</div>
|
||||
<a href="{% url 'netscan-profile-create' %}"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
New Profile
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if not profile_data %}
|
||||
<!-- Empty state -->
|
||||
<div class="text-center py-20">
|
||||
<div class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-shield-alt text-red-500 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-2">No scan profiles yet</h2>
|
||||
<p class="text-gray-500 text-sm mb-6">Create your first scan profile to start monitoring your home network.</p>
|
||||
<a href="{% url 'netscan-profile-create' %}"
|
||||
class="inline-flex items-center px-5 py-2.5 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Create your first scan profile
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Profiles table -->
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Profile</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide hidden sm:table-cell">Network</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide hidden md:table-cell">Last Run</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide hidden sm:table-cell">Findings</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wide">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for item in profile_data %}
|
||||
{% with p=item.profile run=item.last_run worst=item.worst_severity %}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
|
||||
<!-- Profile name + badges -->
|
||||
<td class="px-4 py-4">
|
||||
<div class="font-semibold text-gray-900 flex items-center gap-2">
|
||||
{% if worst %}
|
||||
{% if worst == 'critical' %}🔴{% elif worst == 'warning' %}🟡{% else %}🟢{% endif %}
|
||||
{% endif %}
|
||||
{{ p.name }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{% if p.enabled %}bg-green-100 text-green-700{% else %}bg-gray-100 text-gray-500{% endif %}">
|
||||
{% if p.enabled %}Enabled{% else %}Disabled{% endif %}
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-600">
|
||||
Every {{ p.schedule_interval }} day{{ p.schedule_interval|pluralize }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Network info -->
|
||||
<td class="px-4 py-4 hidden sm:table-cell">
|
||||
<div class="text-gray-700 font-mono text-xs">{{ p.gateway_ip }}</div>
|
||||
<div class="text-gray-400 font-mono text-xs">{{ p.public_ip }}</div>
|
||||
</td>
|
||||
|
||||
<!-- Last run -->
|
||||
<td class="px-4 py-4 hidden md:table-cell text-gray-500 text-xs">
|
||||
{% if p.last_run_at %}{{ p.last_run_at|date:"M d, H:i" }}{% else %}<span class="text-gray-400">Never</span>{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Findings -->
|
||||
<td class="px-4 py-4 hidden sm:table-cell">
|
||||
{% if run %}
|
||||
<div class="flex items-center gap-2">
|
||||
{% if run.summary.critical %}<span class="text-red-600 font-medium text-xs">🔴{{ run.summary.critical }}</span>{% endif %}
|
||||
{% if run.summary.warning %}<span class="text-yellow-600 font-medium text-xs">🟡{{ run.summary.warning }}</span>{% endif %}
|
||||
{% if run.summary.ok %}<span class="text-green-600 font-medium text-xs">🟢{{ run.summary.ok }}</span>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-gray-300 text-xs">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<form method="post" action="{% url 'netscan-trigger' p.pk %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-red-600 text-white text-xs rounded-md hover:bg-red-700 font-medium">
|
||||
<i class="fas fa-play mr-1.5"></i> Run
|
||||
</button>
|
||||
</form>
|
||||
<a href="{% url 'netscan-run-list' p.pk %}"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded-md hover:bg-gray-200">
|
||||
<i class="fas fa-history mr-1.5"></i> History
|
||||
</a>
|
||||
<a href="{% url 'netscan-profile-edit' p.pk %}"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded-md hover:bg-gray-200">
|
||||
<i class="fas fa-edit mr-1.5"></i> Edit
|
||||
</a>
|
||||
<a href="{% url 'netscan-profile-delete' p.pk %}"
|
||||
class="inline-flex items-center px-2 py-1.5 bg-red-50 text-red-500 text-xs rounded-md hover:bg-red-100">
|
||||
<i class="fas fa-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-lg mx-auto px-4 py-12">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 text-center">
|
||||
<div class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-trash text-red-600 text-xl"></i>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-gray-900 mb-2">Delete Scan Profile</h1>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Are you sure you want to delete <strong>{{ object.name }}</strong>?
|
||||
All scan runs and findings for this profile will be permanently deleted.
|
||||
</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="flex justify-center gap-3">
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium">
|
||||
Delete
|
||||
</button>
|
||||
<a href="{% url 'netscan-dashboard' %}"
|
||||
class="px-6 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 font-medium">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,234 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="px-4 py-5 sm:px-6 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-shield-alt mr-3 text-red-500"></i>
|
||||
{{ form_title }}
|
||||
</h1>
|
||||
<a href="{% url 'netscan-dashboard' %}" class="text-sm text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-arrow-left mr-1"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="post" id="profile-form" class="px-4 py-5 sm:p-6 space-y-8">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- General -->
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">General</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Name (full width) -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_name">Name</label>
|
||||
{{ form.name }}
|
||||
{% for error in form.name.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Enabled -->
|
||||
<div class="flex items-center gap-2">
|
||||
{{ form.enabled }}
|
||||
<label class="text-sm font-medium text-gray-700" for="id_enabled">Enabled</label>
|
||||
</div>
|
||||
|
||||
<!-- Schedule interval -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_schedule_interval">Scan interval</label>
|
||||
{{ form.schedule_interval }}
|
||||
{% for error in form.schedule_interval.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-100">
|
||||
|
||||
<!-- Network -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Network</h2>
|
||||
<button type="button" id="auto-detect-btn"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 text-sm font-medium border border-blue-200">
|
||||
<i class="fas fa-magic mr-2"></i>
|
||||
Auto-detect
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_gateway_ip">Gateway IP</label>
|
||||
{{ form.gateway_ip }}
|
||||
{% for error in form.gateway_ip.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_public_ip">Public IP</label>
|
||||
{{ form.public_ip }}
|
||||
{% for error in form.public_ip.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_network_cidr">Network CIDR</label>
|
||||
{{ form.network_cidr }}
|
||||
{% if form.network_cidr.help_text %}<p class="text-xs text-gray-400 mt-1">{{ form.network_cidr.help_text }}</p>{% endif %}
|
||||
{% for error in form.network_cidr.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_auth_provider_host">Auth provider host</label>
|
||||
{{ form.auth_provider_host }}
|
||||
{% if form.auth_provider_host.help_text %}<p class="text-xs text-gray-400 mt-1">{{ form.auth_provider_host.help_text }}</p>{% endif %}
|
||||
{% for error in form.auth_provider_host.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_domains_text">Domains</label>
|
||||
{{ form.domains_text }}
|
||||
{% if form.domains_text.help_text %}<p class="text-xs text-gray-400 mt-1">{{ form.domains_text.help_text }}</p>{% endif %}
|
||||
{% for error in form.domains_text.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_cameras_text">Camera IPs</label>
|
||||
{{ form.cameras_text }}
|
||||
{% if form.cameras_text.help_text %}<p class="text-xs text-gray-400 mt-1">{{ form.cameras_text.help_text }}</p>{% endif %}
|
||||
{% for error in form.cameras_text.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-100">
|
||||
|
||||
<!-- Telegram Notifications -->
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Telegram Notifications</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_telegram_bot_token">Bot token</label>
|
||||
{{ form.telegram_bot_token }}
|
||||
{% for error in form.telegram_bot_token.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_telegram_chat_id">Chat ID</label>
|
||||
{{ form.telegram_chat_id }}
|
||||
{% for error in form.telegram_chat_id.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_notify_on_severity">Notify on severity</label>
|
||||
{{ form.notify_on_severity }}
|
||||
{% for error in form.notify_on_severity.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% if object.pk %}
|
||||
<div class="mt-4">
|
||||
<button type="button" id="test-telegram-btn"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 text-sm font-medium border border-blue-200">
|
||||
<i class="fas fa-paper-plane mr-2"></i>
|
||||
Test Telegram
|
||||
</button>
|
||||
<span id="test-telegram-result" class="ml-3 text-sm hidden"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
||||
<a href="{% url 'netscan-dashboard' %}"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-gray-700 bg-gray-200 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||
Save Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.getElementById('auto-detect-btn').addEventListener('click', function () {
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Detecting...';
|
||||
|
||||
fetch("{% url 'netscan-detect-network' %}")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.gateway_ip) document.getElementById('id_gateway_ip').value = data.gateway_ip;
|
||||
if (data.public_ip) document.getElementById('id_public_ip').value = data.public_ip;
|
||||
if (data.network_cidr) document.getElementById('id_network_cidr').value = data.network_cidr;
|
||||
|
||||
btn.innerHTML = '<i class="fas fa-check mr-2"></i>Detected!';
|
||||
btn.classList.replace('text-blue-700', 'text-green-700');
|
||||
btn.classList.replace('bg-blue-50', 'bg-green-50');
|
||||
btn.classList.replace('border-blue-200', 'border-green-200');
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-magic mr-2"></i>Auto-detect';
|
||||
btn.classList.replace('text-green-700', 'text-blue-700');
|
||||
btn.classList.replace('bg-green-50', 'bg-blue-50');
|
||||
btn.classList.replace('border-green-200', 'border-blue-200');
|
||||
}, 3000);
|
||||
})
|
||||
.catch(() => {
|
||||
btn.innerHTML = '<i class="fas fa-exclamation-triangle mr-2"></i>Failed';
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-magic mr-2"></i>Auto-detect';
|
||||
}, 3000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if object.pk %}
|
||||
<script>
|
||||
document.getElementById('test-telegram-btn').addEventListener('click', function () {
|
||||
const btn = this;
|
||||
const result = document.getElementById('test-telegram-result');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Sending...';
|
||||
result.className = 'ml-3 text-sm hidden';
|
||||
|
||||
fetch("{% url 'netscan-test-telegram' object.pk %}", {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token }}'},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
result.classList.remove('hidden');
|
||||
if (data.ok) {
|
||||
result.className = 'ml-3 text-sm text-green-600';
|
||||
result.textContent = '✓ Test message sent!';
|
||||
} else {
|
||||
result.className = 'ml-3 text-sm text-red-600';
|
||||
result.textContent = '✗ ' + (data.error || 'Failed');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
result.classList.remove('hidden');
|
||||
result.className = 'ml-3 text-sm text-red-600';
|
||||
result.textContent = '✗ Network error';
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>Test Telegram';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,147 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="flex items-center gap-2 text-sm text-gray-400 mb-4">
|
||||
<a href="{% url 'netscan-dashboard' %}" class="hover:text-gray-600">NetScan</a>
|
||||
<span>/</span>
|
||||
<a href="{% url 'netscan-run-list' run.profile.pk %}" class="hover:text-gray-600">{{ run.profile.name }}</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-600">Run #{{ run.pk }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<div class="flex items-start justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900">{{ run.profile.name }} — Run #{{ run.pk }}</h1>
|
||||
<p class="text-gray-500 text-sm mt-1">
|
||||
Started {{ run.started_at|date:"N j, Y H:i:s" }}
|
||||
{% if run.duration_seconds is not None %}· {{ run.duration_seconds }}s{% endif %}
|
||||
· Triggered by <strong>{{ run.triggered_by }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||
{% if run.status == 'success' %}bg-green-100 text-green-700
|
||||
{% elif run.status == 'failed' %}bg-red-100 text-red-700
|
||||
{% elif run.status == 'running' %}bg-blue-100 text-blue-700
|
||||
{% else %}bg-gray-100 text-gray-600{% endif %}">
|
||||
{{ run.status }}
|
||||
</span>
|
||||
<form method="post" action="{% url 'netscan-trigger' run.profile.pk %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium text-sm">
|
||||
<i class="fas fa-redo mr-1.5"></i> Re-run
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary badges -->
|
||||
<div class="flex flex-wrap gap-3 mt-4 pt-4 border-t border-gray-100">
|
||||
{% if run.summary.critical %}
|
||||
<div class="flex items-center gap-1.5 bg-red-50 text-red-700 px-3 py-1.5 rounded-lg text-sm font-semibold">
|
||||
🔴 {{ run.summary.critical }} Critical
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if run.summary.warning %}
|
||||
<div class="flex items-center gap-1.5 bg-yellow-50 text-yellow-700 px-3 py-1.5 rounded-lg text-sm font-semibold">
|
||||
🟡 {{ run.summary.warning }} Warning
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if run.summary.ok %}
|
||||
<div class="flex items-center gap-1.5 bg-green-50 text-green-700 px-3 py-1.5 rounded-lg text-sm font-semibold">
|
||||
🟢 {{ run.summary.ok }} OK
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if run.summary.info %}
|
||||
<div class="flex items-center gap-1.5 bg-blue-50 text-blue-700 px-3 py-1.5 rounded-lg text-sm font-semibold">
|
||||
ℹ️ {{ run.summary.info }} Info
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Critical -->
|
||||
{% if critical_findings %}
|
||||
<details class="mb-4 open" open>
|
||||
<summary class="flex items-center gap-2 cursor-pointer bg-red-50 border border-red-200 rounded-xl px-5 py-3 font-semibold text-red-800 select-none">
|
||||
🔴 Critical Findings ({{ critical_findings|length }})
|
||||
</summary>
|
||||
<div class="mt-2 space-y-3">
|
||||
{% for f in critical_findings %}
|
||||
<div class="bg-white border border-red-200 rounded-xl p-4">
|
||||
<div class="font-medium text-gray-900 mb-1">{{ f.title }}</div>
|
||||
<p class="text-sm text-gray-600">{{ f.detail }}</p>
|
||||
{% if f.raw %}
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-600">View raw data</summary>
|
||||
<pre class="mt-2 bg-gray-50 rounded-lg p-3 text-xs overflow-auto text-gray-700">{{ f.raw|pprint }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<!-- Warnings -->
|
||||
{% if warning_findings %}
|
||||
<details class="mb-4">
|
||||
<summary class="flex items-center gap-2 cursor-pointer bg-yellow-50 border border-yellow-200 rounded-xl px-5 py-3 font-semibold text-yellow-800 select-none">
|
||||
🟡 Warnings ({{ warning_findings|length }})
|
||||
</summary>
|
||||
<div class="mt-2 space-y-3">
|
||||
{% for f in warning_findings %}
|
||||
<div class="bg-white border border-yellow-200 rounded-xl p-4">
|
||||
<div class="font-medium text-gray-900 mb-1">{{ f.title }}</div>
|
||||
<p class="text-sm text-gray-600">{{ f.detail }}</p>
|
||||
{% if f.raw %}
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-600">View raw data</summary>
|
||||
<pre class="mt-2 bg-gray-50 rounded-lg p-3 text-xs overflow-auto text-gray-700">{{ f.raw|pprint }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<!-- OK / Info -->
|
||||
{% if ok_findings %}
|
||||
<details class="mb-4">
|
||||
<summary class="flex items-center gap-2 cursor-pointer bg-green-50 border border-green-200 rounded-xl px-5 py-3 font-semibold text-green-800 select-none">
|
||||
🟢 OK / Info ({{ ok_findings|length }})
|
||||
</summary>
|
||||
<div class="mt-2 space-y-3">
|
||||
{% for f in ok_findings %}
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div class="flex items-center gap-2 font-medium text-gray-900 mb-1">
|
||||
{% if f.severity == 'info' %}ℹ️{% else %}🟢{% endif %}
|
||||
{{ f.title }}
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">{{ f.detail }}</p>
|
||||
{% if f.raw %}
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-600">View raw data</summary>
|
||||
<pre class="mt-2 bg-gray-50 rounded-lg p-3 text-xs overflow-auto text-gray-700">{{ f.raw|pprint }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if not critical_findings and not warning_findings and not ok_findings %}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<i class="fas fa-hourglass-half text-3xl mb-2"></i>
|
||||
<p>No findings recorded yet — the scan may still be running.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,98 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-5xl mx-auto px-4 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<a href="{% url 'netscan-dashboard' %}" class="text-gray-400 hover:text-gray-600 text-sm">
|
||||
<i class="fas fa-arrow-left mr-1"></i> NetScan
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mt-1">{{ profile.name }} — Scan History</h1>
|
||||
</div>
|
||||
<form method="post" action="{% url 'netscan-trigger' profile.pk %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium text-sm">
|
||||
<i class="fas fa-play mr-2"></i> Run Now
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if runs %}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Started</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Duration</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Triggered by</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Status</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Findings</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{% for run in runs %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-gray-800 font-mono text-xs">{{ run.started_at|date:"M d, H:i:s" }}</td>
|
||||
<td class="px-4 py-3 text-gray-500">
|
||||
{% if run.duration_seconds is not None %}{{ run.duration_seconds }}s{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{% if run.triggered_by == 'manual' %}bg-blue-50 text-blue-700{% else %}bg-gray-100 text-gray-600{% endif %}">
|
||||
{{ run.triggered_by }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{% if run.status == 'success' %}bg-green-100 text-green-700
|
||||
{% elif run.status == 'failed' %}bg-red-100 text-red-700
|
||||
{% elif run.status == 'running' %}bg-blue-100 text-blue-700
|
||||
{% else %}bg-gray-100 text-gray-600{% endif %}">
|
||||
{{ run.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="flex items-center gap-2">
|
||||
{% if run.summary.critical %}<span class="text-red-600 font-medium">🔴{{ run.summary.critical }}</span>{% endif %}
|
||||
{% if run.summary.warning %}<span class="text-yellow-600 font-medium">🟡{{ run.summary.warning }}</span>{% endif %}
|
||||
{% if run.summary.ok %}<span class="text-green-600 font-medium">🟢{{ run.summary.ok }}</span>{% endif %}
|
||||
{% if run.summary.info %}<span class="text-blue-600 font-medium">ℹ️{{ run.summary.info }}</span>{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="{% url 'netscan-run-detail' run.pk %}"
|
||||
class="text-blue-600 hover:text-blue-800 text-xs font-medium">View →</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<div class="flex justify-center mt-6 gap-2">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}"
|
||||
class="px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm hover:bg-gray-50">← Prev</a>
|
||||
{% endif %}
|
||||
<span class="px-3 py-1.5 text-sm text-gray-600">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}"
|
||||
class="px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm hover:bg-gray-50">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-16 text-gray-400">
|
||||
<i class="fas fa-history text-4xl mb-3"></i>
|
||||
<p>No scan runs yet. Click <strong>Run Now</strong> to start.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.DashboardView.as_view(), name='netscan-dashboard'),
|
||||
path('profile/new/', views.ProfileCreateView.as_view(), name='netscan-profile-create'),
|
||||
path('profile/<int:pk>/edit/', views.ProfileUpdateView.as_view(), name='netscan-profile-edit'),
|
||||
path('profile/<int:pk>/delete/', views.ProfileDeleteView.as_view(), name='netscan-profile-delete'),
|
||||
path('profile/<int:pk>/runs/', views.ScanRunListView.as_view(), name='netscan-run-list'),
|
||||
path('profile/<int:pk>/trigger/', views.TriggerScanView.as_view(), name='netscan-trigger'),
|
||||
path('profile/<int:pk>/test-telegram/', views.TestTelegramView.as_view(), name='netscan-test-telegram'),
|
||||
path('detect-network/', views.DetectNetworkView.as_view(), name='netscan-detect-network'),
|
||||
path('run/<int:pk>/', views.ScanRunDetailView.as_view(), name='netscan-run-detail'),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -24,6 +24,7 @@ dependencies = [
|
||||
"pillow~=12.1.1",
|
||||
"whoosh==2.7.4",
|
||||
"qdrant-client>=1.13.2",
|
||||
"cryptography>=42.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -129,6 +129,16 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'netscan-dashboard' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>
|
||||
{% trans "NetScan" %}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'mini-apps-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user