phase 4 complete

This commit is contained in:
2025-11-17 14:54:31 -06:00
parent 5301b07f37
commit 5f2314a532
21 changed files with 5046 additions and 509 deletions

14
configs/dmz.yaml Normal file
View File

@@ -0,0 +1,14 @@
title: DMZ
sites:
- name: DMZ Reverse Proxys
ips:
- address: 10.10.99.10
expected:
ping: true
tcp_ports: [22,80,443]
udp_ports: []
- address: 10.10.99.20
expected:
ping: true
tcp_ports: [22,80,443]
udp_ports: []

View File

@@ -11,8 +11,8 @@ services:
# Note: Using host network mode for scanner capabilities, so no port mapping needed
# The Flask app will be accessible at http://localhost:5000
volumes:
# Mount configs directory (read-only) for scan configurations
- ./configs:/app/configs:ro
# Mount configs directory for scan configurations (read-write for web UI management)
- ./configs:/app/configs
# Mount output directory for scan results
- ./output:/app/output
# Mount database file for persistence

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
# SneakyScanner Roadmap
**Status:** Phase 2 Complete ✅ | Phase 3 Ready to Start
**Status:** Phase 4 Complete ✅ | Phase 5 Next Up
## Progress Overview
-**Phase 1: Foundation** - Complete (2025-11-13)
@@ -16,10 +16,42 @@
- Comprehensive error handling and logging
- 100 tests passing (1,825 lines of test code)
-**Phase 3: Dashboard & Scheduling** - Complete (2025-11-14)
- 📋 **Phase 4: Config Creator ** -Next up
- 📋 **Phase 5: Email & Comparisons** - Planned (Weeks 7-8)
- 📋 **Phase 6: CLI as API Client** - Planned (Week 9)
- 📋 **Phase 7: Advanced Features** - Planned (Weeks 10+)
- Dashboard with summary stats and recent scans
- Scan history browser with detail pages
- Scheduled scan management UI
- Background scheduler with APScheduler
- Trend charts with Chart.js
-**Phase 4: Config Creator** - Complete (2025-11-17)
- CIDR-based config creation (simplified workflow)
- YAML editor with CodeMirror (syntax highlighting)
- Config management UI (list, view, edit, download, delete)
- Direct YAML upload for advanced users
- Full REST API for config operations
- Schedule dependency protection (delete blocking)
- 📋 **Phase 5: Email & Comparisons** - Next up
- 📋 **Phase 6: CLI as API Client** - Planned
- 📋 **Phase 7: Advanced Features** - Planned
## Recent Bug Fixes
### 2025-11-17: Chart.js Infinite Canvas Growth Fix
**Issue:** Scan detail page (`scan_detail.html`) was experiencing infinite scrolling and page lock-up due to Chart.js canvas growing infinitely (height reaching 22302px+).
**Root Causes:**
1. Duplicate initialization - `loadScan()` was being called twice on page load
2. Multiple Chart.js instances created on the same canvas without destroying previous ones
3. Canvas element without fixed-height container caused infinite resize loop with `responsive: true` and `maintainAspectRatio: false`
**Fixes Applied:**
1. **Consolidated initialization** (`scan_detail.html:172-175`) - Moved `findPreviousScan()` and `loadHistoricalChart()` into `DOMContentLoaded` event listener, removed duplicate call
2. **Chart instance tracking** (`scan_detail.html:169`) - Added `let historyChart = null;` to store chart reference
3. **Destroy old charts** (`scan_detail.html:501-504`) - Added `historyChart.destroy()` before creating new chart instance
4. **Fixed-height container** (`scan_detail.html:136-138`) - Wrapped canvas in `<div style="position: relative; height: 300px;">` to prevent infinite resize loop
**Files Modified:**
- `web/templates/scan_detail.html`
**Status:** ✅ Fixed and tested
## Vision & Goals
@@ -843,18 +875,27 @@ All API endpoints return JSON and follow RESTful conventions.
- [x] 100 tests passing with comprehensive coverage
- [x] Docker deployment production-ready
### Phase 3 Success (In Progress)
- [ ] Dashboard displays scans and trends with charts
- [ ] Scheduled scans execute automatically
- [ ] Timeline view shows scan history
- [ ] Real-time progress updates for running scans
### Phase 3 Success ✅ ACHIEVED
- [x] Dashboard displays scans and trends with charts
- [x] Scheduled scans execute automatically
- [x] Historical trend charts show scan history
- [x] Real-time progress updates for running scans
### Phase 4 Success
### Phase 4 Success ✅ ACHIEVED
- [x] Users can create configs from CIDR ranges via web UI
- [x] YAML editor with syntax highlighting works correctly
- [x] Config management UI provides list/view/edit/download/delete operations
- [x] Direct YAML upload works for advanced users
- [x] Configs immediately usable in scan triggers and schedules
- [x] Delete protection prevents removal of configs used by schedules
- [x] All tests passing (25+ unit and integration tests)
### Phase 5 Success (Email & Comparisons)
- [ ] Email notifications sent for critical alerts
- [ ] Comparison reports show meaningful diffs
- [ ] Settings UI allows configuration without editing files
- [ ] Settings UI allows SMTP configuration without editing files
### Phase 5 Success
### Phase 6 Success (CLI as API Client)
- [ ] CLI can trigger scans via API
- [ ] API tokens work for authentication
- [ ] Standalone CLI mode still functional
@@ -904,8 +945,10 @@ All API endpoints return JSON and follow RESTful conventions.
| 2025-11-14 | 1.0 | Initial roadmap created based on user requirements |
| 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.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.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-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 |
---
**Last Updated:** 2025-11-14
**Next Review:** Before Phase 3 kickoff (Dashboard enhancement, trend charts, scheduled scans)
**Last Updated:** 2025-11-17
**Next Review:** Before Phase 5 kickoff (Email & Comparisons)

View File

@@ -1,11 +0,0 @@
# Install dependencies
pip install -r requirements-web.txt
# Initialize database
python3 init_db.py --password yourpassword
# Run Flask app
python3 -m web.app
# Test Settings API
curl http://localhost:5000/api/settings/health

483
tests/test_config_api.py Normal file
View File

@@ -0,0 +1,483 @@
"""
Integration tests for Config API endpoints.
Tests all config API endpoints including CSV/YAML upload, listing, downloading,
and deletion with schedule protection.
"""
import pytest
import os
import tempfile
import shutil
from web.app import create_app
from web.models import Base
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
@pytest.fixture
def app():
"""Create test application"""
# Create temporary database
test_db = tempfile.mktemp(suffix='.db')
# Create temporary configs directory
temp_configs_dir = tempfile.mkdtemp()
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{test_db}',
'SECRET_KEY': 'test-secret-key',
'WTF_CSRF_ENABLED': False,
})
# Override configs directory in ConfigService
os.environ['CONFIGS_DIR'] = temp_configs_dir
# Create tables
with app.app_context():
Base.metadata.create_all(bind=app.db_session.get_bind())
yield app
# Cleanup
os.unlink(test_db)
shutil.rmtree(temp_configs_dir)
@pytest.fixture
def client(app):
"""Create test client"""
return app.test_client()
@pytest.fixture
def auth_headers(client):
"""Get authentication headers"""
# First register and login a user
from web.auth.models import User
with client.application.app_context():
# Create test user
user = User(username='testuser')
user.set_password('testpass')
client.application.db_session.add(user)
client.application.db_session.commit()
# Login
response = client.post('/auth/login', data={
'username': 'testuser',
'password': 'testpass'
}, follow_redirects=True)
assert response.status_code == 200
# Return empty headers (session-based auth)
return {}
@pytest.fixture
def sample_csv():
"""Sample CSV content"""
return """scan_title,site_name,ip_address,ping_expected,tcp_ports,udp_ports,services
Test Scan,Web Servers,10.10.20.4,true,"22,80,443",53,"ssh,http,https"
Test Scan,Web Servers,10.10.20.5,true,22,,"ssh"
"""
@pytest.fixture
def sample_yaml():
"""Sample YAML content"""
return """title: Test Scan
sites:
- name: Web Servers
ips:
- address: 10.10.20.4
expected:
ping: true
tcp_ports: [22, 80, 443]
udp_ports: [53]
services: [ssh, http, https]
"""
class TestListConfigs:
"""Tests for GET /api/configs"""
def test_list_configs_empty(self, client, auth_headers):
"""Test listing configs when none exist"""
response = client.get('/api/configs', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert 'configs' in data
assert data['configs'] == []
def test_list_configs_with_files(self, client, auth_headers, app, sample_yaml):
"""Test listing configs with existing files"""
# Create a config file
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml)
response = client.get('/api/configs', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert len(data['configs']) == 1
assert data['configs'][0]['filename'] == 'test-scan.yaml'
assert data['configs'][0]['title'] == 'Test Scan'
assert 'created_at' in data['configs'][0]
assert 'size_bytes' in data['configs'][0]
assert 'used_by_schedules' in data['configs'][0]
def test_list_configs_requires_auth(self, client):
"""Test that listing configs requires authentication"""
response = client.get('/api/configs')
assert response.status_code in [401, 302] # Unauthorized or redirect
class TestGetConfig:
"""Tests for GET /api/configs/<filename>"""
def test_get_config_valid(self, client, auth_headers, app, sample_yaml):
"""Test getting a valid config file"""
# Create a config file
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml)
response = client.get('/api/configs/test-scan.yaml', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data['filename'] == 'test-scan.yaml'
assert 'content' in data
assert 'parsed' in data
assert data['parsed']['title'] == 'Test Scan'
def test_get_config_not_found(self, client, auth_headers):
"""Test getting non-existent config"""
response = client.get('/api/configs/nonexistent.yaml', headers=auth_headers)
assert response.status_code == 404
data = response.get_json()
assert 'error' in data
def test_get_config_requires_auth(self, client):
"""Test that getting config requires authentication"""
response = client.get('/api/configs/test.yaml')
assert response.status_code in [401, 302]
class TestUploadCSV:
"""Tests for POST /api/configs/upload-csv"""
def test_upload_csv_valid(self, client, auth_headers, sample_csv):
"""Test uploading valid CSV"""
from io import BytesIO
data = {
'file': (BytesIO(sample_csv.encode('utf-8')), 'test.csv')
}
response = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 200
result = response.get_json()
assert result['success'] is True
assert 'filename' in result
assert result['filename'].endswith('.yaml')
assert 'preview' in result
def test_upload_csv_no_file(self, client, auth_headers):
"""Test uploading without file"""
response = client.post('/api/configs/upload-csv', data={},
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
def test_upload_csv_invalid_format(self, client, auth_headers):
"""Test uploading invalid CSV"""
from io import BytesIO
invalid_csv = "not,a,valid,csv\nmissing,columns"
data = {
'file': (BytesIO(invalid_csv.encode('utf-8')), 'test.csv')
}
response = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
result = response.get_json()
assert 'error' in result
def test_upload_csv_wrong_extension(self, client, auth_headers):
"""Test uploading file with wrong extension"""
from io import BytesIO
data = {
'file': (BytesIO(b'test'), 'test.txt')
}
response = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_csv_duplicate_filename(self, client, auth_headers, sample_csv):
"""Test uploading CSV that generates duplicate filename"""
from io import BytesIO
data = {
'file': (BytesIO(sample_csv.encode('utf-8')), 'test.csv')
}
# Upload first time
response1 = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response1.status_code == 200
# Upload second time (should fail)
response2 = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response2.status_code == 400
def test_upload_csv_requires_auth(self, client, sample_csv):
"""Test that uploading CSV requires authentication"""
from io import BytesIO
data = {
'file': (BytesIO(sample_csv.encode('utf-8')), 'test.csv')
}
response = client.post('/api/configs/upload-csv', data=data,
content_type='multipart/form-data')
assert response.status_code in [401, 302]
class TestUploadYAML:
"""Tests for POST /api/configs/upload-yaml"""
def test_upload_yaml_valid(self, client, auth_headers, sample_yaml):
"""Test uploading valid YAML"""
from io import BytesIO
data = {
'file': (BytesIO(sample_yaml.encode('utf-8')), 'test.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 200
result = response.get_json()
assert result['success'] is True
assert 'filename' in result
def test_upload_yaml_no_file(self, client, auth_headers):
"""Test uploading without file"""
response = client.post('/api/configs/upload-yaml', data={},
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_yaml_invalid_syntax(self, client, auth_headers):
"""Test uploading YAML with invalid syntax"""
from io import BytesIO
invalid_yaml = "invalid: yaml: syntax: ["
data = {
'file': (BytesIO(invalid_yaml.encode('utf-8')), 'test.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_yaml_missing_required_fields(self, client, auth_headers):
"""Test uploading YAML missing required fields"""
from io import BytesIO
invalid_yaml = """sites:
- name: Test
ips:
- address: 10.0.0.1
"""
data = {
'file': (BytesIO(invalid_yaml.encode('utf-8')), 'test.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_yaml_wrong_extension(self, client, auth_headers):
"""Test uploading file with wrong extension"""
from io import BytesIO
data = {
'file': (BytesIO(b'test'), 'test.txt')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_yaml_requires_auth(self, client, sample_yaml):
"""Test that uploading YAML requires authentication"""
from io import BytesIO
data = {
'file': (BytesIO(sample_yaml.encode('utf-8')), 'test.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
content_type='multipart/form-data')
assert response.status_code in [401, 302]
class TestDownloadTemplate:
"""Tests for GET /api/configs/template"""
def test_download_template(self, client, auth_headers):
"""Test downloading CSV template"""
response = client.get('/api/configs/template', headers=auth_headers)
assert response.status_code == 200
assert response.content_type == 'text/csv; charset=utf-8'
assert b'scan_title,site_name,ip_address' in response.data
def test_download_template_requires_auth(self, client):
"""Test that downloading template requires authentication"""
response = client.get('/api/configs/template')
assert response.status_code in [401, 302]
class TestDownloadConfig:
"""Tests for GET /api/configs/<filename>/download"""
def test_download_config_valid(self, client, auth_headers, app, sample_yaml):
"""Test downloading existing config"""
# Create a config file
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml)
response = client.get('/api/configs/test-scan.yaml/download', headers=auth_headers)
assert response.status_code == 200
assert response.content_type == 'application/x-yaml; charset=utf-8'
assert b'title: Test Scan' in response.data
def test_download_config_not_found(self, client, auth_headers):
"""Test downloading non-existent config"""
response = client.get('/api/configs/nonexistent.yaml/download', headers=auth_headers)
assert response.status_code == 404
def test_download_config_requires_auth(self, client):
"""Test that downloading config requires authentication"""
response = client.get('/api/configs/test.yaml/download')
assert response.status_code in [401, 302]
class TestDeleteConfig:
"""Tests for DELETE /api/configs/<filename>"""
def test_delete_config_valid(self, client, auth_headers, app, sample_yaml):
"""Test deleting a config file"""
# Create a config file
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml)
response = client.delete('/api/configs/test-scan.yaml', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# Verify file is deleted
assert not os.path.exists(config_path)
def test_delete_config_not_found(self, client, auth_headers):
"""Test deleting non-existent config"""
response = client.delete('/api/configs/nonexistent.yaml', headers=auth_headers)
assert response.status_code == 404
def test_delete_config_requires_auth(self, client):
"""Test that deleting config requires authentication"""
response = client.delete('/api/configs/test.yaml')
assert response.status_code in [401, 302]
class TestEndToEndWorkflow:
"""End-to-end workflow tests"""
def test_complete_csv_workflow(self, client, auth_headers, sample_csv):
"""Test complete CSV upload workflow"""
from io import BytesIO
# 1. Download template
response = client.get('/api/configs/template', headers=auth_headers)
assert response.status_code == 200
# 2. Upload CSV
data = {
'file': (BytesIO(sample_csv.encode('utf-8')), 'workflow-test.csv')
}
response = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 200
result = response.get_json()
filename = result['filename']
# 3. List configs (should include new one)
response = client.get('/api/configs', headers=auth_headers)
assert response.status_code == 200
configs = response.get_json()['configs']
assert any(c['filename'] == filename for c in configs)
# 4. Get config details
response = client.get(f'/api/configs/{filename}', headers=auth_headers)
assert response.status_code == 200
# 5. Download config
response = client.get(f'/api/configs/{filename}/download', headers=auth_headers)
assert response.status_code == 200
# 6. Delete config
response = client.delete(f'/api/configs/{filename}', headers=auth_headers)
assert response.status_code == 200
# 7. Verify deletion
response = client.get(f'/api/configs/{filename}', headers=auth_headers)
assert response.status_code == 404
def test_yaml_upload_workflow(self, client, auth_headers, sample_yaml):
"""Test YAML upload workflow"""
from io import BytesIO
# Upload YAML
data = {
'file': (BytesIO(sample_yaml.encode('utf-8')), 'yaml-workflow.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 200
filename = response.get_json()['filename']
# Verify it exists
response = client.get(f'/api/configs/{filename}', headers=auth_headers)
assert response.status_code == 200
# Clean up
client.delete(f'/api/configs/{filename}', headers=auth_headers)

View File

@@ -0,0 +1,505 @@
"""
Unit tests for Config Service
Tests the ConfigService class which manages scan configuration files.
"""
import pytest
import os
import yaml
import tempfile
import shutil
from web.services.config_service import ConfigService
class TestConfigService:
"""Test suite for ConfigService"""
@pytest.fixture
def temp_configs_dir(self):
"""Create a temporary directory for config files"""
temp_dir = tempfile.mkdtemp()
yield temp_dir
shutil.rmtree(temp_dir)
@pytest.fixture
def service(self, temp_configs_dir):
"""Create a ConfigService instance with temp directory"""
return ConfigService(configs_dir=temp_configs_dir)
@pytest.fixture
def sample_yaml_config(self):
"""Sample YAML config content"""
return """title: Test Scan
sites:
- name: Web Servers
ips:
- address: 10.10.20.4
expected:
ping: true
tcp_ports: [22, 80, 443]
udp_ports: [53]
services: [ssh, http, https]
"""
@pytest.fixture
def sample_csv_content(self):
"""Sample CSV content"""
return """scan_title,site_name,ip_address,ping_expected,tcp_ports,udp_ports,services
Test Scan,Web Servers,10.10.20.4,true,"22,80,443",53,"ssh,http,https"
Test Scan,Web Servers,10.10.20.5,true,22,,"ssh"
"""
def test_list_configs_empty_directory(self, service):
"""Test listing configs when directory is empty"""
configs = service.list_configs()
assert configs == []
def test_list_configs_with_files(self, service, temp_configs_dir, sample_yaml_config):
"""Test listing configs with existing files"""
# Create a config file
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml_config)
configs = service.list_configs()
assert len(configs) == 1
assert configs[0]['filename'] == 'test-scan.yaml'
assert configs[0]['title'] == 'Test Scan'
assert 'created_at' in configs[0]
assert 'size_bytes' in configs[0]
assert 'used_by_schedules' in configs[0]
def test_list_configs_ignores_non_yaml_files(self, service, temp_configs_dir):
"""Test that non-YAML files are ignored"""
# Create non-YAML files
with open(os.path.join(temp_configs_dir, 'test.txt'), 'w') as f:
f.write('not a yaml file')
with open(os.path.join(temp_configs_dir, 'readme.md'), 'w') as f:
f.write('# README')
configs = service.list_configs()
assert len(configs) == 0
def test_get_config_valid(self, service, temp_configs_dir, sample_yaml_config):
"""Test getting a valid config file"""
# Create a config file
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml_config)
result = service.get_config('test-scan.yaml')
assert result['filename'] == 'test-scan.yaml'
assert 'content' in result
assert 'parsed' in result
assert result['parsed']['title'] == 'Test Scan'
assert len(result['parsed']['sites']) == 1
def test_get_config_not_found(self, service):
"""Test getting a non-existent config"""
with pytest.raises(FileNotFoundError, match="not found"):
service.get_config('nonexistent.yaml')
def test_get_config_invalid_yaml(self, service, temp_configs_dir):
"""Test getting a config with invalid YAML syntax"""
# Create invalid YAML file
config_path = os.path.join(temp_configs_dir, 'invalid.yaml')
with open(config_path, 'w') as f:
f.write("invalid: yaml: syntax: [")
with pytest.raises(ValueError, match="Invalid YAML syntax"):
service.get_config('invalid.yaml')
def test_create_from_yaml_valid(self, service, sample_yaml_config):
"""Test creating config from valid YAML"""
filename = service.create_from_yaml('test-scan.yaml', sample_yaml_config)
assert filename == 'test-scan.yaml'
assert service.config_exists('test-scan.yaml')
# Verify content
result = service.get_config('test-scan.yaml')
assert result['parsed']['title'] == 'Test Scan'
def test_create_from_yaml_adds_extension(self, service, sample_yaml_config):
"""Test that .yaml extension is added if missing"""
filename = service.create_from_yaml('test-scan', sample_yaml_config)
assert filename == 'test-scan.yaml'
assert service.config_exists('test-scan.yaml')
def test_create_from_yaml_sanitizes_filename(self, service, sample_yaml_config):
"""Test that filename is sanitized"""
filename = service.create_from_yaml('../../../etc/test.yaml', sample_yaml_config)
# secure_filename should remove path traversal
assert '..' not in filename
assert '/' not in filename
def test_create_from_yaml_duplicate_filename(self, service, temp_configs_dir, sample_yaml_config):
"""Test creating config with duplicate filename"""
# Create first config
service.create_from_yaml('test-scan.yaml', sample_yaml_config)
# Try to create duplicate
with pytest.raises(ValueError, match="already exists"):
service.create_from_yaml('test-scan.yaml', sample_yaml_config)
def test_create_from_yaml_invalid_syntax(self, service):
"""Test creating config with invalid YAML syntax"""
invalid_yaml = "invalid: yaml: syntax: ["
with pytest.raises(ValueError, match="Invalid YAML syntax"):
service.create_from_yaml('test.yaml', invalid_yaml)
def test_create_from_yaml_invalid_structure(self, service):
"""Test creating config with invalid structure (missing title)"""
invalid_config = """sites:
- name: Test
ips:
- address: 10.0.0.1
expected:
ping: true
"""
with pytest.raises(ValueError, match="Missing required field: 'title'"):
service.create_from_yaml('test.yaml', invalid_config)
def test_create_from_csv_valid(self, service, sample_csv_content):
"""Test creating config from valid CSV"""
filename, yaml_content = service.create_from_csv(sample_csv_content)
assert filename == 'test-scan.yaml'
assert service.config_exists(filename)
# Verify YAML was created correctly
result = service.get_config(filename)
assert result['parsed']['title'] == 'Test Scan'
assert len(result['parsed']['sites']) == 1
assert len(result['parsed']['sites'][0]['ips']) == 2
def test_create_from_csv_with_suggested_filename(self, service, sample_csv_content):
"""Test creating config with suggested filename"""
filename, yaml_content = service.create_from_csv(sample_csv_content, 'custom-name.yaml')
assert filename == 'custom-name.yaml'
assert service.config_exists(filename)
def test_create_from_csv_invalid(self, service):
"""Test creating config from invalid CSV"""
invalid_csv = """scan_title,site_name,ip_address
Missing,Columns,Here
"""
with pytest.raises(ValueError, match="CSV parsing failed"):
service.create_from_csv(invalid_csv)
def test_create_from_csv_duplicate_filename(self, service, sample_csv_content):
"""Test creating CSV config with duplicate filename"""
# Create first config
service.create_from_csv(sample_csv_content)
# Try to create duplicate (same title generates same filename)
with pytest.raises(ValueError, match="already exists"):
service.create_from_csv(sample_csv_content)
def test_delete_config_valid(self, service, temp_configs_dir, sample_yaml_config):
"""Test deleting a config file"""
# Create a config file
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml_config)
assert service.config_exists('test-scan.yaml')
service.delete_config('test-scan.yaml')
assert not service.config_exists('test-scan.yaml')
def test_delete_config_not_found(self, service):
"""Test deleting non-existent config"""
with pytest.raises(FileNotFoundError, match="not found"):
service.delete_config('nonexistent.yaml')
def test_delete_config_used_by_schedule(self, service, temp_configs_dir, sample_yaml_config, monkeypatch):
"""Test deleting config that is used by schedules"""
# Create a config file
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml_config)
# Mock get_schedules_using_config to return schedules
def mock_get_schedules(filename):
return ['Daily Scan', 'Weekly Audit']
monkeypatch.setattr(service, 'get_schedules_using_config', mock_get_schedules)
with pytest.raises(ValueError, match="used by the following schedules"):
service.delete_config('test-scan.yaml')
# Config should still exist
assert service.config_exists('test-scan.yaml')
def test_validate_config_content_valid(self, service):
"""Test validating valid config content"""
valid_config = {
'title': 'Test Scan',
'sites': [
{
'name': 'Web Servers',
'ips': [
{
'address': '10.10.20.4',
'expected': {
'ping': True,
'tcp_ports': [22, 80, 443],
'udp_ports': [53]
}
}
]
}
]
}
is_valid, error = service.validate_config_content(valid_config)
assert is_valid is True
assert error == ""
def test_validate_config_content_not_dict(self, service):
"""Test validating non-dict content"""
is_valid, error = service.validate_config_content(['not', 'a', 'dict'])
assert is_valid is False
assert 'must be a dictionary' in error
def test_validate_config_content_missing_title(self, service):
"""Test validating config without title"""
config = {
'sites': []
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "Missing required field: 'title'" in error
def test_validate_config_content_missing_sites(self, service):
"""Test validating config without sites"""
config = {
'title': 'Test'
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "Missing required field: 'sites'" in error
def test_validate_config_content_empty_title(self, service):
"""Test validating config with empty title"""
config = {
'title': '',
'sites': []
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "non-empty string" in error
def test_validate_config_content_sites_not_list(self, service):
"""Test validating config with sites as non-list"""
config = {
'title': 'Test',
'sites': 'not a list'
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "must be a list" in error
def test_validate_config_content_no_sites(self, service):
"""Test validating config with empty sites list"""
config = {
'title': 'Test',
'sites': []
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "at least one site" in error
def test_validate_config_content_site_missing_name(self, service):
"""Test validating site without name"""
config = {
'title': 'Test',
'sites': [
{
'ips': []
}
]
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "missing required field: 'name'" in error
def test_validate_config_content_site_missing_ips(self, service):
"""Test validating site without ips"""
config = {
'title': 'Test',
'sites': [
{
'name': 'Test Site'
}
]
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "missing required field: 'ips'" in error
def test_validate_config_content_site_no_ips(self, service):
"""Test validating site with empty ips list"""
config = {
'title': 'Test',
'sites': [
{
'name': 'Test Site',
'ips': []
}
]
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "at least one IP" in error
def test_validate_config_content_ip_missing_address(self, service):
"""Test validating IP without address"""
config = {
'title': 'Test',
'sites': [
{
'name': 'Test Site',
'ips': [
{
'expected': {}
}
]
}
]
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "missing required field: 'address'" in error
def test_validate_config_content_ip_missing_expected(self, service):
"""Test validating IP without expected"""
config = {
'title': 'Test',
'sites': [
{
'name': 'Test Site',
'ips': [
{
'address': '10.0.0.1'
}
]
}
]
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "missing required field: 'expected'" in error
def test_generate_filename_from_title_simple(self, service):
"""Test generating filename from simple title"""
filename = service.generate_filename_from_title('Production Scan')
assert filename == 'production-scan.yaml'
def test_generate_filename_from_title_special_chars(self, service):
"""Test generating filename with special characters"""
filename = service.generate_filename_from_title('Prod Scan (2025)!')
assert filename == 'prod-scan-2025.yaml'
assert '(' not in filename
assert ')' not in filename
assert '!' not in filename
def test_generate_filename_from_title_multiple_spaces(self, service):
"""Test generating filename with multiple spaces"""
filename = service.generate_filename_from_title('Test Multiple Spaces')
assert filename == 'test-multiple-spaces.yaml'
# Should not have consecutive hyphens
assert '--' not in filename
def test_generate_filename_from_title_leading_trailing_spaces(self, service):
"""Test generating filename with leading/trailing spaces"""
filename = service.generate_filename_from_title(' Test Scan ')
assert filename == 'test-scan.yaml'
assert not filename.startswith('-')
assert not filename.endswith('-.yaml')
def test_generate_filename_from_title_long(self, service):
"""Test generating filename from long title"""
long_title = 'A' * 300
filename = service.generate_filename_from_title(long_title)
# Should be limited to 200 chars (195 + .yaml)
assert len(filename) <= 200
def test_generate_filename_from_title_empty(self, service):
"""Test generating filename from empty title"""
filename = service.generate_filename_from_title('')
assert filename == 'config.yaml'
def test_generate_filename_from_title_only_special_chars(self, service):
"""Test generating filename from title with only special characters"""
filename = service.generate_filename_from_title('!@#$%^&*()')
assert filename == 'config.yaml'
def test_get_config_path(self, service, temp_configs_dir):
"""Test getting config path"""
path = service.get_config_path('test.yaml')
assert path == os.path.join(temp_configs_dir, 'test.yaml')
def test_config_exists_true(self, service, temp_configs_dir, sample_yaml_config):
"""Test config_exists returns True for existing file"""
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml_config)
assert service.config_exists('test-scan.yaml') is True
def test_config_exists_false(self, service):
"""Test config_exists returns False for non-existent file"""
assert service.config_exists('nonexistent.yaml') is False
def test_get_schedules_using_config_none(self, service):
"""Test getting schedules when none use the config"""
schedules = service.get_schedules_using_config('test.yaml')
# Should return empty list (ScheduleService might not exist in test env)
assert isinstance(schedules, list)
def test_list_configs_sorted_by_date(self, service, temp_configs_dir, sample_yaml_config):
"""Test that configs are sorted by creation date (most recent first)"""
import time
# Create first config
config1_path = os.path.join(temp_configs_dir, 'config1.yaml')
with open(config1_path, 'w') as f:
f.write(sample_yaml_config)
time.sleep(0.1) # Ensure different timestamps
# Create second config
config2_path = os.path.join(temp_configs_dir, 'config2.yaml')
with open(config2_path, 'w') as f:
f.write(sample_yaml_config)
configs = service.list_configs()
assert len(configs) == 2
# Most recent should be first
assert configs[0]['filename'] == 'config2.yaml'
assert configs[1]['filename'] == 'config1.yaml'
def test_list_configs_handles_parse_errors(self, service, temp_configs_dir):
"""Test that list_configs handles files that can't be parsed"""
# Create invalid YAML file
config_path = os.path.join(temp_configs_dir, 'invalid.yaml')
with open(config_path, 'w') as f:
f.write("invalid: yaml: [")
# Should not raise error, just use filename as title
configs = service.list_configs()
assert len(configs) == 1
assert configs[0]['filename'] == 'invalid.yaml'

458
web/api/configs.py Normal file
View File

@@ -0,0 +1,458 @@
"""
Configs API blueprint.
Handles endpoints for managing scan configuration files, including CSV/YAML upload,
template download, and config management.
"""
import logging
import io
from flask import Blueprint, jsonify, request, send_file
from werkzeug.utils import secure_filename
from web.auth.decorators import api_auth_required
from web.services.config_service import ConfigService
bp = Blueprint('configs', __name__)
logger = logging.getLogger(__name__)
@bp.route('', methods=['GET'])
@api_auth_required
def list_configs():
"""
List all config files with metadata.
Returns:
JSON response with list of configs:
{
"configs": [
{
"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"]
}
]
}
"""
try:
config_service = ConfigService()
configs = config_service.list_configs()
logger.info(f"Listed {len(configs)} config files")
return jsonify({
'configs': configs
})
except Exception as e:
logger.error(f"Unexpected error listing configs: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<filename>', methods=['GET'])
@api_auth_required
def get_config(filename: str):
"""
Get config file content and parsed data.
Args:
filename: Config filename
Returns:
JSON response with config content:
{
"filename": "prod-scan.yaml",
"content": "title: Prod Scan\n...",
"parsed": {"title": "Prod Scan", "sites": [...]}
}
"""
try:
# Sanitize filename
filename = secure_filename(filename)
config_service = ConfigService()
config_data = config_service.get_config(filename)
logger.info(f"Retrieved config file: {filename}")
return jsonify(config_data)
except FileNotFoundError as e:
logger.warning(f"Config file not found: {filename}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
except ValueError as e:
logger.warning(f"Invalid config file: {filename} - {str(e)}")
return jsonify({
'error': 'Invalid config',
'message': str(e)
}), 400
except Exception as e:
logger.error(f"Unexpected error getting config {filename}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/create-from-cidr', methods=['POST'])
@api_auth_required
def create_from_cidr():
"""
Create config from CIDR range.
Request:
JSON with:
{
"title": "My Scan",
"cidr": "10.0.0.0/24",
"site_name": "Production" (optional),
"ping_default": false (optional)
}
Returns:
JSON response with created config info:
{
"success": true,
"filename": "my-scan.yaml",
"preview": "title: My Scan\n..."
}
"""
try:
data = request.get_json()
if not data:
return jsonify({
'error': 'Bad request',
'message': 'Request body must be JSON'
}), 400
# Validate required fields
if 'title' not in data:
return jsonify({
'error': 'Bad request',
'message': 'Missing required field: title'
}), 400
if 'cidr' not in data:
return jsonify({
'error': 'Bad request',
'message': 'Missing required field: cidr'
}), 400
title = data['title']
cidr = data['cidr']
site_name = data.get('site_name', None)
ping_default = data.get('ping_default', False)
# Validate title
if not title or not title.strip():
return jsonify({
'error': 'Validation error',
'message': 'Title cannot be empty'
}), 400
# Create config from CIDR
config_service = ConfigService()
filename, yaml_preview = config_service.create_from_cidr(
title=title,
cidr=cidr,
site_name=site_name,
ping_default=ping_default
)
logger.info(f"Created config from CIDR {cidr}: {filename}")
return jsonify({
'success': True,
'filename': filename,
'preview': yaml_preview
})
except ValueError as e:
logger.warning(f"CIDR validation failed: {str(e)}")
return jsonify({
'error': 'Validation error',
'message': str(e)
}), 400
except Exception as e:
logger.error(f"Unexpected error creating config from CIDR: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/upload-yaml', methods=['POST'])
@api_auth_required
def upload_yaml():
"""
Upload YAML config file directly.
Request:
multipart/form-data with 'file' field containing YAML file
Optional 'filename' field for custom filename
Returns:
JSON response with created config info:
{
"success": true,
"filename": "prod-scan.yaml"
}
"""
try:
# Check if file is present
if 'file' not in request.files:
return jsonify({
'error': 'Bad request',
'message': 'No file provided'
}), 400
file = request.files['file']
# Check if file is selected
if file.filename == '':
return jsonify({
'error': 'Bad request',
'message': 'No file selected'
}), 400
# Check file extension
if not (file.filename.endswith('.yaml') or file.filename.endswith('.yml')):
return jsonify({
'error': 'Bad request',
'message': 'File must be a YAML file (.yaml or .yml extension)'
}), 400
# Read YAML content
yaml_content = file.read().decode('utf-8')
# Get filename (use uploaded filename or custom)
filename = request.form.get('filename', file.filename)
filename = secure_filename(filename)
# Create config from YAML
config_service = ConfigService()
final_filename = config_service.create_from_yaml(filename, yaml_content)
logger.info(f"Created config from YAML upload: {final_filename}")
return jsonify({
'success': True,
'filename': final_filename
})
except ValueError as e:
logger.warning(f"YAML validation failed: {str(e)}")
return jsonify({
'error': 'Validation error',
'message': str(e)
}), 400
except UnicodeDecodeError:
logger.warning("YAML file encoding error")
return jsonify({
'error': 'Encoding error',
'message': 'YAML file must be UTF-8 encoded'
}), 400
except Exception as e:
logger.error(f"Unexpected error uploading YAML: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<filename>/download', methods=['GET'])
@api_auth_required
def download_config(filename: str):
"""
Download existing config file.
Args:
filename: Config filename
Returns:
YAML file download
"""
try:
# Sanitize filename
filename = secure_filename(filename)
config_service = ConfigService()
config_data = config_service.get_config(filename)
# Create file-like object
yaml_file = io.BytesIO(config_data['content'].encode('utf-8'))
yaml_file.seek(0)
logger.info(f"Config file downloaded: {filename}")
# Send file
return send_file(
yaml_file,
mimetype='application/x-yaml',
as_attachment=True,
download_name=filename
)
except FileNotFoundError as e:
logger.warning(f"Config file not found: {filename}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
except Exception as e:
logger.error(f"Unexpected error downloading config {filename}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<filename>', methods=['PUT'])
@api_auth_required
def update_config(filename: str):
"""
Update existing config file with new YAML content.
Args:
filename: Config filename
Request:
JSON with:
{
"content": "title: My Scan\nsites: ..."
}
Returns:
JSON response with success status:
{
"success": true,
"message": "Config updated successfully"
}
Error responses:
- 400: Invalid YAML or config structure
- 404: Config file not found
- 500: Internal server error
"""
try:
# Sanitize filename
filename = secure_filename(filename)
data = request.get_json()
if not data or 'content' not in data:
return jsonify({
'error': 'Bad request',
'message': 'Missing required field: content'
}), 400
yaml_content = data['content']
# Update config
config_service = ConfigService()
config_service.update_config(filename, yaml_content)
logger.info(f"Updated config file: {filename}")
return jsonify({
'success': True,
'message': 'Config updated successfully'
})
except FileNotFoundError as e:
logger.warning(f"Config file not found: {filename}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
except ValueError as e:
logger.warning(f"Invalid config content for {filename}: {str(e)}")
return jsonify({
'error': 'Validation error',
'message': str(e)
}), 400
except Exception as e:
logger.error(f"Unexpected error updating config {filename}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<filename>', methods=['DELETE'])
@api_auth_required
def delete_config(filename: str):
"""
Delete config file.
Args:
filename: Config filename
Returns:
JSON response with success status:
{
"success": true,
"message": "Config deleted successfully"
}
Error responses:
- 404: Config file not found
- 422: Config is used by schedules (cannot delete)
- 500: Internal server error
"""
try:
# Sanitize filename
filename = secure_filename(filename)
config_service = ConfigService()
config_service.delete_config(filename)
logger.info(f"Deleted config file: {filename}")
return jsonify({
'success': True,
'message': 'Config deleted successfully'
})
except FileNotFoundError as e:
logger.warning(f"Config file not found: {filename}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
except ValueError as e:
# Config is used by schedules
logger.warning(f"Cannot delete config {filename}: {str(e)}")
return jsonify({
'error': 'Config in use',
'message': str(e)
}), 422
except Exception as e:
logger.error(f"Unexpected error deleting config {filename}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500

View File

@@ -61,7 +61,7 @@ def create_app(config: dict = None) -> Flask:
SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', 'sqlite:///./sneakyscanner.db'),
SQLALCHEMY_TRACK_MODIFICATIONS=False,
JSON_SORT_KEYS=False, # Preserve order in JSON responses
MAX_CONTENT_LENGTH=50 * 1024 * 1024, # 50MB max upload size
MAX_CONTENT_LENGTH=50 * 1024 * 1024, # 50MB max upload size (supports config files up to ~2MB)
)
# Override with custom config if provided
@@ -330,6 +330,7 @@ def register_blueprints(app: Flask) -> None:
from web.api.alerts import bp as alerts_bp
from web.api.settings import bp as settings_bp
from web.api.stats import bp as stats_bp
from web.api.configs import bp as configs_bp
from web.auth.routes import bp as auth_bp
from web.routes.main import bp as main_bp
@@ -345,6 +346,7 @@ def register_blueprints(app: Flask) -> None:
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
app.register_blueprint(settings_bp, url_prefix='/api/settings')
app.register_blueprint(stats_bp, url_prefix='/api/stats')
app.register_blueprint(configs_bp, url_prefix='/api/configs')
app.logger.info("Blueprints registered")

View File

@@ -162,3 +162,60 @@ def edit_schedule(schedule_id):
# Note: Schedule data is loaded via AJAX in the template
# This just renders the page with the schedule_id in the URL
return render_template('schedule_edit.html', schedule_id=schedule_id)
@bp.route('/configs')
@login_required
def configs():
"""
Configuration files list page - shows all config files.
Returns:
Rendered configs list template
"""
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'))

View File

@@ -0,0 +1,495 @@
"""
Config Service - Business logic for config file management
This service handles all operations related to scan configuration files,
including creation, validation, listing, and deletion.
"""
import os
import re
import yaml
import ipaddress
from typing import Dict, List, Tuple, Any, Optional
from datetime import datetime
from pathlib import Path
from werkzeug.utils import secure_filename
class ConfigService:
"""Business logic for config management"""
def __init__(self, configs_dir: str = '/app/configs'):
"""
Initialize the config service.
Args:
configs_dir: Directory where config files are stored
"""
self.configs_dir = configs_dir
# Ensure configs directory exists
os.makedirs(self.configs_dir, exist_ok=True)
def list_configs(self) -> List[Dict[str, Any]]:
"""
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}")
# 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(self, filename: str, yaml_content: str) -> None:
"""
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(self, filename: str) -> None:
"""
Delete config file if not used by schedules.
Args:
filename: Config filename to delete
Raises:
FileNotFoundError: If config doesn't exist
ValueError: If config used by active schedules
"""
filepath = os.path.join(self.configs_dir, filename)
if not os.path.exists(filepath):
raise FileNotFoundError(f"Config file '{filename}' not found")
# Check if used by schedules
schedules = self.get_schedules_using_config(filename)
if schedules:
schedule_list = ', '.join(schedules)
raise ValueError(
f"Cannot delete config '{filename}' because it is used by the following schedules: {schedule_list}"
)
# Delete file
os.remove(filepath)
def validate_config_content(self, content: Dict) -> Tuple[bool, str]:
"""
Validate parsed YAML config structure.
Args:
content: Parsed YAML config as dict
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"
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'"
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
schedule_service = ScheduleService()
# Get all schedules
schedules = schedule_service.list_schedules()
# Build full path for comparison
config_path = os.path.join(self.configs_dir, filename)
# Find schedules using this config
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:
using_schedules.append(schedule.get('name', 'Unknown'))
return using_schedules
except ImportError:
# If ScheduleService doesn't exist yet, return empty list
return []
except Exception:
# If any error occurs, return empty list (safer than failing)
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)

View File

@@ -0,0 +1,507 @@
/**
* Config Manager Styles
* Phase 4: Config Creator - CSS styling for config management UI
*/
/* ============================================
Dropzone Styling
============================================ */
.dropzone {
border: 2px dashed #6c757d;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background-color: #1e293b;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.dropzone:hover {
border-color: #0d6efd;
background-color: #2d3748;
}
.dropzone.dragover {
border-color: #0d6efd;
background-color: #1a365d;
border-width: 3px;
}
.dropzone i {
font-size: 48px;
color: #94a3b8;
margin-bottom: 16px;
display: block;
}
.dropzone p {
color: #cbd5e0;
margin: 0;
font-size: 1rem;
}
.dropzone:hover i {
color: #0d6efd;
}
/* ============================================
Preview Pane Styling
============================================ */
#yaml-preview {
background-color: #1e293b;
border-radius: 8px;
padding: 16px;
}
#yaml-preview pre {
background-color: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
padding: 16px;
max-height: 500px;
overflow-y: auto;
margin: 0;
}
#yaml-preview pre code {
color: #e2e8f0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
line-height: 1.6;
white-space: pre;
}
#preview-placeholder {
background-color: #1e293b;
border: 2px dashed #475569;
border-radius: 8px;
padding: 60px 20px;
text-align: center;
color: #94a3b8;
}
#preview-placeholder i {
font-size: 3rem;
margin-bottom: 1rem;
display: block;
opacity: 0.5;
}
/* ============================================
Config Table Styling
============================================ */
#configs-table {
background-color: #1e293b;
border-radius: 8px;
overflow: hidden;
}
#configs-table thead {
background-color: #0f172a;
border-bottom: 2px solid #334155;
}
#configs-table thead th {
color: #cbd5e0;
font-weight: 600;
padding: 12px 16px;
border: none;
}
#configs-table tbody tr {
border-bottom: 1px solid #334155;
transition: background-color 0.2s ease;
}
#configs-table tbody tr:hover {
background-color: #2d3748;
}
#configs-table tbody td {
padding: 12px 16px;
color: #e2e8f0;
vertical-align: middle;
border: none;
}
#configs-table tbody td code {
background-color: #0f172a;
padding: 2px 6px;
border-radius: 4px;
color: #60a5fa;
font-size: 0.9rem;
}
/* ============================================
Action Buttons
============================================ */
.config-actions {
white-space: nowrap;
}
.config-actions .btn {
margin-right: 4px;
padding: 4px 8px;
font-size: 0.875rem;
}
.config-actions .btn:last-child {
margin-right: 0;
}
.config-actions .btn i {
font-size: 1rem;
}
/* Disabled button styling */
.config-actions .btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ============================================
Schedule Badge
============================================ */
.schedule-badge {
display: inline-block;
background-color: #3b82f6;
color: white;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
min-width: 24px;
text-align: center;
cursor: help;
}
.schedule-badge:hover {
background-color: #2563eb;
}
/* ============================================
Search Box
============================================ */
#search {
background-color: #1e293b;
border: 1px solid #475569;
color: #e2e8f0;
padding: 8px 12px;
border-radius: 6px;
transition: border-color 0.2s ease;
}
#search:focus {
background-color: #0f172a;
border-color: #3b82f6;
color: #e2e8f0;
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
#search::placeholder {
color: #64748b;
}
/* ============================================
Alert Messages
============================================ */
.alert {
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
}
.alert-danger {
background-color: #7f1d1d;
border: 1px solid #991b1b;
color: #fecaca;
}
.alert-success {
background-color: #14532d;
border: 1px solid #166534;
color: #86efac;
}
.alert i {
margin-right: 8px;
}
/* ============================================
Card Styling
============================================ */
.card {
background-color: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
margin-bottom: 20px;
}
.card-body {
padding: 20px;
}
.card h5 {
color: #cbd5e0;
margin-bottom: 16px;
}
.card .text-muted {
color: #94a3b8 !important;
}
/* ============================================
Tab Navigation
============================================ */
.nav-tabs {
border-bottom: 2px solid #334155;
}
.nav-tabs .nav-link {
color: #94a3b8;
border: none;
border-bottom: 2px solid transparent;
padding: 12px 20px;
transition: all 0.2s ease;
}
.nav-tabs .nav-link:hover {
color: #cbd5e0;
background-color: #2d3748;
border-color: transparent;
}
.nav-tabs .nav-link.active {
color: #60a5fa;
background-color: transparent;
border-color: transparent transparent #60a5fa transparent;
}
/* ============================================
Buttons
============================================ */
.btn {
border-radius: 6px;
padding: 8px 16px;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-primary {
background-color: #3b82f6;
border-color: #3b82f6;
}
.btn-primary:hover {
background-color: #2563eb;
border-color: #2563eb;
}
.btn-success {
background-color: #22c55e;
border-color: #22c55e;
}
.btn-success:hover {
background-color: #16a34a;
border-color: #16a34a;
}
.btn-outline-secondary {
color: #94a3b8;
border-color: #475569;
}
.btn-outline-secondary:hover {
background-color: #475569;
border-color: #475569;
color: #e2e8f0;
}
.btn-outline-primary {
color: #60a5fa;
border-color: #3b82f6;
}
.btn-outline-primary:hover {
background-color: #3b82f6;
border-color: #3b82f6;
color: white;
}
.btn-outline-danger {
color: #f87171;
border-color: #dc2626;
}
.btn-outline-danger:hover {
background-color: #dc2626;
border-color: #dc2626;
color: white;
}
/* ============================================
Modal Styling
============================================ */
.modal-content {
background-color: #1e293b;
border: 1px solid #334155;
color: #e2e8f0;
}
.modal-header {
border-bottom: 1px solid #334155;
}
.modal-footer {
border-top: 1px solid #334155;
}
.modal-title {
color: #cbd5e0;
}
.btn-close {
filter: invert(1);
}
/* ============================================
Spinner/Loading
============================================ */
.spinner-border {
color: #3b82f6;
}
/* ============================================
Responsive Adjustments
============================================ */
@media (max-width: 768px) {
#configs-table {
font-size: 0.875rem;
}
#configs-table thead th,
#configs-table tbody td {
padding: 8px 12px;
}
.config-actions .btn {
padding: 2px 6px;
margin-right: 2px;
}
.config-actions .btn i {
font-size: 0.9rem;
}
.dropzone {
padding: 30px 15px;
min-height: 150px;
}
.dropzone i {
font-size: 36px;
}
#yaml-preview pre {
max-height: 300px;
font-size: 0.8rem;
}
}
@media (max-width: 576px) {
/* Stack table cells on very small screens */
#configs-table thead {
display: none;
}
#configs-table tbody tr {
display: block;
margin-bottom: 16px;
border: 1px solid #334155;
border-radius: 8px;
padding: 12px;
}
#configs-table tbody td {
display: block;
text-align: left;
padding: 6px 0;
border: none;
}
#configs-table tbody td:before {
content: attr(data-label);
font-weight: 600;
color: #94a3b8;
display: inline-block;
width: 100px;
}
.config-actions {
margin-top: 8px;
}
}
/* ============================================
Utility Classes
============================================ */
.text-center {
text-align: center;
}
.py-4 {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.py-5 {
padding-top: 3rem;
padding-bottom: 3rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.mt-3 {
margin-top: 1rem;
}
.mb-3 {
margin-bottom: 1rem;
}
.mb-4 {
margin-bottom: 1.5rem;
}
/* ============================================
Result Count Display
============================================ */
#result-count {
color: #94a3b8;
font-size: 0.9rem;
font-weight: 500;
}

View File

@@ -0,0 +1,633 @@
/**
* Config Manager - Handles configuration file upload, management, and display
* Phase 4: Config Creator
*/
class ConfigManager {
constructor() {
this.apiBase = '/api/configs';
this.currentPreview = null;
this.currentFilename = null;
}
/**
* Load all configurations and populate the table
*/
async loadConfigs() {
try {
const response = await fetch(this.apiBase);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.renderConfigsTable(data.configs || []);
return data.configs;
} catch (error) {
console.error('Error loading configs:', error);
this.showError('Failed to load configurations: ' + error.message);
return [];
}
}
/**
* Get a specific configuration file
*/
async getConfig(filename) {
try {
const response = await fetch(`${this.apiBase}/${encodeURIComponent(filename)}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error getting config:', error);
this.showError('Failed to load configuration: ' + error.message);
throw error;
}
}
/**
* Upload CSV file and convert to YAML
*/
async uploadCSV(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch(`${this.apiBase}/upload-csv`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || `HTTP ${response.status}: ${response.statusText}`);
}
return data;
} catch (error) {
console.error('Error uploading CSV:', error);
throw error;
}
}
/**
* Upload YAML file directly
*/
async uploadYAML(file, filename = null) {
const formData = new FormData();
formData.append('file', file);
if (filename) {
formData.append('filename', filename);
}
try {
const response = await fetch(`${this.apiBase}/upload-yaml`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || `HTTP ${response.status}: ${response.statusText}`);
}
return data;
} catch (error) {
console.error('Error uploading YAML:', error);
throw error;
}
}
/**
* Delete a configuration file
*/
async deleteConfig(filename) {
try {
const response = await fetch(`${this.apiBase}/${encodeURIComponent(filename)}`, {
method: 'DELETE'
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || `HTTP ${response.status}: ${response.statusText}`);
}
return data;
} catch (error) {
console.error('Error deleting config:', error);
throw error;
}
}
/**
* Download CSV template
*/
downloadTemplate() {
window.location.href = `${this.apiBase}/template`;
}
/**
* Download a specific config file
*/
downloadConfig(filename) {
window.location.href = `${this.apiBase}/${encodeURIComponent(filename)}/download`;
}
/**
* Show YAML preview in the preview pane
*/
showPreview(yamlContent, filename = null) {
this.currentPreview = yamlContent;
this.currentFilename = filename;
const previewElement = document.getElementById('yaml-preview');
const contentElement = document.getElementById('yaml-content');
const placeholderElement = document.getElementById('preview-placeholder');
if (contentElement) {
contentElement.textContent = yamlContent;
}
if (previewElement) {
previewElement.style.display = 'block';
}
if (placeholderElement) {
placeholderElement.style.display = 'none';
}
// Enable save button
const saveBtn = document.getElementById('save-config-btn');
if (saveBtn) {
saveBtn.disabled = false;
}
}
/**
* Hide YAML preview
*/
hidePreview() {
this.currentPreview = null;
this.currentFilename = null;
const previewElement = document.getElementById('yaml-preview');
const placeholderElement = document.getElementById('preview-placeholder');
if (previewElement) {
previewElement.style.display = 'none';
}
if (placeholderElement) {
placeholderElement.style.display = 'block';
}
// Disable save button
const saveBtn = document.getElementById('save-config-btn');
if (saveBtn) {
saveBtn.disabled = true;
}
}
/**
* Render configurations table
*/
renderConfigsTable(configs) {
const tbody = document.querySelector('#configs-table tbody');
if (!tbody) {
console.warn('Configs table body not found');
return;
}
// Clear existing rows
tbody.innerHTML = '';
if (configs.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="text-center text-muted py-4">
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
<p class="mt-2">No configuration files found. Create your first config!</p>
</td>
</tr>
`;
return;
}
// Populate table
configs.forEach(config => {
const row = document.createElement('tr');
row.dataset.filename = config.filename;
// Format date
const createdDate = config.created_at ?
new Date(config.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
}) : 'Unknown';
// Format file size
const fileSize = config.size_bytes ?
this.formatFileSize(config.size_bytes) : 'Unknown';
// Schedule usage badge
const scheduleCount = config.used_by_schedules ? config.used_by_schedules.length : 0;
const scheduleBadge = scheduleCount > 0 ?
`<span class="schedule-badge" title="${config.used_by_schedules.join(', ')}">${scheduleCount}</span>` :
'<span class="text-muted">None</span>';
row.innerHTML = `
<td><code>${this.escapeHtml(config.filename)}</code></td>
<td>${this.escapeHtml(config.title || 'Untitled')}</td>
<td>${createdDate}</td>
<td>${fileSize}</td>
<td>${scheduleBadge}</td>
<td class="config-actions">
<button class="btn btn-sm btn-outline-secondary"
onclick="configManager.viewConfig('${this.escapeHtml(config.filename)}')"
title="View config">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-sm btn-outline-primary"
onclick="configManager.downloadConfig('${this.escapeHtml(config.filename)}')"
title="Download config">
<i class="bi bi-download"></i>
</button>
<button class="btn btn-sm btn-outline-danger"
onclick="configManager.confirmDelete('${this.escapeHtml(config.filename)}', ${scheduleCount})"
title="Delete config"
${scheduleCount > 0 ? 'disabled' : ''}>
<i class="bi bi-trash"></i>
</button>
</td>
`;
tbody.appendChild(row);
});
// Update result count
this.updateResultCount(configs.length);
}
/**
* View/preview a configuration file
*/
async viewConfig(filename) {
try {
const config = await this.getConfig(filename);
// Show modal with config content
const modalHtml = `
<div class="modal fade" id="viewConfigModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${this.escapeHtml(filename)}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<pre><code class="language-yaml">${this.escapeHtml(config.content)}</code></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary"
onclick="configManager.downloadConfig('${this.escapeHtml(filename)}')">
<i class="bi bi-download"></i> Download
</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if any
const existingModal = document.getElementById('viewConfigModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to page
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('viewConfigModal'));
modal.show();
// Clean up on close
document.getElementById('viewConfigModal').addEventListener('hidden.bs.modal', function() {
this.remove();
});
} catch (error) {
this.showError('Failed to view configuration: ' + error.message);
}
}
/**
* Confirm deletion of a configuration
*/
confirmDelete(filename, scheduleCount) {
if (scheduleCount > 0) {
this.showError(`Cannot delete "${filename}" - it is used by ${scheduleCount} schedule(s)`);
return;
}
if (confirm(`Are you sure you want to delete "${filename}"?\n\nThis action cannot be undone.`)) {
this.performDelete(filename);
}
}
/**
* Perform the actual deletion
*/
async performDelete(filename) {
try {
await this.deleteConfig(filename);
this.showSuccess(`Configuration "${filename}" deleted successfully`);
// Reload configs table
await this.loadConfigs();
} catch (error) {
this.showError('Failed to delete configuration: ' + error.message);
}
}
/**
* Filter configs table by search term
*/
filterConfigs(searchTerm) {
const term = searchTerm.toLowerCase().trim();
const rows = document.querySelectorAll('#configs-table tbody tr');
let visibleCount = 0;
rows.forEach(row => {
// Skip empty state row
if (row.querySelector('td[colspan]')) {
return;
}
const filename = row.cells[0]?.textContent.toLowerCase() || '';
const title = row.cells[1]?.textContent.toLowerCase() || '';
const matches = filename.includes(term) || title.includes(term);
row.style.display = matches ? '' : 'none';
if (matches) visibleCount++;
});
this.updateResultCount(visibleCount);
}
/**
* Update result count display
*/
updateResultCount(count) {
const countElement = document.getElementById('result-count');
if (countElement) {
countElement.textContent = `${count} config${count !== 1 ? 's' : ''}`;
}
}
/**
* Show error message
*/
showError(message, elementId = 'error-display') {
const errorElement = document.getElementById(elementId);
if (errorElement) {
errorElement.innerHTML = `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle"></i> ${this.escapeHtml(message)}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
errorElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
console.error('Error:', message);
alert('Error: ' + message);
}
}
/**
* Show success message
*/
showSuccess(message, elementId = 'success-display') {
const successElement = document.getElementById(elementId);
if (successElement) {
successElement.innerHTML = `
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle"></i> ${this.escapeHtml(message)}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
successElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
console.log('Success:', message);
}
}
/**
* Clear all messages
*/
clearMessages() {
const elements = ['error-display', 'success-display', 'csv-errors', 'yaml-errors'];
elements.forEach(id => {
const element = document.getElementById(id);
if (element) {
element.innerHTML = '';
}
});
}
/**
* Format file size for display
*/
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];
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Initialize global config manager instance
const configManager = new ConfigManager();
/**
* Setup drag-and-drop zone for file uploads
*/
function setupDropzone(dropzoneId, fileInputId, fileType, onUploadCallback) {
const dropzone = document.getElementById(dropzoneId);
const fileInput = document.getElementById(fileInputId);
if (!dropzone || !fileInput) {
console.warn(`Dropzone setup failed: missing elements (${dropzoneId}, ${fileInputId})`);
return;
}
// Click to browse
dropzone.addEventListener('click', () => {
fileInput.click();
});
// Drag over
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add('dragover');
});
// Drag leave
dropzone.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
});
// Drop
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileUpload(files[0], fileType, onUploadCallback);
}
});
// File input change
fileInput.addEventListener('change', (e) => {
const files = e.target.files;
if (files.length > 0) {
handleFileUpload(files[0], fileType, onUploadCallback);
}
});
}
/**
* Handle file upload (CSV or YAML)
*/
async function handleFileUpload(file, fileType, callback) {
configManager.clearMessages();
// Validate file type
const extension = file.name.split('.').pop().toLowerCase();
if (fileType === 'csv' && extension !== 'csv') {
configManager.showError('Please upload a CSV file (.csv)', 'csv-errors');
return;
}
if (fileType === 'yaml' && !['yaml', 'yml'].includes(extension)) {
configManager.showError('Please upload a YAML file (.yaml or .yml)', 'yaml-errors');
return;
}
// Validate file size (2MB limit for configs)
const maxSize = 2 * 1024 * 1024; // 2MB
if (file.size > maxSize) {
const errorId = fileType === 'csv' ? 'csv-errors' : 'yaml-errors';
configManager.showError(`File too large (${configManager.formatFileSize(file.size)}). Maximum size is 2MB.`, errorId);
return;
}
// Call the provided callback
if (callback) {
try {
await callback(file);
} catch (error) {
const errorId = fileType === 'csv' ? 'csv-errors' : 'yaml-errors';
configManager.showError(error.message, errorId);
}
}
}
/**
* Handle CSV upload and preview
*/
async function handleCSVUpload(file) {
try {
// Show loading state
const previewPlaceholder = document.getElementById('preview-placeholder');
if (previewPlaceholder) {
previewPlaceholder.innerHTML = '<div class="spinner-border" role="status"><span class="visually-hidden">Loading...</span></div>';
}
// Upload CSV
const result = await configManager.uploadCSV(file);
// Show preview
configManager.showPreview(result.preview, result.filename);
// Show success message
configManager.showSuccess(`CSV uploaded successfully! Preview the generated YAML below.`, 'csv-errors');
} catch (error) {
configManager.hidePreview();
throw error;
}
}
/**
* Handle YAML upload
*/
async function handleYAMLUpload(file) {
try {
// Upload YAML
const result = await configManager.uploadYAML(file);
// Show success and redirect
configManager.showSuccess(`Configuration "${result.filename}" uploaded successfully!`, 'yaml-errors');
// Redirect to configs list after 2 seconds
setTimeout(() => {
window.location.href = '/configs';
}, 2000);
} catch (error) {
throw error;
}
}
/**
* Save the previewed configuration (after CSV upload)
*/
async function savePreviewedConfig() {
if (!configManager.currentPreview || !configManager.currentFilename) {
configManager.showError('No configuration to save', 'csv-errors');
return;
}
try {
// The config is already saved during CSV upload, just redirect
configManager.showSuccess(`Configuration "${configManager.currentFilename}" saved successfully!`, 'csv-errors');
// Redirect to configs list after 2 seconds
setTimeout(() => {
window.location.href = '/configs';
}, 2000);
} catch (error) {
configManager.showError('Failed to save configuration: ' + error.message, 'csv-errors');
}
}

View File

@@ -53,6 +53,10 @@
<a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}"
href="{{ url_for('main.schedules') }}">Schedules</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>
</ul>
<ul class="navbar-nav">
<li class="nav-item">

View File

@@ -0,0 +1,263 @@
{% 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 %}

View File

@@ -0,0 +1,415 @@
{% 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 %}

377
web/templates/configs.html Normal file
View File

@@ -0,0 +1,377 @@
{% extends "base.html" %}
{% block title %}Configuration Files - SneakyScanner{% endblock %}
{% block extra_styles %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/config-manager.css') }}">
{% 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;">Configuration Files</h1>
<div>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create New Config
</a>
</div>
</div>
</div>
</div>
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="total-configs">-</div>
<div class="stat-label">Total Configs</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="configs-in-use">-</div>
<div class="stat-label">In Use by Schedules</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="total-size">-</div>
<div class="stat-label">Total Size</div>
</div>
</div>
</div>
<!-- Configs Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: #60a5fa;">All Configurations</h5>
<input type="text" id="search-input" class="form-control" style="max-width: 300px;"
placeholder="Search configs...">
</div>
</div>
<div class="card-body">
<div id="configs-loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading configurations...</p>
</div>
<div id="configs-error" style="display: none;" class="alert alert-danger">
<strong>Error:</strong> <span id="error-message"></span>
</div>
<div id="configs-content" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Filename</th>
<th>Title</th>
<th>Created</th>
<th>Size</th>
<th>Used By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="configs-tbody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
<div id="empty-state" style="display: none;" class="text-center py-5">
<i class="bi bi-file-earmark-text" style="font-size: 3rem; color: #64748b;"></i>
<h5 class="mt-3 text-muted">No configuration files</h5>
<p class="text-muted">Create your first config to define scan targets</p>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary mt-2">
<i class="bi bi-plus-circle"></i> Create Config
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" 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: #f87171;">
<i class="bi bi-exclamation-triangle"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p style="color: #e2e8f0;">Are you sure you want to delete the config file:</p>
<p style="color: #60a5fa; font-weight: bold;" id="delete-config-name"></p>
<p style="color: #fbbf24;" id="delete-warning-schedules" style="display: none;">
<i class="bi bi-exclamation-circle"></i>
This config is used by schedules and cannot be deleted.
</p>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-delete-btn">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
<!-- View Config Modal -->
<div class="modal fade" id="viewModal" 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-file-earmark-code"></i> Config File Details
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<h6 style="color: #94a3b8;">Filename: <span id="view-filename" style="color: #e2e8f0;"></span></h6>
<h6 class="mt-3" style="color: #94a3b8;">Content:</h6>
<pre style="background-color: #0f172a; border: 1px solid #334155; padding: 15px; border-radius: 5px; max-height: 400px; overflow-y: auto;"><code id="view-content" style="color: #e2e8f0;"></code></pre>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<a id="download-link" href="#" class="btn btn-primary">
<i class="bi bi-download"></i> Download
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Global variables
let configsData = [];
let selectedConfigForDeletion = null;
// 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];
}
// Format date
function formatDate(timestamp) {
if (!timestamp) return 'Unknown';
const date = new Date(timestamp);
return date.toLocaleString();
}
// Load configs from API
async function loadConfigs() {
try {
document.getElementById('configs-loading').style.display = 'block';
document.getElementById('configs-error').style.display = 'none';
document.getElementById('configs-content').style.display = 'none';
const response = await fetch('/api/configs');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
configsData = data.configs || [];
updateStats();
renderConfigs(configsData);
document.getElementById('configs-loading').style.display = 'none';
document.getElementById('configs-content').style.display = 'block';
} catch (error) {
console.error('Error loading configs:', error);
document.getElementById('configs-loading').style.display = 'none';
document.getElementById('configs-error').style.display = 'block';
document.getElementById('error-message').textContent = error.message;
}
}
// Update summary stats
function updateStats() {
const totalConfigs = configsData.length;
const configsInUse = configsData.filter(c => c.used_by_schedules && c.used_by_schedules.length > 0).length;
const totalSize = configsData.reduce((sum, c) => sum + (c.size_bytes || 0), 0);
document.getElementById('total-configs').textContent = totalConfigs;
document.getElementById('configs-in-use').textContent = configsInUse;
document.getElementById('total-size').textContent = formatFileSize(totalSize);
}
// Render configs table
function renderConfigs(configs) {
const tbody = document.getElementById('configs-tbody');
const emptyState = document.getElementById('empty-state');
if (configs.length === 0) {
tbody.innerHTML = '';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
tbody.innerHTML = configs.map(config => {
const usedByBadge = config.used_by_schedules && config.used_by_schedules.length > 0
? `<span class="badge bg-info" title="${config.used_by_schedules.join(', ')}">${config.used_by_schedules.length} schedule(s)</span>`
: '<span class="badge bg-secondary">Not used</span>';
return `
<tr>
<td><code>${config.filename}</code></td>
<td>${config.title || config.filename}</td>
<td>${formatDate(config.created_at)}</td>
<td>${formatFileSize(config.size_bytes || 0)}</td>
<td>${usedByBadge}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" onclick="viewConfig('${config.filename}')" title="View">
<i class="bi bi-eye"></i>
</button>
<a href="/configs/edit/${config.filename}" class="btn btn-outline-info" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a href="/api/configs/${config.filename}/download" class="btn btn-outline-success" title="Download">
<i class="bi bi-download"></i>
</a>
<button class="btn btn-outline-danger" onclick="confirmDelete('${config.filename}', ${config.used_by_schedules.length > 0})" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
// View config details
async function viewConfig(filename) {
try {
const response = await fetch(`/api/configs/${filename}`);
if (!response.ok) {
throw new Error(`Failed to load config: ${response.statusText}`);
}
const data = await response.json();
document.getElementById('view-filename').textContent = data.filename;
document.getElementById('view-content').textContent = data.content;
document.getElementById('download-link').href = `/api/configs/${filename}/download`;
new bootstrap.Modal(document.getElementById('viewModal')).show();
} catch (error) {
console.error('Error viewing config:', error);
alert(`Error: ${error.message}`);
}
}
// Confirm delete
function confirmDelete(filename, isInUse) {
selectedConfigForDeletion = filename;
document.getElementById('delete-config-name').textContent = filename;
const warningDiv = document.getElementById('delete-warning-schedules');
const deleteBtn = document.getElementById('confirm-delete-btn');
if (isInUse) {
warningDiv.style.display = 'block';
deleteBtn.disabled = true;
deleteBtn.classList.add('disabled');
} else {
warningDiv.style.display = 'none';
deleteBtn.disabled = false;
deleteBtn.classList.remove('disabled');
}
new bootstrap.Modal(document.getElementById('deleteModal')).show();
}
// Delete config
async function deleteConfig() {
if (!selectedConfigForDeletion) return;
try {
const response = await fetch(`/api/configs/${selectedConfigForDeletion}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
// Hide modal
bootstrap.Modal.getInstance(document.getElementById('deleteModal')).hide();
// Reload configs
await loadConfigs();
// Show success message
showAlert('success', `Config "${selectedConfigForDeletion}" deleted successfully`);
} catch (error) {
console.error('Error deleting config:', error);
showAlert('danger', `Error deleting config: ${error.message}`);
}
}
// Show alert
function showAlert(type, message) {
const alertHtml = `
<div class="alert alert-${type} alert-dismissible fade show mt-3" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
const container = document.querySelector('.container-fluid');
container.insertAdjacentHTML('afterbegin', alertHtml);
// Auto-dismiss after 5 seconds
setTimeout(() => {
const alert = container.querySelector('.alert');
if (alert) {
bootstrap.Alert.getInstance(alert)?.close();
}
}, 5000);
}
// Search filter
document.getElementById('search-input').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
if (!searchTerm) {
renderConfigs(configsData);
return;
}
const filtered = configsData.filter(config =>
config.filename.toLowerCase().includes(searchTerm) ||
(config.title && config.title.toLowerCase().includes(searchTerm))
);
renderConfigs(filtered);
});
// Setup delete button
document.getElementById('confirm-delete-btn').addEventListener('click', deleteConfig);
// Load configs on page load
document.addEventListener('DOMContentLoaded', loadConfigs);
</script>
{% endblock %}

View File

@@ -360,6 +360,9 @@
const data = await response.json();
// Hide error before closing modal to prevent flash
errorEl.style.display = 'none';
// Close modal
bootstrap.Modal.getInstance(document.getElementById('triggerScanModal')).hide();
@@ -370,7 +373,9 @@
Scan triggered successfully! (ID: ${data.scan_id})
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.querySelector('.container-fluid').insertBefore(alertDiv, document.querySelector('.row'));
// Insert at the beginning of container-fluid
const container = document.querySelector('.container-fluid');
container.insertBefore(alertDiv, container.firstChild);
// Refresh scans and stats
refreshScans();

View File

@@ -2,57 +2,6 @@
{% block title %}Login - SneakyScanner{% endblock %}
{% block extra_styles %}
body {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
}
.container-fluid {
max-width: 450px;
padding: 0;
}
.login-card {
background-color: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 3rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.brand-title {
color: #60a5fa;
font-weight: 600;
font-size: 2rem;
margin-bottom: 0.5rem;
}
.brand-subtitle {
color: #94a3b8;
font-size: 0.95rem;
}
.btn-primary {
padding: 0.75rem;
font-size: 1rem;
font-weight: 500;
}
.footer {
position: fixed;
bottom: 20px;
left: 0;
right: 0;
margin: 0;
padding: 0;
border: none;
}
{% endblock %}
{% set hide_nav = true %}
{% block content %}

View File

@@ -133,7 +133,9 @@
<p class="text-muted mb-3">
Historical port count trend for scans using the same configuration
</p>
<canvas id="historyChart" height="80"></canvas>
<div style="position: relative; height: 300px;">
<canvas id="historyChart"></canvas>
</div>
</div>
</div>
</div>
@@ -166,10 +168,14 @@
<script>
const scanId = {{ scan_id }};
let scanData = null;
let historyChart = null; // Store chart instance to prevent duplicates
// Load scan on page load
document.addEventListener('DOMContentLoaded', function() {
loadScan();
loadScan().then(() => {
findPreviousScan();
loadHistoricalChart();
});
// Auto-refresh every 10 seconds if scan is running
setInterval(function() {
@@ -494,8 +500,13 @@
if (data.scans && data.scans.length > 1) {
document.getElementById('historical-chart-row').style.display = 'block';
// Destroy existing chart to prevent canvas growth bug
if (historyChart) {
historyChart.destroy();
}
const ctx = document.getElementById('historyChart').getContext('2d');
new Chart(ctx, {
historyChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
@@ -570,11 +581,5 @@
console.error('Error loading historical chart:', error);
}
}
// Initialize: find previous scan and load chart after loading current scan
loadScan().then(() => {
findPreviousScan();
loadHistoricalChart();
});
</script>
{% endblock %}

View File

@@ -396,6 +396,9 @@
const data = await response.json();
// Hide error before closing modal to prevent flash
errorEl.style.display = 'none';
// Close modal
bootstrap.Modal.getInstance(document.getElementById('triggerScanModal')).hide();
@@ -406,7 +409,9 @@
Scan triggered successfully! (ID: ${data.scan_id})
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.querySelector('.container-fluid').insertBefore(alertDiv, document.querySelector('.row'));
// Insert at the beginning of container-fluid
const container = document.querySelector('.container-fluid');
container.insertBefore(alertDiv, container.firstChild);
// Refresh scans
loadScans();