Compare commits
52 Commits
0ec338e252
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b197e0b3d | |||
| 30f0987a99 | |||
| 9e2fc348b7 | |||
| 847e05abbe | |||
| 07c2bcfd11 | |||
| a560bae800 | |||
| 56828e4184 | |||
| 5e3a70f837 | |||
| 451c7e92ff | |||
| 8b89fd506d | |||
| f24bd11dfd | |||
| 9bd2f67150 | |||
| 3058c69c39 | |||
| 04dc238aea | |||
| c592000c96 | |||
| 4c6b4bf35d | |||
| 3adb51ece2 | |||
| c4cbbee280 | |||
| 889e1eaac3 | |||
| a682e5233c | |||
| 7a14f1602b | |||
| 949bccf644 | |||
| 801ddc8d81 | |||
| db5c828b5f | |||
| a044c19a46 | |||
| a5e2b43944 | |||
| 3219f8a861 | |||
| 480065ed14 | |||
| 73a3b95834 | |||
| 8d8e53c903 | |||
| 12d5aff7a5 | |||
| cc3758f92d | |||
| 9804f9c032 | |||
| e3b647521e | |||
| 7460c9e23e | |||
| 66b02edc84 | |||
| f8b89c46c2 | |||
| 6d5005403c | |||
| 05f846809e | |||
| 7c26824aa1 | |||
| 91507cc8f8 | |||
| 7437716613 | |||
| 657f4784bf | |||
| 73d04cae5e | |||
| b8c3e4e2d8 | |||
| aa7c32381c | |||
| 0fc51eb032 | |||
| fdf689316f | |||
| 41ba4c47b5 | |||
| b2e6efb4b3 | |||
| e7dd207a62 | |||
| 30a29142a0 |
15
.env.example
15
.env.example
File diff suppressed because one or more lines are too long
30
README.md
30
README.md
@@ -3,7 +3,7 @@
|
|||||||
A comprehensive network scanning and infrastructure monitoring platform with web interface and CLI scanner. SneakyScanner uses masscan for fast port discovery, nmap for service detection, sslyze for SSL/TLS analysis, and Playwright for webpage screenshots to perform comprehensive infrastructure audits.
|
A comprehensive network scanning and infrastructure monitoring platform with web interface and CLI scanner. SneakyScanner uses masscan for fast port discovery, nmap for service detection, sslyze for SSL/TLS analysis, and Playwright for webpage screenshots to perform comprehensive infrastructure audits.
|
||||||
|
|
||||||
**Primary Interface**: Web Application (Flask-based GUI)
|
**Primary Interface**: Web Application (Flask-based GUI)
|
||||||
**Alternative**: Standalone CLI Scanner (for testing and CI/CD)
|
**Scripting/Automation**: REST API (see [API Reference](docs/API_REFERENCE.md))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ A comprehensive network scanning and infrastructure monitoring platform with web
|
|||||||
- 🌐 **Web Dashboard** - Modern web UI for scan management, scheduling, and historical analysis
|
- 🌐 **Web Dashboard** - Modern web UI for scan management, scheduling, and historical analysis
|
||||||
- 📊 **Database Storage** - SQLite-based scan history with trend analysis and comparison
|
- 📊 **Database Storage** - SQLite-based scan history with trend analysis and comparison
|
||||||
- ⏰ **Scheduled Scans** - Cron-based automated scanning with APScheduler
|
- ⏰ **Scheduled Scans** - Cron-based automated scanning with APScheduler
|
||||||
- 🔧 **Config Creator** - CIDR-to-YAML configuration builder for quick setup
|
- 🔧 **Config Creator** - Web-based target configuration builder for quick setup
|
||||||
- 🔍 **Network Discovery** - Fast port scanning with masscan (all 65535 ports, TCP/UDP)
|
- 🔍 **Network Discovery** - Fast port scanning with masscan (all 65535 ports, TCP/UDP)
|
||||||
- 🎯 **Service Detection** - Nmap-based service enumeration with version detection
|
- 🎯 **Service Detection** - Nmap-based service enumeration with version detection
|
||||||
- 🔒 **SSL/TLS Analysis** - Certificate extraction, TLS version testing, cipher suite analysis
|
- 🔒 **SSL/TLS Analysis** - Certificate extraction, TLS version testing, cipher suite analysis
|
||||||
@@ -27,7 +27,7 @@ A comprehensive network scanning and infrastructure monitoring platform with web
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Web Application (Recommended)
|
### Web Application
|
||||||
|
|
||||||
**Easy Setup (One Command):**
|
**Easy Setup (One Command):**
|
||||||
|
|
||||||
@@ -69,28 +69,13 @@ docker compose up --build -d
|
|||||||
|
|
||||||
**See [Deployment Guide](docs/DEPLOYMENT.md) for detailed setup instructions.**
|
**See [Deployment Guide](docs/DEPLOYMENT.md) for detailed setup instructions.**
|
||||||
|
|
||||||
### CLI Scanner (Standalone)
|
|
||||||
|
|
||||||
For quick one-off scans without the web interface:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build and run
|
|
||||||
docker compose -f docker-compose-standalone.yml build
|
|
||||||
docker compose -f docker-compose-standalone.yml up
|
|
||||||
|
|
||||||
# Results saved to ./output/
|
|
||||||
```
|
|
||||||
|
|
||||||
**See [CLI Scanning Guide](docs/CLI_SCANNING.md) for detailed usage.**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
### User Guides
|
### User Guides
|
||||||
- **[Deployment Guide](docs/DEPLOYMENT.md)** - Installation, configuration, and production deployment
|
- **[Deployment Guide](docs/DEPLOYMENT.md)** - Installation, configuration, and production deployment
|
||||||
- **[CLI Scanning Guide](docs/CLI_SCANNING.md)** - Standalone scanner usage, configuration, and output formats
|
- **[API Reference](docs/API_REFERENCE.md)** - Complete REST API documentation for scripting and automation
|
||||||
- **[API Reference](docs/API_REFERENCE.md)** - Complete REST API documentation
|
|
||||||
|
|
||||||
### Developer Resources
|
### Developer Resources
|
||||||
- **[Roadmap](docs/ROADMAP.md)** - Project roadmap, architecture, and planned features
|
- **[Roadmap](docs/ROADMAP.md)** - Project roadmap, architecture, and planned features
|
||||||
@@ -107,7 +92,7 @@ docker compose -f docker-compose-standalone.yml up
|
|||||||
- ✅ **Phase 1**: Database schema, SQLAlchemy models, settings system
|
- ✅ **Phase 1**: Database schema, SQLAlchemy models, settings system
|
||||||
- ✅ **Phase 2**: REST API, background jobs, authentication, web UI
|
- ✅ **Phase 2**: REST API, background jobs, authentication, web UI
|
||||||
- ✅ **Phase 3**: Dashboard, scheduling, trend charts
|
- ✅ **Phase 3**: Dashboard, scheduling, trend charts
|
||||||
- ✅ **Phase 4**: Config creator, YAML editor, config management UI
|
- ✅ **Phase 4**: Config creator, target editor, config management UI
|
||||||
- ✅ **Phase 5**: Webhooks & alerting, notification templates, alert rules
|
- ✅ **Phase 5**: Webhooks & alerting, notification templates, alert rules
|
||||||
|
|
||||||
### Next Up: Phase 6 - CLI as API Client
|
### Next Up: Phase 6 - CLI as API Client
|
||||||
@@ -188,7 +173,7 @@ See [Deployment Guide](docs/DEPLOYMENT.md) for production security checklist.
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
This is a personal/small team project. For bugs or feature requests:
|
This is a personal project. For bugs or feature requests:
|
||||||
|
|
||||||
1. Check existing issues
|
1. Check existing issues
|
||||||
2. Create detailed bug reports with reproduction steps
|
2. Create detailed bug reports with reproduction steps
|
||||||
@@ -206,7 +191,6 @@ MIT License - See LICENSE file for details
|
|||||||
|
|
||||||
**Documentation**:
|
**Documentation**:
|
||||||
- [Deployment Guide](docs/DEPLOYMENT.md)
|
- [Deployment Guide](docs/DEPLOYMENT.md)
|
||||||
- [CLI Scanning Guide](docs/CLI_SCANNING.md)
|
|
||||||
- [API Reference](docs/API_REFERENCE.md)
|
- [API Reference](docs/API_REFERENCE.md)
|
||||||
- [Roadmap](docs/ROADMAP.md)
|
- [Roadmap](docs/ROADMAP.md)
|
||||||
|
|
||||||
@@ -214,5 +198,5 @@ MIT License - See LICENSE file for details
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Version**: Phase 5 Complete
|
**Version**: 1.0.0-beta
|
||||||
**Last Updated**: 2025-11-19
|
**Last Updated**: 2025-11-19
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ def init_default_alert_rules(session):
|
|||||||
'webhook_enabled': False,
|
'webhook_enabled': False,
|
||||||
'severity': 'warning',
|
'severity': 'warning',
|
||||||
'filter_conditions': None,
|
'filter_conditions': None,
|
||||||
'config_file': None
|
'config_id': None
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Drift Detection',
|
'name': 'Drift Detection',
|
||||||
@@ -65,7 +65,7 @@ def init_default_alert_rules(session):
|
|||||||
'webhook_enabled': False,
|
'webhook_enabled': False,
|
||||||
'severity': 'info',
|
'severity': 'info',
|
||||||
'filter_conditions': None,
|
'filter_conditions': None,
|
||||||
'config_file': None
|
'config_id': None
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Certificate Expiry Warning',
|
'name': 'Certificate Expiry Warning',
|
||||||
@@ -76,7 +76,7 @@ def init_default_alert_rules(session):
|
|||||||
'webhook_enabled': False,
|
'webhook_enabled': False,
|
||||||
'severity': 'warning',
|
'severity': 'warning',
|
||||||
'filter_conditions': None,
|
'filter_conditions': None,
|
||||||
'config_file': None
|
'config_id': None
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Weak TLS Detection',
|
'name': 'Weak TLS Detection',
|
||||||
@@ -87,7 +87,7 @@ def init_default_alert_rules(session):
|
|||||||
'webhook_enabled': False,
|
'webhook_enabled': False,
|
||||||
'severity': 'warning',
|
'severity': 'warning',
|
||||||
'filter_conditions': None,
|
'filter_conditions': None,
|
||||||
'config_file': None
|
'config_id': None
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Host Down Detection',
|
'name': 'Host Down Detection',
|
||||||
@@ -98,7 +98,7 @@ def init_default_alert_rules(session):
|
|||||||
'webhook_enabled': False,
|
'webhook_enabled': False,
|
||||||
'severity': 'critical',
|
'severity': 'critical',
|
||||||
'filter_conditions': None,
|
'filter_conditions': None,
|
||||||
'config_file': None
|
'config_id': None
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ def init_default_alert_rules(session):
|
|||||||
webhook_enabled=rule_data['webhook_enabled'],
|
webhook_enabled=rule_data['webhook_enabled'],
|
||||||
severity=rule_data['severity'],
|
severity=rule_data['severity'],
|
||||||
filter_conditions=rule_data['filter_conditions'],
|
filter_conditions=rule_data['filter_conditions'],
|
||||||
config_file=rule_data['config_file'],
|
config_id=rule_data['config_id'],
|
||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
updated_at=datetime.now(timezone.utc)
|
updated_at=datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
|||||||
86
app/migrations/versions/011_drop_config_file.py
Normal file
86
app/migrations/versions/011_drop_config_file.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Drop deprecated config_file columns
|
||||||
|
|
||||||
|
Revision ID: 011
|
||||||
|
Revises: 010
|
||||||
|
Create Date: 2025-11-19
|
||||||
|
|
||||||
|
This migration removes the deprecated config_file columns from scans and schedules
|
||||||
|
tables. All functionality now uses config_id to reference database-stored configs.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic
|
||||||
|
revision = '011'
|
||||||
|
down_revision = '010'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
"""
|
||||||
|
Drop config_file columns from scans and schedules tables.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- All scans must have config_id set
|
||||||
|
- All schedules must have config_id set
|
||||||
|
- Code must be updated to no longer reference config_file
|
||||||
|
"""
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# Check for any records missing config_id
|
||||||
|
result = connection.execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM scans WHERE config_id IS NULL"
|
||||||
|
))
|
||||||
|
scans_without_config = result.scalar()
|
||||||
|
|
||||||
|
result = connection.execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM schedules WHERE config_id IS NULL"
|
||||||
|
))
|
||||||
|
schedules_without_config = result.scalar()
|
||||||
|
|
||||||
|
if scans_without_config > 0:
|
||||||
|
print(f"WARNING: {scans_without_config} scans have NULL config_id")
|
||||||
|
print(" These scans will lose their config reference after migration")
|
||||||
|
|
||||||
|
if schedules_without_config > 0:
|
||||||
|
raise Exception(
|
||||||
|
f"Cannot proceed: {schedules_without_config} schedules have NULL config_id. "
|
||||||
|
"Please set config_id for all schedules before running this migration."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop config_file from scans table
|
||||||
|
with op.batch_alter_table('scans', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('config_file')
|
||||||
|
|
||||||
|
# Drop config_file from schedules table
|
||||||
|
with op.batch_alter_table('schedules', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('config_file')
|
||||||
|
|
||||||
|
print("✓ Migration complete: Dropped config_file columns")
|
||||||
|
print(" - Removed config_file from scans table")
|
||||||
|
print(" - Removed config_file from schedules table")
|
||||||
|
print(" - All references should now use config_id")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
"""Re-add config_file columns (data will be lost)."""
|
||||||
|
|
||||||
|
# Add config_file back to scans
|
||||||
|
with op.batch_alter_table('scans', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('config_file', sa.Text(), nullable=True,
|
||||||
|
comment='Path to YAML config used (deprecated)')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add config_file back to schedules
|
||||||
|
with op.batch_alter_table('schedules', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('config_file', sa.Text(), nullable=True,
|
||||||
|
comment='Path to YAML config (deprecated)')
|
||||||
|
)
|
||||||
|
|
||||||
|
print("✓ Downgrade complete: Re-added config_file columns")
|
||||||
|
print(" WARNING: config_file values are lost and will be NULL")
|
||||||
58
app/migrations/versions/012_add_scan_progress.py
Normal file
58
app/migrations/versions/012_add_scan_progress.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Add scan progress tracking
|
||||||
|
|
||||||
|
Revision ID: 012
|
||||||
|
Revises: 011
|
||||||
|
Create Date: 2024-01-01 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '012'
|
||||||
|
down_revision = '011'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Add progress tracking columns to scans table
|
||||||
|
op.add_column('scans', sa.Column('current_phase', sa.String(50), nullable=True,
|
||||||
|
comment='Current scan phase: ping, tcp_scan, udp_scan, service_detection, http_analysis'))
|
||||||
|
op.add_column('scans', sa.Column('total_ips', sa.Integer(), nullable=True,
|
||||||
|
comment='Total number of IPs to scan'))
|
||||||
|
op.add_column('scans', sa.Column('completed_ips', sa.Integer(), nullable=True, default=0,
|
||||||
|
comment='Number of IPs completed in current phase'))
|
||||||
|
|
||||||
|
# Create scan_progress table for per-IP progress tracking
|
||||||
|
op.create_table(
|
||||||
|
'scan_progress',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('scan_id', sa.Integer(), sa.ForeignKey('scans.id'), nullable=False, index=True),
|
||||||
|
sa.Column('ip_address', sa.String(45), nullable=False, comment='IP address being scanned'),
|
||||||
|
sa.Column('site_name', sa.String(255), nullable=True, comment='Site name this IP belongs to'),
|
||||||
|
sa.Column('phase', sa.String(50), nullable=False,
|
||||||
|
comment='Phase: ping, tcp_scan, udp_scan, service_detection, http_analysis'),
|
||||||
|
sa.Column('status', sa.String(20), nullable=False, default='pending',
|
||||||
|
comment='pending, in_progress, completed, failed'),
|
||||||
|
sa.Column('ping_result', sa.Boolean(), nullable=True, comment='Ping response result'),
|
||||||
|
sa.Column('tcp_ports', sa.Text(), nullable=True, comment='JSON array of discovered TCP ports'),
|
||||||
|
sa.Column('udp_ports', sa.Text(), nullable=True, comment='JSON array of discovered UDP ports'),
|
||||||
|
sa.Column('services', sa.Text(), nullable=True, comment='JSON array of detected services'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now(),
|
||||||
|
comment='Entry creation time'),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(),
|
||||||
|
onupdate=sa.func.now(), comment='Last update time'),
|
||||||
|
sa.UniqueConstraint('scan_id', 'ip_address', name='uix_scan_progress_ip')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Drop scan_progress table
|
||||||
|
op.drop_table('scan_progress')
|
||||||
|
|
||||||
|
# Remove progress tracking columns from scans table
|
||||||
|
op.drop_column('scans', 'completed_ips')
|
||||||
|
op.drop_column('scans', 'total_ips')
|
||||||
|
op.drop_column('scans', 'current_phase')
|
||||||
@@ -12,7 +12,7 @@ alembic==1.13.0
|
|||||||
# Authentication & Security
|
# Authentication & Security
|
||||||
Flask-Login==0.6.3
|
Flask-Login==0.6.3
|
||||||
bcrypt==4.1.2
|
bcrypt==4.1.2
|
||||||
cryptography==41.0.7
|
cryptography>=46.0.0
|
||||||
|
|
||||||
# API & Serialization
|
# API & Serialization
|
||||||
Flask-CORS==4.0.0
|
Flask-CORS==4.0.0
|
||||||
@@ -34,4 +34,4 @@ python-dotenv==1.0.0
|
|||||||
|
|
||||||
# Development & Testing
|
# Development & Testing
|
||||||
pytest==7.4.3
|
pytest==7.4.3
|
||||||
pytest-flask==1.3.0
|
pytest-flask==1.3.0
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
PyYAML==6.0.1
|
PyYAML==6.0.1
|
||||||
python-libnmap==0.7.3
|
python-libnmap==0.7.3
|
||||||
sslyze==6.0.0
|
sslyze==6.2.0
|
||||||
playwright==1.40.0
|
playwright==1.40.0
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class HTMLReportGenerator:
|
|||||||
'title': self.report_data.get('title', 'SneakyScanner Report'),
|
'title': self.report_data.get('title', 'SneakyScanner Report'),
|
||||||
'scan_time': self.report_data.get('scan_time'),
|
'scan_time': self.report_data.get('scan_time'),
|
||||||
'scan_duration': self.report_data.get('scan_duration'),
|
'scan_duration': self.report_data.get('scan_duration'),
|
||||||
'config_file': self.report_data.get('config_file'),
|
'config_id': self.report_data.get('config_id'),
|
||||||
'sites': self.report_data.get('sites', []),
|
'sites': self.report_data.get('sites', []),
|
||||||
'summary_stats': summary_stats,
|
'summary_stats': summary_stats,
|
||||||
'drift_alerts': drift_alerts,
|
'drift_alerts': drift_alerts,
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ SneakyScanner - Masscan-based network scanner with YAML configuration
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any, Callable, Optional
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
@@ -22,12 +25,18 @@ from libnmap.parser import NmapParser
|
|||||||
|
|
||||||
from src.screenshot_capture import ScreenshotCapture
|
from src.screenshot_capture import ScreenshotCapture
|
||||||
from src.report_generator import HTMLReportGenerator
|
from src.report_generator import HTMLReportGenerator
|
||||||
|
from web.config import NMAP_HOST_TIMEOUT
|
||||||
|
|
||||||
# Force unbuffered output for Docker
|
# Force unbuffered output for Docker
|
||||||
sys.stdout.reconfigure(line_buffering=True)
|
sys.stdout.reconfigure(line_buffering=True)
|
||||||
sys.stderr.reconfigure(line_buffering=True)
|
sys.stderr.reconfigure(line_buffering=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ScanCancelledError(Exception):
|
||||||
|
"""Raised when a scan is cancelled by the user."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SneakyScanner:
|
class SneakyScanner:
|
||||||
"""Wrapper for masscan to perform network scans based on YAML config or database config"""
|
"""Wrapper for masscan to perform network scans based on YAML config or database config"""
|
||||||
|
|
||||||
@@ -61,6 +70,34 @@ class SneakyScanner:
|
|||||||
|
|
||||||
self.screenshot_capture = None
|
self.screenshot_capture = None
|
||||||
|
|
||||||
|
# Cancellation support
|
||||||
|
self._cancelled = False
|
||||||
|
self._cancel_lock = threading.Lock()
|
||||||
|
self._active_process = None
|
||||||
|
self._process_lock = threading.Lock()
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
"""
|
||||||
|
Cancel the running scan.
|
||||||
|
|
||||||
|
Terminates any active subprocess and sets cancellation flag.
|
||||||
|
"""
|
||||||
|
with self._cancel_lock:
|
||||||
|
self._cancelled = True
|
||||||
|
|
||||||
|
with self._process_lock:
|
||||||
|
if self._active_process and self._active_process.poll() is None:
|
||||||
|
try:
|
||||||
|
# Terminate the process group
|
||||||
|
os.killpg(os.getpgid(self._active_process.pid), signal.SIGTERM)
|
||||||
|
except (ProcessLookupError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_cancelled(self) -> bool:
|
||||||
|
"""Check if scan has been cancelled."""
|
||||||
|
with self._cancel_lock:
|
||||||
|
return self._cancelled
|
||||||
|
|
||||||
def _load_config(self) -> Dict[str, Any]:
|
def _load_config(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Load and validate configuration from file or database.
|
Load and validate configuration from file or database.
|
||||||
@@ -381,11 +418,31 @@ class SneakyScanner:
|
|||||||
raise ValueError(f"Invalid protocol: {protocol}")
|
raise ValueError(f"Invalid protocol: {protocol}")
|
||||||
|
|
||||||
print(f"Running: {' '.join(cmd)}", flush=True)
|
print(f"Running: {' '.join(cmd)}", flush=True)
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
||||||
|
# Use Popen for cancellation support
|
||||||
|
with self._process_lock:
|
||||||
|
self._active_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, stderr = self._active_process.communicate()
|
||||||
|
returncode = self._active_process.returncode
|
||||||
|
|
||||||
|
with self._process_lock:
|
||||||
|
self._active_process = None
|
||||||
|
|
||||||
|
# Check if cancelled
|
||||||
|
if self.is_cancelled():
|
||||||
|
return []
|
||||||
|
|
||||||
print(f"Masscan {protocol.upper()} scan completed", flush=True)
|
print(f"Masscan {protocol.upper()} scan completed", flush=True)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if returncode != 0:
|
||||||
print(f"Masscan stderr: {result.stderr}", file=sys.stderr)
|
print(f"Masscan stderr: {stderr}", file=sys.stderr)
|
||||||
|
|
||||||
# Parse masscan JSON output
|
# Parse masscan JSON output
|
||||||
results = []
|
results = []
|
||||||
@@ -433,11 +490,31 @@ class SneakyScanner:
|
|||||||
]
|
]
|
||||||
|
|
||||||
print(f"Running: {' '.join(cmd)}", flush=True)
|
print(f"Running: {' '.join(cmd)}", flush=True)
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
||||||
|
# Use Popen for cancellation support
|
||||||
|
with self._process_lock:
|
||||||
|
self._active_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, stderr = self._active_process.communicate()
|
||||||
|
returncode = self._active_process.returncode
|
||||||
|
|
||||||
|
with self._process_lock:
|
||||||
|
self._active_process = None
|
||||||
|
|
||||||
|
# Check if cancelled
|
||||||
|
if self.is_cancelled():
|
||||||
|
return {}
|
||||||
|
|
||||||
print(f"Masscan PING scan completed", flush=True)
|
print(f"Masscan PING scan completed", flush=True)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if returncode != 0:
|
||||||
print(f"Masscan stderr: {result.stderr}", file=sys.stderr, flush=True)
|
print(f"Masscan stderr: {stderr}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
# Parse results
|
# Parse results
|
||||||
responding_ips = set()
|
responding_ips = set()
|
||||||
@@ -475,6 +552,10 @@ class SneakyScanner:
|
|||||||
all_services = {}
|
all_services = {}
|
||||||
|
|
||||||
for ip, ports in ip_ports.items():
|
for ip, ports in ip_ports.items():
|
||||||
|
# Check if cancelled before each host
|
||||||
|
if self.is_cancelled():
|
||||||
|
break
|
||||||
|
|
||||||
if not ports:
|
if not ports:
|
||||||
all_services[ip] = []
|
all_services[ip] = []
|
||||||
continue
|
continue
|
||||||
@@ -496,14 +577,33 @@ class SneakyScanner:
|
|||||||
'--version-intensity', '5', # Balanced speed/accuracy
|
'--version-intensity', '5', # Balanced speed/accuracy
|
||||||
'-p', port_list,
|
'-p', port_list,
|
||||||
'-oX', xml_output, # XML output
|
'-oX', xml_output, # XML output
|
||||||
'--host-timeout', '5m', # Timeout per host
|
'--host-timeout', NMAP_HOST_TIMEOUT, # Timeout per host
|
||||||
ip
|
ip
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
# Use Popen for cancellation support
|
||||||
|
with self._process_lock:
|
||||||
|
self._active_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
if result.returncode != 0:
|
stdout, stderr = self._active_process.communicate(timeout=600)
|
||||||
print(f" Nmap warning for {ip}: {result.stderr}", file=sys.stderr, flush=True)
|
returncode = self._active_process.returncode
|
||||||
|
|
||||||
|
with self._process_lock:
|
||||||
|
self._active_process = None
|
||||||
|
|
||||||
|
# Check if cancelled
|
||||||
|
if self.is_cancelled():
|
||||||
|
Path(xml_output).unlink(missing_ok=True)
|
||||||
|
break
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
print(f" Nmap warning for {ip}: {stderr}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
# Parse XML output
|
# Parse XML output
|
||||||
services = self._parse_nmap_xml(xml_output)
|
services = self._parse_nmap_xml(xml_output)
|
||||||
@@ -576,29 +676,57 @@ class SneakyScanner:
|
|||||||
|
|
||||||
return services
|
return services
|
||||||
|
|
||||||
def _is_likely_web_service(self, service: Dict) -> bool:
|
def _is_likely_web_service(self, service: Dict, ip: str = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a service is likely HTTP/HTTPS based on nmap detection or common web ports
|
Check if a service is a web server by actually making an HTTP request
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
service: Service dictionary from nmap results
|
service: Service dictionary from nmap results
|
||||||
|
ip: IP address to test (required for HTTP probe)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if service appears to be web-related
|
True if service responds to HTTP/HTTPS requests
|
||||||
"""
|
"""
|
||||||
# Check service name
|
import requests
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
# Quick check for known web service names first
|
||||||
web_services = ['http', 'https', 'ssl', 'http-proxy', 'https-alt',
|
web_services = ['http', 'https', 'ssl', 'http-proxy', 'https-alt',
|
||||||
'http-alt', 'ssl/http', 'ssl/https']
|
'http-alt', 'ssl/http', 'ssl/https']
|
||||||
service_name = service.get('service', '').lower()
|
service_name = service.get('service', '').lower()
|
||||||
|
|
||||||
if service_name in web_services:
|
# If no IP provided, can't do HTTP probe
|
||||||
return True
|
|
||||||
|
|
||||||
# Check common non-standard web ports
|
|
||||||
web_ports = [80, 443, 8000, 8006, 8008, 8080, 8081, 8443, 8888, 9443]
|
|
||||||
port = service.get('port')
|
port = service.get('port')
|
||||||
|
if not ip or not port:
|
||||||
|
# check just the service if no IP - honestly shouldn't get here, but just incase...
|
||||||
|
if service_name in web_services:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
return port in web_ports
|
# Actually try to connect - this is the definitive test
|
||||||
|
# Try HTTPS first, then HTTP
|
||||||
|
for protocol in ['https', 'http']:
|
||||||
|
url = f"{protocol}://{ip}:{port}/"
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
url,
|
||||||
|
timeout=3,
|
||||||
|
verify=False,
|
||||||
|
allow_redirects=False
|
||||||
|
)
|
||||||
|
# Any status code means it's a web server
|
||||||
|
# (including 404, 500, etc. - still a web server)
|
||||||
|
return True
|
||||||
|
except requests.exceptions.SSLError:
|
||||||
|
# SSL error on HTTPS, try HTTP next
|
||||||
|
continue
|
||||||
|
except (requests.exceptions.ConnectionError,
|
||||||
|
requests.exceptions.Timeout,
|
||||||
|
requests.exceptions.RequestException):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def _detect_http_https(self, ip: str, port: int, timeout: int = 5) -> str:
|
def _detect_http_https(self, ip: str, port: int, timeout: int = 5) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -786,7 +914,7 @@ class SneakyScanner:
|
|||||||
ip_results = {}
|
ip_results = {}
|
||||||
|
|
||||||
for service in services:
|
for service in services:
|
||||||
if not self._is_likely_web_service(service):
|
if not self._is_likely_web_service(service, ip):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
port = service['port']
|
port = service['port']
|
||||||
@@ -832,10 +960,17 @@ class SneakyScanner:
|
|||||||
|
|
||||||
return all_results
|
return all_results
|
||||||
|
|
||||||
def scan(self) -> Dict[str, Any]:
|
def scan(self, progress_callback: Optional[Callable] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Perform complete scan based on configuration
|
Perform complete scan based on configuration
|
||||||
|
|
||||||
|
Args:
|
||||||
|
progress_callback: Optional callback function for progress updates.
|
||||||
|
Called with (phase, ip, data) where:
|
||||||
|
- phase: 'init', 'ping', 'tcp_scan', 'udp_scan', 'service_detection', 'http_analysis'
|
||||||
|
- ip: IP address being processed (or None for phase start)
|
||||||
|
- data: Dict with progress data (results, counts, etc.)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing scan results
|
Dictionary containing scan results
|
||||||
"""
|
"""
|
||||||
@@ -872,17 +1007,61 @@ class SneakyScanner:
|
|||||||
all_ips = sorted(list(all_ips))
|
all_ips = sorted(list(all_ips))
|
||||||
print(f"Total IPs to scan: {len(all_ips)}", flush=True)
|
print(f"Total IPs to scan: {len(all_ips)}", flush=True)
|
||||||
|
|
||||||
|
# Report initialization with total IP count
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('init', None, {
|
||||||
|
'total_ips': len(all_ips),
|
||||||
|
'ip_to_site': ip_to_site
|
||||||
|
})
|
||||||
|
|
||||||
# Perform ping scan
|
# Perform ping scan
|
||||||
print(f"\n[1/5] Performing ping scan on {len(all_ips)} IPs...", flush=True)
|
print(f"\n[1/5] Performing ping scan on {len(all_ips)} IPs...", flush=True)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('ping', None, {'status': 'starting'})
|
||||||
ping_results = self._run_ping_scan(all_ips)
|
ping_results = self._run_ping_scan(all_ips)
|
||||||
|
|
||||||
|
# Check for cancellation
|
||||||
|
if self.is_cancelled():
|
||||||
|
print("\nScan cancelled by user", flush=True)
|
||||||
|
raise ScanCancelledError("Scan cancelled by user")
|
||||||
|
|
||||||
|
# Report ping results
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('ping', None, {
|
||||||
|
'status': 'completed',
|
||||||
|
'results': ping_results
|
||||||
|
})
|
||||||
|
|
||||||
# Perform TCP scan (all ports)
|
# Perform TCP scan (all ports)
|
||||||
print(f"\n[2/5] Performing TCP scan on {len(all_ips)} IPs (ports 0-65535)...", flush=True)
|
print(f"\n[2/5] Performing TCP scan on {len(all_ips)} IPs (ports 0-65535)...", flush=True)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('tcp_scan', None, {'status': 'starting'})
|
||||||
tcp_results = self._run_masscan(all_ips, '0-65535', 'tcp')
|
tcp_results = self._run_masscan(all_ips, '0-65535', 'tcp')
|
||||||
|
|
||||||
# Perform UDP scan (all ports)
|
# Check for cancellation
|
||||||
print(f"\n[3/5] Performing UDP scan on {len(all_ips)} IPs (ports 0-65535)...", flush=True)
|
if self.is_cancelled():
|
||||||
udp_results = self._run_masscan(all_ips, '0-65535', 'udp')
|
print("\nScan cancelled by user", flush=True)
|
||||||
|
raise ScanCancelledError("Scan cancelled by user")
|
||||||
|
|
||||||
|
# Perform UDP scan (if enabled)
|
||||||
|
udp_enabled = os.environ.get('UDP_SCAN_ENABLED', 'false').lower() == 'true'
|
||||||
|
udp_ports = os.environ.get('UDP_PORTS', '53,67,68,69,123,161,500,514,1900')
|
||||||
|
|
||||||
|
if udp_enabled:
|
||||||
|
print(f"\n[3/5] Performing UDP scan on {len(all_ips)} IPs (ports {udp_ports})...", flush=True)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('udp_scan', None, {'status': 'starting'})
|
||||||
|
udp_results = self._run_masscan(all_ips, udp_ports, 'udp')
|
||||||
|
|
||||||
|
# Check for cancellation
|
||||||
|
if self.is_cancelled():
|
||||||
|
print("\nScan cancelled by user", flush=True)
|
||||||
|
raise ScanCancelledError("Scan cancelled by user")
|
||||||
|
else:
|
||||||
|
print(f"\n[3/5] Skipping UDP scan (disabled)...", flush=True)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('udp_scan', None, {'status': 'skipped'})
|
||||||
|
udp_results = []
|
||||||
|
|
||||||
# Organize results by IP
|
# Organize results by IP
|
||||||
results_by_ip = {}
|
results_by_ip = {}
|
||||||
@@ -917,20 +1096,56 @@ class SneakyScanner:
|
|||||||
results_by_ip[ip]['actual']['tcp_ports'].sort()
|
results_by_ip[ip]['actual']['tcp_ports'].sort()
|
||||||
results_by_ip[ip]['actual']['udp_ports'].sort()
|
results_by_ip[ip]['actual']['udp_ports'].sort()
|
||||||
|
|
||||||
|
# Report TCP/UDP scan results with discovered ports per IP
|
||||||
|
if progress_callback:
|
||||||
|
tcp_udp_results = {}
|
||||||
|
for ip in all_ips:
|
||||||
|
tcp_udp_results[ip] = {
|
||||||
|
'tcp_ports': results_by_ip[ip]['actual']['tcp_ports'],
|
||||||
|
'udp_ports': results_by_ip[ip]['actual']['udp_ports']
|
||||||
|
}
|
||||||
|
progress_callback('tcp_scan', None, {
|
||||||
|
'status': 'completed',
|
||||||
|
'results': tcp_udp_results
|
||||||
|
})
|
||||||
|
|
||||||
# Perform service detection on TCP ports
|
# Perform service detection on TCP ports
|
||||||
print(f"\n[4/5] Performing service detection on discovered TCP ports...", flush=True)
|
print(f"\n[4/5] Performing service detection on discovered TCP ports...", flush=True)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('service_detection', None, {'status': 'starting'})
|
||||||
ip_ports = {ip: results_by_ip[ip]['actual']['tcp_ports'] for ip in all_ips}
|
ip_ports = {ip: results_by_ip[ip]['actual']['tcp_ports'] for ip in all_ips}
|
||||||
service_results = self._run_nmap_service_detection(ip_ports)
|
service_results = self._run_nmap_service_detection(ip_ports)
|
||||||
|
|
||||||
|
# Check for cancellation
|
||||||
|
if self.is_cancelled():
|
||||||
|
print("\nScan cancelled by user", flush=True)
|
||||||
|
raise ScanCancelledError("Scan cancelled by user")
|
||||||
|
|
||||||
# Add service information to results
|
# Add service information to results
|
||||||
for ip, services in service_results.items():
|
for ip, services in service_results.items():
|
||||||
if ip in results_by_ip:
|
if ip in results_by_ip:
|
||||||
results_by_ip[ip]['actual']['services'] = services
|
results_by_ip[ip]['actual']['services'] = services
|
||||||
|
|
||||||
|
# Report service detection results
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('service_detection', None, {
|
||||||
|
'status': 'completed',
|
||||||
|
'results': service_results
|
||||||
|
})
|
||||||
|
|
||||||
# Perform HTTP/HTTPS analysis on web services
|
# Perform HTTP/HTTPS analysis on web services
|
||||||
print(f"\n[5/5] Analyzing HTTP/HTTPS services and SSL/TLS configuration...", flush=True)
|
print(f"\n[5/5] Analyzing HTTP/HTTPS services and SSL/TLS configuration...", flush=True)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('http_analysis', None, {'status': 'starting'})
|
||||||
http_results = self._run_http_analysis(service_results)
|
http_results = self._run_http_analysis(service_results)
|
||||||
|
|
||||||
|
# Report HTTP analysis completion
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('http_analysis', None, {
|
||||||
|
'status': 'completed',
|
||||||
|
'results': http_results
|
||||||
|
})
|
||||||
|
|
||||||
# Merge HTTP analysis into service results
|
# Merge HTTP analysis into service results
|
||||||
for ip, port_results in http_results.items():
|
for ip, port_results in http_results.items():
|
||||||
if ip in results_by_ip:
|
if ip in results_by_ip:
|
||||||
@@ -948,7 +1163,6 @@ class SneakyScanner:
|
|||||||
'title': self.config['title'],
|
'title': self.config['title'],
|
||||||
'scan_time': datetime.utcnow().isoformat() + 'Z',
|
'scan_time': datetime.utcnow().isoformat() + 'Z',
|
||||||
'scan_duration': scan_duration,
|
'scan_duration': scan_duration,
|
||||||
'config_file': str(self.config_path) if self.config_path else None,
|
|
||||||
'config_id': self.config_id,
|
'config_id': self.config_id,
|
||||||
'sites': []
|
'sites': []
|
||||||
}
|
}
|
||||||
@@ -1055,6 +1269,8 @@ class SneakyScanner:
|
|||||||
# Preserve directory structure in ZIP
|
# Preserve directory structure in ZIP
|
||||||
arcname = f"{screenshot_dir.name}/{screenshot_file.name}"
|
arcname = f"{screenshot_dir.name}/{screenshot_file.name}"
|
||||||
zipf.write(screenshot_file, arcname)
|
zipf.write(screenshot_file, arcname)
|
||||||
|
# Track screenshot directory for database storage
|
||||||
|
output_paths['screenshots'] = screenshot_dir
|
||||||
|
|
||||||
output_paths['zip'] = zip_path
|
output_paths['zip'] = zip_path
|
||||||
print(f"ZIP archive saved to: {zip_path}", flush=True)
|
print(f"ZIP archive saved to: {zip_path}", flush=True)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -490,8 +490,8 @@
|
|||||||
<div class="header-meta">
|
<div class="header-meta">
|
||||||
<span>📅 <strong>Scan Time:</strong> {{ scan_time | format_date }}</span>
|
<span>📅 <strong>Scan Time:</strong> {{ scan_time | format_date }}</span>
|
||||||
<span>⏱️ <strong>Duration:</strong> {{ scan_duration | format_duration }}</span>
|
<span>⏱️ <strong>Duration:</strong> {{ scan_duration | format_duration }}</span>
|
||||||
{% if config_file %}
|
{% if config_id %}
|
||||||
<span>📄 <strong>Config:</strong> {{ config_file }}</span>
|
<span>📄 <strong>Config ID:</strong> {{ config_id }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from web.app import create_app
|
from web.app import create_app
|
||||||
from web.models import Base, Scan
|
from web.models import Base, Scan, ScanConfig
|
||||||
from web.utils.settings import PasswordManager, SettingsManager
|
from web.utils.settings import PasswordManager, SettingsManager
|
||||||
|
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ def sample_scan_report():
|
|||||||
'title': 'Test Scan',
|
'title': 'Test Scan',
|
||||||
'scan_time': '2025-11-14T10:30:00Z',
|
'scan_time': '2025-11-14T10:30:00Z',
|
||||||
'scan_duration': 125.5,
|
'scan_duration': 125.5,
|
||||||
'config_file': '/app/configs/test.yaml',
|
'config_id': 1,
|
||||||
'sites': [
|
'sites': [
|
||||||
{
|
{
|
||||||
'name': 'Test Site',
|
'name': 'Test Site',
|
||||||
@@ -199,6 +199,53 @@ def sample_invalid_config_file(tmp_path):
|
|||||||
return str(config_file)
|
return str(config_file)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_db_config(db):
|
||||||
|
"""
|
||||||
|
Create a sample database config for testing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session fixture
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScanConfig model instance with ID
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
config_data = {
|
||||||
|
'title': 'Test Scan',
|
||||||
|
'sites': [
|
||||||
|
{
|
||||||
|
'name': 'Test Site',
|
||||||
|
'ips': [
|
||||||
|
{
|
||||||
|
'address': '192.168.1.10',
|
||||||
|
'expected': {
|
||||||
|
'ping': True,
|
||||||
|
'tcp_ports': [22, 80, 443],
|
||||||
|
'udp_ports': [53],
|
||||||
|
'services': ['ssh', 'http', 'https']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
scan_config = ScanConfig(
|
||||||
|
title='Test Scan',
|
||||||
|
config_data=json.dumps(config_data),
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(scan_config)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(scan_config)
|
||||||
|
|
||||||
|
return scan_config
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='function')
|
@pytest.fixture(scope='function')
|
||||||
def app():
|
def app():
|
||||||
"""
|
"""
|
||||||
@@ -269,7 +316,7 @@ def sample_scan(db):
|
|||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.utcnow(),
|
||||||
status='completed',
|
status='completed',
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
title='Test Scan',
|
title='Test Scan',
|
||||||
duration=125.5,
|
duration=125.5,
|
||||||
triggered_by='test',
|
triggered_by='test',
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ class TestBackgroundJobs:
|
|||||||
assert app.scheduler.scheduler is not None
|
assert app.scheduler.scheduler is not None
|
||||||
assert app.scheduler.scheduler.running
|
assert app.scheduler.scheduler.running
|
||||||
|
|
||||||
def test_queue_scan_job(self, app, db, sample_config_file):
|
def test_queue_scan_job(self, app, db, sample_db_config):
|
||||||
"""Test queuing a scan for background execution."""
|
"""Test queuing a scan for background execution."""
|
||||||
# Create a scan via service
|
# Create a scan via service
|
||||||
scan_service = ScanService(db)
|
scan_service = ScanService(db)
|
||||||
scan_id = scan_service.trigger_scan(
|
scan_id = scan_service.trigger_scan(
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
triggered_by='test',
|
triggered_by='test',
|
||||||
scheduler=app.scheduler
|
scheduler=app.scheduler
|
||||||
)
|
)
|
||||||
@@ -43,12 +43,12 @@ class TestBackgroundJobs:
|
|||||||
assert job is not None
|
assert job is not None
|
||||||
assert job.id == f'scan_{scan_id}'
|
assert job.id == f'scan_{scan_id}'
|
||||||
|
|
||||||
def test_trigger_scan_without_scheduler(self, db, sample_config_file):
|
def test_trigger_scan_without_scheduler(self, db, sample_db_config):
|
||||||
"""Test triggering scan without scheduler logs warning."""
|
"""Test triggering scan without scheduler logs warning."""
|
||||||
# Create scan without scheduler
|
# Create scan without scheduler
|
||||||
scan_service = ScanService(db)
|
scan_service = ScanService(db)
|
||||||
scan_id = scan_service.trigger_scan(
|
scan_id = scan_service.trigger_scan(
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
triggered_by='test',
|
triggered_by='test',
|
||||||
scheduler=None # No scheduler
|
scheduler=None # No scheduler
|
||||||
)
|
)
|
||||||
@@ -58,13 +58,13 @@ class TestBackgroundJobs:
|
|||||||
assert scan is not None
|
assert scan is not None
|
||||||
assert scan.status == 'running'
|
assert scan.status == 'running'
|
||||||
|
|
||||||
def test_scheduler_service_queue_scan(self, app, db, sample_config_file):
|
def test_scheduler_service_queue_scan(self, app, db, sample_db_config):
|
||||||
"""Test SchedulerService.queue_scan directly."""
|
"""Test SchedulerService.queue_scan directly."""
|
||||||
# Create scan record first
|
# Create scan record first
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.utcnow(),
|
||||||
status='running',
|
status='running',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
title='Test Scan',
|
title='Test Scan',
|
||||||
triggered_by='test'
|
triggered_by='test'
|
||||||
)
|
)
|
||||||
@@ -72,27 +72,27 @@ class TestBackgroundJobs:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Queue the scan
|
# Queue the scan
|
||||||
job_id = app.scheduler.queue_scan(scan.id, sample_config_file)
|
job_id = app.scheduler.queue_scan(scan.id, sample_db_config)
|
||||||
|
|
||||||
# Verify job was queued
|
# Verify job was queued
|
||||||
assert job_id == f'scan_{scan.id}'
|
assert job_id == f'scan_{scan.id}'
|
||||||
job = app.scheduler.scheduler.get_job(job_id)
|
job = app.scheduler.scheduler.get_job(job_id)
|
||||||
assert job is not None
|
assert job is not None
|
||||||
|
|
||||||
def test_scheduler_list_jobs(self, app, db, sample_config_file):
|
def test_scheduler_list_jobs(self, app, db, sample_db_config):
|
||||||
"""Test listing scheduled jobs."""
|
"""Test listing scheduled jobs."""
|
||||||
# Queue a few scans
|
# Queue a few scans
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.utcnow(),
|
||||||
status='running',
|
status='running',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
title=f'Test Scan {i}',
|
title=f'Test Scan {i}',
|
||||||
triggered_by='test'
|
triggered_by='test'
|
||||||
)
|
)
|
||||||
db.add(scan)
|
db.add(scan)
|
||||||
db.commit()
|
db.commit()
|
||||||
app.scheduler.queue_scan(scan.id, sample_config_file)
|
app.scheduler.queue_scan(scan.id, sample_db_config)
|
||||||
|
|
||||||
# List jobs
|
# List jobs
|
||||||
jobs = app.scheduler.list_jobs()
|
jobs = app.scheduler.list_jobs()
|
||||||
@@ -106,20 +106,20 @@ class TestBackgroundJobs:
|
|||||||
assert 'name' in job
|
assert 'name' in job
|
||||||
assert 'trigger' in job
|
assert 'trigger' in job
|
||||||
|
|
||||||
def test_scheduler_get_job_status(self, app, db, sample_config_file):
|
def test_scheduler_get_job_status(self, app, db, sample_db_config):
|
||||||
"""Test getting status of a specific job."""
|
"""Test getting status of a specific job."""
|
||||||
# Create and queue a scan
|
# Create and queue a scan
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.utcnow(),
|
||||||
status='running',
|
status='running',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
title='Test Scan',
|
title='Test Scan',
|
||||||
triggered_by='test'
|
triggered_by='test'
|
||||||
)
|
)
|
||||||
db.add(scan)
|
db.add(scan)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
job_id = app.scheduler.queue_scan(scan.id, sample_config_file)
|
job_id = app.scheduler.queue_scan(scan.id, sample_db_config)
|
||||||
|
|
||||||
# Get job status
|
# Get job status
|
||||||
status = app.scheduler.get_job_status(job_id)
|
status = app.scheduler.get_job_status(job_id)
|
||||||
@@ -133,13 +133,13 @@ class TestBackgroundJobs:
|
|||||||
status = app.scheduler.get_job_status('nonexistent_job_id')
|
status = app.scheduler.get_job_status('nonexistent_job_id')
|
||||||
assert status is None
|
assert status is None
|
||||||
|
|
||||||
def test_scan_timing_fields(self, db, sample_config_file):
|
def test_scan_timing_fields(self, db, sample_db_config):
|
||||||
"""Test that scan timing fields are properly set."""
|
"""Test that scan timing fields are properly set."""
|
||||||
# Create scan with started_at
|
# Create scan with started_at
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.utcnow(),
|
||||||
status='running',
|
status='running',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
title='Test Scan',
|
title='Test Scan',
|
||||||
triggered_by='test',
|
triggered_by='test',
|
||||||
started_at=datetime.utcnow()
|
started_at=datetime.utcnow()
|
||||||
@@ -161,13 +161,13 @@ class TestBackgroundJobs:
|
|||||||
assert scan.completed_at is not None
|
assert scan.completed_at is not None
|
||||||
assert (scan.completed_at - scan.started_at).total_seconds() >= 0
|
assert (scan.completed_at - scan.started_at).total_seconds() >= 0
|
||||||
|
|
||||||
def test_scan_error_handling(self, db, sample_config_file):
|
def test_scan_error_handling(self, db, sample_db_config):
|
||||||
"""Test that error messages are stored correctly."""
|
"""Test that error messages are stored correctly."""
|
||||||
# Create failed scan
|
# Create failed scan
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.utcnow(),
|
||||||
status='failed',
|
status='failed',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
title='Failed Scan',
|
title='Failed Scan',
|
||||||
triggered_by='test',
|
triggered_by='test',
|
||||||
started_at=datetime.utcnow(),
|
started_at=datetime.utcnow(),
|
||||||
@@ -188,7 +188,7 @@ class TestBackgroundJobs:
|
|||||||
assert status['error_message'] == 'Test error message'
|
assert status['error_message'] == 'Test error message'
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Requires actual scanner execution - slow test")
|
@pytest.mark.skip(reason="Requires actual scanner execution - slow test")
|
||||||
def test_background_scan_execution(self, app, db, sample_config_file):
|
def test_background_scan_execution(self, app, db, sample_db_config):
|
||||||
"""
|
"""
|
||||||
Integration test for actual background scan execution.
|
Integration test for actual background scan execution.
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ class TestBackgroundJobs:
|
|||||||
# Trigger scan
|
# Trigger scan
|
||||||
scan_service = ScanService(db)
|
scan_service = ScanService(db)
|
||||||
scan_id = scan_service.trigger_scan(
|
scan_id = scan_service.trigger_scan(
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
triggered_by='test',
|
triggered_by='test',
|
||||||
scheduler=app.scheduler
|
scheduler=app.scheduler
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,14 +37,14 @@ class TestScanAPIEndpoints:
|
|||||||
assert len(data['scans']) == 1
|
assert len(data['scans']) == 1
|
||||||
assert data['scans'][0]['id'] == sample_scan.id
|
assert data['scans'][0]['id'] == sample_scan.id
|
||||||
|
|
||||||
def test_list_scans_pagination(self, client, db):
|
def test_list_scans_pagination(self, client, db, sample_db_config):
|
||||||
"""Test scan list pagination."""
|
"""Test scan list pagination."""
|
||||||
# Create 25 scans
|
# Create 25 scans
|
||||||
for i in range(25):
|
for i in range(25):
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.utcnow(),
|
||||||
status='completed',
|
status='completed',
|
||||||
config_file=f'/app/configs/test{i}.yaml',
|
config_id=sample_db_config.id,
|
||||||
title=f'Test Scan {i}',
|
title=f'Test Scan {i}',
|
||||||
triggered_by='test'
|
triggered_by='test'
|
||||||
)
|
)
|
||||||
@@ -81,7 +81,7 @@ class TestScanAPIEndpoints:
|
|||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.utcnow(),
|
||||||
status=status,
|
status=status,
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
title=f'{status.capitalize()} Scan',
|
title=f'{status.capitalize()} Scan',
|
||||||
triggered_by='test'
|
triggered_by='test'
|
||||||
)
|
)
|
||||||
@@ -123,10 +123,10 @@ class TestScanAPIEndpoints:
|
|||||||
assert 'error' in data
|
assert 'error' in data
|
||||||
assert data['error'] == 'Not found'
|
assert data['error'] == 'Not found'
|
||||||
|
|
||||||
def test_trigger_scan_success(self, client, db, sample_config_file):
|
def test_trigger_scan_success(self, client, db, sample_db_config):
|
||||||
"""Test triggering a new scan."""
|
"""Test triggering a new scan."""
|
||||||
response = client.post('/api/scans',
|
response = client.post('/api/scans',
|
||||||
json={'config_file': str(sample_config_file)},
|
json={'config_id': sample_db_config.id},
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
@@ -142,8 +142,8 @@ class TestScanAPIEndpoints:
|
|||||||
assert scan.status == 'running'
|
assert scan.status == 'running'
|
||||||
assert scan.triggered_by == 'api'
|
assert scan.triggered_by == 'api'
|
||||||
|
|
||||||
def test_trigger_scan_missing_config_file(self, client, db):
|
def test_trigger_scan_missing_config_id(self, client, db):
|
||||||
"""Test triggering scan without config_file."""
|
"""Test triggering scan without config_id."""
|
||||||
response = client.post('/api/scans',
|
response = client.post('/api/scans',
|
||||||
json={},
|
json={},
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
@@ -152,12 +152,12 @@ class TestScanAPIEndpoints:
|
|||||||
|
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'error' in data
|
assert 'error' in data
|
||||||
assert 'config_file is required' in data['message']
|
assert 'config_id is required' in data['message']
|
||||||
|
|
||||||
def test_trigger_scan_invalid_config_file(self, client, db):
|
def test_trigger_scan_invalid_config_id(self, client, db):
|
||||||
"""Test triggering scan with non-existent config file."""
|
"""Test triggering scan with non-existent config."""
|
||||||
response = client.post('/api/scans',
|
response = client.post('/api/scans',
|
||||||
json={'config_file': '/nonexistent/config.yaml'},
|
json={'config_id': 99999},
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
@@ -222,7 +222,7 @@ class TestScanAPIEndpoints:
|
|||||||
assert 'error' in data
|
assert 'error' in data
|
||||||
assert 'message' in data
|
assert 'message' in data
|
||||||
|
|
||||||
def test_scan_workflow_integration(self, client, db, sample_config_file):
|
def test_scan_workflow_integration(self, client, db, sample_db_config):
|
||||||
"""
|
"""
|
||||||
Test complete scan workflow: trigger → status → retrieve → delete.
|
Test complete scan workflow: trigger → status → retrieve → delete.
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ class TestScanAPIEndpoints:
|
|||||||
"""
|
"""
|
||||||
# Step 1: Trigger scan
|
# Step 1: Trigger scan
|
||||||
response = client.post('/api/scans',
|
response = client.post('/api/scans',
|
||||||
json={'config_file': str(sample_config_file)},
|
json={'config_id': sample_db_config.id},
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ class TestScanComparison:
|
|||||||
"""Tests for scan comparison methods."""
|
"""Tests for scan comparison methods."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def scan1_data(self, test_db, sample_config_file):
|
def scan1_data(self, test_db, sample_db_config):
|
||||||
"""Create first scan with test data."""
|
"""Create first scan with test data."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(test_db)
|
||||||
scan_id = service.trigger_scan(sample_config_file, triggered_by='manual')
|
scan_id = service.trigger_scan(sample_db_config, triggered_by='manual')
|
||||||
|
|
||||||
# Get scan and add some test data
|
# Get scan and add some test data
|
||||||
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
|
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
@@ -77,10 +77,10 @@ class TestScanComparison:
|
|||||||
return scan_id
|
return scan_id
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def scan2_data(self, test_db, sample_config_file):
|
def scan2_data(self, test_db, sample_db_config):
|
||||||
"""Create second scan with modified test data."""
|
"""Create second scan with modified test data."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(test_db)
|
||||||
scan_id = service.trigger_scan(sample_config_file, triggered_by='manual')
|
scan_id = service.trigger_scan(sample_db_config, triggered_by='manual')
|
||||||
|
|
||||||
# Get scan and add some test data
|
# Get scan and add some test data
|
||||||
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
|
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
|
|||||||
@@ -13,49 +13,42 @@ from web.services.scan_service import ScanService
|
|||||||
class TestScanServiceTrigger:
|
class TestScanServiceTrigger:
|
||||||
"""Tests for triggering scans."""
|
"""Tests for triggering scans."""
|
||||||
|
|
||||||
def test_trigger_scan_valid_config(self, test_db, sample_config_file):
|
def test_trigger_scan_valid_config(self, db, sample_db_config):
|
||||||
"""Test triggering a scan with valid config file."""
|
"""Test triggering a scan with valid config."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
scan_id = service.trigger_scan(sample_config_file, triggered_by='manual')
|
scan_id = service.trigger_scan(config_id=sample_db_config.id, triggered_by='manual')
|
||||||
|
|
||||||
# Verify scan created
|
# Verify scan created
|
||||||
assert scan_id is not None
|
assert scan_id is not None
|
||||||
assert isinstance(scan_id, int)
|
assert isinstance(scan_id, int)
|
||||||
|
|
||||||
# Verify scan in database
|
# Verify scan in database
|
||||||
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
|
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
assert scan is not None
|
assert scan is not None
|
||||||
assert scan.status == 'running'
|
assert scan.status == 'running'
|
||||||
assert scan.title == 'Test Scan'
|
assert scan.title == 'Test Scan'
|
||||||
assert scan.triggered_by == 'manual'
|
assert scan.triggered_by == 'manual'
|
||||||
assert scan.config_file == sample_config_file
|
assert scan.config_id == sample_db_config.id
|
||||||
|
|
||||||
def test_trigger_scan_invalid_config(self, test_db, sample_invalid_config_file):
|
def test_trigger_scan_invalid_config(self, db):
|
||||||
"""Test triggering a scan with invalid config file."""
|
"""Test triggering a scan with invalid config ID."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Invalid config file"):
|
with pytest.raises(ValueError, match="not found"):
|
||||||
service.trigger_scan(sample_invalid_config_file)
|
service.trigger_scan(config_id=99999)
|
||||||
|
|
||||||
def test_trigger_scan_nonexistent_file(self, test_db):
|
def test_trigger_scan_with_schedule(self, db, sample_db_config):
|
||||||
"""Test triggering a scan with nonexistent config file."""
|
|
||||||
service = ScanService(test_db)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="does not exist"):
|
|
||||||
service.trigger_scan('/nonexistent/config.yaml')
|
|
||||||
|
|
||||||
def test_trigger_scan_with_schedule(self, test_db, sample_config_file):
|
|
||||||
"""Test triggering a scan via schedule."""
|
"""Test triggering a scan via schedule."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
scan_id = service.trigger_scan(
|
scan_id = service.trigger_scan(
|
||||||
sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
triggered_by='scheduled',
|
triggered_by='scheduled',
|
||||||
schedule_id=42
|
schedule_id=42
|
||||||
)
|
)
|
||||||
|
|
||||||
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
|
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
assert scan.triggered_by == 'scheduled'
|
assert scan.triggered_by == 'scheduled'
|
||||||
assert scan.schedule_id == 42
|
assert scan.schedule_id == 42
|
||||||
|
|
||||||
@@ -63,19 +56,19 @@ class TestScanServiceTrigger:
|
|||||||
class TestScanServiceGet:
|
class TestScanServiceGet:
|
||||||
"""Tests for retrieving scans."""
|
"""Tests for retrieving scans."""
|
||||||
|
|
||||||
def test_get_scan_not_found(self, test_db):
|
def test_get_scan_not_found(self, db):
|
||||||
"""Test getting a nonexistent scan."""
|
"""Test getting a nonexistent scan."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
result = service.get_scan(999)
|
result = service.get_scan(999)
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_get_scan_found(self, test_db, sample_config_file):
|
def test_get_scan_found(self, db, sample_db_config):
|
||||||
"""Test getting an existing scan."""
|
"""Test getting an existing scan."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
# Create a scan
|
# Create a scan
|
||||||
scan_id = service.trigger_scan(sample_config_file)
|
scan_id = service.trigger_scan(config_id=sample_db_config.id)
|
||||||
|
|
||||||
# Retrieve it
|
# Retrieve it
|
||||||
result = service.get_scan(scan_id)
|
result = service.get_scan(scan_id)
|
||||||
@@ -90,9 +83,9 @@ class TestScanServiceGet:
|
|||||||
class TestScanServiceList:
|
class TestScanServiceList:
|
||||||
"""Tests for listing scans."""
|
"""Tests for listing scans."""
|
||||||
|
|
||||||
def test_list_scans_empty(self, test_db):
|
def test_list_scans_empty(self, db):
|
||||||
"""Test listing scans when database is empty."""
|
"""Test listing scans when database is empty."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
result = service.list_scans(page=1, per_page=20)
|
result = service.list_scans(page=1, per_page=20)
|
||||||
|
|
||||||
@@ -100,13 +93,13 @@ class TestScanServiceList:
|
|||||||
assert len(result.items) == 0
|
assert len(result.items) == 0
|
||||||
assert result.pages == 0
|
assert result.pages == 0
|
||||||
|
|
||||||
def test_list_scans_with_data(self, test_db, sample_config_file):
|
def test_list_scans_with_data(self, db, sample_db_config):
|
||||||
"""Test listing scans with multiple scans."""
|
"""Test listing scans with multiple scans."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
# Create 3 scans
|
# Create 3 scans
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
service.trigger_scan(sample_config_file, triggered_by='api')
|
service.trigger_scan(config_id=sample_db_config.id, triggered_by='api')
|
||||||
|
|
||||||
# List all scans
|
# List all scans
|
||||||
result = service.list_scans(page=1, per_page=20)
|
result = service.list_scans(page=1, per_page=20)
|
||||||
@@ -115,13 +108,13 @@ class TestScanServiceList:
|
|||||||
assert len(result.items) == 3
|
assert len(result.items) == 3
|
||||||
assert result.pages == 1
|
assert result.pages == 1
|
||||||
|
|
||||||
def test_list_scans_pagination(self, test_db, sample_config_file):
|
def test_list_scans_pagination(self, db, sample_db_config):
|
||||||
"""Test pagination."""
|
"""Test pagination."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
# Create 5 scans
|
# Create 5 scans
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
service.trigger_scan(sample_config_file)
|
service.trigger_scan(config_id=sample_db_config.id)
|
||||||
|
|
||||||
# Get page 1 (2 items per page)
|
# Get page 1 (2 items per page)
|
||||||
result = service.list_scans(page=1, per_page=2)
|
result = service.list_scans(page=1, per_page=2)
|
||||||
@@ -141,18 +134,18 @@ class TestScanServiceList:
|
|||||||
assert len(result.items) == 1
|
assert len(result.items) == 1
|
||||||
assert result.has_next is False
|
assert result.has_next is False
|
||||||
|
|
||||||
def test_list_scans_filter_by_status(self, test_db, sample_config_file):
|
def test_list_scans_filter_by_status(self, db, sample_db_config):
|
||||||
"""Test filtering scans by status."""
|
"""Test filtering scans by status."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
# Create scans with different statuses
|
# Create scans with different statuses
|
||||||
scan_id_1 = service.trigger_scan(sample_config_file)
|
scan_id_1 = service.trigger_scan(config_id=sample_db_config.id)
|
||||||
scan_id_2 = service.trigger_scan(sample_config_file)
|
scan_id_2 = service.trigger_scan(config_id=sample_db_config.id)
|
||||||
|
|
||||||
# Mark one as completed
|
# Mark one as completed
|
||||||
scan = test_db.query(Scan).filter(Scan.id == scan_id_1).first()
|
scan = db.query(Scan).filter(Scan.id == scan_id_1).first()
|
||||||
scan.status = 'completed'
|
scan.status = 'completed'
|
||||||
test_db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Filter by running
|
# Filter by running
|
||||||
result = service.list_scans(status_filter='running')
|
result = service.list_scans(status_filter='running')
|
||||||
@@ -162,9 +155,9 @@ class TestScanServiceList:
|
|||||||
result = service.list_scans(status_filter='completed')
|
result = service.list_scans(status_filter='completed')
|
||||||
assert result.total == 1
|
assert result.total == 1
|
||||||
|
|
||||||
def test_list_scans_invalid_status_filter(self, test_db):
|
def test_list_scans_invalid_status_filter(self, db):
|
||||||
"""Test filtering with invalid status."""
|
"""Test filtering with invalid status."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Invalid status"):
|
with pytest.raises(ValueError, match="Invalid status"):
|
||||||
service.list_scans(status_filter='invalid_status')
|
service.list_scans(status_filter='invalid_status')
|
||||||
@@ -173,46 +166,46 @@ class TestScanServiceList:
|
|||||||
class TestScanServiceDelete:
|
class TestScanServiceDelete:
|
||||||
"""Tests for deleting scans."""
|
"""Tests for deleting scans."""
|
||||||
|
|
||||||
def test_delete_scan_not_found(self, test_db):
|
def test_delete_scan_not_found(self, db):
|
||||||
"""Test deleting a nonexistent scan."""
|
"""Test deleting a nonexistent scan."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="not found"):
|
with pytest.raises(ValueError, match="not found"):
|
||||||
service.delete_scan(999)
|
service.delete_scan(999)
|
||||||
|
|
||||||
def test_delete_scan_success(self, test_db, sample_config_file):
|
def test_delete_scan_success(self, db, sample_db_config):
|
||||||
"""Test successful scan deletion."""
|
"""Test successful scan deletion."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
# Create a scan
|
# Create a scan
|
||||||
scan_id = service.trigger_scan(sample_config_file)
|
scan_id = service.trigger_scan(config_id=sample_db_config.id)
|
||||||
|
|
||||||
# Verify it exists
|
# Verify it exists
|
||||||
assert test_db.query(Scan).filter(Scan.id == scan_id).first() is not None
|
assert db.query(Scan).filter(Scan.id == scan_id).first() is not None
|
||||||
|
|
||||||
# Delete it
|
# Delete it
|
||||||
result = service.delete_scan(scan_id)
|
result = service.delete_scan(scan_id)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
# Verify it's gone
|
# Verify it's gone
|
||||||
assert test_db.query(Scan).filter(Scan.id == scan_id).first() is None
|
assert db.query(Scan).filter(Scan.id == scan_id).first() is None
|
||||||
|
|
||||||
|
|
||||||
class TestScanServiceStatus:
|
class TestScanServiceStatus:
|
||||||
"""Tests for scan status retrieval."""
|
"""Tests for scan status retrieval."""
|
||||||
|
|
||||||
def test_get_scan_status_not_found(self, test_db):
|
def test_get_scan_status_not_found(self, db):
|
||||||
"""Test getting status of nonexistent scan."""
|
"""Test getting status of nonexistent scan."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
result = service.get_scan_status(999)
|
result = service.get_scan_status(999)
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_get_scan_status_running(self, test_db, sample_config_file):
|
def test_get_scan_status_running(self, db, sample_db_config):
|
||||||
"""Test getting status of running scan."""
|
"""Test getting status of running scan."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
scan_id = service.trigger_scan(sample_config_file)
|
scan_id = service.trigger_scan(config_id=sample_db_config.id)
|
||||||
status = service.get_scan_status(scan_id)
|
status = service.get_scan_status(scan_id)
|
||||||
|
|
||||||
assert status is not None
|
assert status is not None
|
||||||
@@ -221,16 +214,16 @@ class TestScanServiceStatus:
|
|||||||
assert status['progress'] == 'In progress'
|
assert status['progress'] == 'In progress'
|
||||||
assert status['title'] == 'Test Scan'
|
assert status['title'] == 'Test Scan'
|
||||||
|
|
||||||
def test_get_scan_status_completed(self, test_db, sample_config_file):
|
def test_get_scan_status_completed(self, db, sample_db_config):
|
||||||
"""Test getting status of completed scan."""
|
"""Test getting status of completed scan."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
# Create and mark as completed
|
# Create and mark as completed
|
||||||
scan_id = service.trigger_scan(sample_config_file)
|
scan_id = service.trigger_scan(config_id=sample_db_config.id)
|
||||||
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
|
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
scan.status = 'completed'
|
scan.status = 'completed'
|
||||||
scan.duration = 125.5
|
scan.duration = 125.5
|
||||||
test_db.commit()
|
db.commit()
|
||||||
|
|
||||||
status = service.get_scan_status(scan_id)
|
status = service.get_scan_status(scan_id)
|
||||||
|
|
||||||
@@ -242,35 +235,35 @@ class TestScanServiceStatus:
|
|||||||
class TestScanServiceDatabaseMapping:
|
class TestScanServiceDatabaseMapping:
|
||||||
"""Tests for mapping scan reports to database models."""
|
"""Tests for mapping scan reports to database models."""
|
||||||
|
|
||||||
def test_save_scan_to_db(self, test_db, sample_config_file, sample_scan_report):
|
def test_save_scan_to_db(self, db, sample_db_config, sample_scan_report):
|
||||||
"""Test saving a complete scan report to database."""
|
"""Test saving a complete scan report to database."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
# Create a scan
|
# Create a scan
|
||||||
scan_id = service.trigger_scan(sample_config_file)
|
scan_id = service.trigger_scan(config_id=sample_db_config.id)
|
||||||
|
|
||||||
# Save report to database
|
# Save report to database
|
||||||
service._save_scan_to_db(sample_scan_report, scan_id, status='completed')
|
service._save_scan_to_db(sample_scan_report, scan_id, status='completed')
|
||||||
|
|
||||||
# Verify scan updated
|
# Verify scan updated
|
||||||
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
|
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
assert scan.status == 'completed'
|
assert scan.status == 'completed'
|
||||||
assert scan.duration == 125.5
|
assert scan.duration == 125.5
|
||||||
|
|
||||||
# Verify sites created
|
# Verify sites created
|
||||||
sites = test_db.query(ScanSite).filter(ScanSite.scan_id == scan_id).all()
|
sites = db.query(ScanSite).filter(ScanSite.scan_id == scan_id).all()
|
||||||
assert len(sites) == 1
|
assert len(sites) == 1
|
||||||
assert sites[0].site_name == 'Test Site'
|
assert sites[0].site_name == 'Test Site'
|
||||||
|
|
||||||
# Verify IPs created
|
# Verify IPs created
|
||||||
ips = test_db.query(ScanIP).filter(ScanIP.scan_id == scan_id).all()
|
ips = db.query(ScanIP).filter(ScanIP.scan_id == scan_id).all()
|
||||||
assert len(ips) == 1
|
assert len(ips) == 1
|
||||||
assert ips[0].ip_address == '192.168.1.10'
|
assert ips[0].ip_address == '192.168.1.10'
|
||||||
assert ips[0].ping_expected is True
|
assert ips[0].ping_expected is True
|
||||||
assert ips[0].ping_actual is True
|
assert ips[0].ping_actual is True
|
||||||
|
|
||||||
# Verify ports created (TCP: 22, 80, 443, 8080 | UDP: 53)
|
# Verify ports created (TCP: 22, 80, 443, 8080 | UDP: 53)
|
||||||
ports = test_db.query(ScanPort).filter(ScanPort.scan_id == scan_id).all()
|
ports = db.query(ScanPort).filter(ScanPort.scan_id == scan_id).all()
|
||||||
assert len(ports) == 5 # 4 TCP + 1 UDP
|
assert len(ports) == 5 # 4 TCP + 1 UDP
|
||||||
|
|
||||||
# Verify TCP ports
|
# Verify TCP ports
|
||||||
@@ -285,7 +278,7 @@ class TestScanServiceDatabaseMapping:
|
|||||||
assert udp_ports[0].port == 53
|
assert udp_ports[0].port == 53
|
||||||
|
|
||||||
# Verify services created
|
# Verify services created
|
||||||
services = test_db.query(ScanServiceModel).filter(
|
services = db.query(ScanServiceModel).filter(
|
||||||
ScanServiceModel.scan_id == scan_id
|
ScanServiceModel.scan_id == scan_id
|
||||||
).all()
|
).all()
|
||||||
assert len(services) == 4 # SSH, HTTP (80), HTTPS, HTTP (8080)
|
assert len(services) == 4 # SSH, HTTP (80), HTTPS, HTTP (8080)
|
||||||
@@ -300,15 +293,15 @@ class TestScanServiceDatabaseMapping:
|
|||||||
assert https_service.http_protocol == 'https'
|
assert https_service.http_protocol == 'https'
|
||||||
assert https_service.screenshot_path == 'screenshots/192_168_1_10_443.png'
|
assert https_service.screenshot_path == 'screenshots/192_168_1_10_443.png'
|
||||||
|
|
||||||
def test_map_port_expected_vs_actual(self, test_db, sample_config_file, sample_scan_report):
|
def test_map_port_expected_vs_actual(self, db, sample_db_config, sample_scan_report):
|
||||||
"""Test that expected vs actual ports are correctly flagged."""
|
"""Test that expected vs actual ports are correctly flagged."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
scan_id = service.trigger_scan(sample_config_file)
|
scan_id = service.trigger_scan(config_id=sample_db_config.id)
|
||||||
service._save_scan_to_db(sample_scan_report, scan_id)
|
service._save_scan_to_db(sample_scan_report, scan_id)
|
||||||
|
|
||||||
# Check TCP ports
|
# Check TCP ports
|
||||||
tcp_ports = test_db.query(ScanPort).filter(
|
tcp_ports = db.query(ScanPort).filter(
|
||||||
ScanPort.scan_id == scan_id,
|
ScanPort.scan_id == scan_id,
|
||||||
ScanPort.protocol == 'tcp'
|
ScanPort.protocol == 'tcp'
|
||||||
).all()
|
).all()
|
||||||
@@ -322,15 +315,15 @@ class TestScanServiceDatabaseMapping:
|
|||||||
# Port 8080 was not expected
|
# Port 8080 was not expected
|
||||||
assert port.expected is False, f"Port {port.port} should not be expected"
|
assert port.expected is False, f"Port {port.port} should not be expected"
|
||||||
|
|
||||||
def test_map_certificate_and_tls(self, test_db, sample_config_file, sample_scan_report):
|
def test_map_certificate_and_tls(self, db, sample_db_config, sample_scan_report):
|
||||||
"""Test that certificate and TLS data are correctly mapped."""
|
"""Test that certificate and TLS data are correctly mapped."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
scan_id = service.trigger_scan(sample_config_file)
|
scan_id = service.trigger_scan(config_id=sample_db_config.id)
|
||||||
service._save_scan_to_db(sample_scan_report, scan_id)
|
service._save_scan_to_db(sample_scan_report, scan_id)
|
||||||
|
|
||||||
# Find HTTPS service
|
# Find HTTPS service
|
||||||
https_service = test_db.query(ScanServiceModel).filter(
|
https_service = db.query(ScanServiceModel).filter(
|
||||||
ScanServiceModel.scan_id == scan_id,
|
ScanServiceModel.scan_id == scan_id,
|
||||||
ScanServiceModel.service_name == 'https'
|
ScanServiceModel.service_name == 'https'
|
||||||
).first()
|
).first()
|
||||||
@@ -363,11 +356,11 @@ class TestScanServiceDatabaseMapping:
|
|||||||
assert tls_13 is not None
|
assert tls_13 is not None
|
||||||
assert tls_13.supported is True
|
assert tls_13.supported is True
|
||||||
|
|
||||||
def test_get_scan_with_full_details(self, test_db, sample_config_file, sample_scan_report):
|
def test_get_scan_with_full_details(self, db, sample_db_config, sample_scan_report):
|
||||||
"""Test retrieving scan with all nested relationships."""
|
"""Test retrieving scan with all nested relationships."""
|
||||||
service = ScanService(test_db)
|
service = ScanService(db)
|
||||||
|
|
||||||
scan_id = service.trigger_scan(sample_config_file)
|
scan_id = service.trigger_scan(config_id=sample_db_config.id)
|
||||||
service._save_scan_to_db(sample_scan_report, scan_id)
|
service._save_scan_to_db(sample_scan_report, scan_id)
|
||||||
|
|
||||||
# Get full scan details
|
# Get full scan details
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ from web.models import Schedule, Scan
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_schedule(db, sample_config_file):
|
def sample_schedule(db, sample_db_config):
|
||||||
"""
|
"""
|
||||||
Create a sample schedule in the database for testing.
|
Create a sample schedule in the database for testing.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session fixture
|
db: Database session fixture
|
||||||
sample_config_file: Path to test config file
|
sample_db_config: Path to test config file
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Schedule model instance
|
Schedule model instance
|
||||||
"""
|
"""
|
||||||
schedule = Schedule(
|
schedule = Schedule(
|
||||||
name='Daily Test Scan',
|
name='Daily Test Scan',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True,
|
enabled=True,
|
||||||
last_run=None,
|
last_run=None,
|
||||||
@@ -68,13 +68,13 @@ class TestScheduleAPIEndpoints:
|
|||||||
assert data['schedules'][0]['name'] == sample_schedule.name
|
assert data['schedules'][0]['name'] == sample_schedule.name
|
||||||
assert data['schedules'][0]['cron_expression'] == sample_schedule.cron_expression
|
assert data['schedules'][0]['cron_expression'] == sample_schedule.cron_expression
|
||||||
|
|
||||||
def test_list_schedules_pagination(self, client, db, sample_config_file):
|
def test_list_schedules_pagination(self, client, db, sample_db_config):
|
||||||
"""Test schedule list pagination."""
|
"""Test schedule list pagination."""
|
||||||
# Create 25 schedules
|
# Create 25 schedules
|
||||||
for i in range(25):
|
for i in range(25):
|
||||||
schedule = Schedule(
|
schedule = Schedule(
|
||||||
name=f'Schedule {i}',
|
name=f'Schedule {i}',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True,
|
enabled=True,
|
||||||
created_at=datetime.utcnow()
|
created_at=datetime.utcnow()
|
||||||
@@ -101,13 +101,13 @@ class TestScheduleAPIEndpoints:
|
|||||||
assert len(data['schedules']) == 10
|
assert len(data['schedules']) == 10
|
||||||
assert data['page'] == 2
|
assert data['page'] == 2
|
||||||
|
|
||||||
def test_list_schedules_filter_enabled(self, client, db, sample_config_file):
|
def test_list_schedules_filter_enabled(self, client, db, sample_db_config):
|
||||||
"""Test filtering schedules by enabled status."""
|
"""Test filtering schedules by enabled status."""
|
||||||
# Create enabled and disabled schedules
|
# Create enabled and disabled schedules
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
schedule = Schedule(
|
schedule = Schedule(
|
||||||
name=f'Enabled Schedule {i}',
|
name=f'Enabled Schedule {i}',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True,
|
enabled=True,
|
||||||
created_at=datetime.utcnow()
|
created_at=datetime.utcnow()
|
||||||
@@ -117,7 +117,7 @@ class TestScheduleAPIEndpoints:
|
|||||||
for i in range(2):
|
for i in range(2):
|
||||||
schedule = Schedule(
|
schedule = Schedule(
|
||||||
name=f'Disabled Schedule {i}',
|
name=f'Disabled Schedule {i}',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 3 * * *',
|
cron_expression='0 3 * * *',
|
||||||
enabled=False,
|
enabled=False,
|
||||||
created_at=datetime.utcnow()
|
created_at=datetime.utcnow()
|
||||||
@@ -151,7 +151,7 @@ class TestScheduleAPIEndpoints:
|
|||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['id'] == sample_schedule.id
|
assert data['id'] == sample_schedule.id
|
||||||
assert data['name'] == sample_schedule.name
|
assert data['name'] == sample_schedule.name
|
||||||
assert data['config_file'] == sample_schedule.config_file
|
assert data['config_id'] == sample_schedule.config_id
|
||||||
assert data['cron_expression'] == sample_schedule.cron_expression
|
assert data['cron_expression'] == sample_schedule.cron_expression
|
||||||
assert data['enabled'] == sample_schedule.enabled
|
assert data['enabled'] == sample_schedule.enabled
|
||||||
assert 'history' in data
|
assert 'history' in data
|
||||||
@@ -165,11 +165,11 @@ class TestScheduleAPIEndpoints:
|
|||||||
assert 'error' in data
|
assert 'error' in data
|
||||||
assert 'not found' in data['error'].lower()
|
assert 'not found' in data['error'].lower()
|
||||||
|
|
||||||
def test_create_schedule(self, client, db, sample_config_file):
|
def test_create_schedule(self, client, db, sample_db_config):
|
||||||
"""Test creating a new schedule."""
|
"""Test creating a new schedule."""
|
||||||
schedule_data = {
|
schedule_data = {
|
||||||
'name': 'New Test Schedule',
|
'name': 'New Test Schedule',
|
||||||
'config_file': sample_config_file,
|
'config_id': sample_db_config.id,
|
||||||
'cron_expression': '0 3 * * *',
|
'cron_expression': '0 3 * * *',
|
||||||
'enabled': True
|
'enabled': True
|
||||||
}
|
}
|
||||||
@@ -197,7 +197,7 @@ class TestScheduleAPIEndpoints:
|
|||||||
# Missing cron_expression
|
# Missing cron_expression
|
||||||
schedule_data = {
|
schedule_data = {
|
||||||
'name': 'Incomplete Schedule',
|
'name': 'Incomplete Schedule',
|
||||||
'config_file': '/app/configs/test.yaml'
|
'config_id': 1
|
||||||
}
|
}
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -211,11 +211,11 @@ class TestScheduleAPIEndpoints:
|
|||||||
assert 'error' in data
|
assert 'error' in data
|
||||||
assert 'missing' in data['error'].lower()
|
assert 'missing' in data['error'].lower()
|
||||||
|
|
||||||
def test_create_schedule_invalid_cron(self, client, db, sample_config_file):
|
def test_create_schedule_invalid_cron(self, client, db, sample_db_config):
|
||||||
"""Test creating schedule with invalid cron expression."""
|
"""Test creating schedule with invalid cron expression."""
|
||||||
schedule_data = {
|
schedule_data = {
|
||||||
'name': 'Invalid Cron Schedule',
|
'name': 'Invalid Cron Schedule',
|
||||||
'config_file': sample_config_file,
|
'config_id': sample_db_config.id,
|
||||||
'cron_expression': 'invalid cron'
|
'cron_expression': 'invalid cron'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,10 +231,10 @@ class TestScheduleAPIEndpoints:
|
|||||||
assert 'invalid' in data['error'].lower() or 'cron' in data['error'].lower()
|
assert 'invalid' in data['error'].lower() or 'cron' in data['error'].lower()
|
||||||
|
|
||||||
def test_create_schedule_invalid_config(self, client, db):
|
def test_create_schedule_invalid_config(self, client, db):
|
||||||
"""Test creating schedule with non-existent config file."""
|
"""Test creating schedule with non-existent config."""
|
||||||
schedule_data = {
|
schedule_data = {
|
||||||
'name': 'Invalid Config Schedule',
|
'name': 'Invalid Config Schedule',
|
||||||
'config_file': '/nonexistent/config.yaml',
|
'config_id': 99999,
|
||||||
'cron_expression': '0 2 * * *'
|
'cron_expression': '0 2 * * *'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,13 +360,13 @@ class TestScheduleAPIEndpoints:
|
|||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'error' in data
|
assert 'error' in data
|
||||||
|
|
||||||
def test_delete_schedule_preserves_scans(self, client, db, sample_schedule, sample_config_file):
|
def test_delete_schedule_preserves_scans(self, client, db, sample_schedule, sample_db_config):
|
||||||
"""Test that deleting schedule preserves associated scans."""
|
"""Test that deleting schedule preserves associated scans."""
|
||||||
# Create a scan associated with the schedule
|
# Create a scan associated with the schedule
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.utcnow(),
|
||||||
status='completed',
|
status='completed',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
title='Test Scan',
|
title='Test Scan',
|
||||||
triggered_by='scheduled',
|
triggered_by='scheduled',
|
||||||
schedule_id=sample_schedule.id
|
schedule_id=sample_schedule.id
|
||||||
@@ -399,7 +399,7 @@ class TestScheduleAPIEndpoints:
|
|||||||
assert scan is not None
|
assert scan is not None
|
||||||
assert scan.triggered_by == 'manual'
|
assert scan.triggered_by == 'manual'
|
||||||
assert scan.schedule_id == sample_schedule.id
|
assert scan.schedule_id == sample_schedule.id
|
||||||
assert scan.config_file == sample_schedule.config_file
|
assert scan.config_id == sample_schedule.config_id
|
||||||
|
|
||||||
def test_trigger_schedule_not_found(self, client, db):
|
def test_trigger_schedule_not_found(self, client, db):
|
||||||
"""Test triggering non-existent schedule."""
|
"""Test triggering non-existent schedule."""
|
||||||
@@ -409,14 +409,14 @@ class TestScheduleAPIEndpoints:
|
|||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'error' in data
|
assert 'error' in data
|
||||||
|
|
||||||
def test_get_schedule_with_history(self, client, db, sample_schedule, sample_config_file):
|
def test_get_schedule_with_history(self, client, db, sample_schedule, sample_db_config):
|
||||||
"""Test getting schedule includes execution history."""
|
"""Test getting schedule includes execution history."""
|
||||||
# Create some scans for this schedule
|
# Create some scans for this schedule
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.utcnow(),
|
||||||
status='completed',
|
status='completed',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
title=f'Scheduled Scan {i}',
|
title=f'Scheduled Scan {i}',
|
||||||
triggered_by='scheduled',
|
triggered_by='scheduled',
|
||||||
schedule_id=sample_schedule.id
|
schedule_id=sample_schedule.id
|
||||||
@@ -431,12 +431,12 @@ class TestScheduleAPIEndpoints:
|
|||||||
assert 'history' in data
|
assert 'history' in data
|
||||||
assert len(data['history']) == 5
|
assert len(data['history']) == 5
|
||||||
|
|
||||||
def test_schedule_workflow_integration(self, client, db, sample_config_file):
|
def test_schedule_workflow_integration(self, client, db, sample_db_config):
|
||||||
"""Test complete schedule workflow: create → update → trigger → delete."""
|
"""Test complete schedule workflow: create → update → trigger → delete."""
|
||||||
# 1. Create schedule
|
# 1. Create schedule
|
||||||
schedule_data = {
|
schedule_data = {
|
||||||
'name': 'Integration Test Schedule',
|
'name': 'Integration Test Schedule',
|
||||||
'config_file': sample_config_file,
|
'config_id': sample_db_config.id,
|
||||||
'cron_expression': '0 2 * * *',
|
'cron_expression': '0 2 * * *',
|
||||||
'enabled': True
|
'enabled': True
|
||||||
}
|
}
|
||||||
@@ -482,14 +482,14 @@ class TestScheduleAPIEndpoints:
|
|||||||
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
assert scan is not None
|
assert scan is not None
|
||||||
|
|
||||||
def test_list_schedules_ordering(self, client, db, sample_config_file):
|
def test_list_schedules_ordering(self, client, db, sample_db_config):
|
||||||
"""Test that schedules are ordered by next_run time."""
|
"""Test that schedules are ordered by next_run time."""
|
||||||
# Create schedules with different next_run times
|
# Create schedules with different next_run times
|
||||||
schedules = []
|
schedules = []
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
schedule = Schedule(
|
schedule = Schedule(
|
||||||
name=f'Schedule {i}',
|
name=f'Schedule {i}',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True,
|
enabled=True,
|
||||||
next_run=datetime(2025, 11, 15 + i, 2, 0, 0),
|
next_run=datetime(2025, 11, 15 + i, 2, 0, 0),
|
||||||
@@ -501,7 +501,7 @@ class TestScheduleAPIEndpoints:
|
|||||||
# Create a disabled schedule (next_run is None)
|
# Create a disabled schedule (next_run is None)
|
||||||
disabled_schedule = Schedule(
|
disabled_schedule = Schedule(
|
||||||
name='Disabled Schedule',
|
name='Disabled Schedule',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 3 * * *',
|
cron_expression='0 3 * * *',
|
||||||
enabled=False,
|
enabled=False,
|
||||||
next_run=None,
|
next_run=None,
|
||||||
@@ -523,11 +523,11 @@ class TestScheduleAPIEndpoints:
|
|||||||
assert returned_schedules[2]['id'] == schedules[2].id
|
assert returned_schedules[2]['id'] == schedules[2].id
|
||||||
assert returned_schedules[3]['id'] == disabled_schedule.id
|
assert returned_schedules[3]['id'] == disabled_schedule.id
|
||||||
|
|
||||||
def test_create_schedule_with_disabled(self, client, db, sample_config_file):
|
def test_create_schedule_with_disabled(self, client, db, sample_db_config):
|
||||||
"""Test creating a disabled schedule."""
|
"""Test creating a disabled schedule."""
|
||||||
schedule_data = {
|
schedule_data = {
|
||||||
'name': 'Disabled Schedule',
|
'name': 'Disabled Schedule',
|
||||||
'config_file': sample_config_file,
|
'config_id': sample_db_config.id,
|
||||||
'cron_expression': '0 2 * * *',
|
'cron_expression': '0 2 * * *',
|
||||||
'enabled': False
|
'enabled': False
|
||||||
}
|
}
|
||||||
@@ -587,7 +587,7 @@ class TestScheduleAPIAuthentication:
|
|||||||
class TestScheduleAPICronValidation:
|
class TestScheduleAPICronValidation:
|
||||||
"""Test suite for cron expression validation."""
|
"""Test suite for cron expression validation."""
|
||||||
|
|
||||||
def test_valid_cron_expressions(self, client, db, sample_config_file):
|
def test_valid_cron_expressions(self, client, db, sample_db_config):
|
||||||
"""Test various valid cron expressions."""
|
"""Test various valid cron expressions."""
|
||||||
valid_expressions = [
|
valid_expressions = [
|
||||||
'0 2 * * *', # Daily at 2am
|
'0 2 * * *', # Daily at 2am
|
||||||
@@ -600,7 +600,7 @@ class TestScheduleAPICronValidation:
|
|||||||
for cron_expr in valid_expressions:
|
for cron_expr in valid_expressions:
|
||||||
schedule_data = {
|
schedule_data = {
|
||||||
'name': f'Schedule for {cron_expr}',
|
'name': f'Schedule for {cron_expr}',
|
||||||
'config_file': sample_config_file,
|
'config_id': sample_db_config.id,
|
||||||
'cron_expression': cron_expr
|
'cron_expression': cron_expr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,7 +612,7 @@ class TestScheduleAPICronValidation:
|
|||||||
assert response.status_code == 201, \
|
assert response.status_code == 201, \
|
||||||
f"Valid cron expression '{cron_expr}' should be accepted"
|
f"Valid cron expression '{cron_expr}' should be accepted"
|
||||||
|
|
||||||
def test_invalid_cron_expressions(self, client, db, sample_config_file):
|
def test_invalid_cron_expressions(self, client, db, sample_db_config):
|
||||||
"""Test various invalid cron expressions."""
|
"""Test various invalid cron expressions."""
|
||||||
invalid_expressions = [
|
invalid_expressions = [
|
||||||
'invalid',
|
'invalid',
|
||||||
@@ -626,7 +626,7 @@ class TestScheduleAPICronValidation:
|
|||||||
for cron_expr in invalid_expressions:
|
for cron_expr in invalid_expressions:
|
||||||
schedule_data = {
|
schedule_data = {
|
||||||
'name': f'Schedule for {cron_expr}',
|
'name': f'Schedule for {cron_expr}',
|
||||||
'config_file': sample_config_file,
|
'config_id': sample_db_config.id,
|
||||||
'cron_expression': cron_expr
|
'cron_expression': cron_expr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ from web.services.schedule_service import ScheduleService
|
|||||||
class TestScheduleServiceCreate:
|
class TestScheduleServiceCreate:
|
||||||
"""Tests for creating schedules."""
|
"""Tests for creating schedules."""
|
||||||
|
|
||||||
def test_create_schedule_valid(self, test_db, sample_config_file):
|
def test_create_schedule_valid(self, db, sample_db_config):
|
||||||
"""Test creating a schedule with valid parameters."""
|
"""Test creating a schedule with valid parameters."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Daily Scan',
|
name='Daily Scan',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -31,57 +31,57 @@ class TestScheduleServiceCreate:
|
|||||||
assert isinstance(schedule_id, int)
|
assert isinstance(schedule_id, int)
|
||||||
|
|
||||||
# Verify schedule in database
|
# Verify schedule in database
|
||||||
schedule = test_db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||||
assert schedule is not None
|
assert schedule is not None
|
||||||
assert schedule.name == 'Daily Scan'
|
assert schedule.name == 'Daily Scan'
|
||||||
assert schedule.config_file == sample_config_file
|
assert schedule.config_id == sample_db_config.id
|
||||||
assert schedule.cron_expression == '0 2 * * *'
|
assert schedule.cron_expression == '0 2 * * *'
|
||||||
assert schedule.enabled is True
|
assert schedule.enabled is True
|
||||||
assert schedule.next_run is not None
|
assert schedule.next_run is not None
|
||||||
assert schedule.last_run is None
|
assert schedule.last_run is None
|
||||||
|
|
||||||
def test_create_schedule_disabled(self, test_db, sample_config_file):
|
def test_create_schedule_disabled(self, db, sample_db_config):
|
||||||
"""Test creating a disabled schedule."""
|
"""Test creating a disabled schedule."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Disabled Scan',
|
name='Disabled Scan',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 3 * * *',
|
cron_expression='0 3 * * *',
|
||||||
enabled=False
|
enabled=False
|
||||||
)
|
)
|
||||||
|
|
||||||
schedule = test_db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||||
assert schedule.enabled is False
|
assert schedule.enabled is False
|
||||||
assert schedule.next_run is None
|
assert schedule.next_run is None
|
||||||
|
|
||||||
def test_create_schedule_invalid_cron(self, test_db, sample_config_file):
|
def test_create_schedule_invalid_cron(self, db, sample_db_config):
|
||||||
"""Test creating a schedule with invalid cron expression."""
|
"""Test creating a schedule with invalid cron expression."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Invalid cron expression"):
|
with pytest.raises(ValueError, match="Invalid cron expression"):
|
||||||
service.create_schedule(
|
service.create_schedule(
|
||||||
name='Invalid Schedule',
|
name='Invalid Schedule',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='invalid cron',
|
cron_expression='invalid cron',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_create_schedule_nonexistent_config(self, test_db):
|
def test_create_schedule_nonexistent_config(self, db):
|
||||||
"""Test creating a schedule with nonexistent config file."""
|
"""Test creating a schedule with nonexistent config."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Config file not found"):
|
with pytest.raises(ValueError, match="not found"):
|
||||||
service.create_schedule(
|
service.create_schedule(
|
||||||
name='Bad Config',
|
name='Bad Config',
|
||||||
config_file='/nonexistent/config.yaml',
|
config_id=99999,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_create_schedule_various_cron_expressions(self, test_db, sample_config_file):
|
def test_create_schedule_various_cron_expressions(self, db, sample_db_config):
|
||||||
"""Test creating schedules with various valid cron expressions."""
|
"""Test creating schedules with various valid cron expressions."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
cron_expressions = [
|
cron_expressions = [
|
||||||
'0 0 * * *', # Daily at midnight
|
'0 0 * * *', # Daily at midnight
|
||||||
@@ -94,7 +94,7 @@ class TestScheduleServiceCreate:
|
|||||||
for i, cron in enumerate(cron_expressions):
|
for i, cron in enumerate(cron_expressions):
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name=f'Schedule {i}',
|
name=f'Schedule {i}',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression=cron,
|
cron_expression=cron,
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -104,21 +104,21 @@ class TestScheduleServiceCreate:
|
|||||||
class TestScheduleServiceGet:
|
class TestScheduleServiceGet:
|
||||||
"""Tests for retrieving schedules."""
|
"""Tests for retrieving schedules."""
|
||||||
|
|
||||||
def test_get_schedule_not_found(self, test_db):
|
def test_get_schedule_not_found(self, db):
|
||||||
"""Test getting a nonexistent schedule."""
|
"""Test getting a nonexistent schedule."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Schedule .* not found"):
|
with pytest.raises(ValueError, match="Schedule .* not found"):
|
||||||
service.get_schedule(999)
|
service.get_schedule(999)
|
||||||
|
|
||||||
def test_get_schedule_found(self, test_db, sample_config_file):
|
def test_get_schedule_found(self, db, sample_db_config):
|
||||||
"""Test getting an existing schedule."""
|
"""Test getting an existing schedule."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
# Create a schedule
|
# Create a schedule
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test Schedule',
|
name='Test Schedule',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -134,14 +134,14 @@ class TestScheduleServiceGet:
|
|||||||
assert 'history' in result
|
assert 'history' in result
|
||||||
assert isinstance(result['history'], list)
|
assert isinstance(result['history'], list)
|
||||||
|
|
||||||
def test_get_schedule_with_history(self, test_db, sample_config_file):
|
def test_get_schedule_with_history(self, db, sample_db_config):
|
||||||
"""Test getting schedule includes execution history."""
|
"""Test getting schedule includes execution history."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
# Create schedule
|
# Create schedule
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test Schedule',
|
name='Test Schedule',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -151,13 +151,13 @@ class TestScheduleServiceGet:
|
|||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow() - timedelta(days=i),
|
timestamp=datetime.utcnow() - timedelta(days=i),
|
||||||
status='completed',
|
status='completed',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
title=f'Scan {i}',
|
title=f'Scan {i}',
|
||||||
triggered_by='scheduled',
|
triggered_by='scheduled',
|
||||||
schedule_id=schedule_id
|
schedule_id=schedule_id
|
||||||
)
|
)
|
||||||
test_db.add(scan)
|
db.add(scan)
|
||||||
test_db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Get schedule
|
# Get schedule
|
||||||
result = service.get_schedule(schedule_id)
|
result = service.get_schedule(schedule_id)
|
||||||
@@ -169,9 +169,9 @@ class TestScheduleServiceGet:
|
|||||||
class TestScheduleServiceList:
|
class TestScheduleServiceList:
|
||||||
"""Tests for listing schedules."""
|
"""Tests for listing schedules."""
|
||||||
|
|
||||||
def test_list_schedules_empty(self, test_db):
|
def test_list_schedules_empty(self, db):
|
||||||
"""Test listing schedules when database is empty."""
|
"""Test listing schedules when database is empty."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
result = service.list_schedules(page=1, per_page=20)
|
result = service.list_schedules(page=1, per_page=20)
|
||||||
|
|
||||||
@@ -180,15 +180,15 @@ class TestScheduleServiceList:
|
|||||||
assert result['page'] == 1
|
assert result['page'] == 1
|
||||||
assert result['per_page'] == 20
|
assert result['per_page'] == 20
|
||||||
|
|
||||||
def test_list_schedules_populated(self, test_db, sample_config_file):
|
def test_list_schedules_populated(self, db, sample_db_config):
|
||||||
"""Test listing schedules with data."""
|
"""Test listing schedules with data."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
# Create multiple schedules
|
# Create multiple schedules
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
service.create_schedule(
|
service.create_schedule(
|
||||||
name=f'Schedule {i}',
|
name=f'Schedule {i}',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -199,15 +199,15 @@ class TestScheduleServiceList:
|
|||||||
assert len(result['schedules']) == 5
|
assert len(result['schedules']) == 5
|
||||||
assert all('name' in s for s in result['schedules'])
|
assert all('name' in s for s in result['schedules'])
|
||||||
|
|
||||||
def test_list_schedules_pagination(self, test_db, sample_config_file):
|
def test_list_schedules_pagination(self, db, sample_db_config):
|
||||||
"""Test schedule pagination."""
|
"""Test schedule pagination."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
# Create 25 schedules
|
# Create 25 schedules
|
||||||
for i in range(25):
|
for i in range(25):
|
||||||
service.create_schedule(
|
service.create_schedule(
|
||||||
name=f'Schedule {i:02d}',
|
name=f'Schedule {i:02d}',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -226,22 +226,22 @@ class TestScheduleServiceList:
|
|||||||
result_page3 = service.list_schedules(page=3, per_page=10)
|
result_page3 = service.list_schedules(page=3, per_page=10)
|
||||||
assert len(result_page3['schedules']) == 5
|
assert len(result_page3['schedules']) == 5
|
||||||
|
|
||||||
def test_list_schedules_filter_enabled(self, test_db, sample_config_file):
|
def test_list_schedules_filter_enabled(self, db, sample_db_config):
|
||||||
"""Test filtering schedules by enabled status."""
|
"""Test filtering schedules by enabled status."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
# Create enabled and disabled schedules
|
# Create enabled and disabled schedules
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
service.create_schedule(
|
service.create_schedule(
|
||||||
name=f'Enabled {i}',
|
name=f'Enabled {i}',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
for i in range(2):
|
for i in range(2):
|
||||||
service.create_schedule(
|
service.create_schedule(
|
||||||
name=f'Disabled {i}',
|
name=f'Disabled {i}',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=False
|
enabled=False
|
||||||
)
|
)
|
||||||
@@ -262,13 +262,13 @@ class TestScheduleServiceList:
|
|||||||
class TestScheduleServiceUpdate:
|
class TestScheduleServiceUpdate:
|
||||||
"""Tests for updating schedules."""
|
"""Tests for updating schedules."""
|
||||||
|
|
||||||
def test_update_schedule_name(self, test_db, sample_config_file):
|
def test_update_schedule_name(self, db, sample_db_config):
|
||||||
"""Test updating schedule name."""
|
"""Test updating schedule name."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Old Name',
|
name='Old Name',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -278,13 +278,13 @@ class TestScheduleServiceUpdate:
|
|||||||
assert result['name'] == 'New Name'
|
assert result['name'] == 'New Name'
|
||||||
assert result['cron_expression'] == '0 2 * * *'
|
assert result['cron_expression'] == '0 2 * * *'
|
||||||
|
|
||||||
def test_update_schedule_cron(self, test_db, sample_config_file):
|
def test_update_schedule_cron(self, db, sample_db_config):
|
||||||
"""Test updating cron expression recalculates next_run."""
|
"""Test updating cron expression recalculates next_run."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test',
|
name='Test',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -302,13 +302,13 @@ class TestScheduleServiceUpdate:
|
|||||||
assert result['cron_expression'] == '0 3 * * *'
|
assert result['cron_expression'] == '0 3 * * *'
|
||||||
assert result['next_run'] != original_next_run
|
assert result['next_run'] != original_next_run
|
||||||
|
|
||||||
def test_update_schedule_invalid_cron(self, test_db, sample_config_file):
|
def test_update_schedule_invalid_cron(self, db, sample_db_config):
|
||||||
"""Test updating with invalid cron expression fails."""
|
"""Test updating with invalid cron expression fails."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test',
|
name='Test',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -316,67 +316,67 @@ class TestScheduleServiceUpdate:
|
|||||||
with pytest.raises(ValueError, match="Invalid cron expression"):
|
with pytest.raises(ValueError, match="Invalid cron expression"):
|
||||||
service.update_schedule(schedule_id, cron_expression='invalid')
|
service.update_schedule(schedule_id, cron_expression='invalid')
|
||||||
|
|
||||||
def test_update_schedule_not_found(self, test_db):
|
def test_update_schedule_not_found(self, db):
|
||||||
"""Test updating nonexistent schedule fails."""
|
"""Test updating nonexistent schedule fails."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Schedule .* not found"):
|
with pytest.raises(ValueError, match="Schedule .* not found"):
|
||||||
service.update_schedule(999, name='New Name')
|
service.update_schedule(999, name='New Name')
|
||||||
|
|
||||||
def test_update_schedule_invalid_config_file(self, test_db, sample_config_file):
|
def test_update_schedule_invalid_config_id(self, db, sample_db_config):
|
||||||
"""Test updating with nonexistent config file fails."""
|
"""Test updating with nonexistent config ID fails."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test',
|
name='Test',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Config file not found"):
|
with pytest.raises(ValueError, match="not found"):
|
||||||
service.update_schedule(schedule_id, config_file='/nonexistent.yaml')
|
service.update_schedule(schedule_id, config_id=99999)
|
||||||
|
|
||||||
|
|
||||||
class TestScheduleServiceDelete:
|
class TestScheduleServiceDelete:
|
||||||
"""Tests for deleting schedules."""
|
"""Tests for deleting schedules."""
|
||||||
|
|
||||||
def test_delete_schedule(self, test_db, sample_config_file):
|
def test_delete_schedule(self, db, sample_db_config):
|
||||||
"""Test deleting a schedule."""
|
"""Test deleting a schedule."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='To Delete',
|
name='To Delete',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify exists
|
# Verify exists
|
||||||
assert test_db.query(Schedule).filter(Schedule.id == schedule_id).first() is not None
|
assert db.query(Schedule).filter(Schedule.id == schedule_id).first() is not None
|
||||||
|
|
||||||
# Delete
|
# Delete
|
||||||
result = service.delete_schedule(schedule_id)
|
result = service.delete_schedule(schedule_id)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
# Verify deleted
|
# Verify deleted
|
||||||
assert test_db.query(Schedule).filter(Schedule.id == schedule_id).first() is None
|
assert db.query(Schedule).filter(Schedule.id == schedule_id).first() is None
|
||||||
|
|
||||||
def test_delete_schedule_not_found(self, test_db):
|
def test_delete_schedule_not_found(self, db):
|
||||||
"""Test deleting nonexistent schedule fails."""
|
"""Test deleting nonexistent schedule fails."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Schedule .* not found"):
|
with pytest.raises(ValueError, match="Schedule .* not found"):
|
||||||
service.delete_schedule(999)
|
service.delete_schedule(999)
|
||||||
|
|
||||||
def test_delete_schedule_preserves_scans(self, test_db, sample_config_file):
|
def test_delete_schedule_preserves_scans(self, db, sample_db_config):
|
||||||
"""Test that deleting schedule preserves associated scans."""
|
"""Test that deleting schedule preserves associated scans."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
# Create schedule
|
# Create schedule
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test',
|
name='Test',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -385,20 +385,20 @@ class TestScheduleServiceDelete:
|
|||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.utcnow(),
|
||||||
status='completed',
|
status='completed',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
title='Test Scan',
|
title='Test Scan',
|
||||||
triggered_by='scheduled',
|
triggered_by='scheduled',
|
||||||
schedule_id=schedule_id
|
schedule_id=schedule_id
|
||||||
)
|
)
|
||||||
test_db.add(scan)
|
db.add(scan)
|
||||||
test_db.commit()
|
db.commit()
|
||||||
scan_id = scan.id
|
scan_id = scan.id
|
||||||
|
|
||||||
# Delete schedule
|
# Delete schedule
|
||||||
service.delete_schedule(schedule_id)
|
service.delete_schedule(schedule_id)
|
||||||
|
|
||||||
# Verify scan still exists (schedule_id becomes null)
|
# Verify scan still exists (schedule_id becomes null)
|
||||||
remaining_scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
|
remaining_scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
assert remaining_scan is not None
|
assert remaining_scan is not None
|
||||||
assert remaining_scan.schedule_id is None
|
assert remaining_scan.schedule_id is None
|
||||||
|
|
||||||
@@ -406,13 +406,13 @@ class TestScheduleServiceDelete:
|
|||||||
class TestScheduleServiceToggle:
|
class TestScheduleServiceToggle:
|
||||||
"""Tests for toggling schedule enabled status."""
|
"""Tests for toggling schedule enabled status."""
|
||||||
|
|
||||||
def test_toggle_enabled_to_disabled(self, test_db, sample_config_file):
|
def test_toggle_enabled_to_disabled(self, db, sample_db_config):
|
||||||
"""Test disabling an enabled schedule."""
|
"""Test disabling an enabled schedule."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test',
|
name='Test',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -422,13 +422,13 @@ class TestScheduleServiceToggle:
|
|||||||
assert result['enabled'] is False
|
assert result['enabled'] is False
|
||||||
assert result['next_run'] is None
|
assert result['next_run'] is None
|
||||||
|
|
||||||
def test_toggle_disabled_to_enabled(self, test_db, sample_config_file):
|
def test_toggle_disabled_to_enabled(self, db, sample_db_config):
|
||||||
"""Test enabling a disabled schedule."""
|
"""Test enabling a disabled schedule."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test',
|
name='Test',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=False
|
enabled=False
|
||||||
)
|
)
|
||||||
@@ -442,13 +442,13 @@ class TestScheduleServiceToggle:
|
|||||||
class TestScheduleServiceRunTimes:
|
class TestScheduleServiceRunTimes:
|
||||||
"""Tests for updating run times."""
|
"""Tests for updating run times."""
|
||||||
|
|
||||||
def test_update_run_times(self, test_db, sample_config_file):
|
def test_update_run_times(self, db, sample_db_config):
|
||||||
"""Test updating last_run and next_run."""
|
"""Test updating last_run and next_run."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test',
|
name='Test',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -463,9 +463,9 @@ class TestScheduleServiceRunTimes:
|
|||||||
assert schedule['last_run'] is not None
|
assert schedule['last_run'] is not None
|
||||||
assert schedule['next_run'] is not None
|
assert schedule['next_run'] is not None
|
||||||
|
|
||||||
def test_update_run_times_not_found(self, test_db):
|
def test_update_run_times_not_found(self, db):
|
||||||
"""Test updating run times for nonexistent schedule."""
|
"""Test updating run times for nonexistent schedule."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Schedule .* not found"):
|
with pytest.raises(ValueError, match="Schedule .* not found"):
|
||||||
service.update_run_times(
|
service.update_run_times(
|
||||||
@@ -478,9 +478,9 @@ class TestScheduleServiceRunTimes:
|
|||||||
class TestCronValidation:
|
class TestCronValidation:
|
||||||
"""Tests for cron expression validation."""
|
"""Tests for cron expression validation."""
|
||||||
|
|
||||||
def test_validate_cron_valid_expressions(self, test_db):
|
def test_validate_cron_valid_expressions(self, db):
|
||||||
"""Test validating various valid cron expressions."""
|
"""Test validating various valid cron expressions."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
valid_expressions = [
|
valid_expressions = [
|
||||||
'0 0 * * *', # Daily at midnight
|
'0 0 * * *', # Daily at midnight
|
||||||
@@ -496,9 +496,9 @@ class TestCronValidation:
|
|||||||
assert is_valid is True, f"Expression '{expr}' should be valid"
|
assert is_valid is True, f"Expression '{expr}' should be valid"
|
||||||
assert error is None
|
assert error is None
|
||||||
|
|
||||||
def test_validate_cron_invalid_expressions(self, test_db):
|
def test_validate_cron_invalid_expressions(self, db):
|
||||||
"""Test validating invalid cron expressions."""
|
"""Test validating invalid cron expressions."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
invalid_expressions = [
|
invalid_expressions = [
|
||||||
'invalid',
|
'invalid',
|
||||||
@@ -518,9 +518,9 @@ class TestCronValidation:
|
|||||||
class TestNextRunCalculation:
|
class TestNextRunCalculation:
|
||||||
"""Tests for next run time calculation."""
|
"""Tests for next run time calculation."""
|
||||||
|
|
||||||
def test_calculate_next_run(self, test_db):
|
def test_calculate_next_run(self, db):
|
||||||
"""Test calculating next run time."""
|
"""Test calculating next run time."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
# Daily at 2 AM
|
# Daily at 2 AM
|
||||||
next_run = service.calculate_next_run('0 2 * * *')
|
next_run = service.calculate_next_run('0 2 * * *')
|
||||||
@@ -529,9 +529,9 @@ class TestNextRunCalculation:
|
|||||||
assert isinstance(next_run, datetime)
|
assert isinstance(next_run, datetime)
|
||||||
assert next_run > datetime.utcnow()
|
assert next_run > datetime.utcnow()
|
||||||
|
|
||||||
def test_calculate_next_run_from_time(self, test_db):
|
def test_calculate_next_run_from_time(self, db):
|
||||||
"""Test calculating next run from specific time."""
|
"""Test calculating next run from specific time."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
base_time = datetime(2025, 1, 1, 0, 0, 0)
|
base_time = datetime(2025, 1, 1, 0, 0, 0)
|
||||||
next_run = service.calculate_next_run('0 2 * * *', from_time=base_time)
|
next_run = service.calculate_next_run('0 2 * * *', from_time=base_time)
|
||||||
@@ -540,9 +540,9 @@ class TestNextRunCalculation:
|
|||||||
assert next_run.hour == 2
|
assert next_run.hour == 2
|
||||||
assert next_run.minute == 0
|
assert next_run.minute == 0
|
||||||
|
|
||||||
def test_calculate_next_run_invalid_cron(self, test_db):
|
def test_calculate_next_run_invalid_cron(self, db):
|
||||||
"""Test calculating next run with invalid cron raises error."""
|
"""Test calculating next run with invalid cron raises error."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Invalid cron expression"):
|
with pytest.raises(ValueError, match="Invalid cron expression"):
|
||||||
service.calculate_next_run('invalid cron')
|
service.calculate_next_run('invalid cron')
|
||||||
@@ -551,13 +551,13 @@ class TestNextRunCalculation:
|
|||||||
class TestScheduleHistory:
|
class TestScheduleHistory:
|
||||||
"""Tests for schedule execution history."""
|
"""Tests for schedule execution history."""
|
||||||
|
|
||||||
def test_get_schedule_history_empty(self, test_db, sample_config_file):
|
def test_get_schedule_history_empty(self, db, sample_db_config):
|
||||||
"""Test getting history for schedule with no executions."""
|
"""Test getting history for schedule with no executions."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test',
|
name='Test',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -565,13 +565,13 @@ class TestScheduleHistory:
|
|||||||
history = service.get_schedule_history(schedule_id)
|
history = service.get_schedule_history(schedule_id)
|
||||||
assert len(history) == 0
|
assert len(history) == 0
|
||||||
|
|
||||||
def test_get_schedule_history_with_scans(self, test_db, sample_config_file):
|
def test_get_schedule_history_with_scans(self, db, sample_db_config):
|
||||||
"""Test getting history with multiple scans."""
|
"""Test getting history with multiple scans."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test',
|
name='Test',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -581,26 +581,26 @@ class TestScheduleHistory:
|
|||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow() - timedelta(days=i),
|
timestamp=datetime.utcnow() - timedelta(days=i),
|
||||||
status='completed',
|
status='completed',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
title=f'Scan {i}',
|
title=f'Scan {i}',
|
||||||
triggered_by='scheduled',
|
triggered_by='scheduled',
|
||||||
schedule_id=schedule_id
|
schedule_id=schedule_id
|
||||||
)
|
)
|
||||||
test_db.add(scan)
|
db.add(scan)
|
||||||
test_db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Get history (default limit 10)
|
# Get history (default limit 10)
|
||||||
history = service.get_schedule_history(schedule_id, limit=10)
|
history = service.get_schedule_history(schedule_id, limit=10)
|
||||||
assert len(history) == 10
|
assert len(history) == 10
|
||||||
assert history[0]['title'] == 'Scan 0' # Most recent first
|
assert history[0]['title'] == 'Scan 0' # Most recent first
|
||||||
|
|
||||||
def test_get_schedule_history_custom_limit(self, test_db, sample_config_file):
|
def test_get_schedule_history_custom_limit(self, db, sample_db_config):
|
||||||
"""Test getting history with custom limit."""
|
"""Test getting history with custom limit."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test',
|
name='Test',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -610,13 +610,13 @@ class TestScheduleHistory:
|
|||||||
scan = Scan(
|
scan = Scan(
|
||||||
timestamp=datetime.utcnow() - timedelta(days=i),
|
timestamp=datetime.utcnow() - timedelta(days=i),
|
||||||
status='completed',
|
status='completed',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
title=f'Scan {i}',
|
title=f'Scan {i}',
|
||||||
triggered_by='scheduled',
|
triggered_by='scheduled',
|
||||||
schedule_id=schedule_id
|
schedule_id=schedule_id
|
||||||
)
|
)
|
||||||
test_db.add(scan)
|
db.add(scan)
|
||||||
test_db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Get only 5
|
# Get only 5
|
||||||
history = service.get_schedule_history(schedule_id, limit=5)
|
history = service.get_schedule_history(schedule_id, limit=5)
|
||||||
@@ -626,13 +626,13 @@ class TestScheduleHistory:
|
|||||||
class TestScheduleSerialization:
|
class TestScheduleSerialization:
|
||||||
"""Tests for schedule serialization."""
|
"""Tests for schedule serialization."""
|
||||||
|
|
||||||
def test_schedule_to_dict(self, test_db, sample_config_file):
|
def test_schedule_to_dict(self, db, sample_db_config):
|
||||||
"""Test converting schedule to dictionary."""
|
"""Test converting schedule to dictionary."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test Schedule',
|
name='Test Schedule',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
@@ -642,7 +642,7 @@ class TestScheduleSerialization:
|
|||||||
# Verify all required fields
|
# Verify all required fields
|
||||||
assert 'id' in result
|
assert 'id' in result
|
||||||
assert 'name' in result
|
assert 'name' in result
|
||||||
assert 'config_file' in result
|
assert 'config_id' in result
|
||||||
assert 'cron_expression' in result
|
assert 'cron_expression' in result
|
||||||
assert 'enabled' in result
|
assert 'enabled' in result
|
||||||
assert 'last_run' in result
|
assert 'last_run' in result
|
||||||
@@ -652,13 +652,13 @@ class TestScheduleSerialization:
|
|||||||
assert 'updated_at' in result
|
assert 'updated_at' in result
|
||||||
assert 'history' in result
|
assert 'history' in result
|
||||||
|
|
||||||
def test_schedule_relative_time_formatting(self, test_db, sample_config_file):
|
def test_schedule_relative_time_formatting(self, db, sample_db_config):
|
||||||
"""Test relative time formatting in schedule dict."""
|
"""Test relative time formatting in schedule dict."""
|
||||||
service = ScheduleService(test_db)
|
service = ScheduleService(db)
|
||||||
|
|
||||||
schedule_id = service.create_schedule(
|
schedule_id = service.create_schedule(
|
||||||
name='Test',
|
name='Test',
|
||||||
config_file=sample_config_file,
|
config_id=sample_db_config.id,
|
||||||
cron_expression='0 2 * * *',
|
cron_expression='0 2 * * *',
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class TestStatsAPI:
|
|||||||
scan_date = today - timedelta(days=i)
|
scan_date = today - timedelta(days=i)
|
||||||
for j in range(i + 1): # Create 1, 2, 3, 4, 5 scans per day
|
for j in range(i + 1): # Create 1, 2, 3, 4, 5 scans per day
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
timestamp=scan_date,
|
timestamp=scan_date,
|
||||||
status='completed',
|
status='completed',
|
||||||
duration=10.5
|
duration=10.5
|
||||||
@@ -56,7 +56,7 @@ class TestStatsAPI:
|
|||||||
today = datetime.utcnow()
|
today = datetime.utcnow()
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
timestamp=today - timedelta(days=i),
|
timestamp=today - timedelta(days=i),
|
||||||
status='completed',
|
status='completed',
|
||||||
duration=10.5
|
duration=10.5
|
||||||
@@ -105,7 +105,7 @@ class TestStatsAPI:
|
|||||||
|
|
||||||
# Create scan 5 days ago
|
# Create scan 5 days ago
|
||||||
scan1 = Scan(
|
scan1 = Scan(
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
timestamp=today - timedelta(days=5),
|
timestamp=today - timedelta(days=5),
|
||||||
status='completed',
|
status='completed',
|
||||||
duration=10.5
|
duration=10.5
|
||||||
@@ -114,7 +114,7 @@ class TestStatsAPI:
|
|||||||
|
|
||||||
# Create scan 10 days ago
|
# Create scan 10 days ago
|
||||||
scan2 = Scan(
|
scan2 = Scan(
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
timestamp=today - timedelta(days=10),
|
timestamp=today - timedelta(days=10),
|
||||||
status='completed',
|
status='completed',
|
||||||
duration=10.5
|
duration=10.5
|
||||||
@@ -148,7 +148,7 @@ class TestStatsAPI:
|
|||||||
# 5 completed scans
|
# 5 completed scans
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
timestamp=today - timedelta(days=i),
|
timestamp=today - timedelta(days=i),
|
||||||
status='completed',
|
status='completed',
|
||||||
duration=10.5
|
duration=10.5
|
||||||
@@ -158,7 +158,7 @@ class TestStatsAPI:
|
|||||||
# 2 failed scans
|
# 2 failed scans
|
||||||
for i in range(2):
|
for i in range(2):
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
timestamp=today - timedelta(days=i),
|
timestamp=today - timedelta(days=i),
|
||||||
status='failed',
|
status='failed',
|
||||||
duration=5.0
|
duration=5.0
|
||||||
@@ -167,7 +167,7 @@ class TestStatsAPI:
|
|||||||
|
|
||||||
# 1 running scan
|
# 1 running scan
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
timestamp=today,
|
timestamp=today,
|
||||||
status='running',
|
status='running',
|
||||||
duration=None
|
duration=None
|
||||||
@@ -217,7 +217,7 @@ class TestStatsAPI:
|
|||||||
# Create 3 scans today
|
# Create 3 scans today
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
timestamp=today,
|
timestamp=today,
|
||||||
status='completed',
|
status='completed',
|
||||||
duration=10.5
|
duration=10.5
|
||||||
@@ -227,7 +227,7 @@ class TestStatsAPI:
|
|||||||
# Create 2 scans yesterday
|
# Create 2 scans yesterday
|
||||||
for i in range(2):
|
for i in range(2):
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
timestamp=yesterday,
|
timestamp=yesterday,
|
||||||
status='completed',
|
status='completed',
|
||||||
duration=10.5
|
duration=10.5
|
||||||
@@ -250,7 +250,7 @@ class TestStatsAPI:
|
|||||||
# Create scans over the last 10 days
|
# Create scans over the last 10 days
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
timestamp=today - timedelta(days=i),
|
timestamp=today - timedelta(days=i),
|
||||||
status='completed',
|
status='completed',
|
||||||
duration=10.5
|
duration=10.5
|
||||||
@@ -275,7 +275,7 @@ class TestStatsAPI:
|
|||||||
"""Test scan trend returns dates in correct format."""
|
"""Test scan trend returns dates in correct format."""
|
||||||
# Create a scan
|
# Create a scan
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
config_file='/app/configs/test.yaml',
|
config_id=1,
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.utcnow(),
|
||||||
status='completed',
|
status='completed',
|
||||||
duration=10.5
|
duration=10.5
|
||||||
|
|||||||
@@ -146,6 +146,47 @@ def acknowledge_alert(alert_id):
|
|||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/acknowledge-all', methods=['POST'])
|
||||||
|
@api_auth_required
|
||||||
|
def acknowledge_all_alerts():
|
||||||
|
"""
|
||||||
|
Acknowledge all unacknowledged alerts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with count of acknowledged alerts
|
||||||
|
"""
|
||||||
|
acknowledged_by = request.json.get('acknowledged_by', 'api') if request.json else 'api'
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all unacknowledged alerts
|
||||||
|
unacked_alerts = current_app.db_session.query(Alert).filter(
|
||||||
|
Alert.acknowledged == False
|
||||||
|
).all()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for alert in unacked_alerts:
|
||||||
|
alert.acknowledged = True
|
||||||
|
alert.acknowledged_at = datetime.now(timezone.utc)
|
||||||
|
alert.acknowledged_by = acknowledged_by
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
current_app.db_session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Acknowledged {count} alerts',
|
||||||
|
'count': count,
|
||||||
|
'acknowledged_by': acknowledged_by
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.db_session.rollback()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Failed to acknowledge alerts: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/rules', methods=['GET'])
|
@bp.route('/rules', methods=['GET'])
|
||||||
@api_auth_required
|
@api_auth_required
|
||||||
def list_alert_rules():
|
def list_alert_rules():
|
||||||
|
|||||||
@@ -5,19 +5,107 @@ Handles endpoints for triggering scans, listing scan history, and retrieving
|
|||||||
scan results.
|
scan results.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, current_app, jsonify, request
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from web.auth.decorators import api_auth_required
|
from web.auth.decorators import api_auth_required
|
||||||
|
from web.models import Scan, ScanProgress
|
||||||
from web.services.scan_service import ScanService
|
from web.services.scan_service import ScanService
|
||||||
from web.utils.validators import validate_config_file
|
|
||||||
from web.utils.pagination import validate_page_params
|
from web.utils.pagination import validate_page_params
|
||||||
|
from web.jobs.scan_job import stop_scan
|
||||||
|
|
||||||
bp = Blueprint('scans', __name__)
|
bp = Blueprint('scans', __name__)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_orphaned_scan(scan: Scan, session) -> dict:
|
||||||
|
"""
|
||||||
|
Recover an orphaned scan by checking for output files.
|
||||||
|
|
||||||
|
If output files exist: mark as 'completed' (smart recovery)
|
||||||
|
If no output files: mark as 'cancelled'
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan: The orphaned Scan object
|
||||||
|
session: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with recovery result for API response
|
||||||
|
"""
|
||||||
|
# Check for existing output files
|
||||||
|
output_exists = False
|
||||||
|
output_files_found = []
|
||||||
|
|
||||||
|
# Check paths stored in database
|
||||||
|
if scan.json_path and Path(scan.json_path).exists():
|
||||||
|
output_exists = True
|
||||||
|
output_files_found.append('json')
|
||||||
|
if scan.html_path and Path(scan.html_path).exists():
|
||||||
|
output_files_found.append('html')
|
||||||
|
if scan.zip_path and Path(scan.zip_path).exists():
|
||||||
|
output_files_found.append('zip')
|
||||||
|
|
||||||
|
# Also check by timestamp pattern if paths not stored yet
|
||||||
|
if not output_exists and scan.started_at:
|
||||||
|
output_dir = Path('/app/output')
|
||||||
|
if output_dir.exists():
|
||||||
|
timestamp_pattern = scan.started_at.strftime('%Y%m%d')
|
||||||
|
for json_file in output_dir.glob(f'scan_report_{timestamp_pattern}*.json'):
|
||||||
|
output_exists = True
|
||||||
|
output_files_found.append('json')
|
||||||
|
# Update scan record with found paths
|
||||||
|
scan.json_path = str(json_file)
|
||||||
|
html_file = json_file.with_suffix('.html')
|
||||||
|
if html_file.exists():
|
||||||
|
scan.html_path = str(html_file)
|
||||||
|
output_files_found.append('html')
|
||||||
|
zip_file = json_file.with_suffix('.zip')
|
||||||
|
if zip_file.exists():
|
||||||
|
scan.zip_path = str(zip_file)
|
||||||
|
output_files_found.append('zip')
|
||||||
|
break
|
||||||
|
|
||||||
|
if output_exists:
|
||||||
|
# Smart recovery: outputs exist, mark as completed
|
||||||
|
scan.status = 'completed'
|
||||||
|
scan.completed_at = datetime.utcnow()
|
||||||
|
if scan.started_at:
|
||||||
|
scan.duration = (datetime.utcnow() - scan.started_at).total_seconds()
|
||||||
|
scan.error_message = None
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Scan {scan.id}: Recovered as completed (files: {output_files_found})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'scan_id': scan.id,
|
||||||
|
'status': 'completed',
|
||||||
|
'message': f'Scan recovered as completed (output files found: {", ".join(output_files_found)})',
|
||||||
|
'recovery_type': 'smart_recovery'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# No outputs: mark as cancelled
|
||||||
|
scan.status = 'cancelled'
|
||||||
|
scan.completed_at = datetime.utcnow()
|
||||||
|
if scan.started_at:
|
||||||
|
scan.duration = (datetime.utcnow() - scan.started_at).total_seconds()
|
||||||
|
scan.error_message = 'Scan process was interrupted before completion. No output files were generated.'
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Scan {scan.id}: Marked as cancelled (orphaned, no output files)")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'scan_id': scan.id,
|
||||||
|
'status': 'cancelled',
|
||||||
|
'message': 'Orphaned scan cancelled (no output files found)',
|
||||||
|
'recovery_type': 'orphan_cleanup'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@bp.route('', methods=['GET'])
|
@bp.route('', methods=['GET'])
|
||||||
@api_auth_required
|
@api_auth_required
|
||||||
def list_scans():
|
def list_scans():
|
||||||
@@ -241,6 +329,77 @@ def delete_scan(scan_id):
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:scan_id>/stop', methods=['POST'])
|
||||||
|
@api_auth_required
|
||||||
|
def stop_running_scan(scan_id):
|
||||||
|
"""
|
||||||
|
Stop a running scan with smart recovery for orphaned scans.
|
||||||
|
|
||||||
|
If the scan is actively running in the registry, sends a cancel signal.
|
||||||
|
If the scan shows as running/finalizing but is not in the registry (orphaned),
|
||||||
|
performs smart recovery: marks as 'completed' if output files exist,
|
||||||
|
otherwise marks as 'cancelled'.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan_id: Scan ID to stop
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with stop status or recovery result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session = current_app.db_session
|
||||||
|
|
||||||
|
# Check if scan exists
|
||||||
|
scan = session.query(Scan).filter_by(id=scan_id).first()
|
||||||
|
if not scan:
|
||||||
|
logger.warning(f"Scan not found for stop request: {scan_id}")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Not found',
|
||||||
|
'message': f'Scan with ID {scan_id} not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
# Allow stopping scans with status 'running' or 'finalizing'
|
||||||
|
if scan.status not in ('running', 'finalizing'):
|
||||||
|
logger.warning(f"Cannot stop scan {scan_id}: status is '{scan.status}'")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Invalid state',
|
||||||
|
'message': f"Cannot stop scan: status is '{scan.status}'"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Get database URL from app config
|
||||||
|
db_url = current_app.config['SQLALCHEMY_DATABASE_URI']
|
||||||
|
|
||||||
|
# Attempt to stop the scan
|
||||||
|
stopped = stop_scan(scan_id, db_url)
|
||||||
|
|
||||||
|
if stopped:
|
||||||
|
logger.info(f"Stop signal sent to scan {scan_id}")
|
||||||
|
return jsonify({
|
||||||
|
'scan_id': scan_id,
|
||||||
|
'message': 'Stop signal sent to scan',
|
||||||
|
'status': 'stopping'
|
||||||
|
}), 200
|
||||||
|
else:
|
||||||
|
# Scanner not in registry - this is an orphaned scan
|
||||||
|
# Attempt smart recovery
|
||||||
|
logger.warning(f"Scan {scan_id} not in registry, attempting smart recovery")
|
||||||
|
recovery_result = _recover_orphaned_scan(scan, session)
|
||||||
|
return jsonify(recovery_result), 200
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Database error stopping scan {scan_id}: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Database error',
|
||||||
|
'message': 'Failed to stop scan'
|
||||||
|
}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error stopping scan {scan_id}: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Internal server error',
|
||||||
|
'message': 'An unexpected error occurred'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<int:scan_id>/status', methods=['GET'])
|
@bp.route('/<int:scan_id>/status', methods=['GET'])
|
||||||
@api_auth_required
|
@api_auth_required
|
||||||
def get_scan_status(scan_id):
|
def get_scan_status(scan_id):
|
||||||
@@ -282,6 +441,141 @@ def get_scan_status(scan_id):
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:scan_id>/progress', methods=['GET'])
|
||||||
|
@api_auth_required
|
||||||
|
def get_scan_progress(scan_id):
|
||||||
|
"""
|
||||||
|
Get detailed progress for a running scan including per-IP results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan_id: Scan ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with scan progress including:
|
||||||
|
- current_phase: Current scan phase
|
||||||
|
- total_ips: Total IPs being scanned
|
||||||
|
- completed_ips: Number of IPs completed in current phase
|
||||||
|
- progress_entries: List of per-IP progress with discovered results
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session = current_app.db_session
|
||||||
|
|
||||||
|
# Get scan record
|
||||||
|
scan = session.query(Scan).filter_by(id=scan_id).first()
|
||||||
|
if not scan:
|
||||||
|
logger.warning(f"Scan not found for progress check: {scan_id}")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Not found',
|
||||||
|
'message': f'Scan with ID {scan_id} not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
# Get progress entries
|
||||||
|
progress_entries = session.query(ScanProgress).filter_by(scan_id=scan_id).all()
|
||||||
|
|
||||||
|
# Build progress data
|
||||||
|
entries = []
|
||||||
|
for entry in progress_entries:
|
||||||
|
entry_data = {
|
||||||
|
'ip_address': entry.ip_address,
|
||||||
|
'site_name': entry.site_name,
|
||||||
|
'phase': entry.phase,
|
||||||
|
'status': entry.status,
|
||||||
|
'ping_result': entry.ping_result
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse JSON fields
|
||||||
|
if entry.tcp_ports:
|
||||||
|
entry_data['tcp_ports'] = json.loads(entry.tcp_ports)
|
||||||
|
else:
|
||||||
|
entry_data['tcp_ports'] = []
|
||||||
|
|
||||||
|
if entry.udp_ports:
|
||||||
|
entry_data['udp_ports'] = json.loads(entry.udp_ports)
|
||||||
|
else:
|
||||||
|
entry_data['udp_ports'] = []
|
||||||
|
|
||||||
|
if entry.services:
|
||||||
|
entry_data['services'] = json.loads(entry.services)
|
||||||
|
else:
|
||||||
|
entry_data['services'] = []
|
||||||
|
|
||||||
|
entries.append(entry_data)
|
||||||
|
|
||||||
|
# Sort entries by site name then IP (numerically)
|
||||||
|
def ip_sort_key(ip_str):
|
||||||
|
"""Convert IP to tuple of integers for proper numeric sorting."""
|
||||||
|
try:
|
||||||
|
return tuple(int(octet) for octet in ip_str.split('.'))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return (0, 0, 0, 0)
|
||||||
|
|
||||||
|
entries.sort(key=lambda x: (x['site_name'] or '', ip_sort_key(x['ip_address'])))
|
||||||
|
|
||||||
|
response = {
|
||||||
|
'scan_id': scan_id,
|
||||||
|
'status': scan.status,
|
||||||
|
'current_phase': scan.current_phase or 'pending',
|
||||||
|
'total_ips': scan.total_ips or 0,
|
||||||
|
'completed_ips': scan.completed_ips or 0,
|
||||||
|
'progress_entries': entries
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"Retrieved progress for scan {scan_id}: phase={scan.current_phase}, {scan.completed_ips}/{scan.total_ips} IPs")
|
||||||
|
return jsonify(response)
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Database error retrieving scan progress {scan_id}: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Database error',
|
||||||
|
'message': 'Failed to retrieve scan progress'
|
||||||
|
}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error retrieving scan progress {scan_id}: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Internal server error',
|
||||||
|
'message': 'An unexpected error occurred'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/by-ip/<ip_address>', methods=['GET'])
|
||||||
|
@api_auth_required
|
||||||
|
def get_scans_by_ip(ip_address):
|
||||||
|
"""
|
||||||
|
Get last 10 scans containing a specific IP address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip_address: IP address to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with list of scans containing the IP
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get scans from service
|
||||||
|
scan_service = ScanService(current_app.db_session)
|
||||||
|
scans = scan_service.get_scans_by_ip(ip_address)
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(scans)} scans for IP: {ip_address}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'ip_address': ip_address,
|
||||||
|
'scans': scans,
|
||||||
|
'count': len(scans)
|
||||||
|
})
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Database error retrieving scans for IP {ip_address}: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Database error',
|
||||||
|
'message': 'Failed to retrieve scans'
|
||||||
|
}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error retrieving scans for IP {ip_address}: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Internal server error',
|
||||||
|
'message': 'An unexpected error occurred'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<int:scan_id1>/compare/<int:scan_id2>', methods=['GET'])
|
@bp.route('/<int:scan_id1>/compare/<int:scan_id2>', methods=['GET'])
|
||||||
@api_auth_required
|
@api_auth_required
|
||||||
def compare_scans(scan_id1, scan_id2):
|
def compare_scans(scan_id1, scan_id2):
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ def create_schedule():
|
|||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
name: Schedule name (required)
|
name: Schedule name (required)
|
||||||
config_file: Path to YAML config (required)
|
config_id: Database config ID (required)
|
||||||
cron_expression: Cron expression (required, e.g., '0 2 * * *')
|
cron_expression: Cron expression (required, e.g., '0 2 * * *')
|
||||||
enabled: Whether schedule is active (optional, default: true)
|
enabled: Whether schedule is active (optional, default: true)
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ def create_schedule():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|
||||||
# Validate required fields
|
# Validate required fields
|
||||||
required = ['name', 'config_file', 'cron_expression']
|
required = ['name', 'config_id', 'cron_expression']
|
||||||
missing = [field for field in required if field not in data]
|
missing = [field for field in required if field not in data]
|
||||||
if missing:
|
if missing:
|
||||||
return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400
|
return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400
|
||||||
@@ -108,7 +108,7 @@ def create_schedule():
|
|||||||
schedule_service = ScheduleService(current_app.db_session)
|
schedule_service = ScheduleService(current_app.db_session)
|
||||||
schedule_id = schedule_service.create_schedule(
|
schedule_id = schedule_service.create_schedule(
|
||||||
name=data['name'],
|
name=data['name'],
|
||||||
config_file=data['config_file'],
|
config_id=data['config_id'],
|
||||||
cron_expression=data['cron_expression'],
|
cron_expression=data['cron_expression'],
|
||||||
enabled=data.get('enabled', True)
|
enabled=data.get('enabled', True)
|
||||||
)
|
)
|
||||||
@@ -121,7 +121,7 @@ def create_schedule():
|
|||||||
try:
|
try:
|
||||||
current_app.scheduler.add_scheduled_scan(
|
current_app.scheduler.add_scheduled_scan(
|
||||||
schedule_id=schedule_id,
|
schedule_id=schedule_id,
|
||||||
config_file=schedule['config_file'],
|
config_id=schedule['config_id'],
|
||||||
cron_expression=schedule['cron_expression']
|
cron_expression=schedule['cron_expression']
|
||||||
)
|
)
|
||||||
logger.info(f"Schedule {schedule_id} added to APScheduler")
|
logger.info(f"Schedule {schedule_id} added to APScheduler")
|
||||||
@@ -154,7 +154,7 @@ def update_schedule(schedule_id):
|
|||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
name: Schedule name (optional)
|
name: Schedule name (optional)
|
||||||
config_file: Path to YAML config (optional)
|
config_id: Database config ID (optional)
|
||||||
cron_expression: Cron expression (optional)
|
cron_expression: Cron expression (optional)
|
||||||
enabled: Whether schedule is active (optional)
|
enabled: Whether schedule is active (optional)
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ def update_schedule(schedule_id):
|
|||||||
try:
|
try:
|
||||||
# If cron expression or config changed, or enabled status changed
|
# If cron expression or config changed, or enabled status changed
|
||||||
cron_changed = 'cron_expression' in data
|
cron_changed = 'cron_expression' in data
|
||||||
config_changed = 'config_file' in data
|
config_changed = 'config_id' in data
|
||||||
enabled_changed = 'enabled' in data
|
enabled_changed = 'enabled' in data
|
||||||
|
|
||||||
if enabled_changed:
|
if enabled_changed:
|
||||||
@@ -189,7 +189,7 @@ def update_schedule(schedule_id):
|
|||||||
# Re-add to scheduler (replaces existing)
|
# Re-add to scheduler (replaces existing)
|
||||||
current_app.scheduler.add_scheduled_scan(
|
current_app.scheduler.add_scheduled_scan(
|
||||||
schedule_id=schedule_id,
|
schedule_id=schedule_id,
|
||||||
config_file=updated_schedule['config_file'],
|
config_id=updated_schedule['config_id'],
|
||||||
cron_expression=updated_schedule['cron_expression']
|
cron_expression=updated_schedule['cron_expression']
|
||||||
)
|
)
|
||||||
logger.info(f"Schedule {schedule_id} enabled and added to APScheduler")
|
logger.info(f"Schedule {schedule_id} enabled and added to APScheduler")
|
||||||
@@ -201,7 +201,7 @@ def update_schedule(schedule_id):
|
|||||||
# Reload schedule in APScheduler
|
# Reload schedule in APScheduler
|
||||||
current_app.scheduler.add_scheduled_scan(
|
current_app.scheduler.add_scheduled_scan(
|
||||||
schedule_id=schedule_id,
|
schedule_id=schedule_id,
|
||||||
config_file=updated_schedule['config_file'],
|
config_id=updated_schedule['config_id'],
|
||||||
cron_expression=updated_schedule['cron_expression']
|
cron_expression=updated_schedule['cron_expression']
|
||||||
)
|
)
|
||||||
logger.info(f"Schedule {schedule_id} reloaded in APScheduler")
|
logger.info(f"Schedule {schedule_id} reloaded in APScheduler")
|
||||||
@@ -293,7 +293,7 @@ def trigger_schedule(schedule_id):
|
|||||||
scheduler = current_app.scheduler if hasattr(current_app, 'scheduler') else None
|
scheduler = current_app.scheduler if hasattr(current_app, 'scheduler') else None
|
||||||
|
|
||||||
scan_id = scan_service.trigger_scan(
|
scan_id = scan_service.trigger_scan(
|
||||||
config_file=schedule['config_file'],
|
config_id=schedule['config_id'],
|
||||||
triggered_by='manual',
|
triggered_by='manual',
|
||||||
schedule_id=schedule_id,
|
schedule_id=schedule_id,
|
||||||
scheduler=scheduler
|
scheduler=scheduler
|
||||||
|
|||||||
@@ -36,9 +36,15 @@ def list_sites():
|
|||||||
if request.args.get('all', '').lower() == 'true':
|
if request.args.get('all', '').lower() == 'true':
|
||||||
site_service = SiteService(current_app.db_session)
|
site_service = SiteService(current_app.db_session)
|
||||||
sites = site_service.list_all_sites()
|
sites = site_service.list_all_sites()
|
||||||
|
ip_stats = site_service.get_global_ip_stats()
|
||||||
|
|
||||||
logger.info(f"Listed all sites (count={len(sites)})")
|
logger.info(f"Listed all sites (count={len(sites)})")
|
||||||
return jsonify({'sites': sites})
|
return jsonify({
|
||||||
|
'sites': sites,
|
||||||
|
'total_ips': ip_stats['total_ips'],
|
||||||
|
'unique_ips': ip_stats['unique_ips'],
|
||||||
|
'duplicate_ips': ip_stats['duplicate_ips']
|
||||||
|
})
|
||||||
|
|
||||||
# Get and validate query parameters
|
# Get and validate query parameters
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
|
|||||||
@@ -198,12 +198,12 @@ def scan_history(scan_id):
|
|||||||
if not reference_scan:
|
if not reference_scan:
|
||||||
return jsonify({'error': 'Scan not found'}), 404
|
return jsonify({'error': 'Scan not found'}), 404
|
||||||
|
|
||||||
config_file = reference_scan.config_file
|
config_id = reference_scan.config_id
|
||||||
|
|
||||||
# Query historical scans with the same config file
|
# Query historical scans with the same config_id
|
||||||
historical_scans = (
|
historical_scans = (
|
||||||
db_session.query(Scan)
|
db_session.query(Scan)
|
||||||
.filter(Scan.config_file == config_file)
|
.filter(Scan.config_id == config_id)
|
||||||
.filter(Scan.status == 'completed')
|
.filter(Scan.status == 'completed')
|
||||||
.order_by(Scan.timestamp.desc())
|
.order_by(Scan.timestamp.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -247,7 +247,7 @@ def scan_history(scan_id):
|
|||||||
'scans': scans_data,
|
'scans': scans_data,
|
||||||
'labels': labels,
|
'labels': labels,
|
||||||
'port_counts': port_counts,
|
'port_counts': port_counts,
|
||||||
'config_file': config_file
|
'config_id': config_id
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
|
|||||||
@@ -307,9 +307,12 @@ def init_scheduler(app: Flask) -> None:
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
# Clean up any orphaned scans from previous crashes/restarts
|
# Clean up any orphaned scans from previous crashes/restarts
|
||||||
scan_service = ScanService(app.db_session)
|
scan_service = ScanService(app.db_session)
|
||||||
orphaned_count = scan_service.cleanup_orphaned_scans()
|
cleanup_result = scan_service.cleanup_orphaned_scans()
|
||||||
if orphaned_count > 0:
|
if cleanup_result['total'] > 0:
|
||||||
app.logger.warning(f"Cleaned up {orphaned_count} orphaned scan(s) on startup")
|
app.logger.warning(
|
||||||
|
f"Cleaned up {cleanup_result['total']} orphaned scan(s) on startup: "
|
||||||
|
f"{cleanup_result['recovered']} recovered, {cleanup_result['failed']} failed"
|
||||||
|
)
|
||||||
|
|
||||||
# Load all enabled schedules from database
|
# Load all enabled schedules from database
|
||||||
scheduler.load_schedules_on_startup()
|
scheduler.load_schedules_on_startup()
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ that are managed by developers, not stored in the database.
|
|||||||
|
|
||||||
# Application metadata
|
# Application metadata
|
||||||
APP_NAME = 'SneakyScanner'
|
APP_NAME = 'SneakyScanner'
|
||||||
APP_VERSION = '1.0.0-alpha'
|
APP_VERSION = '1.0.0-beta'
|
||||||
|
|
||||||
# Repository URL
|
# Repository URL
|
||||||
REPO_URL = 'https://git.sneakygeek.net/sneakygeek/SneakyScan'
|
REPO_URL = 'https://git.sneakygeek.net/sneakygeek/SneakyScan'
|
||||||
|
|
||||||
|
# Scanner settings
|
||||||
|
NMAP_HOST_TIMEOUT = '2m' # Timeout per host for nmap service detection
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ This module handles the execution of scans in background threads,
|
|||||||
updating database status and handling errors.
|
updating database status and handling errors.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -13,15 +15,170 @@ from pathlib import Path
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from src.scanner import SneakyScanner
|
from src.scanner import SneakyScanner, ScanCancelledError
|
||||||
from web.models import Scan
|
from web.models import Scan, ScanProgress
|
||||||
from web.services.scan_service import ScanService
|
from web.services.scan_service import ScanService
|
||||||
from web.services.alert_service import AlertService
|
from web.services.alert_service import AlertService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Registry for tracking running scanners (scan_id -> SneakyScanner instance)
|
||||||
|
_running_scanners = {}
|
||||||
|
_running_scanners_lock = threading.Lock()
|
||||||
|
|
||||||
def execute_scan(scan_id: int, config_file: str = None, config_id: int = None, db_url: str = None):
|
|
||||||
|
def get_running_scanner(scan_id: int):
|
||||||
|
"""Get a running scanner instance by scan ID."""
|
||||||
|
with _running_scanners_lock:
|
||||||
|
return _running_scanners.get(scan_id)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scan(scan_id: int, db_url: str) -> bool:
|
||||||
|
"""
|
||||||
|
Stop a running scan.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan_id: ID of the scan to stop
|
||||||
|
db_url: Database connection URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if scan was cancelled, False if not found or already stopped
|
||||||
|
"""
|
||||||
|
logger.info(f"Attempting to stop scan {scan_id}")
|
||||||
|
|
||||||
|
# Get the scanner instance
|
||||||
|
scanner = get_running_scanner(scan_id)
|
||||||
|
if not scanner:
|
||||||
|
logger.warning(f"Scanner for scan {scan_id} not found in registry")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Cancel the scanner
|
||||||
|
scanner.cancel()
|
||||||
|
logger.info(f"Cancellation signal sent to scan {scan_id}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def create_progress_callback(scan_id: int, session):
|
||||||
|
"""
|
||||||
|
Create a progress callback function for updating scan progress in database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan_id: ID of the scan record
|
||||||
|
session: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Callback function that accepts (phase, ip, data)
|
||||||
|
"""
|
||||||
|
ip_to_site = {}
|
||||||
|
|
||||||
|
def progress_callback(phase: str, ip: str, data: dict):
|
||||||
|
"""Update scan progress in database."""
|
||||||
|
nonlocal ip_to_site
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get scan record
|
||||||
|
scan = session.query(Scan).filter_by(id=scan_id).first()
|
||||||
|
if not scan:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Handle initialization phase
|
||||||
|
if phase == 'init':
|
||||||
|
scan.total_ips = data.get('total_ips', 0)
|
||||||
|
scan.completed_ips = 0
|
||||||
|
scan.current_phase = 'ping'
|
||||||
|
ip_to_site = data.get('ip_to_site', {})
|
||||||
|
|
||||||
|
# Create progress entries for all IPs
|
||||||
|
for ip_addr, site_name in ip_to_site.items():
|
||||||
|
progress = ScanProgress(
|
||||||
|
scan_id=scan_id,
|
||||||
|
ip_address=ip_addr,
|
||||||
|
site_name=site_name,
|
||||||
|
phase='pending',
|
||||||
|
status='pending'
|
||||||
|
)
|
||||||
|
session.add(progress)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update current phase
|
||||||
|
if data.get('status') == 'starting':
|
||||||
|
scan.current_phase = phase
|
||||||
|
scan.completed_ips = 0
|
||||||
|
session.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Handle phase completion with results
|
||||||
|
if data.get('status') == 'completed':
|
||||||
|
results = data.get('results', {})
|
||||||
|
|
||||||
|
if phase == 'ping':
|
||||||
|
# Update progress entries with ping results
|
||||||
|
for ip_addr, ping_result in results.items():
|
||||||
|
progress = session.query(ScanProgress).filter_by(
|
||||||
|
scan_id=scan_id, ip_address=ip_addr
|
||||||
|
).first()
|
||||||
|
if progress:
|
||||||
|
progress.ping_result = ping_result
|
||||||
|
progress.phase = 'ping'
|
||||||
|
progress.status = 'completed'
|
||||||
|
|
||||||
|
scan.completed_ips = len(results)
|
||||||
|
|
||||||
|
elif phase == 'tcp_scan':
|
||||||
|
# Update progress entries with TCP/UDP port results
|
||||||
|
for ip_addr, port_data in results.items():
|
||||||
|
progress = session.query(ScanProgress).filter_by(
|
||||||
|
scan_id=scan_id, ip_address=ip_addr
|
||||||
|
).first()
|
||||||
|
if progress:
|
||||||
|
progress.tcp_ports = json.dumps(port_data.get('tcp_ports', []))
|
||||||
|
progress.udp_ports = json.dumps(port_data.get('udp_ports', []))
|
||||||
|
progress.phase = 'tcp_scan'
|
||||||
|
progress.status = 'completed'
|
||||||
|
|
||||||
|
scan.completed_ips = len(results)
|
||||||
|
|
||||||
|
elif phase == 'service_detection':
|
||||||
|
# Update progress entries with service detection results
|
||||||
|
for ip_addr, services in results.items():
|
||||||
|
progress = session.query(ScanProgress).filter_by(
|
||||||
|
scan_id=scan_id, ip_address=ip_addr
|
||||||
|
).first()
|
||||||
|
if progress:
|
||||||
|
# Simplify service data for storage
|
||||||
|
service_list = []
|
||||||
|
for svc in services:
|
||||||
|
service_list.append({
|
||||||
|
'port': svc.get('port'),
|
||||||
|
'service': svc.get('service', 'unknown'),
|
||||||
|
'product': svc.get('product', ''),
|
||||||
|
'version': svc.get('version', '')
|
||||||
|
})
|
||||||
|
progress.services = json.dumps(service_list)
|
||||||
|
progress.phase = 'service_detection'
|
||||||
|
progress.status = 'completed'
|
||||||
|
|
||||||
|
scan.completed_ips = len(results)
|
||||||
|
|
||||||
|
elif phase == 'http_analysis':
|
||||||
|
# Mark HTTP analysis as complete
|
||||||
|
scan.current_phase = 'completed'
|
||||||
|
scan.completed_ips = scan.total_ips
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Progress callback error for scan {scan_id}: {str(e)}")
|
||||||
|
# Don't re-raise - we don't want to break the scan
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
return progress_callback
|
||||||
|
|
||||||
|
|
||||||
|
def execute_scan(scan_id: int, config_id: int, db_url: str = None):
|
||||||
"""
|
"""
|
||||||
Execute a scan in the background.
|
Execute a scan in the background.
|
||||||
|
|
||||||
@@ -31,12 +188,9 @@ def execute_scan(scan_id: int, config_file: str = None, config_id: int = None, d
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
scan_id: ID of the scan record in database
|
scan_id: ID of the scan record in database
|
||||||
config_file: Path to YAML configuration file (legacy, optional)
|
config_id: Database config ID
|
||||||
config_id: Database config ID (preferred, optional)
|
|
||||||
db_url: Database connection URL
|
db_url: Database connection URL
|
||||||
|
|
||||||
Note: Provide exactly one of config_file or config_id
|
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. Create new database session for this thread
|
1. Create new database session for this thread
|
||||||
2. Update scan status to 'running'
|
2. Update scan status to 'running'
|
||||||
@@ -45,8 +199,7 @@ def execute_scan(scan_id: int, config_file: str = None, config_id: int = None, d
|
|||||||
5. Save results to database
|
5. Save results to database
|
||||||
6. Update status to 'completed' or 'failed'
|
6. Update status to 'completed' or 'failed'
|
||||||
"""
|
"""
|
||||||
config_desc = f"config_id={config_id}" if config_id else f"config_file={config_file}"
|
logger.info(f"Starting background scan execution: scan_id={scan_id}, config_id={config_id}")
|
||||||
logger.info(f"Starting background scan execution: scan_id={scan_id}, {config_desc}")
|
|
||||||
|
|
||||||
# Create new database session for this thread
|
# Create new database session for this thread
|
||||||
engine = create_engine(db_url, echo=False)
|
engine = create_engine(db_url, echo=False)
|
||||||
@@ -65,39 +218,69 @@ def execute_scan(scan_id: int, config_file: str = None, config_id: int = None, d
|
|||||||
scan.started_at = datetime.utcnow()
|
scan.started_at = datetime.utcnow()
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
logger.info(f"Scan {scan_id}: Initializing scanner with {config_desc}")
|
logger.info(f"Scan {scan_id}: Initializing scanner with config_id={config_id}")
|
||||||
|
|
||||||
# Initialize scanner based on config type
|
# Initialize scanner with database config
|
||||||
if config_id:
|
scanner = SneakyScanner(config_id=config_id)
|
||||||
# Use database config
|
|
||||||
scanner = SneakyScanner(config_id=config_id)
|
|
||||||
else:
|
|
||||||
# Use YAML config file
|
|
||||||
# Convert config_file to full path if it's just a filename
|
|
||||||
if not config_file.startswith('/'):
|
|
||||||
config_path = f'/app/configs/{config_file}'
|
|
||||||
else:
|
|
||||||
config_path = config_file
|
|
||||||
|
|
||||||
scanner = SneakyScanner(config_path=config_path)
|
# Register scanner in the running registry
|
||||||
|
with _running_scanners_lock:
|
||||||
|
_running_scanners[scan_id] = scanner
|
||||||
|
logger.debug(f"Scan {scan_id}: Registered in running scanners registry")
|
||||||
|
|
||||||
# Execute scan
|
# Create progress callback
|
||||||
|
progress_callback = create_progress_callback(scan_id, session)
|
||||||
|
|
||||||
|
# Execute scan with progress tracking
|
||||||
logger.info(f"Scan {scan_id}: Running scanner...")
|
logger.info(f"Scan {scan_id}: Running scanner...")
|
||||||
start_time = datetime.utcnow()
|
start_time = datetime.utcnow()
|
||||||
report, timestamp = scanner.scan()
|
report, timestamp = scanner.scan(progress_callback=progress_callback)
|
||||||
end_time = datetime.utcnow()
|
end_time = datetime.utcnow()
|
||||||
|
|
||||||
scan_duration = (end_time - start_time).total_seconds()
|
scan_duration = (end_time - start_time).total_seconds()
|
||||||
logger.info(f"Scan {scan_id}: Scanner completed in {scan_duration:.2f} seconds")
|
logger.info(f"Scan {scan_id}: Scanner completed in {scan_duration:.2f} seconds")
|
||||||
|
|
||||||
# Generate output files (JSON, HTML, ZIP)
|
# Transition to 'finalizing' status before output generation
|
||||||
logger.info(f"Scan {scan_id}: Generating output files...")
|
try:
|
||||||
scanner.generate_outputs(report, timestamp)
|
scan = session.query(Scan).filter_by(id=scan_id).first()
|
||||||
|
if scan:
|
||||||
|
scan.status = 'finalizing'
|
||||||
|
scan.current_phase = 'generating_outputs'
|
||||||
|
session.commit()
|
||||||
|
logger.info(f"Scan {scan_id}: Status changed to 'finalizing'")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scan {scan_id}: Failed to update status to finalizing: {e}")
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
# Save results to database
|
# Generate output files (JSON, HTML, ZIP) with error handling
|
||||||
logger.info(f"Scan {scan_id}: Saving results to database...")
|
output_paths = {}
|
||||||
scan_service = ScanService(session)
|
output_generation_failed = False
|
||||||
scan_service._save_scan_to_db(report, scan_id, status='completed')
|
try:
|
||||||
|
logger.info(f"Scan {scan_id}: Generating output files...")
|
||||||
|
output_paths = scanner.generate_outputs(report, timestamp)
|
||||||
|
except Exception as e:
|
||||||
|
output_generation_failed = True
|
||||||
|
logger.error(f"Scan {scan_id}: Output generation failed: {str(e)}")
|
||||||
|
logger.error(f"Scan {scan_id}: Traceback:\n{traceback.format_exc()}")
|
||||||
|
# Still mark scan as completed with warning since scan data is valid
|
||||||
|
try:
|
||||||
|
scan = session.query(Scan).filter_by(id=scan_id).first()
|
||||||
|
if scan:
|
||||||
|
scan.status = 'completed'
|
||||||
|
scan.error_message = f"Scan completed but output file generation failed: {str(e)}"
|
||||||
|
scan.completed_at = datetime.utcnow()
|
||||||
|
if scan.started_at:
|
||||||
|
scan.duration = (datetime.utcnow() - scan.started_at).total_seconds()
|
||||||
|
session.commit()
|
||||||
|
logger.info(f"Scan {scan_id}: Marked as completed with output generation warning")
|
||||||
|
except Exception as db_error:
|
||||||
|
logger.error(f"Scan {scan_id}: Failed to update status after output error: {db_error}")
|
||||||
|
|
||||||
|
# Save results to database (only if output generation succeeded)
|
||||||
|
if not output_generation_failed:
|
||||||
|
logger.info(f"Scan {scan_id}: Saving results to database...")
|
||||||
|
scan_service = ScanService(session)
|
||||||
|
scan_service._save_scan_to_db(report, scan_id, status='completed', output_paths=output_paths)
|
||||||
|
|
||||||
# Evaluate alert rules
|
# Evaluate alert rules
|
||||||
logger.info(f"Scan {scan_id}: Evaluating alert rules...")
|
logger.info(f"Scan {scan_id}: Evaluating alert rules...")
|
||||||
@@ -112,6 +295,19 @@ def execute_scan(scan_id: int, config_file: str = None, config_id: int = None, d
|
|||||||
|
|
||||||
logger.info(f"Scan {scan_id}: Completed successfully")
|
logger.info(f"Scan {scan_id}: Completed successfully")
|
||||||
|
|
||||||
|
except ScanCancelledError:
|
||||||
|
# Scan was cancelled by user
|
||||||
|
logger.info(f"Scan {scan_id}: Cancelled by user")
|
||||||
|
|
||||||
|
scan = session.query(Scan).filter_by(id=scan_id).first()
|
||||||
|
if scan:
|
||||||
|
scan.status = 'cancelled'
|
||||||
|
scan.error_message = 'Scan cancelled by user'
|
||||||
|
scan.completed_at = datetime.utcnow()
|
||||||
|
if scan.started_at:
|
||||||
|
scan.duration = (datetime.utcnow() - scan.started_at).total_seconds()
|
||||||
|
session.commit()
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
# Config file not found
|
# Config file not found
|
||||||
error_msg = f"Configuration file not found: {str(e)}"
|
error_msg = f"Configuration file not found: {str(e)}"
|
||||||
@@ -141,6 +337,12 @@ def execute_scan(scan_id: int, config_file: str = None, config_id: int = None, d
|
|||||||
logger.error(f"Scan {scan_id}: Failed to update error status in database: {str(db_error)}")
|
logger.error(f"Scan {scan_id}: Failed to update error status in database: {str(db_error)}")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
# Unregister scanner from registry
|
||||||
|
with _running_scanners_lock:
|
||||||
|
if scan_id in _running_scanners:
|
||||||
|
del _running_scanners[scan_id]
|
||||||
|
logger.debug(f"Scan {scan_id}: Unregistered from running scanners registry")
|
||||||
|
|
||||||
# Always close the session
|
# Always close the session
|
||||||
session.close()
|
session.close()
|
||||||
logger.info(f"Scan {scan_id}: Background job completed, session closed")
|
logger.info(f"Scan {scan_id}: Background job completed, session closed")
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ class Scan(Base):
|
|||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
timestamp = Column(DateTime, nullable=False, index=True, comment="Scan start time (UTC)")
|
timestamp = Column(DateTime, nullable=False, index=True, comment="Scan start time (UTC)")
|
||||||
duration = Column(Float, nullable=True, comment="Total scan duration in seconds")
|
duration = Column(Float, nullable=True, comment="Total scan duration in seconds")
|
||||||
status = Column(String(20), nullable=False, default='running', comment="running, completed, failed")
|
status = Column(String(20), nullable=False, default='running', comment="running, finalizing, completed, failed, cancelled")
|
||||||
config_file = Column(Text, nullable=True, comment="Path to YAML config used (deprecated)")
|
|
||||||
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="FK to scan_configs table")
|
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="FK to scan_configs table")
|
||||||
title = Column(Text, nullable=True, comment="Scan title from config")
|
title = Column(Text, nullable=True, comment="Scan title from config")
|
||||||
json_path = Column(Text, nullable=True, comment="Path to JSON report")
|
json_path = Column(Text, nullable=True, comment="Path to JSON report")
|
||||||
@@ -60,6 +59,11 @@ class Scan(Base):
|
|||||||
completed_at = Column(DateTime, nullable=True, comment="Scan execution completion time")
|
completed_at = Column(DateTime, nullable=True, comment="Scan execution completion time")
|
||||||
error_message = Column(Text, nullable=True, comment="Error message if scan failed")
|
error_message = Column(Text, nullable=True, comment="Error message if scan failed")
|
||||||
|
|
||||||
|
# Progress tracking fields
|
||||||
|
current_phase = Column(String(50), nullable=True, comment="Current scan phase: ping, tcp_scan, udp_scan, service_detection, http_analysis")
|
||||||
|
total_ips = Column(Integer, nullable=True, comment="Total number of IPs to scan")
|
||||||
|
completed_ips = Column(Integer, nullable=True, default=0, comment="Number of IPs completed in current phase")
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
sites = relationship('ScanSite', back_populates='scan', cascade='all, delete-orphan')
|
sites = relationship('ScanSite', back_populates='scan', cascade='all, delete-orphan')
|
||||||
ips = relationship('ScanIP', back_populates='scan', cascade='all, delete-orphan')
|
ips = relationship('ScanIP', back_populates='scan', cascade='all, delete-orphan')
|
||||||
@@ -71,6 +75,7 @@ class Scan(Base):
|
|||||||
schedule = relationship('Schedule', back_populates='scans')
|
schedule = relationship('Schedule', back_populates='scans')
|
||||||
config = relationship('ScanConfig', back_populates='scans')
|
config = relationship('ScanConfig', back_populates='scans')
|
||||||
site_associations = relationship('ScanSiteAssociation', back_populates='scan', cascade='all, delete-orphan')
|
site_associations = relationship('ScanSiteAssociation', back_populates='scan', cascade='all, delete-orphan')
|
||||||
|
progress_entries = relationship('ScanProgress', back_populates='scan', cascade='all, delete-orphan')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Scan(id={self.id}, title='{self.title}', status='{self.status}')>"
|
return f"<Scan(id={self.id}, title='{self.title}', status='{self.status}')>"
|
||||||
@@ -245,6 +250,43 @@ class ScanTLSVersion(Base):
|
|||||||
return f"<ScanTLSVersion(id={self.id}, tls_version='{self.tls_version}', supported={self.supported})>"
|
return f"<ScanTLSVersion(id={self.id}, tls_version='{self.tls_version}', supported={self.supported})>"
|
||||||
|
|
||||||
|
|
||||||
|
class ScanProgress(Base):
|
||||||
|
"""
|
||||||
|
Real-time progress tracking for individual IPs during scan execution.
|
||||||
|
|
||||||
|
Stores intermediate results as they become available, allowing users to
|
||||||
|
see progress and results before the full scan completes.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'scan_progress'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
|
||||||
|
ip_address = Column(String(45), nullable=False, comment="IP address being scanned")
|
||||||
|
site_name = Column(String(255), nullable=True, comment="Site name this IP belongs to")
|
||||||
|
phase = Column(String(50), nullable=False, comment="Phase: ping, tcp_scan, udp_scan, service_detection, http_analysis")
|
||||||
|
status = Column(String(20), nullable=False, default='pending', comment="pending, in_progress, completed, failed")
|
||||||
|
|
||||||
|
# Results data (stored as JSON)
|
||||||
|
ping_result = Column(Boolean, nullable=True, comment="Ping response result")
|
||||||
|
tcp_ports = Column(Text, nullable=True, comment="JSON array of discovered TCP ports")
|
||||||
|
udp_ports = Column(Text, nullable=True, comment="JSON array of discovered UDP ports")
|
||||||
|
services = Column(Text, nullable=True, comment="JSON array of detected services")
|
||||||
|
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Entry creation time")
|
||||||
|
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, comment="Last update time")
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
scan = relationship('Scan', back_populates='progress_entries')
|
||||||
|
|
||||||
|
# Index for efficient lookups
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('scan_id', 'ip_address', name='uix_scan_progress_ip'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ScanProgress(id={self.id}, ip='{self.ip_address}', phase='{self.phase}', status='{self.status}')>"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Reusable Site Definition Tables
|
# Reusable Site Definition Tables
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -403,7 +445,6 @@ class Schedule(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
name = Column(String(255), nullable=False, comment="Schedule name (e.g., 'Daily prod scan')")
|
name = Column(String(255), nullable=False, comment="Schedule name (e.g., 'Daily prod scan')")
|
||||||
config_file = Column(Text, nullable=True, comment="Path to YAML config (deprecated)")
|
|
||||||
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="FK to scan_configs table")
|
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="FK to scan_configs table")
|
||||||
cron_expression = Column(String(100), nullable=False, comment="Cron-like schedule (e.g., '0 2 * * *')")
|
cron_expression = Column(String(100), nullable=False, comment="Cron-like schedule (e.g., '0 2 * * *')")
|
||||||
enabled = Column(Boolean, nullable=False, default=True, comment="Is schedule active?")
|
enabled = Column(Boolean, nullable=False, default=True, comment="Is schedule active?")
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ Provides dashboard and scan viewing pages.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from flask import Blueprint, current_app, redirect, render_template, url_for
|
from flask import Blueprint, current_app, redirect, render_template, request, send_from_directory, url_for
|
||||||
|
|
||||||
from web.auth.decorators import login_required
|
from web.auth.decorators import login_required
|
||||||
|
|
||||||
@@ -82,6 +83,19 @@ def compare_scans(scan_id1, scan_id2):
|
|||||||
return render_template('scan_compare.html', scan_id1=scan_id1, scan_id2=scan_id2)
|
return render_template('scan_compare.html', scan_id1=scan_id1, scan_id2=scan_id2)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/search/ip')
|
||||||
|
@login_required
|
||||||
|
def search_ip():
|
||||||
|
"""
|
||||||
|
IP search results page - shows scans containing a specific IP address.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered search results template
|
||||||
|
"""
|
||||||
|
ip_address = request.args.get('ip', '').strip()
|
||||||
|
return render_template('ip_search_results.html', ip_address=ip_address)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/schedules')
|
@bp.route('/schedules')
|
||||||
@login_required
|
@login_required
|
||||||
def schedules():
|
def schedules():
|
||||||
@@ -101,22 +115,19 @@ def create_schedule():
|
|||||||
Create new schedule form page.
|
Create new schedule form page.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Rendered schedule create template with available config files
|
Rendered schedule create template with available configs
|
||||||
"""
|
"""
|
||||||
import os
|
from web.models import ScanConfig
|
||||||
|
|
||||||
# Get list of available config files
|
# Get list of available configs from database
|
||||||
configs_dir = '/app/configs'
|
configs = []
|
||||||
config_files = []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if os.path.exists(configs_dir):
|
configs = current_app.db_session.query(ScanConfig).order_by(ScanConfig.title).all()
|
||||||
config_files = [f for f in os.listdir(configs_dir) if f.endswith('.yaml')]
|
|
||||||
config_files.sort()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing config files: {e}")
|
logger.error(f"Error listing configs: {e}")
|
||||||
|
|
||||||
return render_template('schedule_create.html', config_files=config_files)
|
return render_template('schedule_create.html', configs=configs)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/schedules/<int:schedule_id>/edit')
|
@bp.route('/schedules/<int:schedule_id>/edit')
|
||||||
@@ -131,8 +142,6 @@ def edit_schedule(schedule_id):
|
|||||||
Returns:
|
Returns:
|
||||||
Rendered schedule edit template
|
Rendered schedule edit template
|
||||||
"""
|
"""
|
||||||
from flask import flash
|
|
||||||
|
|
||||||
# Note: Schedule data is loaded via AJAX in the template
|
# Note: Schedule data is loaded via AJAX in the template
|
||||||
# This just renders the page with the schedule_id in the URL
|
# This just renders the page with the schedule_id in the URL
|
||||||
return render_template('schedule_edit.html', schedule_id=schedule_id)
|
return render_template('schedule_edit.html', schedule_id=schedule_id)
|
||||||
@@ -162,51 +171,6 @@ def configs():
|
|||||||
return render_template('configs.html')
|
return render_template('configs.html')
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/configs/upload')
|
|
||||||
@login_required
|
|
||||||
def upload_config():
|
|
||||||
"""
|
|
||||||
Config upload page - allows CIDR/YAML upload.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered config upload template
|
|
||||||
"""
|
|
||||||
return render_template('config_upload.html')
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/configs/edit/<filename>')
|
|
||||||
@login_required
|
|
||||||
def edit_config(filename):
|
|
||||||
"""
|
|
||||||
Config edit page - allows editing YAML configuration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Config filename to edit
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered config edit template
|
|
||||||
"""
|
|
||||||
from web.services.config_service import ConfigService
|
|
||||||
from flask import flash, redirect
|
|
||||||
|
|
||||||
try:
|
|
||||||
config_service = ConfigService()
|
|
||||||
config_data = config_service.get_config(filename)
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
'config_edit.html',
|
|
||||||
filename=config_data['filename'],
|
|
||||||
content=config_data['content']
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
flash(f"Config file '{filename}' not found", 'error')
|
|
||||||
return redirect(url_for('main.configs'))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error loading config for edit: {e}")
|
|
||||||
flash(f"Error loading config: {str(e)}", 'error')
|
|
||||||
return redirect(url_for('main.configs'))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/alerts')
|
@bp.route('/alerts')
|
||||||
@login_required
|
@login_required
|
||||||
def alerts():
|
def alerts():
|
||||||
@@ -294,3 +258,31 @@ def alert_rules():
|
|||||||
'alert_rules.html',
|
'alert_rules.html',
|
||||||
rules=rules
|
rules=rules
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/help')
|
||||||
|
@login_required
|
||||||
|
def help():
|
||||||
|
"""
|
||||||
|
Help page - explains how to use the application.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered help template
|
||||||
|
"""
|
||||||
|
return render_template('help.html')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/output/<path:filename>')
|
||||||
|
@login_required
|
||||||
|
def serve_output_file(filename):
|
||||||
|
"""
|
||||||
|
Serve output files (JSON, HTML, ZIP) from the output directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Name of the file to serve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The requested file
|
||||||
|
"""
|
||||||
|
output_dir = os.environ.get('OUTPUT_DIR', '/app/output')
|
||||||
|
return send_from_directory(output_dir, filename)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class AlertService:
|
|||||||
for rule in rules:
|
for rule in rules:
|
||||||
try:
|
try:
|
||||||
# Check if rule applies to this scan's config
|
# Check if rule applies to this scan's config
|
||||||
if rule.config_file and scan.config_file != rule.config_file:
|
if rule.config_id and scan.config_id != rule.config_id:
|
||||||
logger.debug(f"Skipping rule {rule.id} - config mismatch")
|
logger.debug(f"Skipping rule {rule.id} - config mismatch")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -178,10 +178,10 @@ class AlertService:
|
|||||||
"""
|
"""
|
||||||
alerts_to_create = []
|
alerts_to_create = []
|
||||||
|
|
||||||
# Find previous scan with same config_file
|
# Find previous scan with same config_id
|
||||||
previous_scan = (
|
previous_scan = (
|
||||||
self.db.query(Scan)
|
self.db.query(Scan)
|
||||||
.filter(Scan.config_file == scan.config_file)
|
.filter(Scan.config_id == scan.config_id)
|
||||||
.filter(Scan.id < scan.id)
|
.filter(Scan.id < scan.id)
|
||||||
.filter(Scan.status == 'completed')
|
.filter(Scan.status == 'completed')
|
||||||
.order_by(Scan.started_at.desc() if Scan.started_at else Scan.timestamp.desc())
|
.order_by(Scan.started_at.desc() if Scan.started_at else Scan.timestamp.desc())
|
||||||
@@ -189,7 +189,7 @@ class AlertService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not previous_scan:
|
if not previous_scan:
|
||||||
logger.info(f"No previous scan found for config {scan.config_file}")
|
logger.info(f"No previous scan found for config_id {scan.config_id}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -6,13 +6,8 @@ both database-stored (primary) and file-based (deprecated).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
from typing import Dict, List, Any, Optional
|
||||||
import yaml
|
|
||||||
import ipaddress
|
|
||||||
from typing import Dict, List, Tuple, Any, Optional
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
@@ -342,645 +337,3 @@ class ConfigService:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
return self.get_config_by_id(config_id)
|
return self.get_config_by_id(config_id)
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Legacy YAML File Operations (Deprecated)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def list_configs_file(self) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
[DEPRECATED] List all config files with metadata.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of config metadata dictionaries:
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"filename": "prod-scan.yaml",
|
|
||||||
"title": "Prod Scan",
|
|
||||||
"path": "/app/configs/prod-scan.yaml",
|
|
||||||
"created_at": "2025-11-15T10:30:00Z",
|
|
||||||
"size_bytes": 1234,
|
|
||||||
"used_by_schedules": ["Daily Scan", "Weekly Audit"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
configs = []
|
|
||||||
|
|
||||||
# Get all YAML files in configs directory
|
|
||||||
if not os.path.exists(self.configs_dir):
|
|
||||||
return configs
|
|
||||||
|
|
||||||
for filename in os.listdir(self.configs_dir):
|
|
||||||
if not filename.endswith(('.yaml', '.yml')):
|
|
||||||
continue
|
|
||||||
|
|
||||||
filepath = os.path.join(self.configs_dir, filename)
|
|
||||||
|
|
||||||
if not os.path.isfile(filepath):
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get file metadata
|
|
||||||
stat_info = os.stat(filepath)
|
|
||||||
created_at = datetime.fromtimestamp(stat_info.st_mtime).isoformat() + 'Z'
|
|
||||||
size_bytes = stat_info.st_size
|
|
||||||
|
|
||||||
# Parse YAML to get title
|
|
||||||
title = None
|
|
||||||
try:
|
|
||||||
with open(filepath, 'r') as f:
|
|
||||||
data = yaml.safe_load(f)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
title = data.get('title', filename)
|
|
||||||
except Exception:
|
|
||||||
title = filename # Fallback to filename if parsing fails
|
|
||||||
|
|
||||||
# Get schedules using this config
|
|
||||||
used_by_schedules = self.get_schedules_using_config(filename)
|
|
||||||
|
|
||||||
configs.append({
|
|
||||||
'filename': filename,
|
|
||||||
'title': title,
|
|
||||||
'path': filepath,
|
|
||||||
'created_at': created_at,
|
|
||||||
'size_bytes': size_bytes,
|
|
||||||
'used_by_schedules': used_by_schedules
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
# Skip files that can't be read
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Sort by created_at (most recent first)
|
|
||||||
configs.sort(key=lambda x: x['created_at'], reverse=True)
|
|
||||||
|
|
||||||
return configs
|
|
||||||
|
|
||||||
def get_config(self, filename: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Get config file content and parsed data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Config filename
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"filename": "prod-scan.yaml",
|
|
||||||
"content": "title: Prod Scan\n...",
|
|
||||||
"parsed": {"title": "Prod Scan", "sites": [...]}
|
|
||||||
}
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
FileNotFoundError: If config doesn't exist
|
|
||||||
ValueError: If config content is invalid
|
|
||||||
"""
|
|
||||||
filepath = os.path.join(self.configs_dir, filename)
|
|
||||||
|
|
||||||
if not os.path.exists(filepath):
|
|
||||||
raise FileNotFoundError(f"Config file '{filename}' not found")
|
|
||||||
|
|
||||||
# Read file content
|
|
||||||
with open(filepath, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Parse YAML
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(content)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"Invalid YAML syntax: {str(e)}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'filename': filename,
|
|
||||||
'content': content,
|
|
||||||
'parsed': parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
def create_from_yaml(self, filename: str, content: str) -> str:
|
|
||||||
"""
|
|
||||||
Create config from YAML content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Desired filename (will be sanitized)
|
|
||||||
content: YAML content string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Final filename (sanitized)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If content invalid or filename conflict
|
|
||||||
"""
|
|
||||||
# Sanitize filename
|
|
||||||
filename = secure_filename(filename)
|
|
||||||
|
|
||||||
# Ensure .yaml extension
|
|
||||||
if not filename.endswith(('.yaml', '.yml')):
|
|
||||||
filename += '.yaml'
|
|
||||||
|
|
||||||
filepath = os.path.join(self.configs_dir, filename)
|
|
||||||
|
|
||||||
# Check for conflicts
|
|
||||||
if os.path.exists(filepath):
|
|
||||||
raise ValueError(f"Config file '{filename}' already exists")
|
|
||||||
|
|
||||||
# Parse and validate YAML
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(content)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"Invalid YAML syntax: {str(e)}")
|
|
||||||
|
|
||||||
# Validate config structure
|
|
||||||
is_valid, error_msg = self.validate_config_content(parsed)
|
|
||||||
if not is_valid:
|
|
||||||
raise ValueError(f"Invalid config structure: {error_msg}")
|
|
||||||
|
|
||||||
# Create inline sites in database (if any)
|
|
||||||
self.create_inline_sites(parsed)
|
|
||||||
|
|
||||||
# Write file
|
|
||||||
with open(filepath, 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
return filename
|
|
||||||
|
|
||||||
def create_from_cidr(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
cidr: str,
|
|
||||||
site_name: Optional[str] = None,
|
|
||||||
ping_default: bool = False
|
|
||||||
) -> Tuple[str, str]:
|
|
||||||
"""
|
|
||||||
Create config from CIDR range.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: Scan configuration title
|
|
||||||
cidr: CIDR range (e.g., "10.0.0.0/24")
|
|
||||||
site_name: Optional site name (defaults to "Site 1")
|
|
||||||
ping_default: Default ping expectation for all IPs
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (final_filename, yaml_content)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If CIDR invalid or other validation errors
|
|
||||||
"""
|
|
||||||
# Validate and parse CIDR
|
|
||||||
try:
|
|
||||||
network = ipaddress.ip_network(cidr, strict=False)
|
|
||||||
except ValueError as e:
|
|
||||||
raise ValueError(f"Invalid CIDR range: {str(e)}")
|
|
||||||
|
|
||||||
# Check if network is too large (prevent expansion of huge ranges)
|
|
||||||
if network.num_addresses > 10000:
|
|
||||||
raise ValueError(f"CIDR range too large: {network.num_addresses} addresses. Maximum is 10,000.")
|
|
||||||
|
|
||||||
# Expand CIDR to list of IP addresses
|
|
||||||
ip_list = [str(ip) for ip in network.hosts()]
|
|
||||||
|
|
||||||
# If network has only 1 address (like /32 or /128), hosts() returns empty
|
|
||||||
# In that case, use the network address itself
|
|
||||||
if not ip_list:
|
|
||||||
ip_list = [str(network.network_address)]
|
|
||||||
|
|
||||||
# Build site name
|
|
||||||
if not site_name or not site_name.strip():
|
|
||||||
site_name = "Site 1"
|
|
||||||
|
|
||||||
# Build IP configurations
|
|
||||||
ips = []
|
|
||||||
for ip_address in ip_list:
|
|
||||||
ips.append({
|
|
||||||
'address': ip_address,
|
|
||||||
'expected': {
|
|
||||||
'ping': ping_default,
|
|
||||||
'tcp_ports': [],
|
|
||||||
'udp_ports': []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
# Build YAML structure
|
|
||||||
config_data = {
|
|
||||||
'title': title.strip(),
|
|
||||||
'sites': [
|
|
||||||
{
|
|
||||||
'name': site_name.strip(),
|
|
||||||
'ips': ips
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Convert to YAML string
|
|
||||||
yaml_content = yaml.dump(config_data, sort_keys=False, default_flow_style=False)
|
|
||||||
|
|
||||||
# Generate filename from title
|
|
||||||
filename = self.generate_filename_from_title(title)
|
|
||||||
|
|
||||||
filepath = os.path.join(self.configs_dir, filename)
|
|
||||||
|
|
||||||
# Check for conflicts
|
|
||||||
if os.path.exists(filepath):
|
|
||||||
raise ValueError(f"Config file '{filename}' already exists")
|
|
||||||
|
|
||||||
# Write file
|
|
||||||
with open(filepath, 'w') as f:
|
|
||||||
f.write(yaml_content)
|
|
||||||
|
|
||||||
return filename, yaml_content
|
|
||||||
|
|
||||||
def update_config_file(self, filename: str, yaml_content: str) -> None:
|
|
||||||
"""
|
|
||||||
[DEPRECATED] Update existing config file with new YAML content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Config filename to update
|
|
||||||
yaml_content: New YAML content string
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
FileNotFoundError: If config doesn't exist
|
|
||||||
ValueError: If YAML content is invalid
|
|
||||||
"""
|
|
||||||
filepath = os.path.join(self.configs_dir, filename)
|
|
||||||
|
|
||||||
# Check if file exists
|
|
||||||
if not os.path.exists(filepath):
|
|
||||||
raise FileNotFoundError(f"Config file '{filename}' not found")
|
|
||||||
|
|
||||||
# Parse and validate YAML
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(yaml_content)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"Invalid YAML syntax: {str(e)}")
|
|
||||||
|
|
||||||
# Validate config structure
|
|
||||||
is_valid, error_msg = self.validate_config_content(parsed)
|
|
||||||
if not is_valid:
|
|
||||||
raise ValueError(f"Invalid config structure: {error_msg}")
|
|
||||||
|
|
||||||
# Write updated content
|
|
||||||
with open(filepath, 'w') as f:
|
|
||||||
f.write(yaml_content)
|
|
||||||
|
|
||||||
def delete_config_file(self, filename: str) -> None:
|
|
||||||
"""
|
|
||||||
[DEPRECATED] Delete config file and cascade delete any associated schedules.
|
|
||||||
|
|
||||||
When a config is deleted, all schedules using that config (both enabled
|
|
||||||
and disabled) are automatically deleted as well, since they would be
|
|
||||||
invalid without the config file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Config filename to delete
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
FileNotFoundError: If config doesn't exist
|
|
||||||
"""
|
|
||||||
filepath = os.path.join(self.configs_dir, filename)
|
|
||||||
|
|
||||||
if not os.path.exists(filepath):
|
|
||||||
raise FileNotFoundError(f"Config file '{filename}' not found")
|
|
||||||
|
|
||||||
# Delete any schedules using this config (both enabled and disabled)
|
|
||||||
try:
|
|
||||||
from web.services.schedule_service import ScheduleService
|
|
||||||
from flask import current_app
|
|
||||||
|
|
||||||
# Get database session from Flask app
|
|
||||||
db = current_app.db_session
|
|
||||||
|
|
||||||
# Get all schedules
|
|
||||||
schedule_service = ScheduleService(db)
|
|
||||||
result = schedule_service.list_schedules(page=1, per_page=10000)
|
|
||||||
schedules = result.get('schedules', [])
|
|
||||||
|
|
||||||
# Build full path for comparison
|
|
||||||
config_path = os.path.join(self.configs_dir, filename)
|
|
||||||
|
|
||||||
# Find and delete all schedules using this config (enabled or disabled)
|
|
||||||
deleted_schedules = []
|
|
||||||
for schedule in schedules:
|
|
||||||
schedule_config = schedule.get('config_file', '')
|
|
||||||
|
|
||||||
# Handle both absolute paths and just filenames
|
|
||||||
if schedule_config == filename or schedule_config == config_path:
|
|
||||||
schedule_id = schedule.get('id')
|
|
||||||
schedule_name = schedule.get('name', 'Unknown')
|
|
||||||
try:
|
|
||||||
schedule_service.delete_schedule(schedule_id)
|
|
||||||
deleted_schedules.append(schedule_name)
|
|
||||||
except Exception as e:
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).warning(
|
|
||||||
f"Failed to delete schedule {schedule_id} ('{schedule_name}'): {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if deleted_schedules:
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).info(
|
|
||||||
f"Cascade deleted {len(deleted_schedules)} schedule(s) associated with config '{filename}': {', '.join(deleted_schedules)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
# If ScheduleService doesn't exist yet, skip schedule deletion
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
# Log error but continue with config deletion
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).error(
|
|
||||||
f"Error deleting schedules for config {filename}: {e}", exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete file
|
|
||||||
os.remove(filepath)
|
|
||||||
|
|
||||||
def validate_config_content(self, content: Dict, check_site_refs: bool = True) -> Tuple[bool, str]:
|
|
||||||
"""
|
|
||||||
Validate parsed YAML config structure.
|
|
||||||
|
|
||||||
Supports both legacy format (inline IPs) and new format (site references or CIDRs).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content: Parsed YAML config as dict
|
|
||||||
check_site_refs: If True, validates that referenced sites exist in database
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (is_valid, error_message)
|
|
||||||
"""
|
|
||||||
if not isinstance(content, dict):
|
|
||||||
return False, "Config must be a dictionary/object"
|
|
||||||
|
|
||||||
# Check required fields
|
|
||||||
if 'title' not in content:
|
|
||||||
return False, "Missing required field: 'title'"
|
|
||||||
|
|
||||||
if 'sites' not in content:
|
|
||||||
return False, "Missing required field: 'sites'"
|
|
||||||
|
|
||||||
# Validate title
|
|
||||||
if not isinstance(content['title'], str) or not content['title'].strip():
|
|
||||||
return False, "Field 'title' must be a non-empty string"
|
|
||||||
|
|
||||||
# Validate sites
|
|
||||||
sites = content['sites']
|
|
||||||
if not isinstance(sites, list):
|
|
||||||
return False, "Field 'sites' must be a list"
|
|
||||||
|
|
||||||
if len(sites) == 0:
|
|
||||||
return False, "Must have at least one site defined"
|
|
||||||
|
|
||||||
# Validate each site
|
|
||||||
for i, site in enumerate(sites):
|
|
||||||
if not isinstance(site, dict):
|
|
||||||
return False, f"Site {i+1} must be a dictionary/object"
|
|
||||||
|
|
||||||
# Check if this is a site reference (new format)
|
|
||||||
if 'site_ref' in site:
|
|
||||||
# Site reference format
|
|
||||||
site_ref = site.get('site_ref')
|
|
||||||
if not isinstance(site_ref, str) or not site_ref.strip():
|
|
||||||
return False, f"Site {i+1} field 'site_ref' must be a non-empty string"
|
|
||||||
|
|
||||||
# Validate site reference exists (if check enabled)
|
|
||||||
if check_site_refs:
|
|
||||||
try:
|
|
||||||
from web.services.site_service import SiteService
|
|
||||||
from flask import current_app
|
|
||||||
|
|
||||||
site_service = SiteService(current_app.db_session)
|
|
||||||
referenced_site = site_service.get_site_by_name(site_ref)
|
|
||||||
if not referenced_site:
|
|
||||||
return False, f"Site {i+1}: Referenced site '{site_ref}' does not exist"
|
|
||||||
except Exception as e:
|
|
||||||
# If we can't check (e.g., outside app context), skip validation
|
|
||||||
pass
|
|
||||||
|
|
||||||
continue # Site reference is valid
|
|
||||||
|
|
||||||
# Check if this is inline site creation with CIDRs (new format)
|
|
||||||
if 'cidrs' in site:
|
|
||||||
# Inline site creation with CIDR format
|
|
||||||
if 'name' not in site:
|
|
||||||
return False, f"Site {i+1} with inline CIDRs missing required field: 'name'"
|
|
||||||
|
|
||||||
cidrs = site.get('cidrs')
|
|
||||||
if not isinstance(cidrs, list):
|
|
||||||
return False, f"Site {i+1} field 'cidrs' must be a list"
|
|
||||||
|
|
||||||
if len(cidrs) == 0:
|
|
||||||
return False, f"Site {i+1} must have at least one CIDR"
|
|
||||||
|
|
||||||
# Validate each CIDR
|
|
||||||
for j, cidr_config in enumerate(cidrs):
|
|
||||||
if not isinstance(cidr_config, dict):
|
|
||||||
return False, f"Site {i+1} CIDR {j+1} must be a dictionary/object"
|
|
||||||
|
|
||||||
if 'cidr' not in cidr_config:
|
|
||||||
return False, f"Site {i+1} CIDR {j+1} missing required field: 'cidr'"
|
|
||||||
|
|
||||||
# Validate CIDR format
|
|
||||||
cidr_str = cidr_config.get('cidr')
|
|
||||||
try:
|
|
||||||
ipaddress.ip_network(cidr_str, strict=False)
|
|
||||||
except ValueError:
|
|
||||||
return False, f"Site {i+1} CIDR {j+1}: Invalid CIDR notation '{cidr_str}'"
|
|
||||||
|
|
||||||
continue # Inline CIDR site is valid
|
|
||||||
|
|
||||||
# Legacy format: inline IPs
|
|
||||||
if 'name' not in site:
|
|
||||||
return False, f"Site {i+1} missing required field: 'name'"
|
|
||||||
|
|
||||||
if 'ips' not in site:
|
|
||||||
return False, f"Site {i+1} missing required field: 'ips' (or use 'site_ref' or 'cidrs')"
|
|
||||||
|
|
||||||
if not isinstance(site['ips'], list):
|
|
||||||
return False, f"Site {i+1} field 'ips' must be a list"
|
|
||||||
|
|
||||||
if len(site['ips']) == 0:
|
|
||||||
return False, f"Site {i+1} must have at least one IP"
|
|
||||||
|
|
||||||
# Validate each IP
|
|
||||||
for j, ip_config in enumerate(site['ips']):
|
|
||||||
if not isinstance(ip_config, dict):
|
|
||||||
return False, f"Site {i+1} IP {j+1} must be a dictionary/object"
|
|
||||||
|
|
||||||
if 'address' not in ip_config:
|
|
||||||
return False, f"Site {i+1} IP {j+1} missing required field: 'address'"
|
|
||||||
|
|
||||||
if 'expected' not in ip_config:
|
|
||||||
return False, f"Site {i+1} IP {j+1} missing required field: 'expected'"
|
|
||||||
|
|
||||||
if not isinstance(ip_config['expected'], dict):
|
|
||||||
return False, f"Site {i+1} IP {j+1} field 'expected' must be a dictionary/object"
|
|
||||||
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
def get_schedules_using_config(self, filename: str) -> List[str]:
|
|
||||||
"""
|
|
||||||
Get list of schedule names using this config.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Config filename
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of schedule names (e.g., ["Daily Scan", "Weekly Audit"])
|
|
||||||
"""
|
|
||||||
# Import here to avoid circular dependency
|
|
||||||
try:
|
|
||||||
from web.services.schedule_service import ScheduleService
|
|
||||||
from flask import current_app
|
|
||||||
|
|
||||||
# Get database session from Flask app
|
|
||||||
db = current_app.db_session
|
|
||||||
|
|
||||||
# Get all schedules (use large per_page to get all)
|
|
||||||
schedule_service = ScheduleService(db)
|
|
||||||
result = schedule_service.list_schedules(page=1, per_page=10000)
|
|
||||||
|
|
||||||
# Extract schedules list from paginated result
|
|
||||||
schedules = result.get('schedules', [])
|
|
||||||
|
|
||||||
# Build full path for comparison
|
|
||||||
config_path = os.path.join(self.configs_dir, filename)
|
|
||||||
|
|
||||||
# Find schedules using this config (only enabled schedules)
|
|
||||||
using_schedules = []
|
|
||||||
for schedule in schedules:
|
|
||||||
schedule_config = schedule.get('config_file', '')
|
|
||||||
|
|
||||||
# Handle both absolute paths and just filenames
|
|
||||||
if schedule_config == filename or schedule_config == config_path:
|
|
||||||
# Only count enabled schedules
|
|
||||||
if schedule.get('enabled', False):
|
|
||||||
using_schedules.append(schedule.get('name', 'Unknown'))
|
|
||||||
|
|
||||||
return using_schedules
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
# If ScheduleService doesn't exist yet, return empty list
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
# If any error occurs, return empty list (safer than failing)
|
|
||||||
# Log the error for debugging
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).error(f"Error getting schedules using config {filename}: {e}", exc_info=True)
|
|
||||||
return []
|
|
||||||
|
|
||||||
def generate_filename_from_title(self, title: str) -> str:
|
|
||||||
"""
|
|
||||||
Generate safe filename from scan title.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: Scan title string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Safe filename (e.g., "Prod Scan 2025" -> "prod-scan-2025.yaml")
|
|
||||||
"""
|
|
||||||
# Convert to lowercase
|
|
||||||
filename = title.lower()
|
|
||||||
|
|
||||||
# Replace spaces with hyphens
|
|
||||||
filename = filename.replace(' ', '-')
|
|
||||||
|
|
||||||
# Remove special characters (keep only alphanumeric, hyphens, underscores)
|
|
||||||
filename = re.sub(r'[^a-z0-9\-_]', '', filename)
|
|
||||||
|
|
||||||
# Remove consecutive hyphens
|
|
||||||
filename = re.sub(r'-+', '-', filename)
|
|
||||||
|
|
||||||
# Remove leading/trailing hyphens
|
|
||||||
filename = filename.strip('-')
|
|
||||||
|
|
||||||
# Limit length (max 200 chars, reserve 5 for .yaml)
|
|
||||||
max_length = 195
|
|
||||||
if len(filename) > max_length:
|
|
||||||
filename = filename[:max_length]
|
|
||||||
|
|
||||||
# Ensure not empty
|
|
||||||
if not filename:
|
|
||||||
filename = 'config'
|
|
||||||
|
|
||||||
# Add .yaml extension
|
|
||||||
filename += '.yaml'
|
|
||||||
|
|
||||||
return filename
|
|
||||||
|
|
||||||
def get_config_path(self, filename: str) -> str:
|
|
||||||
"""
|
|
||||||
Get absolute path for a config file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Config filename
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Absolute path to config file
|
|
||||||
"""
|
|
||||||
return os.path.join(self.configs_dir, filename)
|
|
||||||
|
|
||||||
def config_exists(self, filename: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a config file exists.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Config filename
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if file exists, False otherwise
|
|
||||||
"""
|
|
||||||
filepath = os.path.join(self.configs_dir, filename)
|
|
||||||
return os.path.exists(filepath) and os.path.isfile(filepath)
|
|
||||||
|
|
||||||
def create_inline_sites(self, config_content: Dict) -> None:
|
|
||||||
"""
|
|
||||||
Create sites in the database for inline site definitions in a config.
|
|
||||||
|
|
||||||
This method scans the config for inline site definitions (with CIDRs)
|
|
||||||
and creates them as reusable sites in the database if they don't already exist.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_content: Parsed YAML config dictionary
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If site creation fails
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from web.services.site_service import SiteService
|
|
||||||
from flask import current_app
|
|
||||||
|
|
||||||
site_service = SiteService(current_app.db_session)
|
|
||||||
|
|
||||||
sites = config_content.get('sites', [])
|
|
||||||
|
|
||||||
for site_def in sites:
|
|
||||||
# Skip site references (they already exist)
|
|
||||||
if 'site_ref' in site_def:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip legacy IP-based sites (not creating those as reusable sites)
|
|
||||||
if 'ips' in site_def and 'cidrs' not in site_def:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Process inline CIDR-based sites
|
|
||||||
if 'cidrs' in site_def:
|
|
||||||
site_name = site_def.get('name')
|
|
||||||
|
|
||||||
# Check if site already exists
|
|
||||||
existing_site = site_service.get_site_by_name(site_name)
|
|
||||||
if existing_site:
|
|
||||||
# Site already exists, skip creation
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create new site
|
|
||||||
cidrs = site_def.get('cidrs', [])
|
|
||||||
description = f"Auto-created from config '{config_content.get('title', 'Unknown')}'"
|
|
||||||
|
|
||||||
site_service.create_site(
|
|
||||||
name=site_name,
|
|
||||||
description=description,
|
|
||||||
cidrs=cidrs
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# If site creation fails, log but don't block config creation
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).warning(
|
|
||||||
f"Failed to create inline sites from config: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ from sqlalchemy.orm import Session, joinedload
|
|||||||
|
|
||||||
from web.models import (
|
from web.models import (
|
||||||
Scan, ScanSite, ScanIP, ScanPort, ScanService as ScanServiceModel,
|
Scan, ScanSite, ScanIP, ScanPort, ScanService as ScanServiceModel,
|
||||||
ScanCertificate, ScanTLSVersion, Site, ScanSiteAssociation
|
ScanCertificate, ScanTLSVersion, Site, ScanSiteAssociation, SiteIP
|
||||||
)
|
)
|
||||||
from web.utils.pagination import paginate, PaginatedResult
|
from web.utils.pagination import paginate, PaginatedResult
|
||||||
from web.utils.validators import validate_config_file, validate_scan_status
|
from web.utils.validators import validate_scan_status
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ class ScanService:
|
|||||||
"""
|
"""
|
||||||
self.db = db_session
|
self.db = db_session
|
||||||
|
|
||||||
def trigger_scan(self, config_file: str = None, config_id: int = None,
|
def trigger_scan(self, config_id: int,
|
||||||
triggered_by: str = 'manual', schedule_id: Optional[int] = None,
|
triggered_by: str = 'manual', schedule_id: Optional[int] = None,
|
||||||
scheduler=None) -> int:
|
scheduler=None) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -51,8 +51,7 @@ class ScanService:
|
|||||||
queues the scan for background execution.
|
queues the scan for background execution.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_file: Path to YAML configuration file (legacy, optional)
|
config_id: Database config ID
|
||||||
config_id: Database config ID (preferred, optional)
|
|
||||||
triggered_by: Source that triggered scan (manual, scheduled, api)
|
triggered_by: Source that triggered scan (manual, scheduled, api)
|
||||||
schedule_id: Optional schedule ID if triggered by schedule
|
schedule_id: Optional schedule ID if triggered by schedule
|
||||||
scheduler: Optional SchedulerService instance for queuing background jobs
|
scheduler: Optional SchedulerService instance for queuing background jobs
|
||||||
@@ -61,106 +60,48 @@ class ScanService:
|
|||||||
Scan ID of the created scan
|
Scan ID of the created scan
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If config is invalid or both/neither config_file and config_id provided
|
ValueError: If config is invalid
|
||||||
"""
|
"""
|
||||||
# Validate that exactly one config source is provided
|
from web.models import ScanConfig
|
||||||
if not (bool(config_file) ^ bool(config_id)):
|
|
||||||
raise ValueError("Must provide exactly one of config_file or config_id")
|
|
||||||
|
|
||||||
# Handle database config
|
# Validate config exists
|
||||||
if config_id:
|
db_config = self.db.query(ScanConfig).filter_by(id=config_id).first()
|
||||||
from web.models import ScanConfig
|
if not db_config:
|
||||||
|
raise ValueError(f"Config with ID {config_id} not found")
|
||||||
|
|
||||||
# Validate config exists
|
# Create scan record with config_id
|
||||||
db_config = self.db.query(ScanConfig).filter_by(id=config_id).first()
|
scan = Scan(
|
||||||
if not db_config:
|
timestamp=datetime.utcnow(),
|
||||||
raise ValueError(f"Config with ID {config_id} not found")
|
status='running',
|
||||||
|
config_id=config_id,
|
||||||
|
title=db_config.title,
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
# Create scan record with config_id
|
self.db.add(scan)
|
||||||
scan = Scan(
|
self.db.commit()
|
||||||
timestamp=datetime.utcnow(),
|
self.db.refresh(scan)
|
||||||
status='running',
|
|
||||||
config_id=config_id,
|
|
||||||
title=db_config.title,
|
|
||||||
triggered_by=triggered_by,
|
|
||||||
schedule_id=schedule_id,
|
|
||||||
created_at=datetime.utcnow()
|
|
||||||
)
|
|
||||||
|
|
||||||
self.db.add(scan)
|
logger.info(f"Scan {scan.id} triggered via {triggered_by} with config_id={config_id}")
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(scan)
|
|
||||||
|
|
||||||
logger.info(f"Scan {scan.id} triggered via {triggered_by} with config_id={config_id}")
|
# Queue background job if scheduler provided
|
||||||
|
if scheduler:
|
||||||
# Queue background job if scheduler provided
|
try:
|
||||||
if scheduler:
|
job_id = scheduler.queue_scan(scan.id, config_id=config_id)
|
||||||
try:
|
logger.info(f"Scan {scan.id} queued for background execution (job_id={job_id})")
|
||||||
job_id = scheduler.queue_scan(scan.id, config_id=config_id)
|
except Exception as e:
|
||||||
logger.info(f"Scan {scan.id} queued for background execution (job_id={job_id})")
|
logger.error(f"Failed to queue scan {scan.id}: {str(e)}")
|
||||||
except Exception as e:
|
# Mark scan as failed if job queuing fails
|
||||||
logger.error(f"Failed to queue scan {scan.id}: {str(e)}")
|
scan.status = 'failed'
|
||||||
# Mark scan as failed if job queuing fails
|
scan.error_message = f"Failed to queue background job: {str(e)}"
|
||||||
scan.status = 'failed'
|
self.db.commit()
|
||||||
scan.error_message = f"Failed to queue background job: {str(e)}"
|
raise
|
||||||
self.db.commit()
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
logger.warning(f"Scan {scan.id} created but not queued (no scheduler provided)")
|
|
||||||
|
|
||||||
return scan.id
|
|
||||||
|
|
||||||
# Handle legacy YAML config file
|
|
||||||
else:
|
else:
|
||||||
# Validate config file
|
logger.warning(f"Scan {scan.id} created but not queued (no scheduler provided)")
|
||||||
is_valid, error_msg = validate_config_file(config_file)
|
|
||||||
if not is_valid:
|
|
||||||
raise ValueError(f"Invalid config file: {error_msg}")
|
|
||||||
|
|
||||||
# Convert config_file to full path if it's just a filename
|
return scan.id
|
||||||
if not config_file.startswith('/'):
|
|
||||||
config_path = f'/app/configs/{config_file}'
|
|
||||||
else:
|
|
||||||
config_path = config_file
|
|
||||||
|
|
||||||
# Load config to get title
|
|
||||||
import yaml
|
|
||||||
with open(config_path, 'r') as f:
|
|
||||||
config = yaml.safe_load(f)
|
|
||||||
|
|
||||||
# Create scan record
|
|
||||||
scan = Scan(
|
|
||||||
timestamp=datetime.utcnow(),
|
|
||||||
status='running',
|
|
||||||
config_file=config_file,
|
|
||||||
title=config.get('title', 'Untitled Scan'),
|
|
||||||
triggered_by=triggered_by,
|
|
||||||
schedule_id=schedule_id,
|
|
||||||
created_at=datetime.utcnow()
|
|
||||||
)
|
|
||||||
|
|
||||||
self.db.add(scan)
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(scan)
|
|
||||||
|
|
||||||
logger.info(f"Scan {scan.id} triggered via {triggered_by}")
|
|
||||||
|
|
||||||
# Queue background job if scheduler provided
|
|
||||||
if scheduler:
|
|
||||||
try:
|
|
||||||
job_id = scheduler.queue_scan(scan.id, config_file=config_file)
|
|
||||||
logger.info(f"Scan {scan.id} queued for background execution (job_id={job_id})")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to queue scan {scan.id}: {str(e)}")
|
|
||||||
# Mark scan as failed if job queuing fails
|
|
||||||
scan.status = 'failed'
|
|
||||||
scan.error_message = f"Failed to queue background job: {str(e)}"
|
|
||||||
self.db.commit()
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
logger.warning(f"Scan {scan.id} created but not queued (no scheduler provided)")
|
|
||||||
|
|
||||||
return scan.id
|
|
||||||
|
|
||||||
def get_scan(self, scan_id: int) -> Optional[Dict[str, Any]]:
|
def get_scan(self, scan_id: int) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -316,58 +257,128 @@ class ScanService:
|
|||||||
elif scan.status == 'failed':
|
elif scan.status == 'failed':
|
||||||
status_info['progress'] = 'Failed'
|
status_info['progress'] = 'Failed'
|
||||||
status_info['error_message'] = scan.error_message
|
status_info['error_message'] = scan.error_message
|
||||||
|
elif scan.status == 'cancelled':
|
||||||
|
status_info['progress'] = 'Cancelled'
|
||||||
|
status_info['error_message'] = scan.error_message
|
||||||
|
|
||||||
return status_info
|
return status_info
|
||||||
|
|
||||||
def cleanup_orphaned_scans(self) -> int:
|
def get_scans_by_ip(self, ip_address: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Clean up orphaned scans that are stuck in 'running' status.
|
Get the last N scans containing a specific IP address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip_address: IP address to search for
|
||||||
|
limit: Maximum number of scans to return (default: 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of scan summary dictionaries, most recent first
|
||||||
|
"""
|
||||||
|
scans = (
|
||||||
|
self.db.query(Scan)
|
||||||
|
.join(ScanIP, Scan.id == ScanIP.scan_id)
|
||||||
|
.filter(ScanIP.ip_address == ip_address)
|
||||||
|
.filter(Scan.status == 'completed')
|
||||||
|
.order_by(Scan.timestamp.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [self._scan_to_summary_dict(scan) for scan in scans]
|
||||||
|
|
||||||
|
def cleanup_orphaned_scans(self) -> dict:
|
||||||
|
"""
|
||||||
|
Clean up orphaned scans with smart recovery.
|
||||||
|
|
||||||
|
For scans stuck in 'running' or 'finalizing' status:
|
||||||
|
- If output files exist: mark as 'completed' (smart recovery)
|
||||||
|
- If no output files: mark as 'failed'
|
||||||
|
|
||||||
This should be called on application startup to handle scans that
|
This should be called on application startup to handle scans that
|
||||||
were running when the system crashed or was restarted.
|
were running when the system crashed or was restarted.
|
||||||
|
|
||||||
Scans in 'running' status are marked as 'failed' with an appropriate
|
|
||||||
error message indicating they were orphaned.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of orphaned scans cleaned up
|
Dictionary with cleanup results: {'recovered': N, 'failed': N, 'total': N}
|
||||||
"""
|
"""
|
||||||
# Find all scans with status='running'
|
# Find all scans with status='running' or 'finalizing'
|
||||||
orphaned_scans = self.db.query(Scan).filter(Scan.status == 'running').all()
|
orphaned_scans = self.db.query(Scan).filter(
|
||||||
|
Scan.status.in_(['running', 'finalizing'])
|
||||||
|
).all()
|
||||||
|
|
||||||
if not orphaned_scans:
|
if not orphaned_scans:
|
||||||
logger.info("No orphaned scans found")
|
logger.info("No orphaned scans found")
|
||||||
return 0
|
return {'recovered': 0, 'failed': 0, 'total': 0}
|
||||||
|
|
||||||
count = len(orphaned_scans)
|
count = len(orphaned_scans)
|
||||||
logger.warning(f"Found {count} orphaned scan(s) in 'running' status, marking as failed")
|
logger.warning(f"Found {count} orphaned scan(s), attempting smart recovery")
|
||||||
|
|
||||||
|
recovered_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
output_dir = Path('/app/output')
|
||||||
|
|
||||||
# Mark each orphaned scan as failed
|
|
||||||
for scan in orphaned_scans:
|
for scan in orphaned_scans:
|
||||||
scan.status = 'failed'
|
# Check for existing output files
|
||||||
|
output_exists = False
|
||||||
|
output_files_found = []
|
||||||
|
|
||||||
|
# Check paths stored in database
|
||||||
|
if scan.json_path and Path(scan.json_path).exists():
|
||||||
|
output_exists = True
|
||||||
|
output_files_found.append('json')
|
||||||
|
if scan.html_path and Path(scan.html_path).exists():
|
||||||
|
output_files_found.append('html')
|
||||||
|
if scan.zip_path and Path(scan.zip_path).exists():
|
||||||
|
output_files_found.append('zip')
|
||||||
|
|
||||||
|
# Also check by timestamp pattern if paths not stored yet
|
||||||
|
if not output_exists and scan.started_at and output_dir.exists():
|
||||||
|
timestamp_pattern = scan.started_at.strftime('%Y%m%d')
|
||||||
|
for json_file in output_dir.glob(f'scan_report_{timestamp_pattern}*.json'):
|
||||||
|
output_exists = True
|
||||||
|
output_files_found.append('json')
|
||||||
|
# Update scan record with found paths
|
||||||
|
scan.json_path = str(json_file)
|
||||||
|
html_file = json_file.with_suffix('.html')
|
||||||
|
if html_file.exists():
|
||||||
|
scan.html_path = str(html_file)
|
||||||
|
output_files_found.append('html')
|
||||||
|
zip_file = json_file.with_suffix('.zip')
|
||||||
|
if zip_file.exists():
|
||||||
|
scan.zip_path = str(zip_file)
|
||||||
|
output_files_found.append('zip')
|
||||||
|
break
|
||||||
|
|
||||||
|
if output_exists:
|
||||||
|
# Smart recovery: outputs exist, mark as completed
|
||||||
|
scan.status = 'completed'
|
||||||
|
scan.error_message = f'Recovered from orphaned state (output files found: {", ".join(output_files_found)})'
|
||||||
|
recovered_count += 1
|
||||||
|
logger.info(f"Recovered orphaned scan {scan.id} as completed (files: {output_files_found})")
|
||||||
|
else:
|
||||||
|
# No outputs: mark as failed
|
||||||
|
scan.status = 'failed'
|
||||||
|
scan.error_message = (
|
||||||
|
"Scan was interrupted by system shutdown or crash. "
|
||||||
|
"No output files were generated."
|
||||||
|
)
|
||||||
|
failed_count += 1
|
||||||
|
logger.info(f"Marked orphaned scan {scan.id} as failed (no output files)")
|
||||||
|
|
||||||
scan.completed_at = datetime.utcnow()
|
scan.completed_at = datetime.utcnow()
|
||||||
scan.error_message = (
|
|
||||||
"Scan was interrupted by system shutdown or crash. "
|
|
||||||
"The scan was running but did not complete normally."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate duration if we have a started_at time
|
|
||||||
if scan.started_at:
|
if scan.started_at:
|
||||||
duration = (datetime.utcnow() - scan.started_at).total_seconds()
|
scan.duration = (datetime.utcnow() - scan.started_at).total_seconds()
|
||||||
scan.duration = duration
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Marked orphaned scan {scan.id} as failed "
|
|
||||||
f"(started: {scan.started_at.isoformat() if scan.started_at else 'unknown'})"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
logger.info(f"Cleaned up {count} orphaned scan(s)")
|
logger.info(f"Cleaned up {count} orphaned scan(s): {recovered_count} recovered, {failed_count} failed")
|
||||||
|
|
||||||
return count
|
return {
|
||||||
|
'recovered': recovered_count,
|
||||||
|
'failed': failed_count,
|
||||||
|
'total': count
|
||||||
|
}
|
||||||
|
|
||||||
def _save_scan_to_db(self, report: Dict[str, Any], scan_id: int,
|
def _save_scan_to_db(self, report: Dict[str, Any], scan_id: int,
|
||||||
status: str = 'completed') -> None:
|
status: str = 'completed', output_paths: Dict = None) -> None:
|
||||||
"""
|
"""
|
||||||
Save scan results to database.
|
Save scan results to database.
|
||||||
|
|
||||||
@@ -378,6 +389,7 @@ class ScanService:
|
|||||||
report: Scan report dictionary from scanner
|
report: Scan report dictionary from scanner
|
||||||
scan_id: Scan ID to update
|
scan_id: Scan ID to update
|
||||||
status: Final scan status (completed or failed)
|
status: Final scan status (completed or failed)
|
||||||
|
output_paths: Dictionary with paths to generated files {'json': Path, 'html': Path, 'zip': Path}
|
||||||
"""
|
"""
|
||||||
scan = self.db.query(Scan).filter(Scan.id == scan_id).first()
|
scan = self.db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
if not scan:
|
if not scan:
|
||||||
@@ -388,6 +400,17 @@ class ScanService:
|
|||||||
scan.duration = report.get('scan_duration')
|
scan.duration = report.get('scan_duration')
|
||||||
scan.completed_at = datetime.utcnow()
|
scan.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Save output file paths
|
||||||
|
if output_paths:
|
||||||
|
if 'json' in output_paths:
|
||||||
|
scan.json_path = str(output_paths['json'])
|
||||||
|
if 'html' in output_paths:
|
||||||
|
scan.html_path = str(output_paths['html'])
|
||||||
|
if 'zip' in output_paths:
|
||||||
|
scan.zip_path = str(output_paths['zip'])
|
||||||
|
if 'screenshots' in output_paths:
|
||||||
|
scan.screenshot_dir = str(output_paths['screenshots'])
|
||||||
|
|
||||||
# Map report data to database models
|
# Map report data to database models
|
||||||
self._map_report_to_models(report, scan)
|
self._map_report_to_models(report, scan)
|
||||||
|
|
||||||
@@ -498,9 +521,10 @@ class ScanService:
|
|||||||
|
|
||||||
# Process certificate and TLS info if present
|
# Process certificate and TLS info if present
|
||||||
http_info = service_data.get('http_info', {})
|
http_info = service_data.get('http_info', {})
|
||||||
if http_info.get('certificate'):
|
ssl_tls = http_info.get('ssl_tls', {})
|
||||||
|
if ssl_tls.get('certificate'):
|
||||||
self._process_certificate(
|
self._process_certificate(
|
||||||
http_info['certificate'],
|
ssl_tls,
|
||||||
scan_obj.id,
|
scan_obj.id,
|
||||||
service.id
|
service.id
|
||||||
)
|
)
|
||||||
@@ -538,16 +562,19 @@ class ScanService:
|
|||||||
return service
|
return service
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _process_certificate(self, cert_data: Dict[str, Any], scan_id: int,
|
def _process_certificate(self, ssl_tls_data: Dict[str, Any], scan_id: int,
|
||||||
service_id: int) -> None:
|
service_id: int) -> None:
|
||||||
"""
|
"""
|
||||||
Process certificate and TLS version data.
|
Process certificate and TLS version data.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cert_data: Certificate data dictionary
|
ssl_tls_data: SSL/TLS data dictionary containing 'certificate' and 'tls_versions'
|
||||||
scan_id: Scan ID
|
scan_id: Scan ID
|
||||||
service_id: Service ID
|
service_id: Service ID
|
||||||
"""
|
"""
|
||||||
|
# Extract certificate data from ssl_tls structure
|
||||||
|
cert_data = ssl_tls_data.get('certificate', {})
|
||||||
|
|
||||||
# Create ScanCertificate record
|
# Create ScanCertificate record
|
||||||
cert = ScanCertificate(
|
cert = ScanCertificate(
|
||||||
scan_id=scan_id,
|
scan_id=scan_id,
|
||||||
@@ -565,7 +592,7 @@ class ScanService:
|
|||||||
self.db.flush()
|
self.db.flush()
|
||||||
|
|
||||||
# Process TLS versions
|
# Process TLS versions
|
||||||
tls_versions = cert_data.get('tls_versions', {})
|
tls_versions = ssl_tls_data.get('tls_versions', {})
|
||||||
for version, version_data in tls_versions.items():
|
for version, version_data in tls_versions.items():
|
||||||
tls = ScanTLSVersion(
|
tls = ScanTLSVersion(
|
||||||
scan_id=scan_id,
|
scan_id=scan_id,
|
||||||
@@ -614,7 +641,7 @@ class ScanService:
|
|||||||
'duration': scan.duration,
|
'duration': scan.duration,
|
||||||
'status': scan.status,
|
'status': scan.status,
|
||||||
'title': scan.title,
|
'title': scan.title,
|
||||||
'config_file': scan.config_file,
|
'config_id': scan.config_id,
|
||||||
'json_path': scan.json_path,
|
'json_path': scan.json_path,
|
||||||
'html_path': scan.html_path,
|
'html_path': scan.html_path,
|
||||||
'zip_path': scan.zip_path,
|
'zip_path': scan.zip_path,
|
||||||
@@ -640,24 +667,54 @@ class ScanService:
|
|||||||
'duration': scan.duration,
|
'duration': scan.duration,
|
||||||
'status': scan.status,
|
'status': scan.status,
|
||||||
'title': scan.title,
|
'title': scan.title,
|
||||||
'config_file': scan.config_file,
|
'config_id': scan.config_id,
|
||||||
'triggered_by': scan.triggered_by,
|
'triggered_by': scan.triggered_by,
|
||||||
'created_at': scan.created_at.isoformat() if scan.created_at else None
|
'created_at': scan.created_at.isoformat() if scan.created_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
def _site_to_dict(self, site: ScanSite) -> Dict[str, Any]:
|
def _site_to_dict(self, site: ScanSite) -> Dict[str, Any]:
|
||||||
"""Convert ScanSite to dictionary."""
|
"""Convert ScanSite to dictionary."""
|
||||||
|
# Look up the master Site ID from ScanSiteAssociation
|
||||||
|
master_site_id = None
|
||||||
|
assoc = (
|
||||||
|
self.db.query(ScanSiteAssociation)
|
||||||
|
.filter(
|
||||||
|
ScanSiteAssociation.scan_id == site.scan_id,
|
||||||
|
)
|
||||||
|
.join(Site)
|
||||||
|
.filter(Site.name == site.site_name)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if assoc:
|
||||||
|
master_site_id = assoc.site_id
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': site.id,
|
'id': site.id,
|
||||||
'name': site.site_name,
|
'name': site.site_name,
|
||||||
'ips': [self._ip_to_dict(ip) for ip in site.ips]
|
'site_id': master_site_id, # The actual Site ID for config updates
|
||||||
|
'ips': [self._ip_to_dict(ip, master_site_id) for ip in site.ips]
|
||||||
}
|
}
|
||||||
|
|
||||||
def _ip_to_dict(self, ip: ScanIP) -> Dict[str, Any]:
|
def _ip_to_dict(self, ip: ScanIP, site_id: Optional[int] = None) -> Dict[str, Any]:
|
||||||
"""Convert ScanIP to dictionary."""
|
"""Convert ScanIP to dictionary."""
|
||||||
|
# Look up the SiteIP ID for this IP address in the master Site
|
||||||
|
site_ip_id = None
|
||||||
|
if site_id:
|
||||||
|
site_ip = (
|
||||||
|
self.db.query(SiteIP)
|
||||||
|
.filter(
|
||||||
|
SiteIP.site_id == site_id,
|
||||||
|
SiteIP.ip_address == ip.ip_address
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if site_ip:
|
||||||
|
site_ip_id = site_ip.id
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': ip.id,
|
'id': ip.id,
|
||||||
'address': ip.ip_address,
|
'address': ip.ip_address,
|
||||||
|
'site_ip_id': site_ip_id, # The actual SiteIP ID for config updates
|
||||||
'ping_expected': ip.ping_expected,
|
'ping_expected': ip.ping_expected,
|
||||||
'ping_actual': ip.ping_actual,
|
'ping_actual': ip.ping_actual,
|
||||||
'ports': [self._port_to_dict(port) for port in ip.ports]
|
'ports': [self._port_to_dict(port) for port in ip.ports]
|
||||||
@@ -783,17 +840,17 @@ class ScanService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if scans use the same configuration
|
# Check if scans use the same configuration
|
||||||
config1 = scan1.get('config_file', '')
|
config1 = scan1.get('config_id')
|
||||||
config2 = scan2.get('config_file', '')
|
config2 = scan2.get('config_id')
|
||||||
same_config = (config1 == config2) and (config1 != '')
|
same_config = (config1 == config2) and (config1 is not None)
|
||||||
|
|
||||||
# Generate warning message if configs differ
|
# Generate warning message if configs differ
|
||||||
config_warning = None
|
config_warning = None
|
||||||
if not same_config:
|
if not same_config:
|
||||||
config_warning = (
|
config_warning = (
|
||||||
f"These scans use different configurations. "
|
f"These scans use different configurations. "
|
||||||
f"Scan #{scan1_id} used '{config1 or 'unknown'}' and "
|
f"Scan #{scan1_id} used config_id={config1 or 'unknown'} and "
|
||||||
f"Scan #{scan2_id} used '{config2 or 'unknown'}'. "
|
f"Scan #{scan2_id} used config_id={config2 or 'unknown'}. "
|
||||||
f"The comparison may show all changes as additions/removals if the scans "
|
f"The comparison may show all changes as additions/removals if the scans "
|
||||||
f"cover different IP ranges or infrastructure."
|
f"cover different IP ranges or infrastructure."
|
||||||
)
|
)
|
||||||
@@ -832,14 +889,14 @@ class ScanService:
|
|||||||
'timestamp': scan1['timestamp'],
|
'timestamp': scan1['timestamp'],
|
||||||
'title': scan1['title'],
|
'title': scan1['title'],
|
||||||
'status': scan1['status'],
|
'status': scan1['status'],
|
||||||
'config_file': config1
|
'config_id': config1
|
||||||
},
|
},
|
||||||
'scan2': {
|
'scan2': {
|
||||||
'id': scan2['id'],
|
'id': scan2['id'],
|
||||||
'timestamp': scan2['timestamp'],
|
'timestamp': scan2['timestamp'],
|
||||||
'title': scan2['title'],
|
'title': scan2['title'],
|
||||||
'status': scan2['status'],
|
'status': scan2['status'],
|
||||||
'config_file': config2
|
'config_id': config2
|
||||||
},
|
},
|
||||||
'same_config': same_config,
|
'same_config': same_config,
|
||||||
'config_warning': config_warning,
|
'config_warning': config_warning,
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ scheduled scans with cron expressions.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
from datetime import datetime, timezone
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from web.models import Schedule, Scan
|
from web.models import Schedule, Scan, ScanConfig
|
||||||
from web.utils.pagination import paginate, PaginatedResult
|
from web.utils.pagination import paginate, PaginatedResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -39,7 +38,7 @@ class ScheduleService:
|
|||||||
def create_schedule(
|
def create_schedule(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
config_file: str,
|
config_id: int,
|
||||||
cron_expression: str,
|
cron_expression: str,
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -48,7 +47,7 @@ class ScheduleService:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Human-readable schedule name
|
name: Human-readable schedule name
|
||||||
config_file: Path to YAML configuration file
|
config_id: Database config ID
|
||||||
cron_expression: Cron expression (e.g., '0 2 * * *')
|
cron_expression: Cron expression (e.g., '0 2 * * *')
|
||||||
enabled: Whether schedule is active
|
enabled: Whether schedule is active
|
||||||
|
|
||||||
@@ -56,36 +55,32 @@ class ScheduleService:
|
|||||||
Schedule ID of the created schedule
|
Schedule ID of the created schedule
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If cron expression is invalid or config file doesn't exist
|
ValueError: If cron expression is invalid or config doesn't exist
|
||||||
"""
|
"""
|
||||||
# Validate cron expression
|
# Validate cron expression
|
||||||
is_valid, error_msg = self.validate_cron_expression(cron_expression)
|
is_valid, error_msg = self.validate_cron_expression(cron_expression)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
raise ValueError(f"Invalid cron expression: {error_msg}")
|
raise ValueError(f"Invalid cron expression: {error_msg}")
|
||||||
|
|
||||||
# Validate config file exists
|
# Validate config exists
|
||||||
# If config_file is just a filename, prepend the configs directory
|
db_config = self.db.query(ScanConfig).filter_by(id=config_id).first()
|
||||||
if not config_file.startswith('/'):
|
if not db_config:
|
||||||
config_file_path = os.path.join('/app/configs', config_file)
|
raise ValueError(f"Config with ID {config_id} not found")
|
||||||
else:
|
|
||||||
config_file_path = config_file
|
|
||||||
|
|
||||||
if not os.path.isfile(config_file_path):
|
|
||||||
raise ValueError(f"Config file not found: {config_file}")
|
|
||||||
|
|
||||||
# Calculate next run time
|
# Calculate next run time
|
||||||
next_run = self.calculate_next_run(cron_expression) if enabled else None
|
next_run = self.calculate_next_run(cron_expression) if enabled else None
|
||||||
|
|
||||||
# Create schedule record
|
# Create schedule record
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
schedule = Schedule(
|
schedule = Schedule(
|
||||||
name=name,
|
name=name,
|
||||||
config_file=config_file,
|
config_id=config_id,
|
||||||
cron_expression=cron_expression,
|
cron_expression=cron_expression,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
last_run=None,
|
last_run=None,
|
||||||
next_run=next_run,
|
next_run=next_run,
|
||||||
created_at=datetime.utcnow(),
|
created_at=now_utc,
|
||||||
updated_at=datetime.utcnow()
|
updated_at=now_utc
|
||||||
)
|
)
|
||||||
|
|
||||||
self.db.add(schedule)
|
self.db.add(schedule)
|
||||||
@@ -109,7 +104,14 @@ class ScheduleService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If schedule not found
|
ValueError: If schedule not found
|
||||||
"""
|
"""
|
||||||
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
schedule = (
|
||||||
|
self.db.query(Schedule)
|
||||||
|
.options(joinedload(Schedule.config))
|
||||||
|
.filter(Schedule.id == schedule_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
if not schedule:
|
if not schedule:
|
||||||
raise ValueError(f"Schedule {schedule_id} not found")
|
raise ValueError(f"Schedule {schedule_id} not found")
|
||||||
@@ -144,8 +146,10 @@ class ScheduleService:
|
|||||||
'pages': int
|
'pages': int
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
# Build query
|
from sqlalchemy.orm import joinedload
|
||||||
query = self.db.query(Schedule)
|
|
||||||
|
# Build query and eagerly load config relationship
|
||||||
|
query = self.db.query(Schedule).options(joinedload(Schedule.config))
|
||||||
|
|
||||||
# Apply filter
|
# Apply filter
|
||||||
if enabled_filter is not None:
|
if enabled_filter is not None:
|
||||||
@@ -200,17 +204,11 @@ class ScheduleService:
|
|||||||
if schedule.enabled or updates.get('enabled', False):
|
if schedule.enabled or updates.get('enabled', False):
|
||||||
updates['next_run'] = self.calculate_next_run(updates['cron_expression'])
|
updates['next_run'] = self.calculate_next_run(updates['cron_expression'])
|
||||||
|
|
||||||
# Validate config file if being updated
|
# Validate config_id if being updated
|
||||||
if 'config_file' in updates:
|
if 'config_id' in updates:
|
||||||
config_file = updates['config_file']
|
db_config = self.db.query(ScanConfig).filter_by(id=updates['config_id']).first()
|
||||||
# If config_file is just a filename, prepend the configs directory
|
if not db_config:
|
||||||
if not config_file.startswith('/'):
|
raise ValueError(f"Config with ID {updates['config_id']} not found")
|
||||||
config_file_path = os.path.join('/app/configs', config_file)
|
|
||||||
else:
|
|
||||||
config_file_path = config_file
|
|
||||||
|
|
||||||
if not os.path.isfile(config_file_path):
|
|
||||||
raise ValueError(f"Config file not found: {updates['config_file']}")
|
|
||||||
|
|
||||||
# Handle enabled toggle
|
# Handle enabled toggle
|
||||||
if 'enabled' in updates:
|
if 'enabled' in updates:
|
||||||
@@ -227,7 +225,7 @@ class ScheduleService:
|
|||||||
if hasattr(schedule, key):
|
if hasattr(schedule, key):
|
||||||
setattr(schedule, key, value)
|
setattr(schedule, key, value)
|
||||||
|
|
||||||
schedule.updated_at = datetime.utcnow()
|
schedule.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(schedule)
|
self.db.refresh(schedule)
|
||||||
@@ -310,7 +308,7 @@ class ScheduleService:
|
|||||||
|
|
||||||
schedule.last_run = last_run
|
schedule.last_run = last_run
|
||||||
schedule.next_run = next_run
|
schedule.next_run = next_run
|
||||||
schedule.updated_at = datetime.utcnow()
|
schedule.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
@@ -323,23 +321,43 @@ class ScheduleService:
|
|||||||
Validate a cron expression.
|
Validate a cron expression.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cron_expr: Cron expression to validate
|
cron_expr: Cron expression to validate in standard crontab format
|
||||||
|
Format: minute hour day month day_of_week
|
||||||
|
Day of week: 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||||
|
(APScheduler will convert this to its internal format automatically)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (is_valid, error_message)
|
Tuple of (is_valid, error_message)
|
||||||
- (True, None) if valid
|
- (True, None) if valid
|
||||||
- (False, error_message) if invalid
|
- (False, error_message) if invalid
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This validates using croniter which uses standard crontab format.
|
||||||
|
APScheduler's from_crontab() will handle the conversion when the
|
||||||
|
schedule is registered with the scheduler.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Try to create a croniter instance
|
# Try to create a croniter instance
|
||||||
base_time = datetime.utcnow()
|
# croniter uses standard crontab format (Sunday=0)
|
||||||
|
from datetime import timezone
|
||||||
|
base_time = datetime.now(timezone.utc)
|
||||||
cron = croniter(cron_expr, base_time)
|
cron = croniter(cron_expr, base_time)
|
||||||
|
|
||||||
# Try to get the next run time (validates the expression)
|
# Try to get the next run time (validates the expression)
|
||||||
cron.get_next(datetime)
|
cron.get_next(datetime)
|
||||||
|
|
||||||
|
# Validate basic format (5 fields)
|
||||||
|
fields = cron_expr.split()
|
||||||
|
if len(fields) != 5:
|
||||||
|
return (False, f"Cron expression must have 5 fields (minute hour day month day_of_week), got {len(fields)}")
|
||||||
|
|
||||||
return (True, None)
|
return (True, None)
|
||||||
except (ValueError, KeyError) as e:
|
except (ValueError, KeyError) as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
# Add helpful hint for day_of_week errors
|
||||||
|
if "day" in error_msg.lower() and len(cron_expr.split()) >= 5:
|
||||||
|
hint = "\nNote: Use standard crontab format where 0=Sunday, 1=Monday, ..., 6=Saturday"
|
||||||
|
return (False, f"{error_msg}{hint}")
|
||||||
return (False, str(e))
|
return (False, str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return (False, f"Unexpected error: {str(e)}")
|
return (False, f"Unexpected error: {str(e)}")
|
||||||
@@ -357,17 +375,24 @@ class ScheduleService:
|
|||||||
from_time: Base time (defaults to now UTC)
|
from_time: Base time (defaults to now UTC)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Next run datetime (UTC)
|
Next run datetime (UTC, timezone-aware)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If cron expression is invalid
|
ValueError: If cron expression is invalid
|
||||||
"""
|
"""
|
||||||
if from_time is None:
|
if from_time is None:
|
||||||
from_time = datetime.utcnow()
|
from_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cron = croniter(cron_expr, from_time)
|
cron = croniter(cron_expr, from_time)
|
||||||
return cron.get_next(datetime)
|
next_run = cron.get_next(datetime)
|
||||||
|
|
||||||
|
# croniter returns naive datetime, so we need to add timezone info
|
||||||
|
# Since we're using UTC for all calculations, add UTC timezone
|
||||||
|
if next_run.tzinfo is None:
|
||||||
|
next_run = next_run.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
return next_run
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Invalid cron expression '{cron_expr}': {str(e)}")
|
raise ValueError(f"Invalid cron expression '{cron_expr}': {str(e)}")
|
||||||
|
|
||||||
@@ -400,7 +425,7 @@ class ScheduleService:
|
|||||||
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
|
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
|
||||||
'status': scan.status,
|
'status': scan.status,
|
||||||
'title': scan.title,
|
'title': scan.title,
|
||||||
'config_file': scan.config_file
|
'config_id': scan.config_id
|
||||||
}
|
}
|
||||||
for scan in scans
|
for scan in scans
|
||||||
]
|
]
|
||||||
@@ -415,10 +440,16 @@ class ScheduleService:
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary representation
|
Dictionary representation
|
||||||
"""
|
"""
|
||||||
|
# Get config title if relationship is loaded
|
||||||
|
config_name = None
|
||||||
|
if schedule.config:
|
||||||
|
config_name = schedule.config.title
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': schedule.id,
|
'id': schedule.id,
|
||||||
'name': schedule.name,
|
'name': schedule.name,
|
||||||
'config_file': schedule.config_file,
|
'config_id': schedule.config_id,
|
||||||
|
'config_name': config_name,
|
||||||
'cron_expression': schedule.cron_expression,
|
'cron_expression': schedule.cron_expression,
|
||||||
'enabled': schedule.enabled,
|
'enabled': schedule.enabled,
|
||||||
'last_run': schedule.last_run.isoformat() if schedule.last_run else None,
|
'last_run': schedule.last_run.isoformat() if schedule.last_run else None,
|
||||||
@@ -433,7 +464,7 @@ class ScheduleService:
|
|||||||
Format datetime as relative time.
|
Format datetime as relative time.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dt: Datetime to format (UTC)
|
dt: Datetime to format (UTC, can be naive or aware)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Human-readable relative time (e.g., "in 2 hours", "yesterday")
|
Human-readable relative time (e.g., "in 2 hours", "yesterday")
|
||||||
@@ -441,7 +472,13 @@ class ScheduleService:
|
|||||||
if dt is None:
|
if dt is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
now = datetime.utcnow()
|
# Ensure both datetimes are timezone-aware for comparison
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# If dt is naive, assume it's UTC and add timezone info
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
diff = dt - now
|
diff = dt - now
|
||||||
|
|
||||||
# Future times
|
# Future times
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ class SchedulerService:
|
|||||||
try:
|
try:
|
||||||
self.add_scheduled_scan(
|
self.add_scheduled_scan(
|
||||||
schedule_id=schedule.id,
|
schedule_id=schedule.id,
|
||||||
config_file=schedule.config_file,
|
config_id=schedule.config_id,
|
||||||
cron_expression=schedule.cron_expression
|
cron_expression=schedule.cron_expression
|
||||||
)
|
)
|
||||||
logger.info(f"Loaded schedule {schedule.id}: '{schedule.name}'")
|
logger.info(f"Loaded schedule {schedule.id}: '{schedule.name}'")
|
||||||
@@ -149,16 +149,58 @@ class SchedulerService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True)
|
logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
def queue_scan(self, scan_id: int, config_file: str = None, config_id: int = None) -> str:
|
@staticmethod
|
||||||
|
def validate_cron_expression(cron_expression: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate a cron expression and provide helpful feedback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cron_expression: Cron expression to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid: bool, message: str)
|
||||||
|
- If valid: (True, "Valid cron expression")
|
||||||
|
- If invalid: (False, "Error message with details")
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Standard crontab format: minute hour day month day_of_week
|
||||||
|
Day of week: 0=Sunday, 1=Monday, ..., 6=Saturday (or 7=Sunday)
|
||||||
|
"""
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to parse the expression
|
||||||
|
trigger = CronTrigger.from_crontab(cron_expression)
|
||||||
|
|
||||||
|
# Validate basic format (5 fields)
|
||||||
|
fields = cron_expression.split()
|
||||||
|
if len(fields) != 5:
|
||||||
|
return False, f"Cron expression must have 5 fields (minute hour day month day_of_week), got {len(fields)}"
|
||||||
|
|
||||||
|
return True, "Valid cron expression"
|
||||||
|
|
||||||
|
except (ValueError, KeyError) as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
|
||||||
|
# Provide helpful hints for common errors
|
||||||
|
if "day_of_week" in error_msg.lower() or (len(cron_expression.split()) >= 5):
|
||||||
|
# Check if day_of_week field might be using APScheduler format by mistake
|
||||||
|
fields = cron_expression.split()
|
||||||
|
if len(fields) == 5:
|
||||||
|
dow_field = fields[4]
|
||||||
|
if dow_field.isdigit() and int(dow_field) >= 0:
|
||||||
|
hint = "\nNote: Use standard crontab format where 0=Sunday, 1=Monday, ..., 6=Saturday"
|
||||||
|
return False, f"Invalid cron expression: {error_msg}{hint}"
|
||||||
|
|
||||||
|
return False, f"Invalid cron expression: {error_msg}"
|
||||||
|
|
||||||
|
def queue_scan(self, scan_id: int, config_id: int) -> str:
|
||||||
"""
|
"""
|
||||||
Queue a scan for immediate background execution.
|
Queue a scan for immediate background execution.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
scan_id: Database ID of the scan
|
scan_id: Database ID of the scan
|
||||||
config_file: Path to YAML configuration file (legacy, optional)
|
config_id: Database config ID
|
||||||
config_id: Database config ID (preferred, optional)
|
|
||||||
|
|
||||||
Note: Provide exactly one of config_file or config_id
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Job ID from APScheduler
|
Job ID from APScheduler
|
||||||
@@ -172,7 +214,7 @@ class SchedulerService:
|
|||||||
# Add job to run immediately
|
# Add job to run immediately
|
||||||
job = self.scheduler.add_job(
|
job = self.scheduler.add_job(
|
||||||
func=execute_scan,
|
func=execute_scan,
|
||||||
kwargs={'scan_id': scan_id, 'config_file': config_file, 'config_id': config_id, 'db_url': self.db_url},
|
kwargs={'scan_id': scan_id, 'config_id': config_id, 'db_url': self.db_url},
|
||||||
id=f'scan_{scan_id}',
|
id=f'scan_{scan_id}',
|
||||||
name=f'Scan {scan_id}',
|
name=f'Scan {scan_id}',
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
@@ -182,15 +224,19 @@ class SchedulerService:
|
|||||||
logger.info(f"Queued scan {scan_id} for background execution (job_id={job.id})")
|
logger.info(f"Queued scan {scan_id} for background execution (job_id={job.id})")
|
||||||
return job.id
|
return job.id
|
||||||
|
|
||||||
def add_scheduled_scan(self, schedule_id: int, config_file: str,
|
def add_scheduled_scan(self, schedule_id: int, config_id: int,
|
||||||
cron_expression: str) -> str:
|
cron_expression: str) -> str:
|
||||||
"""
|
"""
|
||||||
Add a recurring scheduled scan.
|
Add a recurring scheduled scan.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
schedule_id: Database ID of the schedule
|
schedule_id: Database ID of the schedule
|
||||||
config_file: Path to YAML configuration file
|
config_id: Database config ID
|
||||||
cron_expression: Cron expression (e.g., "0 2 * * *" for 2am daily)
|
cron_expression: Cron expression (e.g., "0 2 * * *" for 2am daily)
|
||||||
|
IMPORTANT: Use standard crontab format where:
|
||||||
|
- Day of week: 0 = Sunday, 1 = Monday, ..., 6 = Saturday
|
||||||
|
- APScheduler automatically converts to its internal format
|
||||||
|
- from_crontab() handles the conversion properly
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Job ID from APScheduler
|
Job ID from APScheduler
|
||||||
@@ -198,18 +244,29 @@ class SchedulerService:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If scheduler not initialized
|
RuntimeError: If scheduler not initialized
|
||||||
ValueError: If cron expression is invalid
|
ValueError: If cron expression is invalid
|
||||||
|
|
||||||
|
Note:
|
||||||
|
APScheduler internally uses Monday=0, but from_crontab() accepts
|
||||||
|
standard crontab format (Sunday=0) and converts it automatically.
|
||||||
"""
|
"""
|
||||||
if not self.scheduler:
|
if not self.scheduler:
|
||||||
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
|
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
|
||||||
|
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
# Validate cron expression first to provide helpful error messages
|
||||||
|
is_valid, message = self.validate_cron_expression(cron_expression)
|
||||||
|
if not is_valid:
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
# Create cron trigger from expression using local timezone
|
# Create cron trigger from expression using local timezone
|
||||||
# This allows users to specify times in their local timezone
|
# from_crontab() parses standard crontab format (Sunday=0)
|
||||||
|
# and converts to APScheduler's internal format (Monday=0) automatically
|
||||||
try:
|
try:
|
||||||
trigger = CronTrigger.from_crontab(cron_expression)
|
trigger = CronTrigger.from_crontab(cron_expression)
|
||||||
# timezone defaults to local system timezone
|
# timezone defaults to local system timezone
|
||||||
except (ValueError, KeyError) as e:
|
except (ValueError, KeyError) as e:
|
||||||
|
# This should not happen due to validation above, but catch anyway
|
||||||
raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}")
|
raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}")
|
||||||
|
|
||||||
# Add cron job
|
# Add cron job
|
||||||
@@ -286,22 +343,27 @@ class SchedulerService:
|
|||||||
# Create and trigger scan
|
# Create and trigger scan
|
||||||
scan_service = ScanService(session)
|
scan_service = ScanService(session)
|
||||||
scan_id = scan_service.trigger_scan(
|
scan_id = scan_service.trigger_scan(
|
||||||
config_file=schedule['config_file'],
|
config_id=schedule['config_id'],
|
||||||
triggered_by='scheduled',
|
triggered_by='scheduled',
|
||||||
schedule_id=schedule_id,
|
schedule_id=schedule_id,
|
||||||
scheduler=None # Don't pass scheduler to avoid recursion
|
scheduler=None # Don't pass scheduler to avoid recursion
|
||||||
)
|
)
|
||||||
|
|
||||||
# Queue the scan for execution
|
# Queue the scan for execution
|
||||||
self.queue_scan(scan_id, schedule['config_file'])
|
self.queue_scan(scan_id, schedule['config_id'])
|
||||||
|
|
||||||
# Update schedule's last_run and next_run
|
# Update schedule's last_run and next_run
|
||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
next_run = croniter(schedule['cron_expression'], datetime.utcnow()).get_next(datetime)
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
next_run = croniter(schedule['cron_expression'], now_utc).get_next(datetime)
|
||||||
|
|
||||||
|
# croniter returns naive datetime, add UTC timezone
|
||||||
|
if next_run.tzinfo is None:
|
||||||
|
next_run = next_run.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
schedule_service.update_run_times(
|
schedule_service.update_run_times(
|
||||||
schedule_id=schedule_id,
|
schedule_id=schedule_id,
|
||||||
last_run=datetime.utcnow(),
|
last_run=now_utc,
|
||||||
next_run=next_run
|
next_run=next_run
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
|
|
||||||
from web.models import (
|
from web.models import (
|
||||||
Site, SiteIP, ScanSiteAssociation
|
Site, SiteIP, ScanSiteAssociation
|
||||||
@@ -229,6 +228,34 @@ class SiteService:
|
|||||||
|
|
||||||
return [self._site_to_dict(site) for site in sites]
|
return [self._site_to_dict(site) for site in sites]
|
||||||
|
|
||||||
|
def get_global_ip_stats(self) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Get global IP statistics across all sites.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with:
|
||||||
|
- total_ips: Total count of IP entries (including duplicates)
|
||||||
|
- unique_ips: Count of distinct IP addresses
|
||||||
|
- duplicate_ips: Number of duplicate entries (total - unique)
|
||||||
|
"""
|
||||||
|
# Total IP entries
|
||||||
|
total_ips = (
|
||||||
|
self.db.query(func.count(SiteIP.id))
|
||||||
|
.scalar() or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unique IP addresses
|
||||||
|
unique_ips = (
|
||||||
|
self.db.query(func.count(func.distinct(SiteIP.ip_address)))
|
||||||
|
.scalar() or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_ips': total_ips,
|
||||||
|
'unique_ips': unique_ips,
|
||||||
|
'duplicate_ips': total_ips - unique_ips
|
||||||
|
}
|
||||||
|
|
||||||
def bulk_add_ips_from_cidr(self, site_id: int, cidr: str,
|
def bulk_add_ips_from_cidr(self, site_id: int, cidr: str,
|
||||||
expected_ping: Optional[bool] = None,
|
expected_ping: Optional[bool] = None,
|
||||||
expected_tcp_ports: Optional[List[int]] = None,
|
expected_tcp_ports: Optional[List[int]] = None,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class TemplateService:
|
|||||||
"timestamp": scan.timestamp,
|
"timestamp": scan.timestamp,
|
||||||
"duration": scan.duration,
|
"duration": scan.duration,
|
||||||
"status": scan.status,
|
"status": scan.status,
|
||||||
"config_file": scan.config_file,
|
"config_id": scan.config_id,
|
||||||
"triggered_by": scan.triggered_by,
|
"triggered_by": scan.triggered_by,
|
||||||
"started_at": scan.started_at,
|
"started_at": scan.started_at,
|
||||||
"completed_at": scan.completed_at,
|
"completed_at": scan.completed_at,
|
||||||
@@ -247,7 +247,7 @@ class TemplateService:
|
|||||||
"timestamp": datetime.utcnow(),
|
"timestamp": datetime.utcnow(),
|
||||||
"duration": 125.5,
|
"duration": 125.5,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"config_file": "production-scan.yaml",
|
"config_id": 1,
|
||||||
"triggered_by": "schedule",
|
"triggered_by": "schedule",
|
||||||
"started_at": datetime.utcnow(),
|
"started_at": datetime.utcnow(),
|
||||||
"completed_at": datetime.utcnow(),
|
"completed_at": datetime.utcnow(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="color: #60a5fa;">Alert Rules</h1>
|
<h1>Alert Rules</h1>
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('main.alerts') }}" class="btn btn-outline-primary me-2">
|
<a href="{{ url_for('main.alerts') }}" class="btn btn-outline-primary me-2">
|
||||||
<i class="bi bi-bell"></i> View Alerts
|
<i class="bi bi-bell"></i> View Alerts
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="text-muted mb-2">Total Rules</h6>
|
<h6 class="text-muted mb-2">Total Rules</h6>
|
||||||
<h3 class="mb-0" style="color: #60a5fa;">{{ rules | length }}</h3>
|
<h3 class="mb-0">{{ rules | length }}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0" style="color: #60a5fa;">Alert Rules Configuration</h5>
|
<h5 class="mb-0">Alert Rules Configuration</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if rules %}
|
{% if rules %}
|
||||||
@@ -121,9 +121,9 @@
|
|||||||
onchange="toggleRule({{ rule.id }}, this.checked)">
|
onchange="toggleRule({{ rule.id }}, this.checked)">
|
||||||
<label class="form-check-label" for="rule-enabled-{{ rule.id }}">
|
<label class="form-check-label" for="rule-enabled-{{ rule.id }}">
|
||||||
{% if rule.enabled %}
|
{% if rule.enabled %}
|
||||||
<span class="text-success">Active</span>
|
<span class="text-success ms-2">Active</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">Inactive</span>
|
<span class="text-muted ms-2">Inactive</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,10 +5,15 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="color: #60a5fa;">Alert History</h1>
|
<h1>Alert History</h1>
|
||||||
<a href="{{ url_for('main.alert_rules') }}" class="btn btn-primary">
|
<div>
|
||||||
<i class="bi bi-gear"></i> Manage Alert Rules
|
<button class="btn btn-success me-2" onclick="acknowledgeAllAlerts()">
|
||||||
</a>
|
<i class="bi bi-check-all"></i> Ack All
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('main.alert_rules') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-gear"></i> Manage Alert Rules
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -18,7 +23,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="text-muted mb-2">Total Alerts</h6>
|
<h6 class="text-muted mb-2">Total Alerts</h6>
|
||||||
<h3 class="mb-0" style="color: #60a5fa;">{{ pagination.total }}</h3>
|
<h3 class="mb-0">{{ pagination.total }}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,7 +51,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="text-muted mb-2">Unacknowledged</h6>
|
<h6 class="text-muted mb-2">Unacknowledged</h6>
|
||||||
<h3 class="mb-0" style="color: #f97316;">
|
<h3 class="mb-0 text-warning">
|
||||||
{{ alerts | rejectattr('acknowledged') | list | length }}
|
{{ alerts | rejectattr('acknowledged') | list | length }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,7 +109,7 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0" style="color: #60a5fa;">Alerts</h5>
|
<h5 class="mb-0">Alerts</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if alerts %}
|
{% if alerts %}
|
||||||
@@ -265,5 +270,34 @@ function acknowledgeAlert(alertId) {
|
|||||||
alert('Failed to acknowledge alert');
|
alert('Failed to acknowledge alert');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function acknowledgeAllAlerts() {
|
||||||
|
if (!confirm('Acknowledge all unacknowledged alerts?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/alerts/acknowledge-all', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': localStorage.getItem('api_key') || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
acknowledged_by: 'web_user'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to acknowledge alerts: ' + (data.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Failed to acknowledge alerts');
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -45,6 +45,16 @@
|
|||||||
<a class="nav-link {% if request.endpoint == 'main.dashboard' %}active{% endif %}"
|
<a class="nav-link {% if request.endpoint == 'main.dashboard' %}active{% endif %}"
|
||||||
href="{{ url_for('main.dashboard') }}">Dashboard</a>
|
href="{{ url_for('main.dashboard') }}">Dashboard</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle {% if request.endpoint and ('config' in request.endpoint or request.endpoint == 'main.sites') %}active{% endif %}"
|
||||||
|
href="#" id="configsDropdown" role="button" data-bs-toggle="dropdown">
|
||||||
|
Configs
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="configsDropdown">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('main.configs') }}">Scan Configs</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('main.sites') }}">Sites</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint == 'main.scans' %}active{% endif %}"
|
<a class="nav-link {% if request.endpoint == 'main.scans' %}active{% endif %}"
|
||||||
href="{{ url_for('main.scans') }}">Scans</a>
|
href="{{ url_for('main.scans') }}">Scans</a>
|
||||||
@@ -53,14 +63,6 @@
|
|||||||
<a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}"
|
<a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}"
|
||||||
href="{{ url_for('main.schedules') }}">Schedules</a>
|
href="{{ url_for('main.schedules') }}">Schedules</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if request.endpoint == 'main.sites' %}active{% endif %}"
|
|
||||||
href="{{ url_for('main.sites') }}">Sites</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if request.endpoint and 'config' in request.endpoint %}active{% endif %}"
|
|
||||||
href="{{ url_for('main.configs') }}">Configs</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link dropdown-toggle {% if request.endpoint and ('alert' in request.endpoint or 'webhook' in request.endpoint) %}active{% endif %}"
|
<a class="nav-link dropdown-toggle {% if request.endpoint and ('alert' in request.endpoint or 'webhook' in request.endpoint) %}active{% endif %}"
|
||||||
href="#" id="alertsDropdown" role="button" data-bs-toggle="dropdown">
|
href="#" id="alertsDropdown" role="button" data-bs-toggle="dropdown">
|
||||||
@@ -74,7 +76,20 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<form class="d-flex me-3" action="{{ url_for('main.search_ip') }}" method="GET">
|
||||||
|
<input class="form-control form-control-sm me-2" type="search" name="ip"
|
||||||
|
placeholder="Search IP..." aria-label="Search IP" style="width: 150px;">
|
||||||
|
<button class="btn btn-outline-primary btn-sm" type="submit">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'main.help' %}active{% endif %}"
|
||||||
|
href="{{ url_for('main.help') }}">
|
||||||
|
<i class="bi bi-question-circle"></i> Help
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
|
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -105,6 +120,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Global notification container - always above modals -->
|
||||||
|
<div id="notification-container" style="position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 300px;"></div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,263 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Edit Config - SneakyScanner{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_styles %}
|
|
||||||
<!-- CodeMirror CSS -->
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/dracula.min.css">
|
|
||||||
<style>
|
|
||||||
.config-editor-container {
|
|
||||||
background: #1e293b;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CodeMirror {
|
|
||||||
height: 600px;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
background: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-actions {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validation-feedback {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validation-feedback.success {
|
|
||||||
background: #065f46;
|
|
||||||
border: 1px solid #10b981;
|
|
||||||
color: #d1fae5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validation-feedback.error {
|
|
||||||
background: #7f1d1d;
|
|
||||||
border: 1px solid #ef4444;
|
|
||||||
color: #fee2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link {
|
|
||||||
color: #94a3b8;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover {
|
|
||||||
color: #cbd5e1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-lg mt-4">
|
|
||||||
<a href="{{ url_for('main.configs') }}" class="back-link">
|
|
||||||
<i class="bi bi-arrow-left"></i> Back to Configs
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<h2>Edit Configuration</h2>
|
|
||||||
<p class="text-muted">Edit the YAML configuration for <strong>{{ filename }}</strong></p>
|
|
||||||
|
|
||||||
<div class="config-editor-container">
|
|
||||||
<div class="editor-header">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-file-earmark-code"></i> YAML Editor
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="validateConfig()">
|
|
||||||
<i class="bi bi-check-circle"></i> Validate
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea id="yaml-editor">{{ content }}</textarea>
|
|
||||||
|
|
||||||
<div class="validation-feedback" id="validation-feedback"></div>
|
|
||||||
|
|
||||||
<div class="editor-actions">
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveConfig()">
|
|
||||||
<i class="bi bi-save"></i> Save Changes
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="resetEditor()">
|
|
||||||
<i class="bi bi-arrow-counterclockwise"></i> Reset
|
|
||||||
</button>
|
|
||||||
<a href="{{ url_for('main.configs') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-x-circle"></i> Cancel
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success Modal -->
|
|
||||||
<div class="modal fade" id="successModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header bg-success text-white">
|
|
||||||
<h5 class="modal-title">
|
|
||||||
<i class="bi bi-check-circle-fill"></i> Success
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
Configuration updated successfully!
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<a href="{{ url_for('main.configs') }}" class="btn btn-success">
|
|
||||||
Back to Configs
|
|
||||||
</a>
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
|
||||||
Continue Editing
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<!-- CodeMirror JS -->
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/yaml/yaml.min.js"></script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Initialize CodeMirror editor
|
|
||||||
const editor = CodeMirror.fromTextArea(document.getElementById('yaml-editor'), {
|
|
||||||
mode: 'yaml',
|
|
||||||
theme: 'dracula',
|
|
||||||
lineNumbers: true,
|
|
||||||
lineWrapping: true,
|
|
||||||
indentUnit: 2,
|
|
||||||
tabSize: 2,
|
|
||||||
indentWithTabs: false,
|
|
||||||
extraKeys: {
|
|
||||||
"Tab": function(cm) {
|
|
||||||
cm.replaceSelection(" ", "end");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store original content for reset
|
|
||||||
const originalContent = editor.getValue();
|
|
||||||
|
|
||||||
// Validation function
|
|
||||||
async function validateConfig() {
|
|
||||||
const feedback = document.getElementById('validation-feedback');
|
|
||||||
const content = editor.getValue();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Basic YAML syntax check (client-side)
|
|
||||||
// Just check for common YAML issues
|
|
||||||
if (content.trim() === '') {
|
|
||||||
showFeedback('error', 'Configuration cannot be empty');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for basic structure
|
|
||||||
if (!content.includes('title:')) {
|
|
||||||
showFeedback('error', 'Missing required field: title');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!content.includes('sites:')) {
|
|
||||||
showFeedback('error', 'Missing required field: sites');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
showFeedback('success', 'Configuration appears valid. Click "Save Changes" to save.');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
showFeedback('error', 'Validation error: ' + error.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save configuration
|
|
||||||
async function saveConfig() {
|
|
||||||
const content = editor.getValue();
|
|
||||||
const filename = '{{ filename }}';
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
const saveBtn = event.target;
|
|
||||||
const originalText = saveBtn.innerHTML;
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/configs/${filename}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ content: content })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Show success modal
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('successModal'));
|
|
||||||
modal.show();
|
|
||||||
} else {
|
|
||||||
// Show error feedback
|
|
||||||
showFeedback('error', data.message || 'Failed to save configuration');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showFeedback('error', 'Network error: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
// Restore button state
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
saveBtn.innerHTML = originalText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset editor to original content
|
|
||||||
function resetEditor() {
|
|
||||||
if (confirm('Are you sure you want to reset all changes?')) {
|
|
||||||
editor.setValue(originalContent);
|
|
||||||
hideFeedback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show validation feedback
|
|
||||||
function showFeedback(type, message) {
|
|
||||||
const feedback = document.getElementById('validation-feedback');
|
|
||||||
feedback.className = `validation-feedback ${type}`;
|
|
||||||
feedback.innerHTML = `
|
|
||||||
<i class="bi bi-${type === 'success' ? 'check-circle-fill' : 'exclamation-triangle-fill'}"></i>
|
|
||||||
${message}
|
|
||||||
`;
|
|
||||||
feedback.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide validation feedback
|
|
||||||
function hideFeedback() {
|
|
||||||
const feedback = document.getElementById('validation-feedback');
|
|
||||||
feedback.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-validate on content change (debounced)
|
|
||||||
let validationTimeout;
|
|
||||||
editor.on('change', function() {
|
|
||||||
clearTimeout(validationTimeout);
|
|
||||||
hideFeedback();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Create Configuration - SneakyScanner{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_styles %}
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/config-manager.css') }}">
|
|
||||||
<style>
|
|
||||||
.file-info {
|
|
||||||
background-color: #1e293b;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
padding: 10px 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin-top: 15px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-info-name {
|
|
||||||
color: #60a5fa;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-info-size {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1 style="color: #60a5fa;">Create New Configuration</h1>
|
|
||||||
<a href="{{ url_for('main.configs') }}" class="btn btn-secondary">
|
|
||||||
<i class="bi bi-arrow-left"></i> Back to Configs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload Tabs -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<ul class="nav nav-tabs mb-4" id="uploadTabs" role="tablist">
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link active" id="cidr-tab" data-bs-toggle="tab" data-bs-target="#cidr"
|
|
||||||
type="button" role="tab" style="color: #60a5fa;">
|
|
||||||
<i class="bi bi-diagram-3"></i> Create from CIDR
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link" id="yaml-tab" data-bs-toggle="tab" data-bs-target="#yaml"
|
|
||||||
type="button" role="tab" style="color: #60a5fa;">
|
|
||||||
<i class="bi bi-filetype-yml"></i> Upload YAML
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="tab-content" id="uploadTabsContent">
|
|
||||||
<!-- CIDR Form Tab -->
|
|
||||||
<div class="tab-pane fade show active" id="cidr" role="tabpanel">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-8 offset-lg-2">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0" style="color: #60a5fa;">
|
|
||||||
<i class="bi bi-diagram-3"></i> Create Configuration from CIDR Range
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted">
|
|
||||||
<i class="bi bi-info-circle"></i>
|
|
||||||
Specify a CIDR range to automatically generate a configuration for all IPs in that range.
|
|
||||||
You can edit the configuration afterwards to add expected ports and services.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form id="cidr-form">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="config-title" class="form-label" style="color: #94a3b8;">
|
|
||||||
Config Title <span class="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" class="form-control" id="config-title"
|
|
||||||
placeholder="e.g., Production Infrastructure Scan" required>
|
|
||||||
<div class="form-text">A descriptive title for your scan configuration</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="cidr-range" class="form-label" style="color: #94a3b8;">
|
|
||||||
CIDR Range <span class="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" class="form-control" id="cidr-range"
|
|
||||||
placeholder="e.g., 10.0.0.0/24 or 192.168.1.0/28" required>
|
|
||||||
<div class="form-text">
|
|
||||||
Enter a CIDR range (e.g., 10.0.0.0/24 for 254 hosts).
|
|
||||||
Maximum 10,000 addresses per range.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="site-name" class="form-label" style="color: #94a3b8;">
|
|
||||||
Site Name (optional)
|
|
||||||
</label>
|
|
||||||
<input type="text" class="form-control" id="site-name"
|
|
||||||
placeholder="e.g., Production Servers">
|
|
||||||
<div class="form-text">
|
|
||||||
Logical grouping name for these IPs (default: "Site 1")
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="ping-default">
|
|
||||||
<label class="form-check-label" for="ping-default" style="color: #94a3b8;">
|
|
||||||
Expect ping response by default
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">
|
|
||||||
Sets the default expectation for ICMP ping responses from these IPs
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="cidr-errors" class="alert alert-danger" style="display:none;">
|
|
||||||
<strong>Error:</strong> <span id="cidr-error-message"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg">
|
|
||||||
<i class="bi bi-plus-circle"></i> Create Configuration
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div id="cidr-success" class="alert alert-success mt-3" style="display:none;">
|
|
||||||
<i class="bi bi-check-circle-fill"></i>
|
|
||||||
<strong>Success!</strong> Configuration created: <span id="cidr-created-filename"></span>
|
|
||||||
<div class="mt-2">
|
|
||||||
<a href="#" id="edit-new-config-link" class="btn btn-sm btn-outline-success">
|
|
||||||
<i class="bi bi-pencil"></i> Edit Now
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- YAML Upload Tab -->
|
|
||||||
<div class="tab-pane fade" id="yaml" role="tabpanel">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-8 offset-lg-2">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0" style="color: #60a5fa;">
|
|
||||||
<i class="bi bi-cloud-upload"></i> Upload YAML Configuration
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted">
|
|
||||||
<i class="bi bi-info-circle"></i>
|
|
||||||
For advanced users: upload a YAML config file directly.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div id="yaml-dropzone" class="dropzone">
|
|
||||||
<i class="bi bi-cloud-upload"></i>
|
|
||||||
<p>Drag & drop YAML file here or click to browse</p>
|
|
||||||
<input type="file" id="yaml-file-input" accept=".yaml,.yml" hidden>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="yaml-file-info" class="file-info">
|
|
||||||
<div class="file-info-name" id="yaml-filename"></div>
|
|
||||||
<div class="file-info-size" id="yaml-filesize"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<label for="yaml-custom-filename" class="form-label" style="color: #94a3b8;">
|
|
||||||
Custom Filename (optional):
|
|
||||||
</label>
|
|
||||||
<input type="text" id="yaml-custom-filename" class="form-control"
|
|
||||||
placeholder="Leave empty to use uploaded filename">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="upload-yaml-btn" class="btn btn-primary mt-3" disabled>
|
|
||||||
<i class="bi bi-upload"></i> Upload YAML
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div id="yaml-errors" class="alert alert-danger mt-3" style="display:none;">
|
|
||||||
<strong>Error:</strong> <span id="yaml-error-message"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success Modal -->
|
|
||||||
<div class="modal fade" id="successModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
|
||||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
|
||||||
<h5 class="modal-title" style="color: #10b981;">
|
|
||||||
<i class="bi bi-check-circle"></i> Success
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p style="color: #e2e8f0;">Configuration saved successfully!</p>
|
|
||||||
<p style="color: #60a5fa; font-weight: bold;" id="success-filename"></p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
|
||||||
<a href="{{ url_for('main.configs') }}" class="btn btn-primary">
|
|
||||||
<i class="bi bi-list"></i> View All Configs
|
|
||||||
</a>
|
|
||||||
<button type="button" class="btn btn-success" onclick="location.reload()">
|
|
||||||
<i class="bi bi-plus-circle"></i> Create Another
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
// Global variables
|
|
||||||
let yamlFile = null;
|
|
||||||
|
|
||||||
// ============== CIDR Form Submission ==============
|
|
||||||
|
|
||||||
document.getElementById('cidr-form').addEventListener('submit', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const title = document.getElementById('config-title').value.trim();
|
|
||||||
const cidr = document.getElementById('cidr-range').value.trim();
|
|
||||||
const siteName = document.getElementById('site-name').value.trim();
|
|
||||||
const pingDefault = document.getElementById('ping-default').checked;
|
|
||||||
|
|
||||||
// Validate inputs
|
|
||||||
if (!title) {
|
|
||||||
showError('cidr', 'Config title is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cidr) {
|
|
||||||
showError('cidr', 'CIDR range is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
||||||
const originalText = submitBtn.innerHTML;
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/configs/create-from-cidr', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: title,
|
|
||||||
cidr: cidr,
|
|
||||||
site_name: siteName || null,
|
|
||||||
ping_default: pingDefault
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.message || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Hide error, show success
|
|
||||||
document.getElementById('cidr-errors').style.display = 'none';
|
|
||||||
document.getElementById('cidr-created-filename').textContent = data.filename;
|
|
||||||
|
|
||||||
// Set edit link
|
|
||||||
document.getElementById('edit-new-config-link').href = `/configs/edit/${data.filename}`;
|
|
||||||
|
|
||||||
document.getElementById('cidr-success').style.display = 'block';
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
e.target.reset();
|
|
||||||
|
|
||||||
// Show success modal
|
|
||||||
showSuccess(data.filename);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating config from CIDR:', error);
|
|
||||||
showError('cidr', error.message);
|
|
||||||
} finally {
|
|
||||||
// Restore button state
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.innerHTML = originalText;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============== YAML Upload ==============
|
|
||||||
|
|
||||||
// Setup YAML dropzone
|
|
||||||
const yamlDropzone = document.getElementById('yaml-dropzone');
|
|
||||||
const yamlFileInput = document.getElementById('yaml-file-input');
|
|
||||||
|
|
||||||
yamlDropzone.addEventListener('click', () => yamlFileInput.click());
|
|
||||||
|
|
||||||
yamlDropzone.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
yamlDropzone.classList.add('dragover');
|
|
||||||
});
|
|
||||||
|
|
||||||
yamlDropzone.addEventListener('dragleave', () => {
|
|
||||||
yamlDropzone.classList.remove('dragover');
|
|
||||||
});
|
|
||||||
|
|
||||||
yamlDropzone.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
yamlDropzone.classList.remove('dragover');
|
|
||||||
const file = e.dataTransfer.files[0];
|
|
||||||
handleYAMLFile(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
yamlFileInput.addEventListener('change', (e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
handleYAMLFile(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle YAML file selection
|
|
||||||
function handleYAMLFile(file) {
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// Check file extension
|
|
||||||
if (!file.name.endsWith('.yaml') && !file.name.endsWith('.yml')) {
|
|
||||||
showError('yaml', 'Please select a YAML file (.yaml or .yml)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
yamlFile = file;
|
|
||||||
|
|
||||||
// Show file info
|
|
||||||
document.getElementById('yaml-filename').textContent = file.name;
|
|
||||||
document.getElementById('yaml-filesize').textContent = formatFileSize(file.size);
|
|
||||||
document.getElementById('yaml-file-info').style.display = 'block';
|
|
||||||
|
|
||||||
// Enable upload button
|
|
||||||
document.getElementById('upload-yaml-btn').disabled = false;
|
|
||||||
document.getElementById('yaml-errors').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload YAML file
|
|
||||||
async function uploadYAMLFile() {
|
|
||||||
if (!yamlFile) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', yamlFile);
|
|
||||||
|
|
||||||
const customFilename = document.getElementById('yaml-custom-filename').value.trim();
|
|
||||||
if (customFilename) {
|
|
||||||
formData.append('filename', customFilename);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/api/configs/upload-yaml', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.message || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
showSuccess(data.filename);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error uploading YAML:', error);
|
|
||||||
showError('yaml', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('upload-yaml-btn').addEventListener('click', uploadYAMLFile);
|
|
||||||
|
|
||||||
// ============== Helper Functions ==============
|
|
||||||
|
|
||||||
// Format file size
|
|
||||||
function formatFileSize(bytes) {
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show error
|
|
||||||
function showError(type, message) {
|
|
||||||
const errorDiv = document.getElementById(`${type}-errors`);
|
|
||||||
const errorMsg = document.getElementById(`${type}-error-message`);
|
|
||||||
errorMsg.textContent = message;
|
|
||||||
errorDiv.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show success
|
|
||||||
function showSuccess(filename) {
|
|
||||||
document.getElementById('success-filename').textContent = filename;
|
|
||||||
new bootstrap.Modal(document.getElementById('successModal')).show();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="color: #60a5fa;">Scan Configurations</h1>
|
<h1>Scan Configurations</h1>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createConfigModal">
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createConfigModal">
|
||||||
<i class="bi bi-plus-circle"></i> Create New Config
|
<i class="bi bi-plus-circle"></i> Create New Config
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0" style="color: #60a5fa;">All Configurations</h5>
|
<h5 class="mb-0">All Configurations</h5>
|
||||||
<input type="text" id="search-input" class="form-control" style="max-width: 300px;"
|
<input type="text" id="search-input" class="form-control" style="max-width: 300px;"
|
||||||
placeholder="Search configs...">
|
placeholder="Search configs...">
|
||||||
</div>
|
</div>
|
||||||
@@ -93,12 +93,12 @@
|
|||||||
<!-- Create Config Modal -->
|
<!-- Create Config Modal -->
|
||||||
<div class="modal fade" id="createConfigModal" tabindex="-1">
|
<div class="modal fade" id="createConfigModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
<div class="modal-content">
|
||||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" style="color: #60a5fa;">
|
<h5 class="modal-title">
|
||||||
<i class="bi bi-plus-circle"></i> Create New Configuration
|
<i class="bi bi-plus-circle me-2"></i>Create New Configuration
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="create-config-form">
|
<form id="create-config-form">
|
||||||
@@ -133,10 +133,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" id="create-config-btn">
|
<button type="button" class="btn btn-primary" id="create-config-btn">
|
||||||
<i class="bi bi-check-circle"></i> Create Configuration
|
<i class="bi bi-check-circle me-1"></i>Create Configuration
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,12 +146,12 @@
|
|||||||
<!-- Edit Config Modal -->
|
<!-- Edit Config Modal -->
|
||||||
<div class="modal fade" id="editConfigModal" tabindex="-1">
|
<div class="modal fade" id="editConfigModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
<div class="modal-content">
|
||||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" style="color: #60a5fa;">
|
<h5 class="modal-title">
|
||||||
<i class="bi bi-pencil"></i> Edit Configuration
|
<i class="bi bi-pencil me-2"></i>Edit Configuration
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="edit-config-form">
|
<form id="edit-config-form">
|
||||||
@@ -179,10 +179,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" id="edit-config-btn">
|
<button type="button" class="btn btn-primary" id="edit-config-btn">
|
||||||
<i class="bi bi-check-circle"></i> Save Changes
|
<i class="bi bi-check-circle me-1"></i>Save Changes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,19 +192,19 @@
|
|||||||
<!-- View Config Modal -->
|
<!-- View Config Modal -->
|
||||||
<div class="modal fade" id="viewConfigModal" tabindex="-1">
|
<div class="modal fade" id="viewConfigModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
<div class="modal-content">
|
||||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" style="color: #60a5fa;">
|
<h5 class="modal-title">
|
||||||
<i class="bi bi-eye"></i> Configuration Details
|
<i class="bi bi-eye me-2"></i>Configuration Details
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div id="view-config-content">
|
<div id="view-config-content">
|
||||||
<!-- Populated by JavaScript -->
|
<!-- Populated by JavaScript -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,22 +214,22 @@
|
|||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<div class="modal fade" id="deleteConfigModal" tabindex="-1">
|
<div class="modal fade" id="deleteConfigModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
<div class="modal-content">
|
||||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" style="color: #ef4444;">
|
<h5 class="modal-title text-danger">
|
||||||
<i class="bi bi-trash"></i> Delete Configuration
|
<i class="bi bi-trash me-2"></i>Delete Configuration
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Are you sure you want to delete configuration <strong id="delete-config-name"></strong>?</p>
|
<p>Are you sure you want to delete configuration <strong id="delete-config-name"></strong>?</p>
|
||||||
<p class="text-warning"><i class="bi bi-exclamation-triangle"></i> This action cannot be undone.</p>
|
<p class="text-warning"><i class="bi bi-exclamation-triangle"></i> This action cannot be undone.</p>
|
||||||
<input type="hidden" id="delete-config-id">
|
<input type="hidden" id="delete-config-id">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-danger" id="confirm-delete-btn">
|
<button type="button" class="btn btn-danger" id="confirm-delete-btn">
|
||||||
<i class="bi bi-trash"></i> Delete
|
<i class="bi bi-trash me-1"></i>Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h1 class="mb-4" style="color: #60a5fa;">Dashboard</h1>
|
<h1 class="mb-4">Dashboard</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0" style="color: #60a5fa;">Quick Actions</h5>
|
<h5 class="mb-0">Quick Actions</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<button class="btn btn-primary btn-lg" onclick="showTriggerScanModal()">
|
<button class="btn btn-primary btn-lg" onclick="showTriggerScanModal()">
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0" style="color: #60a5fa;">Scan Activity (Last 30 Days)</h5>
|
<h5 class="mb-0">Scan Activity (Last 30 Days)</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div id="chart-loading" class="text-center py-4">
|
<div id="chart-loading" class="text-center py-4">
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0" style="color: #60a5fa;">Upcoming Schedules</h5>
|
<h5 class="mb-0">Upcoming Schedules</h5>
|
||||||
<a href="{{ url_for('main.schedules') }}" class="btn btn-sm btn-secondary">Manage</a>
|
<a href="{{ url_for('main.schedules') }}" class="btn btn-sm btn-secondary">Manage</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0" style="color: #60a5fa;">Recent Scans</h5>
|
<h5 class="mb-0">Recent Scans</h5>
|
||||||
<button class="btn btn-sm btn-secondary" onclick="refreshScans()">
|
<button class="btn btn-sm btn-secondary" onclick="refreshScans()">
|
||||||
<span id="refresh-text">Refresh</span>
|
<span id="refresh-text">Refresh</span>
|
||||||
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
||||||
@@ -145,9 +145,9 @@
|
|||||||
<!-- Trigger Scan Modal -->
|
<!-- Trigger Scan Modal -->
|
||||||
<div class="modal fade" id="triggerScanModal" tabindex="-1">
|
<div class="modal fade" id="triggerScanModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
<div class="modal-content">
|
||||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" style="color: #60a5fa;">Trigger New Scan</h5>
|
<h5 class="modal-title">Trigger New Scan</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()">
|
<button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()">
|
||||||
<span id="modal-trigger-text">Trigger Scan</span>
|
<span id="modal-trigger-text">Trigger Scan</span>
|
||||||
|
|||||||
375
app/web/templates/help.html
Normal file
375
app/web/templates/help.html
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Help - SneakyScanner{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1 class="mb-4"><i class="bi bi-question-circle"></i> Help & Documentation</h1>
|
||||||
|
<p class="text-muted">Learn how to use SneakyScanner to manage your network scanning operations.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Navigation -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-compass"></i> Quick Navigation</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-3 col-6">
|
||||||
|
<a href="#getting-started" class="btn btn-outline-primary w-100">Getting Started</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6">
|
||||||
|
<a href="#sites" class="btn btn-outline-primary w-100">Sites</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6">
|
||||||
|
<a href="#scan-configs" class="btn btn-outline-primary w-100">Scan Configs</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6">
|
||||||
|
<a href="#running-scans" class="btn btn-outline-primary w-100">Running Scans</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6">
|
||||||
|
<a href="#scheduling" class="btn btn-outline-primary w-100">Scheduling</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6">
|
||||||
|
<a href="#comparisons" class="btn btn-outline-primary w-100">Comparisons</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6">
|
||||||
|
<a href="#alerts" class="btn btn-outline-primary w-100">Alerts</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6">
|
||||||
|
<a href="#webhooks" class="btn btn-outline-primary w-100">Webhooks</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Getting Started -->
|
||||||
|
<div class="row mb-4" id="getting-started">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-rocket-takeoff"></i> Getting Started</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>SneakyScanner helps you perform network vulnerability scans and track changes over time. Here's the typical workflow:</p>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Basic Workflow:</strong>
|
||||||
|
<ol class="mb-0 mt-2">
|
||||||
|
<li><strong>Create a Site</strong> - Define a logical grouping for your targets</li>
|
||||||
|
<li><strong>Add IPs</strong> - Add IP addresses or ranges to your site</li>
|
||||||
|
<li><strong>Create a Scan Config</strong> - Configure how scans should run using your site</li>
|
||||||
|
<li><strong>Run a Scan</strong> - Execute scans manually or on a schedule</li>
|
||||||
|
<li><strong>Review Results</strong> - Analyze findings and compare scans over time</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sites -->
|
||||||
|
<div class="row mb-4" id="sites">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-globe"></i> Creating Sites & Adding IPs</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>What is a Site?</h6>
|
||||||
|
<p>A Site is a logical grouping of IP addresses that you want to scan together. For example, you might create separate sites for "Production Servers", "Development Environment", or "Office Network".</p>
|
||||||
|
|
||||||
|
<h6>Creating a Site</h6>
|
||||||
|
<ol>
|
||||||
|
<li>Navigate to <strong>Configs → Sites</strong> in the navigation menu</li>
|
||||||
|
<li>Click the <strong>Create Site</strong> button</li>
|
||||||
|
<li>Enter a descriptive name for your site</li>
|
||||||
|
<li>Optionally add a description to help identify the site's purpose</li>
|
||||||
|
<li>Click <strong>Create</strong> to save the site</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h6>Adding IP Addresses</h6>
|
||||||
|
<p>After creating a site, you need to add the IP addresses you want to scan:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Find your site in the Sites list</li>
|
||||||
|
<li>Click the <strong>Manage IPs</strong> button (or the site name)</li>
|
||||||
|
<li>Click <strong>Add IP</strong></li>
|
||||||
|
<li>Enter the IP address or CIDR range (e.g., <code>192.168.1.1</code> or <code>192.168.1.0/24</code>)</li>
|
||||||
|
<li>Click <strong>Add</strong> to save</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i> <strong>Note:</strong> You can add individual IPs or CIDR notation ranges. Large ranges will result in longer scan times.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scan Configs -->
|
||||||
|
<div class="row mb-4" id="scan-configs">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-gear"></i> Creating Scan Configurations</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>What is a Scan Config?</h6>
|
||||||
|
<p>A Scan Configuration defines how a scan should be performed. It links to a Site and specifies scanning parameters like ports to scan, timing options, and other settings.</p>
|
||||||
|
|
||||||
|
<h6>Creating a Scan Config</h6>
|
||||||
|
<ol>
|
||||||
|
<li>Navigate to <strong>Configs → Scan Configs</strong> in the navigation menu</li>
|
||||||
|
<li>Click the <strong>Create Config</strong> button</li>
|
||||||
|
<li>Enter a name for the configuration</li>
|
||||||
|
<li>Select the <strong>Site</strong> to associate with this config</li>
|
||||||
|
<li>Configure scan parameters:
|
||||||
|
<ul>
|
||||||
|
<li><strong>Ports</strong> - Specify ports to scan (e.g., <code>22,80,443</code> or <code>1-1000</code>)</li>
|
||||||
|
<li><strong>Timing</strong> - Set scan speed/aggressiveness</li>
|
||||||
|
<li><strong>Additional Options</strong> - Configure other nmap parameters as needed</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Click <strong>Create</strong> to save the configuration</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i> <strong>Tip:</strong> Create different configs for different purposes - a quick config for daily checks and a thorough config for weekly deep scans.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Running Scans -->
|
||||||
|
<div class="row mb-4" id="running-scans">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-play-circle"></i> Running Scans</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>Starting a Manual Scan</h6>
|
||||||
|
<ol>
|
||||||
|
<li>Navigate to <strong>Scans</strong> in the navigation menu</li>
|
||||||
|
<li>Click the <strong>New Scan</strong> button</li>
|
||||||
|
<li>Select the <strong>Scan Config</strong> you want to use</li>
|
||||||
|
<li>Click <strong>Start Scan</strong></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h6>Monitoring Scan Progress</h6>
|
||||||
|
<p>While a scan is running:</p>
|
||||||
|
<ul>
|
||||||
|
<li>The scan will appear in the Scans list with a <span class="badge badge-warning">Running</span> status</li>
|
||||||
|
<li>You can view live progress by clicking on the scan</li>
|
||||||
|
<li>The Dashboard also shows active scans</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h6>Viewing Scan Results</h6>
|
||||||
|
<ol>
|
||||||
|
<li>Once complete, click on a scan in the Scans list</li>
|
||||||
|
<li>View discovered hosts, open ports, and services</li>
|
||||||
|
<li>Export results or compare with previous scans</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scheduling -->
|
||||||
|
<div class="row mb-4" id="scheduling">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-calendar-check"></i> Scheduling Scans</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>Why Schedule Scans?</h6>
|
||||||
|
<p>Scheduled scans allow you to automatically run scans at regular intervals, ensuring continuous monitoring of your network without manual intervention.</p>
|
||||||
|
|
||||||
|
<h6>Creating a Schedule</h6>
|
||||||
|
<ol>
|
||||||
|
<li>Navigate to <strong>Schedules</strong> in the navigation menu</li>
|
||||||
|
<li>Click the <strong>Create Schedule</strong> button</li>
|
||||||
|
<li>Enter a name for the schedule</li>
|
||||||
|
<li>Select the <strong>Scan Config</strong> to use</li>
|
||||||
|
<li>Configure the schedule:
|
||||||
|
<ul>
|
||||||
|
<li><strong>Frequency</strong> - How often to run (daily, weekly, monthly, custom cron)</li>
|
||||||
|
<li><strong>Time</strong> - When to start the scan</li>
|
||||||
|
<li><strong>Days</strong> - Which days to run (for weekly schedules)</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Enable/disable the schedule as needed</li>
|
||||||
|
<li>Click <strong>Create</strong> to save</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h6>Managing Schedules</h6>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Enable/Disable</strong> - Toggle schedules on or off without deleting them</li>
|
||||||
|
<li><strong>Edit</strong> - Modify the schedule timing or associated config</li>
|
||||||
|
<li><strong>Delete</strong> - Remove schedules you no longer need</li>
|
||||||
|
<li><strong>View History</strong> - See past runs triggered by the schedule</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i> <strong>Tip:</strong> Schedule comprehensive scans during off-peak hours to minimize network impact.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scan Comparisons -->
|
||||||
|
<div class="row mb-4" id="comparisons">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-arrow-left-right"></i> Scan Comparisons</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>Why Compare Scans?</h6>
|
||||||
|
<p>Comparing scans helps you identify changes in your network over time - new hosts, closed ports, new services, or potential security issues.</p>
|
||||||
|
|
||||||
|
<h6>Comparing Two Scans</h6>
|
||||||
|
<ol>
|
||||||
|
<li>Navigate to <strong>Scans</strong> in the navigation menu</li>
|
||||||
|
<li>Find the scan you want to use as the baseline</li>
|
||||||
|
<li>Click on the scan to view its details</li>
|
||||||
|
<li>Click the <strong>Compare</strong> button</li>
|
||||||
|
<li>Select another scan to compare against</li>
|
||||||
|
<li>Review the comparison results</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h6>Understanding Comparison Results</h6>
|
||||||
|
<p>The comparison view shows:</p>
|
||||||
|
<ul>
|
||||||
|
<li><span class="badge badge-success">New</span> - Hosts or ports that appear in the newer scan but not the older one</li>
|
||||||
|
<li><span class="badge badge-danger">Removed</span> - Hosts or ports that were in the older scan but not the newer one</li>
|
||||||
|
<li><span class="badge badge-warning">Changed</span> - Services or states that differ between scans</li>
|
||||||
|
<li><span class="badge badge-info">Unchanged</span> - Items that remain the same</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i> <strong>Security Note:</strong> Pay close attention to unexpected new open ports or services - these could indicate unauthorized changes or potential compromises.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts -->
|
||||||
|
<div class="row mb-4" id="alerts">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-bell"></i> Alerts & Alert Rules</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>Understanding Alerts</h6>
|
||||||
|
<p>Alerts notify you when scan results match certain conditions you define. This helps you stay informed about important changes without manually reviewing every scan.</p>
|
||||||
|
|
||||||
|
<h6>Viewing Alert History</h6>
|
||||||
|
<ol>
|
||||||
|
<li>Navigate to <strong>Alerts → Alert History</strong></li>
|
||||||
|
<li>View all triggered alerts with timestamps and details</li>
|
||||||
|
<li>Filter alerts by severity, date, or type</li>
|
||||||
|
<li>Click on an alert to see full details and the associated scan</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h6>Creating Alert Rules</h6>
|
||||||
|
<ol>
|
||||||
|
<li>Navigate to <strong>Alerts → Alert Rules</strong></li>
|
||||||
|
<li>Click <strong>Create Rule</strong></li>
|
||||||
|
<li>Configure the rule:
|
||||||
|
<ul>
|
||||||
|
<li><strong>Name</strong> - A descriptive name for the rule</li>
|
||||||
|
<li><strong>Condition</strong> - What triggers the alert (e.g., new open port, new host, specific service detected)</li>
|
||||||
|
<li><strong>Severity</strong> - How critical is this alert (Info, Warning, Critical)</li>
|
||||||
|
<li><strong>Scope</strong> - Which sites or configs this rule applies to</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Enable the rule</li>
|
||||||
|
<li>Click <strong>Create</strong> to save</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h6>Common Alert Rule Examples</h6>
|
||||||
|
<ul>
|
||||||
|
<li><strong>New Host Detected</strong> - Alert when a previously unknown host appears</li>
|
||||||
|
<li><strong>New Open Port</strong> - Alert when a new port opens on any host</li>
|
||||||
|
<li><strong>Critical Port Open</strong> - Alert for specific high-risk ports (e.g., 23/Telnet, 3389/RDP)</li>
|
||||||
|
<li><strong>Service Change</strong> - Alert when a service version changes</li>
|
||||||
|
<li><strong>Host Offline</strong> - Alert when an expected host stops responding</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i> <strong>Tip:</strong> Start with a few important rules and refine them over time to avoid alert fatigue.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhooks -->
|
||||||
|
<div class="row mb-4" id="webhooks">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-broadcast"></i> Webhooks</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>What are Webhooks?</h6>
|
||||||
|
<p>Webhooks allow SneakyScanner to send notifications to external services when events occur, such as scan completion or alert triggers. This enables integration with tools like Slack, Discord, Microsoft Teams, or custom systems.</p>
|
||||||
|
|
||||||
|
<h6>Creating a Webhook</h6>
|
||||||
|
<ol>
|
||||||
|
<li>Navigate to <strong>Alerts → Webhooks</strong></li>
|
||||||
|
<li>Click <strong>Create Webhook</strong></li>
|
||||||
|
<li>Configure the webhook:
|
||||||
|
<ul>
|
||||||
|
<li><strong>Name</strong> - A descriptive name</li>
|
||||||
|
<li><strong>URL</strong> - The endpoint to send notifications to</li>
|
||||||
|
<li><strong>Events</strong> - Which events trigger this webhook</li>
|
||||||
|
<li><strong>Secret</strong> - Optional secret for request signing</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Test the webhook to verify it works</li>
|
||||||
|
<li>Click <strong>Create</strong> to save</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h6>Webhook Events</h6>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Scan Started</strong> - When a scan begins</li>
|
||||||
|
<li><strong>Scan Completed</strong> - When a scan finishes</li>
|
||||||
|
<li><strong>Scan Failed</strong> - When a scan encounters an error</li>
|
||||||
|
<li><strong>Alert Triggered</strong> - When an alert rule matches</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h6>Integration Examples</h6>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Slack</strong> - Use a Slack Incoming Webhook URL</li>
|
||||||
|
<li><strong>Discord</strong> - Use a Discord Webhook URL</li>
|
||||||
|
<li><strong>Microsoft Teams</strong> - Use a Teams Incoming Webhook</li>
|
||||||
|
<li><strong>Custom API</strong> - Send to your own endpoint for custom processing</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back to Top -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12 text-center">
|
||||||
|
<a href="#" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-up"></i> Back to Top
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
175
app/web/templates/ip_search_results.html
Normal file
175
app/web/templates/ip_search_results.html
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Search Results for {{ ip_address }} - SneakyScanner{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
Search Results
|
||||||
|
{% if ip_address %}
|
||||||
|
<small class="text-muted">for {{ ip_address }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</h1>
|
||||||
|
<a href="{{ url_for('main.scans') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Scans
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not ip_address %}
|
||||||
|
<!-- No IP provided -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="bi bi-exclamation-circle text-warning" style="font-size: 3rem;"></i>
|
||||||
|
<h4 class="mt-3">No IP Address Provided</h4>
|
||||||
|
<p class="text-muted">Please enter an IP address in the search box to find related scans.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Results Table -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Last 10 Scans Containing {{ ip_address }}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="results-loading" class="text-center py-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-muted">Searching for scans...</p>
|
||||||
|
</div>
|
||||||
|
<div id="results-error" class="alert alert-danger" style="display: none;"></div>
|
||||||
|
<div id="results-empty" class="text-center py-5 text-muted" style="display: none;">
|
||||||
|
<i class="bi bi-search" style="font-size: 3rem;"></i>
|
||||||
|
<h5 class="mt-3">No Scans Found</h5>
|
||||||
|
<p>No completed scans contain the IP address <strong>{{ ip_address }}</strong>.</p>
|
||||||
|
</div>
|
||||||
|
<div id="results-table-container" style="display: none;">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 80px;">ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style="width: 200px;">Timestamp</th>
|
||||||
|
<th style="width: 100px;">Duration</th>
|
||||||
|
<th style="width: 120px;">Status</th>
|
||||||
|
<th style="width: 100px;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="results-tbody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted mt-3">
|
||||||
|
Found <span id="result-count">0</span> scan(s) containing this IP address.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const ipAddress = "{{ ip_address | e }}";
|
||||||
|
|
||||||
|
// Load results when page loads
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (ipAddress) {
|
||||||
|
loadResults();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load search results from API
|
||||||
|
async function loadResults() {
|
||||||
|
const loadingEl = document.getElementById('results-loading');
|
||||||
|
const errorEl = document.getElementById('results-error');
|
||||||
|
const emptyEl = document.getElementById('results-empty');
|
||||||
|
const tableEl = document.getElementById('results-table-container');
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
loadingEl.style.display = 'block';
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
emptyEl.style.display = 'none';
|
||||||
|
tableEl.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/scans/by-ip/${encodeURIComponent(ipAddress)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to search for scans');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const scans = data.scans || [];
|
||||||
|
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
|
||||||
|
if (scans.length === 0) {
|
||||||
|
emptyEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
tableEl.style.display = 'block';
|
||||||
|
renderResultsTable(scans);
|
||||||
|
document.getElementById('result-count').textContent = data.count;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching for scans:', error);
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
errorEl.textContent = 'Failed to search for scans. Please try again.';
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render results table
|
||||||
|
function renderResultsTable(scans) {
|
||||||
|
const tbody = document.getElementById('results-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
scans.forEach(scan => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('scan-row');
|
||||||
|
|
||||||
|
// Format timestamp
|
||||||
|
const timestamp = new Date(scan.timestamp).toLocaleString();
|
||||||
|
|
||||||
|
// Format duration
|
||||||
|
const duration = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
|
||||||
|
|
||||||
|
// Status badge
|
||||||
|
let statusBadge = '';
|
||||||
|
if (scan.status === 'completed') {
|
||||||
|
statusBadge = '<span class="badge badge-success">Completed</span>';
|
||||||
|
} else if (scan.status === 'running') {
|
||||||
|
statusBadge = '<span class="badge badge-info">Running</span>';
|
||||||
|
} else if (scan.status === 'failed') {
|
||||||
|
statusBadge = '<span class="badge badge-danger">Failed</span>';
|
||||||
|
} else {
|
||||||
|
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td class="mono">${scan.id}</td>
|
||||||
|
<td>${scan.title || 'Untitled Scan'}</td>
|
||||||
|
<td class="text-muted">${timestamp}</td>
|
||||||
|
<td class="mono">${duration}</td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -375,12 +375,12 @@
|
|||||||
document.getElementById('scan1-id').textContent = data.scan1.id;
|
document.getElementById('scan1-id').textContent = data.scan1.id;
|
||||||
document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan';
|
document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan';
|
||||||
document.getElementById('scan1-timestamp').textContent = new Date(data.scan1.timestamp).toLocaleString();
|
document.getElementById('scan1-timestamp').textContent = new Date(data.scan1.timestamp).toLocaleString();
|
||||||
document.getElementById('scan1-config').textContent = data.scan1.config_file || 'Unknown';
|
document.getElementById('scan1-config').textContent = data.scan1.config_id || 'Unknown';
|
||||||
|
|
||||||
document.getElementById('scan2-id').textContent = data.scan2.id;
|
document.getElementById('scan2-id').textContent = data.scan2.id;
|
||||||
document.getElementById('scan2-title').textContent = data.scan2.title || 'Untitled Scan';
|
document.getElementById('scan2-title').textContent = data.scan2.title || 'Untitled Scan';
|
||||||
document.getElementById('scan2-timestamp').textContent = new Date(data.scan2.timestamp).toLocaleString();
|
document.getElementById('scan2-timestamp').textContent = new Date(data.scan2.timestamp).toLocaleString();
|
||||||
document.getElementById('scan2-config').textContent = data.scan2.config_file || 'Unknown';
|
document.getElementById('scan2-config').textContent = data.scan2.config_id || 'Unknown';
|
||||||
|
|
||||||
// Ports comparison
|
// Ports comparison
|
||||||
populatePortsComparison(data.ports);
|
populatePortsComparison(data.ports);
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
<span id="refresh-text">Refresh</span>
|
<span id="refresh-text">Refresh</span>
|
||||||
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-warning ms-2" onclick="stopScan()" id="stop-btn" style="display: none;">
|
||||||
|
<span id="stop-text">Stop Scan</span>
|
||||||
|
<span id="stop-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
||||||
|
</button>
|
||||||
<button class="btn btn-danger ms-2" onclick="deleteScan()" id="delete-btn">Delete Scan</button>
|
<button class="btn btn-danger ms-2" onclick="deleteScan()" id="delete-btn">Delete Scan</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,6 +88,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Section (shown when scan is running) -->
|
||||||
|
<div class="row mb-4" id="progress-section" style="display: none;">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">
|
||||||
|
<i class="bi bi-hourglass-split"></i> Scan Progress
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Phase and Progress Bar -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span>Current Phase: <strong id="current-phase">Initializing...</strong></span>
|
||||||
|
<span id="progress-count">0 / 0 IPs</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 20px; background-color: #334155;">
|
||||||
|
<div id="progress-bar" class="progress-bar bg-info" role="progressbar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-IP Results Table -->
|
||||||
|
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead style="position: sticky; top: 0; background-color: #1e293b;">
|
||||||
|
<tr>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Ping</th>
|
||||||
|
<th>TCP Ports</th>
|
||||||
|
<th>UDP Ports</th>
|
||||||
|
<th>Services</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="progress-table-body">
|
||||||
|
<tr><td colspan="6" class="text-center text-muted">Waiting for results...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stats Row -->
|
<!-- Stats Row -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -154,6 +202,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Certificate Details Modal -->
|
||||||
|
<div class="modal fade" id="certificateModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
||||||
|
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
||||||
|
<h5 class="modal-title" style="color: #60a5fa;">
|
||||||
|
<i class="bi bi-shield-lock"></i> Certificate Details
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label text-muted">Subject</label>
|
||||||
|
<div id="cert-subject" class="mono" style="word-break: break-all;">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label text-muted">Issuer</label>
|
||||||
|
<div id="cert-issuer" class="mono" style="word-break: break-all;">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label text-muted">Valid From</label>
|
||||||
|
<div id="cert-valid-from" class="mono">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label text-muted">Valid Until</label>
|
||||||
|
<div id="cert-valid-until" class="mono">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label text-muted">Days Until Expiry</label>
|
||||||
|
<div id="cert-days-expiry">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label text-muted">Serial Number</label>
|
||||||
|
<div id="cert-serial" class="mono" style="word-break: break-all;">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label text-muted">Self-Signed</label>
|
||||||
|
<div id="cert-self-signed">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label text-muted">Subject Alternative Names (SANs)</label>
|
||||||
|
<div id="cert-sans">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label text-muted">TLS Version Support</label>
|
||||||
|
<div id="cert-tls-versions">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
@@ -161,22 +270,162 @@
|
|||||||
const scanId = {{ scan_id }};
|
const scanId = {{ scan_id }};
|
||||||
let scanData = null;
|
let scanData = null;
|
||||||
let historyChart = null; // Store chart instance to prevent duplicates
|
let historyChart = null; // Store chart instance to prevent duplicates
|
||||||
|
let progressInterval = null; // Store progress polling interval
|
||||||
|
|
||||||
|
// Show alert notification
|
||||||
|
function showAlert(type, message) {
|
||||||
|
const container = document.getElementById('notification-container');
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
|
||||||
|
|
||||||
|
notification.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(notification);
|
||||||
|
|
||||||
|
// Auto-dismiss after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.remove();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
// Load scan on page load
|
// Load scan on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadScan().then(() => {
|
loadScan().then(() => {
|
||||||
findPreviousScan();
|
findPreviousScan();
|
||||||
loadHistoricalChart();
|
loadHistoricalChart();
|
||||||
|
|
||||||
|
// Start progress polling if scan is running
|
||||||
|
if (scanData && scanData.status === 'running') {
|
||||||
|
startProgressPolling();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-refresh every 10 seconds if scan is running
|
|
||||||
setInterval(function() {
|
|
||||||
if (scanData && scanData.status === 'running') {
|
|
||||||
loadScan();
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start polling for progress updates
|
||||||
|
function startProgressPolling() {
|
||||||
|
// Show progress section
|
||||||
|
document.getElementById('progress-section').style.display = 'block';
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadProgress();
|
||||||
|
|
||||||
|
// Poll every 3 seconds
|
||||||
|
progressInterval = setInterval(loadProgress, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop polling for progress updates
|
||||||
|
function stopProgressPolling() {
|
||||||
|
if (progressInterval) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
progressInterval = null;
|
||||||
|
}
|
||||||
|
// Hide progress section when scan completes
|
||||||
|
document.getElementById('progress-section').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load progress data
|
||||||
|
async function loadProgress() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/scans/${scanId}/progress`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const progress = await response.json();
|
||||||
|
|
||||||
|
// Check if scan is still running
|
||||||
|
if (progress.status !== 'running') {
|
||||||
|
stopProgressPolling();
|
||||||
|
loadScan(); // Refresh full scan data
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderProgress(progress);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading progress:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render progress data
|
||||||
|
function renderProgress(progress) {
|
||||||
|
// Update phase display
|
||||||
|
const phaseNames = {
|
||||||
|
'pending': 'Initializing',
|
||||||
|
'ping': 'Ping Scan',
|
||||||
|
'tcp_scan': 'TCP Port Scan',
|
||||||
|
'udp_scan': 'UDP Port Scan',
|
||||||
|
'service_detection': 'Service Detection',
|
||||||
|
'http_analysis': 'HTTP/HTTPS Analysis',
|
||||||
|
'completed': 'Completing'
|
||||||
|
};
|
||||||
|
|
||||||
|
const phaseName = phaseNames[progress.current_phase] || progress.current_phase;
|
||||||
|
document.getElementById('current-phase').textContent = phaseName;
|
||||||
|
|
||||||
|
// Update progress count and bar
|
||||||
|
const total = progress.total_ips || 0;
|
||||||
|
const completed = progress.completed_ips || 0;
|
||||||
|
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
|
||||||
|
document.getElementById('progress-count').textContent = `${completed} / ${total} IPs`;
|
||||||
|
document.getElementById('progress-bar').style.width = `${percent}%`;
|
||||||
|
|
||||||
|
// Update progress table
|
||||||
|
const tbody = document.getElementById('progress-table-body');
|
||||||
|
const entries = progress.progress_entries || [];
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">Waiting for results...</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
entries.forEach(entry => {
|
||||||
|
// Ping result
|
||||||
|
let pingDisplay = '-';
|
||||||
|
if (entry.ping_result !== null && entry.ping_result !== undefined) {
|
||||||
|
pingDisplay = entry.ping_result
|
||||||
|
? '<span class="badge badge-success">Yes</span>'
|
||||||
|
: '<span class="badge badge-danger">No</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCP ports
|
||||||
|
const tcpPorts = entry.tcp_ports || [];
|
||||||
|
let tcpDisplay = tcpPorts.length > 0
|
||||||
|
? `<span class="badge bg-info">${tcpPorts.length}</span> <small class="text-muted">${tcpPorts.slice(0, 5).join(', ')}${tcpPorts.length > 5 ? '...' : ''}</small>`
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
// UDP ports
|
||||||
|
const udpPorts = entry.udp_ports || [];
|
||||||
|
let udpDisplay = udpPorts.length > 0
|
||||||
|
? `<span class="badge bg-info">${udpPorts.length}</span>`
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
// Services
|
||||||
|
const services = entry.services || [];
|
||||||
|
let svcDisplay = '-';
|
||||||
|
if (services.length > 0) {
|
||||||
|
const svcNames = services.map(s => s.service || 'unknown').slice(0, 3);
|
||||||
|
svcDisplay = `<span class="badge bg-info">${services.length}</span> <small class="text-muted">${svcNames.join(', ')}${services.length > 3 ? '...' : ''}</small>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr class="scan-row">
|
||||||
|
<td>${entry.site_name || '-'}</td>
|
||||||
|
<td class="mono">${entry.ip_address}</td>
|
||||||
|
<td>${pingDisplay}</td>
|
||||||
|
<td>${tcpDisplay}</td>
|
||||||
|
<td>${udpDisplay}</td>
|
||||||
|
<td>${svcDisplay}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
// Load scan details
|
// Load scan details
|
||||||
async function loadScan() {
|
async function loadScan() {
|
||||||
const loadingEl = document.getElementById('scan-loading');
|
const loadingEl = document.getElementById('scan-loading');
|
||||||
@@ -218,7 +467,6 @@
|
|||||||
document.getElementById('scan-timestamp').textContent = new Date(scan.timestamp).toLocaleString();
|
document.getElementById('scan-timestamp').textContent = new Date(scan.timestamp).toLocaleString();
|
||||||
document.getElementById('scan-duration').textContent = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
|
document.getElementById('scan-duration').textContent = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
|
||||||
document.getElementById('scan-triggered-by').textContent = scan.triggered_by || 'manual';
|
document.getElementById('scan-triggered-by').textContent = scan.triggered_by || 'manual';
|
||||||
document.getElementById('scan-config-file').textContent = scan.config_file || '-';
|
|
||||||
|
|
||||||
// Status badge
|
// Status badge
|
||||||
let statusBadge = '';
|
let statusBadge = '';
|
||||||
@@ -227,8 +475,11 @@
|
|||||||
} else if (scan.status === 'running') {
|
} else if (scan.status === 'running') {
|
||||||
statusBadge = '<span class="badge badge-info">Running</span>';
|
statusBadge = '<span class="badge badge-info">Running</span>';
|
||||||
document.getElementById('delete-btn').disabled = true;
|
document.getElementById('delete-btn').disabled = true;
|
||||||
|
document.getElementById('stop-btn').style.display = 'inline-block';
|
||||||
} else if (scan.status === 'failed') {
|
} else if (scan.status === 'failed') {
|
||||||
statusBadge = '<span class="badge badge-danger">Failed</span>';
|
statusBadge = '<span class="badge badge-danger">Failed</span>';
|
||||||
|
} else if (scan.status === 'cancelled') {
|
||||||
|
statusBadge = '<span class="badge badge-warning">Cancelled</span>';
|
||||||
} else {
|
} else {
|
||||||
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
|
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
|
||||||
}
|
}
|
||||||
@@ -313,6 +564,8 @@
|
|||||||
<th>Product</th>
|
<th>Product</th>
|
||||||
<th>Version</th>
|
<th>Version</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Screenshot</th>
|
||||||
|
<th>Certificate</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="site-${siteIdx}-ip-${ipIdx}-ports"></tbody>
|
<tbody id="site-${siteIdx}-ip-${ipIdx}-ports"></tbody>
|
||||||
@@ -326,10 +579,25 @@
|
|||||||
const ports = ip.ports || [];
|
const ports = ip.ports || [];
|
||||||
|
|
||||||
if (ports.length === 0) {
|
if (ports.length === 0) {
|
||||||
portsContainer.innerHTML = '<tr class="scan-row"><td colspan="7" class="text-center text-muted">No ports found</td></tr>';
|
portsContainer.innerHTML = '<tr class="scan-row"><td colspan="9" class="text-center text-muted">No ports found</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
ports.forEach(port => {
|
ports.forEach(port => {
|
||||||
const service = port.services && port.services.length > 0 ? port.services[0] : null;
|
const service = port.services && port.services.length > 0 ? port.services[0] : null;
|
||||||
|
const screenshotPath = service && service.screenshot_path ? service.screenshot_path : null;
|
||||||
|
const certificate = service && service.certificates && service.certificates.length > 0 ? service.certificates[0] : null;
|
||||||
|
|
||||||
|
// Build status cell with optional "Mark Expected" button
|
||||||
|
let statusCell;
|
||||||
|
if (port.expected) {
|
||||||
|
statusCell = '<span class="badge badge-good">Expected</span>';
|
||||||
|
} else {
|
||||||
|
// Show "Unexpected" badge with "Mark Expected" button if site_id and site_ip_id are available
|
||||||
|
const canMarkExpected = site.site_id && ip.site_ip_id;
|
||||||
|
statusCell = `<span class="badge badge-warning">Unexpected</span>`;
|
||||||
|
if (canMarkExpected) {
|
||||||
|
statusCell += ` <button class="btn btn-sm btn-outline-success ms-1" onclick="markPortExpected(${site.site_id}, ${ip.site_ip_id}, ${port.port}, '${port.protocol}')" title="Add to expected ports"><i class="bi bi-plus-circle"></i></button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.classList.add('scan-row'); // Fix white row bug
|
row.classList.add('scan-row'); // Fix white row bug
|
||||||
@@ -340,7 +608,9 @@
|
|||||||
<td>${service ? service.service_name : '-'}</td>
|
<td>${service ? service.service_name : '-'}</td>
|
||||||
<td>${service ? service.product || '-' : '-'}</td>
|
<td>${service ? service.product || '-' : '-'}</td>
|
||||||
<td class="mono">${service ? service.version || '-' : '-'}</td>
|
<td class="mono">${service ? service.version || '-' : '-'}</td>
|
||||||
<td>${port.expected ? '<span class="badge badge-good">Expected</span>' : '<span class="badge badge-warning">Unexpected</span>'}</td>
|
<td>${statusCell}</td>
|
||||||
|
<td>${screenshotPath ? `<a href="/output/${screenshotPath.replace(/^\/?(?:app\/)?output\/?/, '')}" target="_blank" class="btn btn-sm btn-outline-primary" title="View Screenshot"><i class="bi bi-image"></i></a>` : '-'}</td>
|
||||||
|
<td>${certificate ? `<button class="btn btn-sm btn-outline-info" onclick='showCertificateModal(${JSON.stringify(certificate).replace(/'/g, "'")})' title="View Certificate"><i class="bi bi-shield-lock"></i></button>` : '-'}</td>
|
||||||
`;
|
`;
|
||||||
portsContainer.appendChild(row);
|
portsContainer.appendChild(row);
|
||||||
});
|
});
|
||||||
@@ -439,7 +709,7 @@
|
|||||||
window.location.href = '{{ url_for("main.scans") }}';
|
window.location.href = '{{ url_for("main.scans") }}';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting scan:', error);
|
console.error('Error deleting scan:', error);
|
||||||
alert(`Failed to delete scan: ${error.message}`);
|
showAlert('danger', `Failed to delete scan: ${error.message}`);
|
||||||
|
|
||||||
// Re-enable button on error
|
// Re-enable button on error
|
||||||
deleteBtn.disabled = false;
|
deleteBtn.disabled = false;
|
||||||
@@ -447,15 +717,136 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop scan
|
||||||
|
async function stopScan() {
|
||||||
|
if (!confirm(`Are you sure you want to stop scan ${scanId}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopBtn = document.getElementById('stop-btn');
|
||||||
|
const stopText = document.getElementById('stop-text');
|
||||||
|
const stopSpinner = document.getElementById('stop-spinner');
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
stopBtn.disabled = true;
|
||||||
|
stopText.style.display = 'none';
|
||||||
|
stopSpinner.style.display = 'inline-block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/scans/${scanId}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `HTTP ${response.status}: Failed to stop scan`;
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
errorMessage = data.message || errorMessage;
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore JSON parse errors
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showAlert('success', `Stop signal sent to scan ${scanId}.`);
|
||||||
|
|
||||||
|
// Refresh scan data after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
loadScan();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error stopping scan:', error);
|
||||||
|
showAlert('danger', `Failed to stop scan: ${error.message}`);
|
||||||
|
|
||||||
|
// Re-enable button on error
|
||||||
|
stopBtn.disabled = false;
|
||||||
|
stopText.style.display = 'inline';
|
||||||
|
stopSpinner.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark a port as expected in the site config
|
||||||
|
async function markPortExpected(siteId, ipId, portNumber, protocol) {
|
||||||
|
try {
|
||||||
|
// First, get the current IP settings - fetch all IPs with high per_page to find the one we need
|
||||||
|
const getResponse = await fetch(`/api/sites/${siteId}/ips?per_page=200`);
|
||||||
|
if (!getResponse.ok) {
|
||||||
|
throw new Error('Failed to get site IPs');
|
||||||
|
}
|
||||||
|
const ipsData = await getResponse.json();
|
||||||
|
|
||||||
|
// Find the IP in the site
|
||||||
|
const ipData = ipsData.ips.find(ip => ip.id === ipId);
|
||||||
|
if (!ipData) {
|
||||||
|
throw new Error('IP not found in site');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current expected ports
|
||||||
|
let expectedTcpPorts = ipData.expected_tcp_ports || [];
|
||||||
|
let expectedUdpPorts = ipData.expected_udp_ports || [];
|
||||||
|
|
||||||
|
// Add the new port to the appropriate list
|
||||||
|
if (protocol.toLowerCase() === 'tcp') {
|
||||||
|
if (!expectedTcpPorts.includes(portNumber)) {
|
||||||
|
expectedTcpPorts.push(portNumber);
|
||||||
|
expectedTcpPorts.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
} else if (protocol.toLowerCase() === 'udp') {
|
||||||
|
if (!expectedUdpPorts.includes(portNumber)) {
|
||||||
|
expectedUdpPorts.push(portNumber);
|
||||||
|
expectedUdpPorts.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the IP settings
|
||||||
|
const updateResponse = await fetch(`/api/sites/${siteId}/ips/${ipId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_tcp_ports: expectedTcpPorts,
|
||||||
|
expected_udp_ports: expectedUdpPorts
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
let errorMessage = 'Failed to update IP settings';
|
||||||
|
try {
|
||||||
|
const errorData = await updateResponse.json();
|
||||||
|
errorMessage = errorData.message || errorMessage;
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore JSON parse errors
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showAlert('success', `Port ${portNumber}/${protocol.toUpperCase()} added to expected ports for this IP. Refresh the page to see updated status.`);
|
||||||
|
|
||||||
|
// Optionally refresh the scan data to show the change
|
||||||
|
// Note: The scan data itself won't change, but the user knows it's been updated
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking port as expected:', error);
|
||||||
|
showAlert('danger', `Failed to mark port as expected: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Find previous scan and show compare button
|
// Find previous scan and show compare button
|
||||||
let previousScanId = null;
|
let previousScanId = null;
|
||||||
let currentConfigFile = null;
|
let currentConfigId = null;
|
||||||
async function findPreviousScan() {
|
async function findPreviousScan() {
|
||||||
try {
|
try {
|
||||||
// Get current scan details first to know which config it used
|
// Get current scan details first to know which config it used
|
||||||
const currentScanResponse = await fetch(`/api/scans/${scanId}`);
|
const currentScanResponse = await fetch(`/api/scans/${scanId}`);
|
||||||
const currentScanData = await currentScanResponse.json();
|
const currentScanData = await currentScanResponse.json();
|
||||||
currentConfigFile = currentScanData.config_file;
|
currentConfigId = currentScanData.config_id;
|
||||||
|
|
||||||
// Get list of completed scans
|
// Get list of completed scans
|
||||||
const response = await fetch('/api/scans?per_page=100&status=completed');
|
const response = await fetch('/api/scans?per_page=100&status=completed');
|
||||||
@@ -466,12 +857,12 @@
|
|||||||
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
|
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
|
||||||
|
|
||||||
if (currentScanIndex !== -1) {
|
if (currentScanIndex !== -1) {
|
||||||
// Look for the most recent previous scan with the SAME config file
|
// Look for the most recent previous scan with the SAME config
|
||||||
for (let i = currentScanIndex + 1; i < data.scans.length; i++) {
|
for (let i = currentScanIndex + 1; i < data.scans.length; i++) {
|
||||||
const previousScan = data.scans[i];
|
const previousScan = data.scans[i];
|
||||||
|
|
||||||
// Check if this scan uses the same config
|
// Check if this scan uses the same config
|
||||||
if (previousScan.config_file === currentConfigFile) {
|
if (previousScan.config_id === currentConfigId) {
|
||||||
previousScanId = previousScan.id;
|
previousScanId = previousScan.id;
|
||||||
|
|
||||||
// Show the compare button
|
// Show the compare button
|
||||||
@@ -593,5 +984,97 @@
|
|||||||
console.error('Error loading historical chart:', error);
|
console.error('Error loading historical chart:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show certificate details modal
|
||||||
|
function showCertificateModal(cert) {
|
||||||
|
// Populate modal fields
|
||||||
|
document.getElementById('cert-subject').textContent = cert.subject || '-';
|
||||||
|
document.getElementById('cert-issuer').textContent = cert.issuer || '-';
|
||||||
|
document.getElementById('cert-serial').textContent = cert.serial_number || '-';
|
||||||
|
|
||||||
|
// Format dates
|
||||||
|
document.getElementById('cert-valid-from').textContent = cert.not_valid_before
|
||||||
|
? new Date(cert.not_valid_before).toLocaleString()
|
||||||
|
: '-';
|
||||||
|
document.getElementById('cert-valid-until').textContent = cert.not_valid_after
|
||||||
|
? new Date(cert.not_valid_after).toLocaleString()
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
// Days until expiry with color coding
|
||||||
|
if (cert.days_until_expiry !== null && cert.days_until_expiry !== undefined) {
|
||||||
|
let badgeClass = 'badge-success';
|
||||||
|
if (cert.days_until_expiry < 0) {
|
||||||
|
badgeClass = 'badge-danger';
|
||||||
|
} else if (cert.days_until_expiry < 30) {
|
||||||
|
badgeClass = 'badge-warning';
|
||||||
|
}
|
||||||
|
document.getElementById('cert-days-expiry').innerHTML =
|
||||||
|
`<span class="badge ${badgeClass}">${cert.days_until_expiry} days</span>`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('cert-days-expiry').textContent = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-signed indicator
|
||||||
|
document.getElementById('cert-self-signed').innerHTML = cert.is_self_signed
|
||||||
|
? '<span class="badge badge-warning">Yes</span>'
|
||||||
|
: '<span class="badge badge-success">No</span>';
|
||||||
|
|
||||||
|
// SANs
|
||||||
|
if (cert.sans && cert.sans.length > 0) {
|
||||||
|
document.getElementById('cert-sans').innerHTML = cert.sans
|
||||||
|
.map(san => `<span class="badge bg-secondary me-1 mb-1">${san}</span>`)
|
||||||
|
.join('');
|
||||||
|
} else {
|
||||||
|
document.getElementById('cert-sans').textContent = 'None';
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS versions
|
||||||
|
if (cert.tls_versions && cert.tls_versions.length > 0) {
|
||||||
|
let tlsHtml = '<div class="table-responsive"><table class="table table-sm mb-0">';
|
||||||
|
tlsHtml += '<thead><tr><th>Version</th><th>Status</th><th>Cipher Suites</th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
cert.tls_versions.forEach(tls => {
|
||||||
|
const statusBadge = tls.supported
|
||||||
|
? '<span class="badge badge-success">Supported</span>'
|
||||||
|
: '<span class="badge badge-danger">Not Supported</span>';
|
||||||
|
|
||||||
|
let ciphers = '-';
|
||||||
|
if (tls.cipher_suites && tls.cipher_suites.length > 0) {
|
||||||
|
ciphers = `<small class="text-muted">${tls.cipher_suites.length} cipher(s)</small>
|
||||||
|
<button class="btn btn-sm btn-link p-0 ms-1" onclick="toggleCiphers(this, '${tls.tls_version}')" data-ciphers='${JSON.stringify(tls.cipher_suites).replace(/'/g, "'")}'>
|
||||||
|
<i class="bi bi-chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
<div class="cipher-list" style="display:none; font-size: 0.75rem; max-height: 100px; overflow-y: auto;"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsHtml += `<tr class="scan-row"><td>${tls.tls_version}</td><td>${statusBadge}</td><td>${ciphers}</td></tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
tlsHtml += '</tbody></table></div>';
|
||||||
|
document.getElementById('cert-tls-versions').innerHTML = tlsHtml;
|
||||||
|
} else {
|
||||||
|
document.getElementById('cert-tls-versions').textContent = 'No TLS information available';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('certificateModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle cipher suites display
|
||||||
|
function toggleCiphers(btn, version) {
|
||||||
|
const cipherList = btn.nextElementSibling;
|
||||||
|
const icon = btn.querySelector('i');
|
||||||
|
|
||||||
|
if (cipherList.style.display === 'none') {
|
||||||
|
const ciphers = JSON.parse(btn.dataset.ciphers);
|
||||||
|
cipherList.innerHTML = ciphers.map(c => `<div class="mono">${c}</div>`).join('');
|
||||||
|
cipherList.style.display = 'block';
|
||||||
|
icon.className = 'bi bi-chevron-up';
|
||||||
|
} else {
|
||||||
|
cipherList.style.display = 'none';
|
||||||
|
icon.className = 'bi bi-chevron-down';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="color: #60a5fa;">All Scans</h1>
|
<h1>All Scans</h1>
|
||||||
<button class="btn btn-primary" onclick="showTriggerScanModal()">
|
<button class="btn btn-primary" onclick="showTriggerScanModal()">
|
||||||
<span id="trigger-btn-text">Trigger New Scan</span>
|
<span id="trigger-btn-text">Trigger New Scan</span>
|
||||||
<span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
<span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
<option value="running">Running</option>
|
<option value="running">Running</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
<option value="failed">Failed</option>
|
<option value="failed">Failed</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -54,7 +55,7 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0" style="color: #60a5fa;">Scan History</h5>
|
<h5 class="mb-0">Scan History</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div id="scans-loading" class="text-center py-5">
|
<div id="scans-loading" class="text-center py-5">
|
||||||
@@ -105,9 +106,9 @@
|
|||||||
<!-- Trigger Scan Modal -->
|
<!-- Trigger Scan Modal -->
|
||||||
<div class="modal fade" id="triggerScanModal" tabindex="-1">
|
<div class="modal fade" id="triggerScanModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
<div class="modal-content">
|
||||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" style="color: #60a5fa;">Trigger New Scan</h5>
|
<h5 class="modal-title">Trigger New Scan</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -132,7 +133,7 @@
|
|||||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()">
|
<button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()">
|
||||||
<span id="modal-trigger-text">Trigger Scan</span>
|
<span id="modal-trigger-text">Trigger Scan</span>
|
||||||
@@ -151,6 +152,25 @@
|
|||||||
let statusFilter = '';
|
let statusFilter = '';
|
||||||
let totalCount = 0;
|
let totalCount = 0;
|
||||||
|
|
||||||
|
// Show alert notification
|
||||||
|
function showAlert(type, message) {
|
||||||
|
const container = document.getElementById('notification-container');
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
|
||||||
|
|
||||||
|
notification.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(notification);
|
||||||
|
|
||||||
|
// Auto-dismiss after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.remove();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
// Load initial data when page loads
|
// Load initial data when page loads
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadScans();
|
loadScans();
|
||||||
@@ -229,20 +249,27 @@
|
|||||||
statusBadge = '<span class="badge badge-info">Running</span>';
|
statusBadge = '<span class="badge badge-info">Running</span>';
|
||||||
} else if (scan.status === 'failed') {
|
} else if (scan.status === 'failed') {
|
||||||
statusBadge = '<span class="badge badge-danger">Failed</span>';
|
statusBadge = '<span class="badge badge-danger">Failed</span>';
|
||||||
|
} else if (scan.status === 'cancelled') {
|
||||||
|
statusBadge = '<span class="badge badge-warning">Cancelled</span>';
|
||||||
} else {
|
} else {
|
||||||
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
|
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
let actionButtons = `<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>`;
|
||||||
|
if (scan.status === 'running') {
|
||||||
|
actionButtons += `<button class="btn btn-sm btn-warning ms-1" onclick="stopScan(${scan.id})">Stop</button>`;
|
||||||
|
} else {
|
||||||
|
actionButtons += `<button class="btn btn-sm btn-danger ms-1" onclick="deleteScan(${scan.id})">Delete</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td class="mono">${scan.id}</td>
|
<td class="mono">${scan.id}</td>
|
||||||
<td>${scan.title || 'Untitled Scan'}</td>
|
<td>${scan.title || 'Untitled Scan'}</td>
|
||||||
<td class="text-muted">${timestamp}</td>
|
<td class="text-muted">${timestamp}</td>
|
||||||
<td class="mono">${duration}</td>
|
<td class="mono">${duration}</td>
|
||||||
<td>${statusBadge}</td>
|
<td>${statusBadge}</td>
|
||||||
<td>
|
<td>${actionButtons}</td>
|
||||||
<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>
|
|
||||||
${scan.status !== 'running' ? `<button class="btn btn-sm btn-danger ms-1" onclick="deleteScan(${scan.id})">Delete</button>` : ''}
|
|
||||||
</td>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
@@ -456,15 +483,7 @@
|
|||||||
bootstrap.Modal.getInstance(document.getElementById('triggerScanModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('triggerScanModal')).hide();
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
const alertDiv = document.createElement('div');
|
showAlert('success', `Scan triggered successfully! (ID: ${data.scan_id})`);
|
||||||
alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3';
|
|
||||||
alertDiv.innerHTML = `
|
|
||||||
Scan triggered successfully! (ID: ${data.scan_id})
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
`;
|
|
||||||
// Insert at the beginning of container-fluid
|
|
||||||
const container = document.querySelector('.container-fluid');
|
|
||||||
container.insertBefore(alertDiv, container.firstChild);
|
|
||||||
|
|
||||||
// Refresh scans
|
// Refresh scans
|
||||||
loadScans();
|
loadScans();
|
||||||
@@ -478,6 +497,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop scan
|
||||||
|
async function stopScan(scanId) {
|
||||||
|
if (!confirm(`Are you sure you want to stop scan ${scanId}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/scans/${scanId}/stop`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.message || 'Failed to stop scan');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showAlert('success', `Stop signal sent to scan ${scanId}.`);
|
||||||
|
|
||||||
|
// Refresh scans after a short delay
|
||||||
|
setTimeout(() => loadScans(), 1000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error stopping scan:', error);
|
||||||
|
showAlert('danger', `Failed to stop scan: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete scan
|
// Delete scan
|
||||||
async function deleteScan(scanId) {
|
async function deleteScan(scanId) {
|
||||||
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
|
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
|
||||||
@@ -490,44 +536,20 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to delete scan');
|
const data = await response.json();
|
||||||
|
throw new Error(data.message || 'Failed to delete scan');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
const alertDiv = document.createElement('div');
|
showAlert('success', `Scan ${scanId} deleted successfully.`);
|
||||||
alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3';
|
|
||||||
alertDiv.innerHTML = `
|
|
||||||
Scan ${scanId} deleted successfully.
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
`;
|
|
||||||
document.querySelector('.container-fluid').insertBefore(alertDiv, document.querySelector('.row'));
|
|
||||||
|
|
||||||
// Refresh scans
|
// Refresh scans
|
||||||
loadScans();
|
loadScans();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting scan:', error);
|
console.error('Error deleting scan:', error);
|
||||||
alert('Failed to delete scan. Please try again.');
|
showAlert('danger', `Failed to delete scan: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom pagination styles
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
.pagination {
|
|
||||||
--bs-pagination-bg: #1e293b;
|
|
||||||
--bs-pagination-border-color: #334155;
|
|
||||||
--bs-pagination-hover-bg: #334155;
|
|
||||||
--bs-pagination-hover-border-color: #475569;
|
|
||||||
--bs-pagination-focus-bg: #334155;
|
|
||||||
--bs-pagination-active-bg: #3b82f6;
|
|
||||||
--bs-pagination-active-border-color: #3b82f6;
|
|
||||||
--bs-pagination-disabled-bg: #0f172a;
|
|
||||||
--bs-pagination-disabled-border-color: #334155;
|
|
||||||
--bs-pagination-color: #e2e8f0;
|
|
||||||
--bs-pagination-hover-color: #e2e8f0;
|
|
||||||
--bs-pagination-disabled-color: #64748b;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -32,13 +32,13 @@
|
|||||||
<small class="form-text text-muted">A descriptive name for this schedule</small>
|
<small class="form-text text-muted">A descriptive name for this schedule</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Config File -->
|
<!-- Config -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="config-file" class="form-label">Configuration File <span class="text-danger">*</span></label>
|
<label for="config-id" class="form-label">Configuration <span class="text-danger">*</span></label>
|
||||||
<select class="form-select" id="config-file" name="config_file" required>
|
<select class="form-select" id="config-id" name="config_id" required>
|
||||||
<option value="">Select a configuration file...</option>
|
<option value="">Select a configuration...</option>
|
||||||
{% for config in config_files %}
|
{% for config in configs %}
|
||||||
<option value="{{ config }}">{{ config }}</option>
|
<option value="{{ config.id }}">{{ config.title }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<small class="form-text text-muted">The scan configuration to use for this schedule</small>
|
<small class="form-text text-muted">The scan configuration to use for this schedule</small>
|
||||||
@@ -369,13 +369,13 @@ document.getElementById('create-schedule-form').addEventListener('submit', async
|
|||||||
// Get form data
|
// Get form data
|
||||||
const formData = {
|
const formData = {
|
||||||
name: document.getElementById('schedule-name').value.trim(),
|
name: document.getElementById('schedule-name').value.trim(),
|
||||||
config_file: document.getElementById('config-file').value,
|
config_id: parseInt(document.getElementById('config-id').value),
|
||||||
cron_expression: document.getElementById('cron-expression').value.trim(),
|
cron_expression: document.getElementById('cron-expression').value.trim(),
|
||||||
enabled: document.getElementById('schedule-enabled').checked
|
enabled: document.getElementById('schedule-enabled').checked
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
if (!formData.name || !formData.config_file || !formData.cron_expression) {
|
if (!formData.name || !formData.config_id || !formData.cron_expression) {
|
||||||
showNotification('Please fill in all required fields', 'warning');
|
showNotification('Please fill in all required fields', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -419,20 +419,16 @@ document.getElementById('create-schedule-form').addEventListener('submit', async
|
|||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
function showNotification(message, type = 'info') {
|
function showNotification(message, type = 'info') {
|
||||||
|
const container = document.getElementById('notification-container');
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
|
||||||
notification.style.position = 'fixed';
|
|
||||||
notification.style.top = '20px';
|
|
||||||
notification.style.right = '20px';
|
|
||||||
notification.style.zIndex = '9999';
|
|
||||||
notification.style.minWidth = '300px';
|
|
||||||
|
|
||||||
notification.innerHTML = `
|
notification.innerHTML = `
|
||||||
${message}
|
${message}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
container.appendChild(notification);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
notification.remove();
|
notification.remove();
|
||||||
|
|||||||
@@ -298,7 +298,11 @@ async function loadSchedule() {
|
|||||||
function populateForm(schedule) {
|
function populateForm(schedule) {
|
||||||
document.getElementById('schedule-id').value = schedule.id;
|
document.getElementById('schedule-id').value = schedule.id;
|
||||||
document.getElementById('schedule-name').value = schedule.name;
|
document.getElementById('schedule-name').value = schedule.name;
|
||||||
document.getElementById('config-file').value = schedule.config_file;
|
// Display config name and ID in the readonly config-file field
|
||||||
|
const configDisplay = schedule.config_name
|
||||||
|
? `${schedule.config_name} (ID: ${schedule.config_id})`
|
||||||
|
: `Config ID: ${schedule.config_id}`;
|
||||||
|
document.getElementById('config-file').value = configDisplay;
|
||||||
document.getElementById('cron-expression').value = schedule.cron_expression;
|
document.getElementById('cron-expression').value = schedule.cron_expression;
|
||||||
document.getElementById('schedule-enabled').checked = schedule.enabled;
|
document.getElementById('schedule-enabled').checked = schedule.enabled;
|
||||||
|
|
||||||
@@ -554,20 +558,16 @@ async function deleteSchedule() {
|
|||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
function showNotification(message, type = 'info') {
|
function showNotification(message, type = 'info') {
|
||||||
|
const container = document.getElementById('notification-container');
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
|
||||||
notification.style.position = 'fixed';
|
|
||||||
notification.style.top = '20px';
|
|
||||||
notification.style.right = '20px';
|
|
||||||
notification.style.zIndex = '9999';
|
|
||||||
notification.style.minWidth = '300px';
|
|
||||||
|
|
||||||
notification.innerHTML = `
|
notification.innerHTML = `
|
||||||
${message}
|
${message}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
container.appendChild(notification);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
notification.remove();
|
notification.remove();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="color: #60a5fa;">Scheduled Scans</h1>
|
<h1>Scheduled Scans</h1>
|
||||||
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary">
|
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle"></i> New Schedule
|
<i class="bi bi-plus-circle"></i> New Schedule
|
||||||
</a>
|
</a>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0" style="color: #60a5fa;">All Schedules</h5>
|
<h5 class="mb-0">All Schedules</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div id="schedules-loading" class="text-center py-5">
|
<div id="schedules-loading" class="text-center py-5">
|
||||||
@@ -198,7 +198,7 @@ function renderSchedules() {
|
|||||||
<td>
|
<td>
|
||||||
<strong>${escapeHtml(schedule.name)}</strong>
|
<strong>${escapeHtml(schedule.name)}</strong>
|
||||||
<br>
|
<br>
|
||||||
<small class="text-muted">${escapeHtml(schedule.config_file)}</small>
|
<small class="text-muted">Config ID: ${schedule.config_id || 'N/A'}</small>
|
||||||
</td>
|
</td>
|
||||||
<td class="mono"><code>${escapeHtml(schedule.cron_expression)}</code></td>
|
<td class="mono"><code>${escapeHtml(schedule.cron_expression)}</code></td>
|
||||||
<td>${formatRelativeTime(schedule.next_run)}</td>
|
<td>${formatRelativeTime(schedule.next_run)}</td>
|
||||||
@@ -352,21 +352,16 @@ async function deleteSchedule(scheduleId) {
|
|||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
function showNotification(message, type = 'info') {
|
function showNotification(message, type = 'info') {
|
||||||
// Create notification element
|
const container = document.getElementById('notification-container');
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
|
||||||
notification.style.position = 'fixed';
|
|
||||||
notification.style.top = '20px';
|
|
||||||
notification.style.right = '20px';
|
|
||||||
notification.style.zIndex = '9999';
|
|
||||||
notification.style.minWidth = '300px';
|
|
||||||
|
|
||||||
notification.innerHTML = `
|
notification.innerHTML = `
|
||||||
${message}
|
${message}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
container.appendChild(notification);
|
||||||
|
|
||||||
// Auto-remove after 5 seconds
|
// Auto-remove after 5 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,95 +1,60 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en" data-bs-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Setup - SneakyScanner</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
||||||
}
|
|
||||||
.setup-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
.brand-title {
|
|
||||||
color: #00d9ff;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="setup-container">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body p-5">
|
|
||||||
<div class="text-center mb-4">
|
|
||||||
<h1 class="brand-title">SneakyScanner</h1>
|
|
||||||
<p class="text-muted">Initial Setup</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-info mb-4">
|
{% block title %}Setup - SneakyScanner{% endblock %}
|
||||||
<strong>Welcome!</strong> Please set an application password to secure your scanner.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% set hide_nav = true %}
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
|
||||||
{{ message }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('auth.setup') }}">
|
{% block content %}
|
||||||
<div class="mb-3">
|
<div class="login-card">
|
||||||
<label for="password" class="form-label">Password</label>
|
<div class="text-center mb-4">
|
||||||
<input type="password"
|
<h1 class="brand-title">SneakyScanner</h1>
|
||||||
class="form-control"
|
<p class="brand-subtitle">Initial Setup</p>
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
required
|
|
||||||
minlength="8"
|
|
||||||
autofocus
|
|
||||||
placeholder="Enter password (min 8 characters)">
|
|
||||||
<div class="form-text">Password must be at least 8 characters long.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="confirm_password" class="form-label">Confirm Password</label>
|
|
||||||
<input type="password"
|
|
||||||
class="form-control"
|
|
||||||
id="confirm_password"
|
|
||||||
name="confirm_password"
|
|
||||||
required
|
|
||||||
minlength="8"
|
|
||||||
placeholder="Confirm your password">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
|
||||||
Set Password
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center mt-3">
|
|
||||||
<small class="text-muted">SneakyScanner v1.0 - Phase 2</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<div class="alert alert-info mb-4">
|
||||||
</body>
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
</html>
|
<strong>Welcome!</strong> Please set an application password to secure your scanner.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('auth.setup') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autofocus
|
||||||
|
placeholder="Enter password (min 8 characters)">
|
||||||
|
<div class="form-text">Password must be at least 8 characters long.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="confirm_password" class="form-label">Confirm Password</label>
|
||||||
|
<input type="password"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
placeholder="Confirm your password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||||
|
Set Password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="color: #60a5fa;">Site Management</h1>
|
<h1>Site Management</h1>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createSiteModal">
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createSiteModal">
|
||||||
<i class="bi bi-plus-circle"></i> Create New Site
|
<i class="bi bi-plus-circle"></i> Create New Site
|
||||||
@@ -26,8 +26,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value" id="total-ips">-</div>
|
<div class="stat-value" id="unique-ips">-</div>
|
||||||
<div class="stat-label">Total IPs</div>
|
<div class="stat-label">Unique IPs</div>
|
||||||
|
<div class="stat-sublabel" id="duplicate-ips-label" style="display: none; font-size: 0.75rem; color: #fbbf24;">
|
||||||
|
(<span id="duplicate-ips">0</span> duplicates)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -44,7 +47,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0" style="color: #60a5fa;">All Sites</h5>
|
<h5 class="mb-0">All Sites</h5>
|
||||||
<input type="text" id="search-input" class="form-control" style="max-width: 300px;"
|
<input type="text" id="search-input" class="form-control" style="max-width: 300px;"
|
||||||
placeholder="Search sites...">
|
placeholder="Search sites...">
|
||||||
</div>
|
</div>
|
||||||
@@ -93,31 +96,31 @@
|
|||||||
<!-- Create Site Modal -->
|
<!-- Create Site Modal -->
|
||||||
<div class="modal fade" id="createSiteModal" tabindex="-1">
|
<div class="modal fade" id="createSiteModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
<div class="modal-content">
|
||||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" style="color: #60a5fa;">
|
<h5 class="modal-title">
|
||||||
<i class="bi bi-plus-circle"></i> Create New Site
|
<i class="bi bi-plus-circle me-2"></i>Create New Site
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="create-site-form">
|
<form id="create-site-form">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="site-name" class="form-label" style="color: #e2e8f0;">Site Name *</label>
|
<label for="site-name" class="form-label">Site Name <span class="text-danger">*</span></label>
|
||||||
<input type="text" class="form-control" id="site-name" required
|
<input type="text" class="form-control" id="site-name" required
|
||||||
placeholder="e.g., Production Web Servers">
|
placeholder="e.g., Production Web Servers">
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="site-description" class="form-label" style="color: #e2e8f0;">Description</label>
|
<label for="site-description" class="form-label">Description</label>
|
||||||
<textarea class="form-control" id="site-description" rows="3"
|
<textarea class="form-control" id="site-description" rows="3"
|
||||||
placeholder="Optional description of this site"></textarea>
|
placeholder="Optional description of this site"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-info" style="background-color: #1e3a5f; border-color: #2d5a8c; color: #a5d6ff;">
|
<div class="alert alert-info">
|
||||||
<i class="bi bi-info-circle"></i> After creating the site, you'll be able to add IP addresses using CIDRs, individual IPs, or bulk import.
|
<i class="bi bi-info-circle me-1"></i>After creating the site, you'll be able to add IP addresses using CIDRs, individual IPs, or bulk import.
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" onclick="createSite()">
|
<button type="button" class="btn btn-primary" onclick="createSite()">
|
||||||
<i class="bi bi-check-circle"></i> Create Site
|
<i class="bi bi-check-circle"></i> Create Site
|
||||||
@@ -499,7 +502,7 @@ async function loadSites() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
sitesData = data.sites || [];
|
sitesData = data.sites || [];
|
||||||
|
|
||||||
updateStats();
|
updateStats(data.unique_ips, data.duplicate_ips);
|
||||||
renderSites(sitesData);
|
renderSites(sitesData);
|
||||||
|
|
||||||
document.getElementById('sites-loading').style.display = 'none';
|
document.getElementById('sites-loading').style.display = 'none';
|
||||||
@@ -514,12 +517,20 @@ async function loadSites() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update summary stats
|
// Update summary stats
|
||||||
function updateStats() {
|
function updateStats(uniqueIps, duplicateIps) {
|
||||||
const totalSites = sitesData.length;
|
const totalSites = sitesData.length;
|
||||||
const totalIps = sitesData.reduce((sum, site) => sum + (site.ip_count || 0), 0);
|
|
||||||
|
|
||||||
document.getElementById('total-sites').textContent = totalSites;
|
document.getElementById('total-sites').textContent = totalSites;
|
||||||
document.getElementById('total-ips').textContent = totalIps;
|
document.getElementById('unique-ips').textContent = uniqueIps || 0;
|
||||||
|
|
||||||
|
// Show duplicate count if there are any
|
||||||
|
if (duplicateIps && duplicateIps > 0) {
|
||||||
|
document.getElementById('duplicate-ips').textContent = duplicateIps;
|
||||||
|
document.getElementById('duplicate-ips-label').style.display = 'block';
|
||||||
|
} else {
|
||||||
|
document.getElementById('duplicate-ips-label').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('sites-in-use').textContent = '-'; // Will be updated async
|
document.getElementById('sites-in-use').textContent = '-'; // Will be updated async
|
||||||
|
|
||||||
// Count sites in use (async)
|
// Count sites in use (async)
|
||||||
@@ -688,6 +699,18 @@ async function loadSiteIps(siteId) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const ips = data.ips || [];
|
const ips = data.ips || [];
|
||||||
|
|
||||||
|
// Sort IPs by numeric octets
|
||||||
|
ips.sort((a, b) => {
|
||||||
|
const partsA = a.ip_address.split('.').map(Number);
|
||||||
|
const partsB = b.ip_address.split('.').map(Number);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
if (partsA[i] !== partsB[i]) {
|
||||||
|
return partsA[i] - partsB[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('ip-count').textContent = data.total || ips.length;
|
document.getElementById('ip-count').textContent = data.total || ips.length;
|
||||||
|
|
||||||
// Render flat IP table
|
// Render flat IP table
|
||||||
@@ -1108,22 +1131,20 @@ async function saveIp() {
|
|||||||
|
|
||||||
// Show alert
|
// Show alert
|
||||||
function showAlert(type, message) {
|
function showAlert(type, message) {
|
||||||
const alertHtml = `
|
const container = document.getElementById('notification-container');
|
||||||
<div class="alert alert-${type} alert-dismissible fade show mt-3" role="alert">
|
const notification = document.createElement('div');
|
||||||
${message}
|
notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
notification.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const container = document.querySelector('.container-fluid');
|
container.appendChild(notification);
|
||||||
container.insertAdjacentHTML('afterbegin', alertHtml);
|
|
||||||
|
|
||||||
// Auto-dismiss after 5 seconds
|
// Auto-dismiss after 5 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const alert = container.querySelector('.alert');
|
notification.remove();
|
||||||
if (alert) {
|
|
||||||
bootstrap.Alert.getInstance(alert)?.close();
|
|
||||||
}
|
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1163,52 +1184,4 @@ document.getElementById('save-ip-btn').addEventListener('click', saveIp);
|
|||||||
document.addEventListener('DOMContentLoaded', loadSites);
|
document.addEventListener('DOMContentLoaded', loadSites);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
.stat-card {
|
|
||||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
|
||||||
border: 1px solid #475569;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #60a5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background-color: #1e293b;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
background-color: #334155;
|
|
||||||
border-bottom: 1px solid #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table thead th {
|
|
||||||
color: #94a3b8;
|
|
||||||
border-bottom: 1px solid #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr {
|
|
||||||
border-bottom: 1px solid #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr:hover {
|
|
||||||
background-color: #334155;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="color: #60a5fa;">Webhook Management</h1>
|
<h1>Webhook Management</h1>
|
||||||
<a href="{{ url_for('webhooks.new_webhook') }}" class="btn btn-primary">
|
<a href="{{ url_for('webhooks.new_webhook') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle"></i> Add Webhook
|
<i class="bi bi-plus-circle"></i> Add Webhook
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,89 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Input validation utilities for SneakyScanner web application.
|
Input validation utilities for SneakyScanner web application.
|
||||||
|
|
||||||
Provides validation functions for API inputs, file paths, and data integrity.
|
Provides validation functions for API inputs and data integrity.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
|
||||||
def validate_config_file(file_path: str) -> tuple[bool, Optional[str]]:
|
|
||||||
"""
|
|
||||||
Validate that a configuration file exists and is valid YAML.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: Path to configuration file (absolute or relative filename)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (is_valid, error_message)
|
|
||||||
If valid, returns (True, None)
|
|
||||||
If invalid, returns (False, error_message)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> validate_config_file('/app/configs/example.yaml')
|
|
||||||
(True, None)
|
|
||||||
>>> validate_config_file('example.yaml')
|
|
||||||
(True, None)
|
|
||||||
>>> validate_config_file('/nonexistent.yaml')
|
|
||||||
(False, 'File does not exist: /nonexistent.yaml')
|
|
||||||
"""
|
|
||||||
# Check if path is provided
|
|
||||||
if not file_path:
|
|
||||||
return False, 'Config file path is required'
|
|
||||||
|
|
||||||
# If file_path is just a filename (not absolute), prepend configs directory
|
|
||||||
if not file_path.startswith('/'):
|
|
||||||
file_path = f'/app/configs/{file_path}'
|
|
||||||
|
|
||||||
# Convert to Path object
|
|
||||||
path = Path(file_path)
|
|
||||||
|
|
||||||
# Check if file exists
|
|
||||||
if not path.exists():
|
|
||||||
return False, f'File does not exist: {file_path}'
|
|
||||||
|
|
||||||
# Check if it's a file (not directory)
|
|
||||||
if not path.is_file():
|
|
||||||
return False, f'Path is not a file: {file_path}'
|
|
||||||
|
|
||||||
# Check file extension
|
|
||||||
if path.suffix.lower() not in ['.yaml', '.yml']:
|
|
||||||
return False, f'File must be YAML (.yaml or .yml): {file_path}'
|
|
||||||
|
|
||||||
# Try to parse as YAML
|
|
||||||
try:
|
|
||||||
with open(path, 'r') as f:
|
|
||||||
config = yaml.safe_load(f)
|
|
||||||
|
|
||||||
# Check if it's a dictionary (basic structure validation)
|
|
||||||
if not isinstance(config, dict):
|
|
||||||
return False, 'Config file must contain a YAML dictionary'
|
|
||||||
|
|
||||||
# Check for required top-level keys
|
|
||||||
if 'title' not in config:
|
|
||||||
return False, 'Config file missing required "title" field'
|
|
||||||
|
|
||||||
if 'sites' not in config:
|
|
||||||
return False, 'Config file missing required "sites" field'
|
|
||||||
|
|
||||||
# Validate sites structure
|
|
||||||
if not isinstance(config['sites'], list):
|
|
||||||
return False, '"sites" must be a list'
|
|
||||||
|
|
||||||
if len(config['sites']) == 0:
|
|
||||||
return False, '"sites" list cannot be empty'
|
|
||||||
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
return False, f'Invalid YAML syntax: {str(e)}'
|
|
||||||
except Exception as e:
|
|
||||||
return False, f'Error reading config file: {str(e)}'
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_scan_status(status: str) -> tuple[bool, Optional[str]]:
|
def validate_scan_status(status: str) -> tuple[bool, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
@@ -101,190 +23,9 @@ def validate_scan_status(status: str) -> tuple[bool, Optional[str]]:
|
|||||||
>>> validate_scan_status('invalid')
|
>>> validate_scan_status('invalid')
|
||||||
(False, 'Invalid status: invalid. Must be one of: running, completed, failed')
|
(False, 'Invalid status: invalid. Must be one of: running, completed, failed')
|
||||||
"""
|
"""
|
||||||
valid_statuses = ['running', 'completed', 'failed']
|
valid_statuses = ['running', 'finalizing', 'completed', 'failed', 'cancelled']
|
||||||
|
|
||||||
if status not in valid_statuses:
|
if status not in valid_statuses:
|
||||||
return False, f'Invalid status: {status}. Must be one of: {", ".join(valid_statuses)}'
|
return False, f'Invalid status: {status}. Must be one of: {", ".join(valid_statuses)}'
|
||||||
|
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
def validate_triggered_by(triggered_by: str) -> tuple[bool, Optional[str]]:
|
|
||||||
"""
|
|
||||||
Validate triggered_by value.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
triggered_by: Source that triggered the scan
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (is_valid, error_message)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> validate_triggered_by('manual')
|
|
||||||
(True, None)
|
|
||||||
>>> validate_triggered_by('api')
|
|
||||||
(True, None)
|
|
||||||
"""
|
|
||||||
valid_sources = ['manual', 'scheduled', 'api']
|
|
||||||
|
|
||||||
if triggered_by not in valid_sources:
|
|
||||||
return False, f'Invalid triggered_by: {triggered_by}. Must be one of: {", ".join(valid_sources)}'
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_scan_id(scan_id: any) -> tuple[bool, Optional[str]]:
|
|
||||||
"""
|
|
||||||
Validate scan ID is a positive integer.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
scan_id: Scan ID to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (is_valid, error_message)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> validate_scan_id(42)
|
|
||||||
(True, None)
|
|
||||||
>>> validate_scan_id('42')
|
|
||||||
(True, None)
|
|
||||||
>>> validate_scan_id(-1)
|
|
||||||
(False, 'Scan ID must be a positive integer')
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
scan_id_int = int(scan_id)
|
|
||||||
if scan_id_int <= 0:
|
|
||||||
return False, 'Scan ID must be a positive integer'
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False, f'Invalid scan ID: {scan_id}'
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_file_path(file_path: str, must_exist: bool = True) -> tuple[bool, Optional[str]]:
|
|
||||||
"""
|
|
||||||
Validate a file path.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: Path to validate
|
|
||||||
must_exist: If True, file must exist. If False, only validate format.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (is_valid, error_message)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> validate_file_path('/app/output/scan.json', must_exist=False)
|
|
||||||
(True, None)
|
|
||||||
>>> validate_file_path('', must_exist=False)
|
|
||||||
(False, 'File path is required')
|
|
||||||
"""
|
|
||||||
if not file_path:
|
|
||||||
return False, 'File path is required'
|
|
||||||
|
|
||||||
# Check for path traversal attempts
|
|
||||||
if '..' in file_path:
|
|
||||||
return False, 'Path traversal not allowed'
|
|
||||||
|
|
||||||
if must_exist:
|
|
||||||
path = Path(file_path)
|
|
||||||
if not path.exists():
|
|
||||||
return False, f'File does not exist: {file_path}'
|
|
||||||
if not path.is_file():
|
|
||||||
return False, f'Path is not a file: {file_path}'
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_filename(filename: str) -> str:
|
|
||||||
"""
|
|
||||||
Sanitize a filename by removing/replacing unsafe characters.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Original filename
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Sanitized filename safe for filesystem
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> sanitize_filename('my scan.json')
|
|
||||||
'my_scan.json'
|
|
||||||
>>> sanitize_filename('../../etc/passwd')
|
|
||||||
'etc_passwd'
|
|
||||||
"""
|
|
||||||
# Remove path components
|
|
||||||
filename = os.path.basename(filename)
|
|
||||||
|
|
||||||
# Replace unsafe characters with underscore
|
|
||||||
unsafe_chars = ['/', '\\', '..', ' ', ':', '*', '?', '"', '<', '>', '|']
|
|
||||||
for char in unsafe_chars:
|
|
||||||
filename = filename.replace(char, '_')
|
|
||||||
|
|
||||||
# Remove leading/trailing underscores and dots
|
|
||||||
filename = filename.strip('_.')
|
|
||||||
|
|
||||||
# Ensure filename is not empty
|
|
||||||
if not filename:
|
|
||||||
filename = 'unnamed'
|
|
||||||
|
|
||||||
return filename
|
|
||||||
|
|
||||||
|
|
||||||
def validate_port(port: any) -> tuple[bool, Optional[str]]:
|
|
||||||
"""
|
|
||||||
Validate port number.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
port: Port number to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (is_valid, error_message)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> validate_port(443)
|
|
||||||
(True, None)
|
|
||||||
>>> validate_port(70000)
|
|
||||||
(False, 'Port must be between 1 and 65535')
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
port_int = int(port)
|
|
||||||
if port_int < 1 or port_int > 65535:
|
|
||||||
return False, 'Port must be between 1 and 65535'
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False, f'Invalid port: {port}'
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_ip_address(ip: str) -> tuple[bool, Optional[str]]:
|
|
||||||
"""
|
|
||||||
Validate IPv4 address format (basic validation).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ip: IP address string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (is_valid, error_message)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> validate_ip_address('192.168.1.1')
|
|
||||||
(True, None)
|
|
||||||
>>> validate_ip_address('256.1.1.1')
|
|
||||||
(False, 'Invalid IP address format')
|
|
||||||
"""
|
|
||||||
if not ip:
|
|
||||||
return False, 'IP address is required'
|
|
||||||
|
|
||||||
# Basic IPv4 validation
|
|
||||||
parts = ip.split('.')
|
|
||||||
if len(parts) != 4:
|
|
||||||
return False, 'Invalid IP address format'
|
|
||||||
|
|
||||||
try:
|
|
||||||
for part in parts:
|
|
||||||
num = int(part)
|
|
||||||
if num < 0 or num > 255:
|
|
||||||
return False, 'Invalid IP address format'
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False, 'Invalid IP address format'
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
scanner:
|
|
||||||
build: .
|
|
||||||
image: sneakyscanner:latest
|
|
||||||
container_name: sneakyscanner
|
|
||||||
privileged: true # Required for masscan raw socket access
|
|
||||||
network_mode: host # Required for network scanning
|
|
||||||
volumes:
|
|
||||||
- ./configs:/app/configs:ro
|
|
||||||
- ./output:/app/output
|
|
||||||
command: /app/configs/example-site.yaml
|
|
||||||
@@ -41,6 +41,9 @@ services:
|
|||||||
# Scheduler configuration (APScheduler)
|
# Scheduler configuration (APScheduler)
|
||||||
- SCHEDULER_EXECUTORS=${SCHEDULER_EXECUTORS:-2}
|
- SCHEDULER_EXECUTORS=${SCHEDULER_EXECUTORS:-2}
|
||||||
- SCHEDULER_JOB_DEFAULTS_MAX_INSTANCES=${SCHEDULER_JOB_DEFAULTS_MAX_INSTANCES:-3}
|
- SCHEDULER_JOB_DEFAULTS_MAX_INSTANCES=${SCHEDULER_JOB_DEFAULTS_MAX_INSTANCES:-3}
|
||||||
|
# UDP scanning configuration
|
||||||
|
- UDP_SCAN_ENABLED=${UDP_SCAN_ENABLED:-false}
|
||||||
|
- UDP_PORTS=${UDP_PORTS:-53,67,68,69,123,161,500,514,1900}
|
||||||
# Scanner functionality requires privileged mode and host network for masscan/nmap
|
# Scanner functionality requires privileged mode and host network for masscan/nmap
|
||||||
privileged: true
|
privileged: true
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -163,7 +163,7 @@ Machine-readable scan data with all discovered services, ports, and SSL/TLS info
|
|||||||
"title": "Scan Title",
|
"title": "Scan Title",
|
||||||
"scan_time": "2025-01-15T10:30:00Z",
|
"scan_time": "2025-01-15T10:30:00Z",
|
||||||
"scan_duration": 95.3,
|
"scan_duration": 95.3,
|
||||||
"config_file": "/app/configs/example-site.yaml",
|
"config_id": "/app/configs/example-site.yaml",
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"name": "Site Name",
|
"name": "Site Name",
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ SneakyScanner is deployed as a Docker container running a Flask web application
|
|||||||
|
|
||||||
**Architecture:**
|
**Architecture:**
|
||||||
- **Web Application**: Flask app on port 5000 with modern web UI
|
- **Web Application**: Flask app on port 5000 with modern web UI
|
||||||
- **Database**: SQLite (persisted to volume)
|
- **Database**: SQLite (persisted to volume) - stores all configurations, scan results, and settings
|
||||||
- **Background Jobs**: APScheduler for async scan execution
|
- **Background Jobs**: APScheduler for async scan execution
|
||||||
- **Scanner**: masscan, nmap, sslyze, Playwright
|
- **Scanner**: masscan, nmap, sslyze, Playwright
|
||||||
- **Config Creator**: Web-based CIDR-to-YAML configuration builder
|
- **Config Management**: Database-backed configuration system managed entirely via web UI
|
||||||
- **Scheduling**: Cron-based scheduled scans with dashboard management
|
- **Scheduling**: Cron-based scheduled scans with dashboard management
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -143,6 +143,13 @@ docker compose -f docker-compose-standalone.yml up
|
|||||||
|
|
||||||
SneakyScanner is configured via environment variables. The recommended approach is to use a `.env` file.
|
SneakyScanner is configured via environment variables. The recommended approach is to use a `.env` file.
|
||||||
|
|
||||||
|
|
||||||
|
**UDP Port Scanning**
|
||||||
|
|
||||||
|
- UDP Port scanning is disabled by default.
|
||||||
|
- You can turn it on via the .env variable.
|
||||||
|
- By Default, UDP port scanning only scans the top 20 ports, for convenience I have included the NMAP top 100 UDP ports as well.
|
||||||
|
|
||||||
#### Creating Your .env File
|
#### Creating Your .env File
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -160,6 +167,7 @@ python3 -c "from cryptography.fernet import Fernet; print('SNEAKYSCANNER_ENCRYPT
|
|||||||
nano .env
|
nano .env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
#### Key Configuration Options
|
#### Key Configuration Options
|
||||||
|
|
||||||
| Variable | Description | Default | Required |
|
| Variable | Description | Default | Required |
|
||||||
@@ -190,54 +198,30 @@ The application needs these directories (created automatically by Docker):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Verify directories exist
|
# Verify directories exist
|
||||||
ls -la configs/ data/ output/ logs/
|
ls -la data/ output/ logs/
|
||||||
|
|
||||||
# If missing, create them
|
# If missing, create them
|
||||||
mkdir -p configs data output logs
|
mkdir -p data output logs
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Configure Scan Targets
|
### Step 2: Configure Scan Targets
|
||||||
|
|
||||||
You can create scan configurations in two ways:
|
After starting the application, create scan configurations using the web UI:
|
||||||
|
|
||||||
**Option A: Using the Web UI (Recommended - Phase 4 Feature)**
|
**Creating Configurations via Web UI**
|
||||||
|
|
||||||
1. Navigate to **Configs** in the web interface
|
1. Navigate to **Configs** in the web interface
|
||||||
2. Click **"Create New Config"**
|
2. Click **"Create New Config"**
|
||||||
3. Use the CIDR-based config creator for quick setup:
|
3. Use the form-based config creator:
|
||||||
- Enter site name
|
- Enter site name
|
||||||
- Enter CIDR range (e.g., `192.168.1.0/24`)
|
- Enter CIDR range (e.g., `192.168.1.0/24`)
|
||||||
- Select expected ports from dropdowns
|
- Select expected TCP/UDP ports from dropdowns
|
||||||
- Click **"Generate Config"**
|
- Optionally enable ping checks
|
||||||
4. Or use the **YAML Editor** for advanced configurations
|
4. Click **"Save Configuration"**
|
||||||
5. Save and use immediately in scans or schedules
|
5. Configuration is saved to database and immediately available for scans and schedules
|
||||||
|
|
||||||
**Option B: Manual YAML Files**
|
**Note**: All configurations are stored in the database, not as files. This provides better reliability, easier backup, and seamless management through the web interface.
|
||||||
|
|
||||||
Create YAML configuration files manually in the `configs/` directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Example configuration
|
|
||||||
cat > configs/my-network.yaml <<EOF
|
|
||||||
title: "My Network Infrastructure"
|
|
||||||
sites:
|
|
||||||
- name: "Web Servers"
|
|
||||||
cidr: "192.168.1.0/24" # Scan entire subnet
|
|
||||||
expected_ports:
|
|
||||||
- port: 80
|
|
||||||
protocol: tcp
|
|
||||||
service: "http"
|
|
||||||
- port: 443
|
|
||||||
protocol: tcp
|
|
||||||
service: "https"
|
|
||||||
- port: 22
|
|
||||||
protocol: tcp
|
|
||||||
service: "ssh"
|
|
||||||
ping_expected: true
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: Phase 4 introduced a powerful config creator in the web UI that makes it easy to generate configs from CIDR ranges without manually editing YAML.
|
|
||||||
|
|
||||||
### Step 3: Build Docker Image
|
### Step 3: Build Docker Image
|
||||||
|
|
||||||
@@ -389,38 +373,37 @@ The dashboard provides a central view of your scanning activity:
|
|||||||
- **Trend Charts**: Port count trends over time using Chart.js
|
- **Trend Charts**: Port count trends over time using Chart.js
|
||||||
- **Quick Actions**: Buttons to run scans, create configs, manage schedules
|
- **Quick Actions**: Buttons to run scans, create configs, manage schedules
|
||||||
|
|
||||||
### Managing Scan Configurations (Phase 4)
|
### Managing Scan Configurations
|
||||||
|
|
||||||
|
All scan configurations are stored in the database and managed entirely through the web interface.
|
||||||
|
|
||||||
**Creating Configs:**
|
**Creating Configs:**
|
||||||
1. Navigate to **Configs** → **Create New Config**
|
1. Navigate to **Configs** → **Create New Config**
|
||||||
2. **CIDR Creator Mode**:
|
2. Fill in the configuration form:
|
||||||
- Enter site name (e.g., "Production Servers")
|
- Enter site name (e.g., "Production Servers")
|
||||||
- Enter CIDR range (e.g., `192.168.1.0/24`)
|
- Enter CIDR range (e.g., `192.168.1.0/24`)
|
||||||
- Select expected TCP/UDP ports from dropdowns
|
- Select expected TCP/UDP ports from dropdowns
|
||||||
- Click **"Generate Config"** to create YAML
|
- Enable/disable ping checks
|
||||||
3. **YAML Editor Mode**:
|
3. Click **"Save Configuration"**
|
||||||
- Switch to editor tab for advanced configurations
|
4. Configuration is immediately stored in database and available for use
|
||||||
- Syntax highlighting with line numbers
|
|
||||||
- Validate YAML before saving
|
|
||||||
|
|
||||||
**Editing Configs:**
|
**Editing Configs:**
|
||||||
1. Navigate to **Configs** → Select config
|
1. Navigate to **Configs** → Select config from list
|
||||||
2. Click **"Edit"** button
|
2. Click **"Edit"** button
|
||||||
3. Make changes in YAML editor
|
3. Modify any fields in the configuration form
|
||||||
4. Save changes (validates YAML automatically)
|
4. Click **"Save Changes"** to update database
|
||||||
|
|
||||||
**Uploading Configs:**
|
**Viewing Configs:**
|
||||||
1. Navigate to **Configs** → **Upload**
|
- Navigate to **Configs** page to see all saved configurations
|
||||||
2. Select YAML file from your computer
|
- Each config shows site name, CIDR range, and expected ports
|
||||||
3. File is validated and saved to `configs/` directory
|
- Click on any config to view full details
|
||||||
|
|
||||||
**Downloading Configs:**
|
|
||||||
- Click **"Download"** button next to any config
|
|
||||||
- Saves YAML file to your local machine
|
|
||||||
|
|
||||||
**Deleting Configs:**
|
**Deleting Configs:**
|
||||||
- Click **"Delete"** button
|
- Click **"Delete"** button next to any config
|
||||||
- **Warning**: Cannot delete configs used by active schedules
|
- **Warning**: Cannot delete configs used by active schedules
|
||||||
|
- Deletion removes the configuration from the database permanently
|
||||||
|
|
||||||
|
**Note**: All configurations are database-backed, providing automatic backups when you backup the database file.
|
||||||
|
|
||||||
### Running Scans
|
### Running Scans
|
||||||
|
|
||||||
@@ -477,12 +460,11 @@ SneakyScanner uses several mounted volumes for data persistence:
|
|||||||
|
|
||||||
| Volume | Container Path | Purpose | Important? |
|
| Volume | Container Path | Purpose | Important? |
|
||||||
|--------|----------------|---------|------------|
|
|--------|----------------|---------|------------|
|
||||||
| `./configs` | `/app/configs` | Scan configuration files (managed via web UI) | Yes |
|
| `./data` | `/app/data` | SQLite database (contains configurations, scan history, settings) | **Critical** |
|
||||||
| `./data` | `/app/data` | SQLite database (contains all scan history) | **Critical** |
|
|
||||||
| `./output` | `/app/output` | Scan results (JSON, HTML, ZIP, screenshots) | Yes |
|
| `./output` | `/app/output` | Scan results (JSON, HTML, ZIP, screenshots) | Yes |
|
||||||
| `./logs` | `/app/logs` | Application logs (rotating file handler) | No |
|
| `./logs` | `/app/logs` | Application logs (rotating file handler) | No |
|
||||||
|
|
||||||
**Note**: As of Phase 4, the `./configs` volume is read-write to support the web-based config creator and editor. The web UI can now create, edit, and delete configuration files directly.
|
**Note**: All scan configurations are stored in the SQLite database (`./data/sneakyscanner.db`). There is no separate configs directory or YAML files. Backing up the database file ensures all your configurations are preserved.
|
||||||
|
|
||||||
### Backing Up Data
|
### Backing Up Data
|
||||||
|
|
||||||
@@ -490,23 +472,22 @@ SneakyScanner uses several mounted volumes for data persistence:
|
|||||||
# Create backup directory
|
# Create backup directory
|
||||||
mkdir -p backups/$(date +%Y%m%d)
|
mkdir -p backups/$(date +%Y%m%d)
|
||||||
|
|
||||||
# Backup database
|
# Backup database (includes all configurations)
|
||||||
cp data/sneakyscanner.db backups/$(date +%Y%m%d)/
|
cp data/sneakyscanner.db backups/$(date +%Y%m%d)/
|
||||||
|
|
||||||
# Backup scan outputs
|
# Backup scan outputs
|
||||||
tar -czf backups/$(date +%Y%m%d)/output.tar.gz output/
|
tar -czf backups/$(date +%Y%m%d)/output.tar.gz output/
|
||||||
|
|
||||||
# Backup configurations
|
|
||||||
tar -czf backups/$(date +%Y%m%d)/configs.tar.gz configs/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Important**: The database backup includes all scan configurations, settings, schedules, and scan history. No separate configuration file backup is needed.
|
||||||
|
|
||||||
### Restoring Data
|
### Restoring Data
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Stop application
|
# Stop application
|
||||||
docker compose -f docker-compose.yml down
|
docker compose -f docker-compose.yml down
|
||||||
|
|
||||||
# Restore database
|
# Restore database (includes all configurations)
|
||||||
cp backups/YYYYMMDD/sneakyscanner.db data/
|
cp backups/YYYYMMDD/sneakyscanner.db data/
|
||||||
|
|
||||||
# Restore outputs
|
# Restore outputs
|
||||||
@@ -516,6 +497,8 @@ tar -xzf backups/YYYYMMDD/output.tar.gz
|
|||||||
docker compose -f docker-compose.yml up -d
|
docker compose -f docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note**: Restoring the database file restores all configurations, settings, schedules, and scan history.
|
||||||
|
|
||||||
### Cleaning Up Old Scan Results
|
### Cleaning Up Old Scan Results
|
||||||
|
|
||||||
**Option A: Using the Web UI (Recommended)**
|
**Option A: Using the Web UI (Recommended)**
|
||||||
@@ -564,50 +547,52 @@ curl -X POST http://localhost:5000/api/auth/logout \
|
|||||||
-b cookies.txt
|
-b cookies.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config Management (Phase 4)
|
### Config Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all configs
|
# List all configs
|
||||||
curl http://localhost:5000/api/configs \
|
curl http://localhost:5000/api/configs \
|
||||||
-b cookies.txt
|
-b cookies.txt
|
||||||
|
|
||||||
# Get specific config
|
# Get specific config by ID
|
||||||
curl http://localhost:5000/api/configs/prod-network.yaml \
|
curl http://localhost:5000/api/configs/1 \
|
||||||
-b cookies.txt
|
-b cookies.txt
|
||||||
|
|
||||||
# Create new config
|
# Create new config
|
||||||
curl -X POST http://localhost:5000/api/configs \
|
curl -X POST http://localhost:5000/api/configs \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"filename": "test-network.yaml",
|
"name": "Test Network",
|
||||||
"content": "title: Test Network\nsites:\n - name: Test\n cidr: 10.0.0.0/24"
|
"cidr": "10.0.0.0/24",
|
||||||
|
"expected_ports": [
|
||||||
|
{"port": 80, "protocol": "tcp", "service": "http"},
|
||||||
|
{"port": 443, "protocol": "tcp", "service": "https"}
|
||||||
|
],
|
||||||
|
"ping_expected": true
|
||||||
}' \
|
}' \
|
||||||
-b cookies.txt
|
-b cookies.txt
|
||||||
|
|
||||||
# Update config
|
# Update config
|
||||||
curl -X PUT http://localhost:5000/api/configs/test-network.yaml \
|
curl -X PUT http://localhost:5000/api/configs/1 \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"content": "title: Updated Test Network\nsites:\n - name: Test Site\n cidr: 10.0.0.0/24"
|
"name": "Updated Test Network",
|
||||||
|
"cidr": "10.0.1.0/24"
|
||||||
}' \
|
}' \
|
||||||
-b cookies.txt
|
-b cookies.txt
|
||||||
|
|
||||||
# Download config
|
|
||||||
curl http://localhost:5000/api/configs/test-network.yaml/download \
|
|
||||||
-b cookies.txt -o test-network.yaml
|
|
||||||
|
|
||||||
# Delete config
|
# Delete config
|
||||||
curl -X DELETE http://localhost:5000/api/configs/test-network.yaml \
|
curl -X DELETE http://localhost:5000/api/configs/1 \
|
||||||
-b cookies.txt
|
-b cookies.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### Scan Management
|
### Scan Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Trigger a scan
|
# Trigger a scan (using config ID from database)
|
||||||
curl -X POST http://localhost:5000/api/scans \
|
curl -X POST http://localhost:5000/api/scans \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"config_file": "/app/configs/prod-network.yaml"}' \
|
-d '{"config_id": 1}' \
|
||||||
-b cookies.txt
|
-b cookies.txt
|
||||||
|
|
||||||
# List all scans
|
# List all scans
|
||||||
@@ -634,12 +619,12 @@ curl -X DELETE http://localhost:5000/api/scans/123 \
|
|||||||
curl http://localhost:5000/api/schedules \
|
curl http://localhost:5000/api/schedules \
|
||||||
-b cookies.txt
|
-b cookies.txt
|
||||||
|
|
||||||
# Create schedule
|
# Create schedule (using config ID from database)
|
||||||
curl -X POST http://localhost:5000/api/schedules \
|
curl -X POST http://localhost:5000/api/schedules \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "Daily Production Scan",
|
"name": "Daily Production Scan",
|
||||||
"config_file": "/app/configs/prod-network.yaml",
|
"config_id": 1,
|
||||||
"cron_expression": "0 2 * * *",
|
"cron_expression": "0 2 * * *",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}' \
|
}' \
|
||||||
@@ -875,24 +860,25 @@ docker compose -f docker-compose.yml logs web | grep -E "(ERROR|Exception|Traceb
|
|||||||
docker compose -f docker-compose.yml exec web which masscan nmap
|
docker compose -f docker-compose.yml exec web which masscan nmap
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config Files Not Appearing in Web UI
|
### Configs Not Appearing in Web UI
|
||||||
|
|
||||||
**Problem**: Manually created configs don't show up in web interface
|
**Problem**: Created configs don't show up in web interface
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check file permissions (must be readable by web container)
|
# Check database connectivity
|
||||||
ls -la configs/
|
docker compose -f docker-compose.yml logs web | grep -i "database"
|
||||||
|
|
||||||
# Fix permissions if needed
|
# Verify database file exists and is readable
|
||||||
sudo chown -R 1000:1000 configs/
|
ls -lh data/sneakyscanner.db
|
||||||
chmod 644 configs/*.yaml
|
|
||||||
|
|
||||||
# Verify YAML syntax is valid
|
# Check for errors when creating configs
|
||||||
docker compose -f docker-compose.yml exec web python3 -c \
|
|
||||||
"import yaml; yaml.safe_load(open('/app/configs/your-config.yaml'))"
|
|
||||||
|
|
||||||
# Check web logs for parsing errors
|
|
||||||
docker compose -f docker-compose.yml logs web | grep -i "config"
|
docker compose -f docker-compose.yml logs web | grep -i "config"
|
||||||
|
|
||||||
|
# Try accessing configs via API
|
||||||
|
curl http://localhost:5000/api/configs -b cookies.txt
|
||||||
|
|
||||||
|
# If database is corrupted, check integrity
|
||||||
|
docker compose -f docker-compose.yml exec web sqlite3 /app/data/sneakyscanner.db "PRAGMA integrity_check;"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Health Check Failing
|
### Health Check Failing
|
||||||
@@ -979,11 +965,11 @@ server {
|
|||||||
# Ensure proper ownership of data directories
|
# Ensure proper ownership of data directories
|
||||||
sudo chown -R $USER:$USER data/ output/ logs/
|
sudo chown -R $USER:$USER data/ output/ logs/
|
||||||
|
|
||||||
# Restrict database file permissions
|
# Restrict database file permissions (contains configurations and sensitive data)
|
||||||
chmod 600 data/sneakyscanner.db
|
chmod 600 data/sneakyscanner.db
|
||||||
|
|
||||||
# Configs should be read-only
|
# Ensure database directory is writable
|
||||||
chmod 444 configs/*.yaml
|
chmod 700 data/
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1051,19 +1037,17 @@ mkdir -p "$BACKUP_DIR"
|
|||||||
# Stop application for consistent backup
|
# Stop application for consistent backup
|
||||||
docker compose -f docker-compose.yml stop web
|
docker compose -f docker-compose.yml stop web
|
||||||
|
|
||||||
# Backup database
|
# Backup database (includes all configurations)
|
||||||
cp data/sneakyscanner.db "$BACKUP_DIR/"
|
cp data/sneakyscanner.db "$BACKUP_DIR/"
|
||||||
|
|
||||||
# Backup outputs (last 30 days only)
|
# Backup outputs (last 30 days only)
|
||||||
find output/ -type f -mtime -30 -exec cp --parents {} "$BACKUP_DIR/" \;
|
find output/ -type f -mtime -30 -exec cp --parents {} "$BACKUP_DIR/" \;
|
||||||
|
|
||||||
# Backup configs
|
|
||||||
cp -r configs/ "$BACKUP_DIR/"
|
|
||||||
|
|
||||||
# Restart application
|
# Restart application
|
||||||
docker compose -f docker-compose.yml start web
|
docker compose -f docker-compose.yml start web
|
||||||
|
|
||||||
echo "Backup complete: $BACKUP_DIR"
|
echo "Backup complete: $BACKUP_DIR"
|
||||||
|
echo "Database backup includes all configurations, settings, and scan history"
|
||||||
```
|
```
|
||||||
|
|
||||||
Make executable and schedule with cron:
|
Make executable and schedule with cron:
|
||||||
@@ -1083,15 +1067,18 @@ crontab -e
|
|||||||
# Stop application
|
# Stop application
|
||||||
docker compose -f docker-compose.yml down
|
docker compose -f docker-compose.yml down
|
||||||
|
|
||||||
# Restore files
|
# Restore database (includes all configurations)
|
||||||
cp backups/YYYYMMDD_HHMMSS/sneakyscanner.db data/
|
cp backups/YYYYMMDD_HHMMSS/sneakyscanner.db data/
|
||||||
cp -r backups/YYYYMMDD_HHMMSS/configs/* configs/
|
|
||||||
|
# Restore output files
|
||||||
cp -r backups/YYYYMMDD_HHMMSS/output/* output/
|
cp -r backups/YYYYMMDD_HHMMSS/output/* output/
|
||||||
|
|
||||||
# Start application
|
# Start application
|
||||||
docker compose -f docker-compose.yml up -d
|
docker compose -f docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note**: Restoring the database file will restore all configurations, settings, schedules, and scan history from the backup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Support and Further Reading
|
## Support and Further Reading
|
||||||
@@ -1105,13 +1092,13 @@ docker compose -f docker-compose.yml up -d
|
|||||||
|
|
||||||
## What's New
|
## What's New
|
||||||
|
|
||||||
### Phase 4 (2025-11-17) - Config Creator ✅
|
### Phase 4+ (2025-11-17) - Database-Backed Configuration System ✅
|
||||||
- **CIDR-based Config Creator**: Web UI for generating scan configs from CIDR ranges
|
- **Database-Backed Configs**: All configurations stored in SQLite database (no YAML files)
|
||||||
- **YAML Editor**: Built-in editor with syntax highlighting (CodeMirror)
|
- **Web-Based Config Creator**: Form-based UI for creating scan configs from CIDR ranges
|
||||||
- **Config Management UI**: List, view, edit, download, and delete configs via web interface
|
- **Config Management UI**: List, view, edit, and delete configs via web interface
|
||||||
- **Config Upload**: Direct YAML file upload for advanced users
|
- **REST API**: Full config management via RESTful API with database storage
|
||||||
- **REST API**: 7 new config management endpoints
|
|
||||||
- **Schedule Protection**: Prevents deleting configs used by active schedules
|
- **Schedule Protection**: Prevents deleting configs used by active schedules
|
||||||
|
- **Automatic Backups**: Configurations included in database backups
|
||||||
|
|
||||||
### Phase 3 (2025-11-14) - Dashboard & Scheduling ✅
|
### Phase 3 (2025-11-14) - Dashboard & Scheduling ✅
|
||||||
- **Dashboard**: Summary stats, recent scans, trend charts
|
- **Dashboard**: Summary stats, recent scans, trend charts
|
||||||
@@ -1133,5 +1120,5 @@ docker compose -f docker-compose.yml up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: 2025-11-17
|
**Last Updated**: 2025-11-24
|
||||||
**Version**: Phase 4 - Config Creator Complete
|
**Version**: Phase 4+ - Database-Backed Configuration System
|
||||||
|
|||||||
0
docs/KNOWN_ISSUES.md
Normal file
0
docs/KNOWN_ISSUES.md
Normal file
700
docs/ROADMAP.md
700
docs/ROADMAP.md
@@ -4,677 +4,115 @@
|
|||||||
|
|
||||||
SneakyScanner is a comprehensive **Flask web application** for infrastructure monitoring and security auditing. The primary interface is the web GUI, with a CLI API client planned for scripting and automation needs.
|
SneakyScanner is a comprehensive **Flask web application** for infrastructure monitoring and security auditing. The primary interface is the web GUI, with a CLI API client planned for scripting and automation needs.
|
||||||
|
|
||||||
**Current Phase:** Phase 5 Complete ✅ | Phase 6 Next Up
|
## Version 1.0.0 - Complete ✅
|
||||||
|
|
||||||
## Progress Overview
|
### Phase 1: Foundation ✅
|
||||||
|
|
||||||
**Note:** For detailed architecture and technology stack information, see [README.md](../README.md)
|
|
||||||
|
|
||||||
- ✅ **Phase 1: Foundation** - Complete (2025-11-13)
|
|
||||||
- Database schema, SQLAlchemy models, settings system, Flask app structure
|
|
||||||
- ✅ **Phase 2: Flask Web App Core** - Complete (2025-11-14)
|
|
||||||
- REST API, background jobs, authentication, web UI, testing (100 tests)
|
|
||||||
- ✅ **Phase 3: Dashboard & Scheduling** - Complete (2025-11-14)
|
|
||||||
- Dashboard, scan history, scheduled scans, trend charts
|
|
||||||
- ✅ **Phase 4: Config Creator** - Complete (2025-11-17)
|
|
||||||
- CIDR-based config creation, YAML editor, config management UI
|
|
||||||
- ✅ **Phase 5: Webhooks & Alerting** - Complete (2025-11-19)
|
|
||||||
- Webhook notifications, alert rules, notification templates
|
|
||||||
- 📋 **Phase 6: CLI as API Client** - Planned
|
|
||||||
- CLI for scripting and automation via API
|
|
||||||
- 📋 **Phase 7: Advanced Features** - Future
|
|
||||||
- Email notifications, scan comparison, CVE integration, timeline view, PDF export
|
|
||||||
|
|
||||||
|
|
||||||
## Target Users
|
|
||||||
|
|
||||||
- **Infrastructure teams** monitoring on-premises networks
|
|
||||||
- **Security professionals** performing periodic security audits
|
|
||||||
- **DevOps engineers** tracking infrastructure drift
|
|
||||||
- **Single users or small teams** (not enterprise multi-tenant)
|
|
||||||
|
|
||||||
## Database Schema Design
|
|
||||||
|
|
||||||
### Core Tables
|
|
||||||
|
|
||||||
#### `scans`
|
|
||||||
Stores metadata about each scan execution.
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | INTEGER PRIMARY KEY | Unique scan ID |
|
|
||||||
| `timestamp` | DATETIME | Scan start time (UTC) |
|
|
||||||
| `duration` | FLOAT | Total scan duration (seconds) |
|
|
||||||
| `status` | VARCHAR(20) | `running`, `completed`, `failed` |
|
|
||||||
| `config_file` | TEXT | Path to YAML config used |
|
|
||||||
| `title` | TEXT | Scan title from config |
|
|
||||||
| `json_path` | TEXT | Path to JSON report |
|
|
||||||
| `html_path` | TEXT | Path to HTML report |
|
|
||||||
| `zip_path` | TEXT | Path to ZIP archive |
|
|
||||||
| `screenshot_dir` | TEXT | Path to screenshot directory |
|
|
||||||
| `created_at` | DATETIME | Record creation time |
|
|
||||||
| `triggered_by` | VARCHAR(50) | `manual`, `scheduled`, `api` |
|
|
||||||
| `schedule_id` | INTEGER | FK to schedules (if triggered by schedule) |
|
|
||||||
|
|
||||||
#### `scan_sites`
|
|
||||||
Logical grouping of IPs by site.
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | INTEGER PRIMARY KEY | Unique site record ID |
|
|
||||||
| `scan_id` | INTEGER | FK to scans |
|
|
||||||
| `site_name` | VARCHAR(255) | Site name from config |
|
|
||||||
|
|
||||||
#### `scan_ips`
|
|
||||||
IP addresses scanned in each scan.
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | INTEGER PRIMARY KEY | Unique IP record ID |
|
|
||||||
| `scan_id` | INTEGER | FK to scans |
|
|
||||||
| `site_id` | INTEGER | FK to scan_sites |
|
|
||||||
| `ip_address` | VARCHAR(45) | IPv4 or IPv6 address |
|
|
||||||
| `ping_expected` | BOOLEAN | Expected ping response |
|
|
||||||
| `ping_actual` | BOOLEAN | Actual ping response |
|
|
||||||
|
|
||||||
#### `scan_ports`
|
|
||||||
Discovered TCP/UDP ports.
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | INTEGER PRIMARY KEY | Unique port record ID |
|
|
||||||
| `scan_id` | INTEGER | FK to scans |
|
|
||||||
| `ip_id` | INTEGER | FK to scan_ips |
|
|
||||||
| `port` | INTEGER | Port number (1-65535) |
|
|
||||||
| `protocol` | VARCHAR(10) | `tcp` or `udp` |
|
|
||||||
| `expected` | BOOLEAN | Was this port expected? |
|
|
||||||
| `state` | VARCHAR(20) | `open`, `closed`, `filtered` |
|
|
||||||
|
|
||||||
#### `scan_services`
|
|
||||||
Detected services on open ports.
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | INTEGER PRIMARY KEY | Unique service record ID |
|
|
||||||
| `scan_id` | INTEGER | FK to scans |
|
|
||||||
| `port_id` | INTEGER | FK to scan_ports |
|
|
||||||
| `service_name` | VARCHAR(100) | Service name (e.g., `ssh`, `http`) |
|
|
||||||
| `product` | VARCHAR(255) | Product name (e.g., `OpenSSH`) |
|
|
||||||
| `version` | VARCHAR(100) | Version string |
|
|
||||||
| `extrainfo` | TEXT | Additional nmap info |
|
|
||||||
| `ostype` | VARCHAR(100) | OS type if detected |
|
|
||||||
| `http_protocol` | VARCHAR(10) | `http` or `https` (if web service) |
|
|
||||||
| `screenshot_path` | TEXT | Relative path to screenshot |
|
|
||||||
|
|
||||||
#### `scan_certificates`
|
|
||||||
SSL/TLS certificates discovered on HTTPS services.
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | INTEGER PRIMARY KEY | Unique certificate record ID |
|
|
||||||
| `scan_id` | INTEGER | FK to scans |
|
|
||||||
| `service_id` | INTEGER | FK to scan_services |
|
|
||||||
| `subject` | TEXT | Certificate subject (CN) |
|
|
||||||
| `issuer` | TEXT | Certificate issuer |
|
|
||||||
| `serial_number` | TEXT | Serial number |
|
|
||||||
| `not_valid_before` | DATETIME | Validity start date |
|
|
||||||
| `not_valid_after` | DATETIME | Validity end date |
|
|
||||||
| `days_until_expiry` | INTEGER | Days until expiration |
|
|
||||||
| `sans` | TEXT | JSON array of SANs |
|
|
||||||
| `is_self_signed` | BOOLEAN | Self-signed certificate flag |
|
|
||||||
|
|
||||||
#### `scan_tls_versions`
|
|
||||||
TLS version support and cipher suites.
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | INTEGER PRIMARY KEY | Unique TLS version record ID |
|
|
||||||
| `scan_id` | INTEGER | FK to scans |
|
|
||||||
| `certificate_id` | INTEGER | FK to scan_certificates |
|
|
||||||
| `tls_version` | VARCHAR(20) | `TLS 1.0`, `TLS 1.1`, `TLS 1.2`, `TLS 1.3` |
|
|
||||||
| `supported` | BOOLEAN | Is this version supported? |
|
|
||||||
| `cipher_suites` | TEXT | JSON array of cipher suites |
|
|
||||||
|
|
||||||
### Scheduling & Notifications Tables
|
|
||||||
|
|
||||||
#### `schedules`
|
|
||||||
Scheduled scan configurations.
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | INTEGER PRIMARY KEY | Unique schedule ID |
|
|
||||||
| `name` | VARCHAR(255) | Schedule name (e.g., "Daily prod scan") |
|
|
||||||
| `config_file` | TEXT | Path to YAML config |
|
|
||||||
| `cron_expression` | VARCHAR(100) | Cron-like schedule (e.g., `0 2 * * *`) |
|
|
||||||
| `enabled` | BOOLEAN | Is schedule active? |
|
|
||||||
| `last_run` | DATETIME | Last execution time |
|
|
||||||
| `next_run` | DATETIME | Next scheduled execution |
|
|
||||||
| `created_at` | DATETIME | Schedule creation time |
|
|
||||||
| `updated_at` | DATETIME | Last modification time |
|
|
||||||
|
|
||||||
#### `alerts`
|
|
||||||
Alert history and notifications sent.
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | INTEGER PRIMARY KEY | Unique alert ID |
|
|
||||||
| `scan_id` | INTEGER | FK to scans |
|
|
||||||
| `alert_type` | VARCHAR(50) | `new_port`, `cert_expiry`, `service_change`, `ping_failed` |
|
|
||||||
| `severity` | VARCHAR(20) | `info`, `warning`, `critical` |
|
|
||||||
| `message` | TEXT | Human-readable alert message |
|
|
||||||
| `ip_address` | VARCHAR(45) | Related IP (optional) |
|
|
||||||
| `port` | INTEGER | Related port (optional) |
|
|
||||||
| `email_sent` | BOOLEAN | Was email notification sent? |
|
|
||||||
| `email_sent_at` | DATETIME | Email send timestamp |
|
|
||||||
| `created_at` | DATETIME | Alert creation time |
|
|
||||||
|
|
||||||
#### `alert_rules`
|
|
||||||
User-defined alert rules.
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | INTEGER PRIMARY KEY | Unique rule ID |
|
|
||||||
| `rule_type` | VARCHAR(50) | `unexpected_port`, `cert_expiry`, `service_down`, etc. |
|
|
||||||
| `enabled` | BOOLEAN | Is rule active? |
|
|
||||||
| `threshold` | INTEGER | Threshold value (e.g., days for cert expiry) |
|
|
||||||
| `email_enabled` | BOOLEAN | Send email for this rule? |
|
|
||||||
| `created_at` | DATETIME | Rule creation time |
|
|
||||||
|
|
||||||
### Settings Table
|
|
||||||
|
|
||||||
#### `settings`
|
|
||||||
Application configuration key-value store.
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | INTEGER PRIMARY KEY | Unique setting ID |
|
|
||||||
| `key` | VARCHAR(255) UNIQUE | Setting key (e.g., `smtp_server`) |
|
|
||||||
| `value` | TEXT | Setting value (JSON for complex values) |
|
|
||||||
| `updated_at` | DATETIME | Last modification time |
|
|
||||||
|
|
||||||
**Example Settings:**
|
|
||||||
- `smtp_server` - SMTP server hostname
|
|
||||||
- `smtp_port` - SMTP port (587, 465, 25)
|
|
||||||
- `smtp_username` - SMTP username
|
|
||||||
- `smtp_password` - SMTP password (encrypted)
|
|
||||||
- `smtp_from_email` - From email address
|
|
||||||
- `smtp_to_emails` - JSON array of recipient emails
|
|
||||||
- `app_password` - Single-user password hash (bcrypt)
|
|
||||||
- `retention_days` - How long to keep old scans (0 = forever)
|
|
||||||
|
|
||||||
## API Design
|
|
||||||
|
|
||||||
### REST API Endpoints
|
|
||||||
|
|
||||||
All API endpoints return JSON and follow RESTful conventions.
|
|
||||||
|
|
||||||
#### Scans
|
|
||||||
|
|
||||||
| Method | Endpoint | Description | Request Body | Response |
|
|
||||||
|--------|----------|-------------|--------------|----------|
|
|
||||||
| `GET` | `/api/scans` | List all scans (paginated) | - | `{ "scans": [...], "total": N, "page": 1 }` |
|
|
||||||
| `GET` | `/api/scans/{id}` | Get scan details | - | `{ "scan": {...} }` |
|
|
||||||
| `POST` | `/api/scans` | Trigger new scan | `{ "config_file": "path" }` | `{ "scan_id": N, "status": "running" }` |
|
|
||||||
| `DELETE` | `/api/scans/{id}` | Delete scan and files | - | `{ "status": "deleted" }` |
|
|
||||||
| `GET` | `/api/scans/{id}/status` | Get scan status | - | `{ "status": "running", "progress": "45%" }` |
|
|
||||||
| `GET` | `/api/scans/{id1}/compare/{id2}` | Compare two scans | - | `{ "diff": {...} }` |
|
|
||||||
|
|
||||||
#### Schedules
|
|
||||||
|
|
||||||
| Method | Endpoint | Description | Request Body | Response |
|
|
||||||
|--------|----------|-------------|--------------|----------|
|
|
||||||
| `GET` | `/api/schedules` | List all schedules | - | `{ "schedules": [...] }` |
|
|
||||||
| `GET` | `/api/schedules/{id}` | Get schedule details | - | `{ "schedule": {...} }` |
|
|
||||||
| `POST` | `/api/schedules` | Create new schedule | `{ "name": "...", "config_file": "...", "cron_expression": "..." }` | `{ "schedule_id": N }` |
|
|
||||||
| `PUT` | `/api/schedules/{id}` | Update schedule | `{ "enabled": true, "cron_expression": "..." }` | `{ "status": "updated" }` |
|
|
||||||
| `DELETE` | `/api/schedules/{id}` | Delete schedule | - | `{ "status": "deleted" }` |
|
|
||||||
| `POST` | `/api/schedules/{id}/trigger` | Manually trigger scheduled scan | - | `{ "scan_id": N }` |
|
|
||||||
|
|
||||||
#### Alerts
|
|
||||||
|
|
||||||
| Method | Endpoint | Description | Request Body | Response |
|
|
||||||
|--------|----------|-------------|--------------|----------|
|
|
||||||
| `GET` | `/api/alerts` | List recent alerts | - | `{ "alerts": [...] }` |
|
|
||||||
| `GET` | `/api/alerts/rules` | List alert rules | - | `{ "rules": [...] }` |
|
|
||||||
| `POST` | `/api/alerts/rules` | Create alert rule | `{ "rule_type": "...", "threshold": N }` | `{ "rule_id": N }` |
|
|
||||||
| `PUT` | `/api/alerts/rules/{id}` | Update alert rule | `{ "enabled": false }` | `{ "status": "updated" }` |
|
|
||||||
| `DELETE` | `/api/alerts/rules/{id}` | Delete alert rule | - | `{ "status": "deleted" }` |
|
|
||||||
|
|
||||||
#### Settings
|
|
||||||
|
|
||||||
| Method | Endpoint | Description | Request Body | Response |
|
|
||||||
|--------|----------|-------------|--------------|----------|
|
|
||||||
| `GET` | `/api/settings` | Get all settings (sanitized) | - | `{ "settings": {...} }` |
|
|
||||||
| `PUT` | `/api/settings` | Update settings | `{ "smtp_server": "...", ... }` | `{ "status": "updated" }` |
|
|
||||||
| `POST` | `/api/settings/test-email` | Test email configuration | - | `{ "status": "sent" }` |
|
|
||||||
|
|
||||||
#### Statistics & Trends
|
|
||||||
|
|
||||||
| Method | Endpoint | Description | Request Body | Response |
|
|
||||||
|--------|----------|-------------|--------------|----------|
|
|
||||||
| `GET` | `/api/stats/summary` | Dashboard summary stats | - | `{ "total_scans": N, "last_scan": "...", ... }` |
|
|
||||||
| `GET` | `/api/stats/trends` | Trend data for charts | `?days=30&metric=port_count` | `{ "data": [...] }` |
|
|
||||||
| `GET` | `/api/stats/certificates` | Certificate expiry overview | - | `{ "expiring_soon": [...], "expired": [...] }` |
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
**Phase 2-3:** Simple session-based authentication (single-user)
|
|
||||||
- Login endpoint: `POST /api/auth/login` (username/password)
|
|
||||||
- Logout endpoint: `POST /api/auth/logout`
|
|
||||||
- Session cookies with Flask-Login
|
|
||||||
- Password stored as bcrypt hash in settings table
|
|
||||||
|
|
||||||
**Phase 6:** API token authentication for CLI client
|
|
||||||
- Generate API token: `POST /api/auth/token`
|
|
||||||
- Revoke token: `DELETE /api/auth/token`
|
|
||||||
- CLI sends token in `Authorization: Bearer <token>` header
|
|
||||||
|
|
||||||
## Phased Roadmap
|
|
||||||
|
|
||||||
### Phase 1: Foundation ✅ COMPLETE
|
|
||||||
**Completed:** 2025-11-13
|
**Completed:** 2025-11-13
|
||||||
|
|
||||||
**Deliverables:**
|
- Database schema with 11 tables (SQLAlchemy ORM, Alembic migrations)
|
||||||
- SQLite database with 11 tables (scans, sites, IPs, ports, services, certificates, TLS versions, schedules, alerts, alert_rules, settings)
|
- Settings system with encryption (bcrypt, Fernet)
|
||||||
- SQLAlchemy ORM models with relationships
|
|
||||||
- Alembic migration system
|
|
||||||
- Settings system with encryption (bcrypt for passwords, Fernet for sensitive data)
|
|
||||||
- Flask app structure with API blueprints
|
- Flask app structure with API blueprints
|
||||||
- Docker Compose deployment configuration
|
- Docker Compose deployment
|
||||||
- Validation script for verification
|
|
||||||
|
|
||||||
---
|
### Phase 2: Flask Web App Core ✅
|
||||||
|
|
||||||
### Phase 2: Flask Web App Core ✅ COMPLETE
|
|
||||||
**Completed:** 2025-11-14
|
**Completed:** 2025-11-14
|
||||||
|
|
||||||
**Deliverables:**
|
- REST API (8 endpoints for scans, settings)
|
||||||
- REST API with 8 endpoints (scans: trigger, list, get, status, delete; settings: get, update, test-email)
|
- Background job queue (APScheduler, 3 concurrent scans)
|
||||||
- Background job queue using APScheduler (up to 3 concurrent scans)
|
- Session-based authentication (Flask-Login)
|
||||||
- Session-based authentication with Flask-Login
|
- Web UI templates (dashboard, scan list/detail, login)
|
||||||
- Database integration for scan results (full normalized schema population)
|
- Comprehensive test suite (100 tests)
|
||||||
- Web UI templates (dashboard, scan list/detail, login, error pages)
|
|
||||||
- Error handling with content negotiation (JSON/HTML) and request IDs
|
|
||||||
- Logging system with rotating file handlers
|
|
||||||
- Production Docker Compose deployment
|
|
||||||
- Comprehensive test suite (100 tests, all passing)
|
|
||||||
- Documentation (API_REFERENCE.md, DEPLOYMENT.md)
|
|
||||||
|
|
||||||
---
|
### Phase 3: Dashboard & Scheduling ✅
|
||||||
|
|
||||||
### Phase 3: Dashboard & Scheduling ✅ COMPLETE
|
|
||||||
**Completed:** 2025-11-14
|
**Completed:** 2025-11-14
|
||||||
|
|
||||||
**Deliverables:**
|
- Dashboard with summary stats and trend charts (Chart.js)
|
||||||
- Dashboard with summary stats (total scans, IPs, ports, services)
|
- Scan detail pages with full results display
|
||||||
- Recent scans table with clickable details
|
- Scheduled scan management (cron expressions)
|
||||||
- Scan detail page with full results display
|
- Download buttons for reports (JSON, HTML, ZIP)
|
||||||
- Historical trend charts using Chart.js (port counts over time)
|
|
||||||
- Scheduled scan management UI (create, edit, delete, enable/disable)
|
|
||||||
- Schedule execution with APScheduler and cron expressions
|
|
||||||
- Manual scan trigger from web UI
|
|
||||||
- Navigation menu (Dashboard, Scans, Schedules, Configs, Settings)
|
|
||||||
- Download buttons for scan reports (JSON, HTML, ZIP)
|
|
||||||
|
|
||||||
---
|
### Phase 4: Config Creator ✅
|
||||||
|
|
||||||
### Phase 4: Config Creator ✅ COMPLETE
|
|
||||||
**Completed:** 2025-11-17
|
**Completed:** 2025-11-17
|
||||||
|
|
||||||
**Deliverables:**
|
- CIDR-based config creation UI
|
||||||
- CIDR-based config creation UI (simplified workflow for quick config generation)
|
- YAML editor with CodeMirror
|
||||||
- YAML editor with CodeMirror (syntax highlighting, line numbers)
|
- Config management (list, view, edit, download, delete)
|
||||||
- Config management UI (list, view, edit, download, delete)
|
- REST API for config operations (7 endpoints)
|
||||||
- Direct YAML upload for advanced users
|
|
||||||
- REST API for config operations (7 endpoints: list, get, create, update, delete, upload, download)
|
|
||||||
- Schedule dependency protection (prevents deleting configs used by schedules)
|
|
||||||
- Comprehensive testing (25+ unit and integration tests)
|
|
||||||
|
|
||||||
---
|
### Phase 5: Webhooks & Alerting ✅
|
||||||
|
|
||||||
### Phase 5: Webhooks & Alerting ✅ COMPLETE
|
|
||||||
**Completed:** 2025-11-19
|
**Completed:** 2025-11-19
|
||||||
|
|
||||||
**Goals:**
|
- Alert Rule Engine (9 alert types: unexpected_port, cert_expiry, ping_failed, etc.)
|
||||||
- ✅ Implement webhook notification system for real-time alerting
|
- Webhook notifications with retry logic
|
||||||
- ✅ Add alert rule configuration for unexpected exposure detection
|
- Multiple webhook URLs with independent filtering
|
||||||
- ✅ Create notification template system for flexible alerting
|
- Notification templates (Slack, Discord, PagerDuty support)
|
||||||
|
- Alert deduplication
|
||||||
**Core Use Case:**
|
|
||||||
Monitor infrastructure for misconfigurations that expose unexpected ports/services to the world. When a scan detects an open port that wasn't defined in the YAML config's `expected_ports` list, trigger immediate notifications via webhooks.
|
|
||||||
|
|
||||||
**Implemented Features:**
|
|
||||||
|
|
||||||
#### 1. Alert Rule Engine ✅
|
|
||||||
**Purpose:** Automatically detect and classify infrastructure anomalies after each scan.
|
|
||||||
|
|
||||||
**Alert Types:**
|
|
||||||
- `unexpected_port` - Port open but not in config's `expected_ports` list
|
|
||||||
- `unexpected_service` - Service detected that doesn't match expected service name
|
|
||||||
- `cert_expiry` - SSL/TLS certificate expiring soon (configurable threshold)
|
|
||||||
- `ping_failed` - Expected host not responding to ping
|
|
||||||
- `service_down` - Previously detected service no longer responding
|
|
||||||
- `service_change` - Service version/product changed between scans
|
|
||||||
- `weak_tls` - TLS 1.0/1.1 detected or weak cipher suites
|
|
||||||
- `new_host` - New IP address responding in CIDR range
|
|
||||||
- `host_disappeared` - Previously seen IP no longer responding
|
|
||||||
|
|
||||||
**Alert Severity Levels:**
|
|
||||||
- `critical` - Unexpected internet-facing service (ports 80/443/22/3389/etc.)
|
|
||||||
- `warning` - Minor configuration drift or upcoming cert expiry
|
|
||||||
- `info` - Informational alerts (new host discovered, service version change)
|
|
||||||
|
|
||||||
**Alert Rule Configuration:**
|
|
||||||
```yaml
|
|
||||||
# Example alert rule configuration (stored in DB)
|
|
||||||
alert_rules:
|
|
||||||
- id: 1
|
|
||||||
rule_type: unexpected_port
|
|
||||||
enabled: true
|
|
||||||
severity: critical
|
|
||||||
webhook_enabled: true
|
|
||||||
filter_conditions:
|
|
||||||
ports: [22, 80, 443, 3389, 3306, 5432, 27017] # High-risk ports
|
|
||||||
|
|
||||||
- id: 2
|
|
||||||
rule_type: cert_expiry
|
|
||||||
enabled: true
|
|
||||||
severity: warning
|
|
||||||
threshold: 30 # Days before expiry
|
|
||||||
webhook_enabled: true
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
- ✅ Evaluate alert rules after each scan completes
|
|
||||||
- ✅ Compare current scan results to expected configuration
|
|
||||||
- ✅ Generate alerts and store in `alerts` table
|
|
||||||
- ✅ Trigger notifications based on rule configuration
|
|
||||||
- ✅ Alert deduplication (don't spam for same issue)
|
|
||||||
|
|
||||||
#### 2. Webhook Notifications ✅
|
|
||||||
**Purpose:** Real-time HTTP POST notifications for integration with external systems (Slack, PagerDuty, custom dashboards, SIEM tools).
|
|
||||||
|
|
||||||
**Webhook Configuration (via Settings API):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"webhook_enabled": true,
|
|
||||||
"webhook_urls": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "Slack Security Channel",
|
|
||||||
"url": "https://hooks.slack.com/services/XXX/YYY/ZZZ",
|
|
||||||
"enabled": true,
|
|
||||||
"auth_type": "none",
|
|
||||||
"custom_headers": {},
|
|
||||||
"alert_types": ["unexpected_port", "unexpected_service", "weak_tls"],
|
|
||||||
"severity_filter": ["critical", "warning"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"name": "PagerDuty",
|
|
||||||
"url": "https://events.pagerduty.com/v2/enqueue",
|
|
||||||
"enabled": true,
|
|
||||||
"auth_type": "bearer",
|
|
||||||
"auth_token": "encrypted_token",
|
|
||||||
"custom_headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"alert_types": ["unexpected_port"],
|
|
||||||
"severity_filter": ["critical"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Webhook Payload Format (JSON):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event_type": "scan_alert",
|
|
||||||
"alert_id": 42,
|
|
||||||
"alert_type": "unexpected_port",
|
|
||||||
"severity": "critical",
|
|
||||||
"timestamp": "2025-11-17T14:23:45Z",
|
|
||||||
"scan": {
|
|
||||||
"scan_id": 123,
|
|
||||||
"title": "Production Network Scan",
|
|
||||||
"timestamp": "2025-11-17T14:15:00Z",
|
|
||||||
"config_file": "prod_config.yaml",
|
|
||||||
"triggered_by": "scheduled"
|
|
||||||
},
|
|
||||||
"alert_details": {
|
|
||||||
"message": "Unexpected port 3306 (MySQL) exposed on 192.168.1.100",
|
|
||||||
"ip_address": "192.168.1.100",
|
|
||||||
"port": 3306,
|
|
||||||
"protocol": "tcp",
|
|
||||||
"state": "open",
|
|
||||||
"service": {
|
|
||||||
"name": "mysql",
|
|
||||||
"product": "MySQL",
|
|
||||||
"version": "8.0.32"
|
|
||||||
},
|
|
||||||
"expected": false,
|
|
||||||
"site_name": "Production Servers"
|
|
||||||
},
|
|
||||||
"recommended_actions": [
|
|
||||||
"Verify if MySQL should be exposed externally",
|
|
||||||
"Check firewall rules for 192.168.1.100",
|
|
||||||
"Review MySQL bind-address configuration"
|
|
||||||
],
|
|
||||||
"web_url": "https://sneakyscanner.local/scans/123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Webhook Features:**
|
|
||||||
- ✅ Multiple webhook URLs with independent configuration
|
|
||||||
- ✅ Per-webhook filtering by alert type and severity
|
|
||||||
- ✅ Custom headers support (for API keys, auth tokens)
|
|
||||||
- ✅ Authentication methods:
|
|
||||||
- `none` - No authentication
|
|
||||||
- `bearer` - Bearer token in Authorization header
|
|
||||||
- `basic` - Basic authentication
|
|
||||||
- `custom` - Custom header-based auth
|
|
||||||
- ✅ Retry logic with exponential backoff (3 attempts)
|
|
||||||
- ✅ Webhook delivery tracking (webhook_sent, webhook_sent_at, webhook_response_code)
|
|
||||||
- ✅ Test webhook functionality in Settings UI
|
|
||||||
- ✅ Timeout configuration (default 10 seconds)
|
|
||||||
- ✅ Webhook delivery history and logs
|
|
||||||
|
|
||||||
**Webhook API Endpoints:**
|
|
||||||
- ✅ `POST /api/webhooks` - Create webhook configuration
|
|
||||||
- ✅ `GET /api/webhooks` - List all webhooks
|
|
||||||
- ✅ `PUT /api/webhooks/{id}` - Update webhook configuration
|
|
||||||
- ✅ `DELETE /api/webhooks/{id}` - Delete webhook
|
|
||||||
- ✅ `POST /api/webhooks/{id}/test` - Send test webhook
|
|
||||||
- ✅ `GET /api/webhooks/{id}/history` - Get delivery history
|
|
||||||
|
|
||||||
**Notification Templates:**
|
|
||||||
Flexible template system supporting multiple platforms (Slack, Discord, PagerDuty, etc.):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"text": "SneakyScanner Alert: Unexpected Port Detected",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"color": "danger",
|
|
||||||
"fields": [
|
|
||||||
{"title": "IP Address", "value": "192.168.1.100", "short": true},
|
|
||||||
{"title": "Port", "value": "3306/tcp", "short": true},
|
|
||||||
{"title": "Service", "value": "MySQL 8.0.32", "short": true},
|
|
||||||
{"title": "Severity", "value": "CRITICAL", "short": true}
|
|
||||||
],
|
|
||||||
"footer": "SneakyScanner",
|
|
||||||
"ts": 1700234625
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Deliverables:**
|
## Planned Features
|
||||||
- ✅ Alert Rule Engine with 9 alert types (unexpected_port, unexpected_service, cert_expiry, ping_failed, service_down, service_change, weak_tls, new_host, host_disappeared)
|
|
||||||
- ✅ Alert severity classification (critical, warning, info)
|
|
||||||
- ✅ Alert rule configuration API (CRUD operations)
|
|
||||||
- ✅ Webhook notification system with retry logic
|
|
||||||
- ✅ Multiple webhook URL support with independent configuration
|
|
||||||
- ✅ Notification template system for flexible platform integration (Slack, Discord, PagerDuty, custom)
|
|
||||||
- ✅ Webhook API endpoints (create, list, update, delete, test, history)
|
|
||||||
- ✅ Custom headers and authentication support (none, bearer, basic, custom)
|
|
||||||
- ✅ Webhook delivery tracking and logging
|
|
||||||
- ✅ Alert deduplication to prevent notification spam
|
|
||||||
- ✅ Integration with scan completion workflow
|
|
||||||
|
|
||||||
**Success Criteria Met:**
|
### Version 1.1.0 - Communication & Automation
|
||||||
- ✅ Alerts triggered within 30 seconds of scan completion
|
|
||||||
- ✅ Webhook POST delivered with retry on failure
|
#### CLI as API Client
|
||||||
- ✅ Zero false positives for expected ports/services
|
- CLI tool for scripting and automation via REST API
|
||||||
- ✅ Alert deduplication prevents notification spam
|
- API token authentication (Bearer tokens)
|
||||||
|
- Commands for scan management, schedules, alerts
|
||||||
|
|
||||||
|
#### Email Notifications
|
||||||
|
- SMTP integration with Flask-Mail
|
||||||
|
- Jinja2 email templates (HTML + plain text)
|
||||||
|
- Configurable recipients and rate limiting
|
||||||
|
|
||||||
|
#### Site CSV Export/Import
|
||||||
|
- Bulk site management via CSV files
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 6: CLI as API Client
|
### Version 1.2.0 - Reporting & Analysis
|
||||||
**Status:** Planned
|
|
||||||
**Priority:** MEDIUM
|
|
||||||
|
|
||||||
**Goals:**
|
#### Scan Comparison
|
||||||
- Create CLI API client for scripting and automation
|
- Compare two scans API endpoint
|
||||||
- Maintain standalone mode for testing
|
- Side-by-side comparison view with color-coded differences
|
||||||
- API token authentication
|
- Export comparison report to PDF/HTML
|
||||||
|
|
||||||
**Planned Features:**
|
#### Enhanced Reports
|
||||||
1. **API Client Mode:**
|
- Sortable/filterable tables (DataTables.js)
|
||||||
- `--api-mode` flag to enable API client mode
|
- PDF export (WeasyPrint)
|
||||||
- `--api-url` and `--api-token` arguments
|
|
||||||
- Trigger scans via API, poll for status, download results
|
|
||||||
- Scans stored centrally in database
|
|
||||||
- Standalone mode still available for testing
|
|
||||||
|
|
||||||
2. **API Token System:**
|
|
||||||
- Token generation UI in settings page
|
|
||||||
- Secure token storage (hashed in database)
|
|
||||||
- Token authentication middleware
|
|
||||||
- Token expiration and revocation
|
|
||||||
|
|
||||||
3. **Benefits:**
|
|
||||||
- Centralized scan history accessible via web dashboard
|
|
||||||
- No need to mount volumes for output
|
|
||||||
- Scheduled scans managed through web UI
|
|
||||||
- Scriptable automation while leveraging web features
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 7: Advanced Features
|
### Version 1.3.0 - Visualization
|
||||||
**Status:** Future/Deferred
|
|
||||||
**Priority:** LOW
|
|
||||||
|
|
||||||
**Planned Features:**
|
#### Timeline View
|
||||||
|
- Visual scan history timeline
|
||||||
|
- Filter by site/IP
|
||||||
|
- Event annotations
|
||||||
|
|
||||||
1. **Email Notifications:**
|
#### Advanced Charts
|
||||||
- SMTP integration with Flask-Mail
|
- Port activity heatmap
|
||||||
- Jinja2 email templates (HTML + plain text)
|
- Certificate expiration forecast
|
||||||
- Settings API for email configuration
|
|
||||||
- Test email functionality
|
|
||||||
- Email delivery tracking
|
|
||||||
- Rate limiting to prevent email flood
|
|
||||||
- Configurable recipients (multiple emails)
|
|
||||||
|
|
||||||
2. **Scan Comparison:**
|
|
||||||
- Compare two scans API endpoint
|
|
||||||
- Side-by-side comparison view
|
|
||||||
- Color-coded differences (green=new, red=removed, yellow=changed)
|
|
||||||
- Filter by change type
|
|
||||||
- Export comparison report to PDF/HTML
|
|
||||||
- "Compare with previous scan" button on scan detail page
|
|
||||||
|
|
||||||
3. **Enhanced Reports:**
|
|
||||||
- Sortable/filterable tables (DataTables.js)
|
|
||||||
- Inline screenshot thumbnails with lightbox
|
|
||||||
- PDF export (WeasyPrint)
|
|
||||||
|
|
||||||
4. **Vulnerability Detection:**
|
|
||||||
- CVE database integration (NVD API)
|
|
||||||
- Service version matching to known CVEs
|
|
||||||
- CVSS severity scores
|
|
||||||
- Alert rules for critical CVEs
|
|
||||||
|
|
||||||
5. **Timeline View:**
|
|
||||||
- Visual scan history timeline
|
|
||||||
- Filter by site/IP
|
|
||||||
- Event annotations
|
|
||||||
|
|
||||||
6. **Advanced Charts:**
|
|
||||||
- Port activity heatmap
|
|
||||||
- Service version tracking
|
|
||||||
- Certificate expiration forecast
|
|
||||||
|
|
||||||
7. **Additional Integrations:**
|
|
||||||
- Prometheus metrics export
|
|
||||||
- CSV export/import
|
|
||||||
- Advanced reporting dashboards
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development Workflow
|
### Version 2.0.0 - Security Intelligence
|
||||||
|
|
||||||
### Iteration Cycle
|
#### Vulnerability Detection
|
||||||
1. **Plan** - Define features for phase
|
- CVE database integration (NVD API)
|
||||||
2. **Implement** - Code backend + frontend
|
- Service version matching to known CVEs
|
||||||
3. **Test** - Unit tests + manual testing
|
- CVSS severity scores
|
||||||
4. **Deploy** - Update Docker Compose
|
|
||||||
5. **Document** - Update README.md, ROADMAP.md
|
|
||||||
6. **Review** - Get user feedback
|
|
||||||
7. **Iterate** - Adjust priorities based on feedback
|
|
||||||
|
|
||||||
### Git Workflow
|
---
|
||||||
- **main branch** - Stable releases
|
|
||||||
- **develop branch** - Active development
|
|
||||||
- **feature branches** - Individual features (`feature/dashboard`, `feature/scheduler`)
|
|
||||||
- **Pull requests** - Review before merge
|
|
||||||
|
|
||||||
### Testing Strategy
|
|
||||||
- **Unit tests** - pytest for models, API endpoints
|
|
||||||
- **Integration tests** - Full scan → DB → API workflow
|
|
||||||
- **Manual testing** - UI/UX testing in browser
|
|
||||||
- **Performance tests** - Large scans, database queries
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- **README.md** - User-facing documentation (updated each phase)
|
|
||||||
- **ROADMAP.md** - This file (updated as priorities shift)
|
|
||||||
- **CLAUDE.md** - Developer documentation (architecture, code references)
|
|
||||||
- **API.md** - API documentation (OpenAPI/Swagger in Phase 4)
|
|
||||||
|
|
||||||
## Resources & References
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- [Flask Documentation](https://flask.palletsprojects.com/)
|
|
||||||
- [SQLAlchemy ORM](https://docs.sqlalchemy.org/)
|
|
||||||
- [APScheduler](https://apscheduler.readthedocs.io/)
|
|
||||||
- [Chart.js](https://www.chartjs.org/docs/)
|
|
||||||
- [Bootstrap 5](https://getbootstrap.com/docs/5.3/)
|
|
||||||
|
|
||||||
### Tutorials
|
|
||||||
- [Flask Mega-Tutorial](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world)
|
|
||||||
- [SQLAlchemy Tutorial](https://docs.sqlalchemy.org/en/20/tutorial/)
|
|
||||||
- [APScheduler with Flask](https://github.com/viniciuschiele/flask-apscheduler)
|
|
||||||
|
|
||||||
### Similar Projects (Inspiration)
|
|
||||||
- [OpenVAS](https://www.openvas.org/) - Vulnerability scanner with web UI
|
|
||||||
- [Nessus](https://www.tenable.com/products/nessus) - Commercial scanner (inspiration for UI/UX)
|
|
||||||
- [OWASP ZAP](https://www.zaproxy.org/) - Web app scanner (comparison reports, alerts)
|
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
| Date | Version | Changes |
|
| Date | Version | Changes |
|
||||||
|------|---------|---------|
|
|------|---------|---------|
|
||||||
| 2025-11-14 | 1.0 | Initial roadmap created based on user requirements |
|
| 2025-11-13 | 1.0.0-alpha | Phase 1 complete - Foundation |
|
||||||
| 2025-11-13 | 1.1 | **Phase 1 COMPLETE** - Database schema, SQLAlchemy models, Flask app structure, settings system with encryption, Alembic migrations, API blueprints, Docker support, validation script |
|
| 2025-11-14 | 1.0.0-beta | Phases 2-3 complete - Web App Core, Dashboard & Scheduling |
|
||||||
| 2025-11-14 | 1.2 | **Phase 2 COMPLETE** - REST API (5 scan endpoints, 3 settings endpoints), background jobs (APScheduler), authentication (Flask-Login), web UI (dashboard, scans, login, errors), error handling (content negotiation, request IDs, logging), 100 tests passing, comprehensive documentation (API_REFERENCE.md, DEPLOYMENT.md, PHASE2_COMPLETE.md) |
|
| 2025-11-17 | 1.0.0-rc1 | Phase 4 complete - Config Creator |
|
||||||
| 2025-11-17 | 1.3 | **Bug Fix** - Fixed Chart.js infinite canvas growth issue in scan detail page (duplicate initialization, missing chart.destroy(), missing fixed-height container) |
|
| 2025-11-19 | 1.0.0 | Phase 5 complete - Webhooks & Alerting |
|
||||||
| 2025-11-17 | 1.4 | **Phase 4 COMPLETE** - Config Creator with CIDR-based creation, YAML editor (CodeMirror), config management UI (list/edit/delete), REST API (7 endpoints), Docker volume permissions fix, comprehensive testing and documentation |
|
|
||||||
| 2025-11-17 | 1.5 | **Roadmap Compression** - Condensed completed phases (1-4) into concise summaries, updated project scope to emphasize web GUI frontend with CLI as API client coming soon (Phase 6), reorganized phases for clarity |
|
|
||||||
| 2025-11-19 | 1.6 | **Phase 5 Progress** - Completed webhooks, notification templates, and alerting rules. Alert Rule Engine and Webhook System implemented. |
|
|
||||||
| 2025-11-19 | 1.7 | **Phase 5 COMPLETE** - Webhooks & Alerting phase completed. Moved Email Notifications and Scan Comparison to Phase 7. Alert rules, webhook notifications, and notification templates fully implemented and tested. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated:** 2025-11-19
|
**Last Updated:** 2025-11-20
|
||||||
**Next Review:** Before Phase 6 kickoff (CLI as API Client)
|
|
||||||
|
|||||||
BIN
docs/alerts.png
BIN
docs/alerts.png
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB |
BIN
docs/configs.png
BIN
docs/configs.png
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB |
BIN
docs/scans.png
BIN
docs/scans.png
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB |
99
scripts/release.sh
Executable file
99
scripts/release.sh
Executable file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# SneakyScan Release Script
|
||||||
|
# Handles version bumping, branch merging, tagging, and pushing
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CONFIG_FILE="app/web/config.py"
|
||||||
|
DEVELOP_BRANCH="nightly"
|
||||||
|
STAGING_BRANCH="beta"
|
||||||
|
MAIN_BRANCH="master"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${GREEN}=== SneakyScan Release Script ===${NC}\n"
|
||||||
|
|
||||||
|
# Ensure we're in the repo root
|
||||||
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
echo -e "${RED}Error: Must run from repository root${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for uncommitted changes
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo -e "${RED}Error: You have uncommitted changes. Please commit or stash them first.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prompt for version
|
||||||
|
read -p "Enter version (e.g., 1.0.0): " VERSION
|
||||||
|
|
||||||
|
# Validate version format (semver)
|
||||||
|
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?$ ]]; then
|
||||||
|
echo -e "${RED}Error: Invalid version format. Use semver (e.g., 1.0.0 or 1.0.0-beta)${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAG_NAME="v$VERSION"
|
||||||
|
|
||||||
|
# Check if tag already exists
|
||||||
|
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: Tag $TAG_NAME already exists${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}Release version: $VERSION${NC}"
|
||||||
|
echo -e "${YELLOW}Tag name: $TAG_NAME${NC}\n"
|
||||||
|
|
||||||
|
read -p "Proceed with release? (y/n): " CONFIRM
|
||||||
|
if [ "$CONFIRM" != "y" ]; then
|
||||||
|
echo "Release cancelled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fetch latest from remote
|
||||||
|
echo -e "\n${GREEN}Fetching latest from remote...${NC}"
|
||||||
|
git fetch origin
|
||||||
|
|
||||||
|
# Update version in config.py
|
||||||
|
echo -e "\n${GREEN}Updating version in $CONFIG_FILE...${NC}"
|
||||||
|
sed -i "s/APP_VERSION = .*/APP_VERSION = '$VERSION'/" "$CONFIG_FILE"
|
||||||
|
|
||||||
|
# Checkout develop and commit version change
|
||||||
|
echo -e "\n${GREEN}Committing version change on $DEVELOP_BRANCH...${NC}"
|
||||||
|
git checkout "$DEVELOP_BRANCH"
|
||||||
|
git add "$CONFIG_FILE"
|
||||||
|
git commit -m "Bump version to $VERSION"
|
||||||
|
|
||||||
|
# Merge develop into staging
|
||||||
|
echo -e "\n${GREEN}Merging $DEVELOP_BRANCH into $STAGING_BRANCH...${NC}"
|
||||||
|
git checkout "$STAGING_BRANCH"
|
||||||
|
git merge "$DEVELOP_BRANCH" -m "Merge $DEVELOP_BRANCH into $STAGING_BRANCH for release $VERSION"
|
||||||
|
|
||||||
|
# Merge staging into main
|
||||||
|
echo -e "\n${GREEN}Merging $STAGING_BRANCH into $MAIN_BRANCH...${NC}"
|
||||||
|
git checkout "$MAIN_BRANCH"
|
||||||
|
git merge "$STAGING_BRANCH" -m "Merge $STAGING_BRANCH into $MAIN_BRANCH for release $VERSION"
|
||||||
|
|
||||||
|
# Create tag
|
||||||
|
echo -e "\n${GREEN}Creating tag $TAG_NAME...${NC}"
|
||||||
|
git tag -a "$TAG_NAME" -m "Release $VERSION"
|
||||||
|
|
||||||
|
# Push everything
|
||||||
|
echo -e "\n${GREEN}Pushing branches and tag to remote...${NC}"
|
||||||
|
git push origin "$DEVELOP_BRANCH"
|
||||||
|
git push origin "$STAGING_BRANCH"
|
||||||
|
git push origin "$MAIN_BRANCH"
|
||||||
|
git push origin "$TAG_NAME"
|
||||||
|
|
||||||
|
# Return to develop branch
|
||||||
|
git checkout "$DEVELOP_BRANCH"
|
||||||
|
|
||||||
|
echo -e "\n${GREEN}=== Release $VERSION complete! ===${NC}"
|
||||||
|
echo -e "Tag: $TAG_NAME"
|
||||||
|
echo -e "All branches and tags have been pushed to origin."
|
||||||
4
setup.sh
4
setup.sh
@@ -79,6 +79,10 @@ CORS_ORIGINS=*
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "✓ .env file created with secure keys"
|
echo "✓ .env file created with secure keys"
|
||||||
|
|
||||||
|
# Remove the init marker so the password gets set on next container start
|
||||||
|
rm -f data/.db_initialized
|
||||||
|
echo "✓ Password will be updated on next container start"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create required directories
|
# Create required directories
|
||||||
|
|||||||
Reference in New Issue
Block a user