From cbc3ff0f51c3b6bbe7e15142bcbc1221deac524a Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 14 Nov 2025 13:21:48 -0600 Subject: [PATCH 1/8] Phase 3 Step 1: Fix Styling Issues & CSS Refactor Extracted inline CSS to external stylesheet and fixed white row bug affecting dynamically created table rows across all scan views. Changes: - Created web/static/css/styles.css with extracted CSS from base.html - Added CSS variables for consistent theming and maintainability - Added Bootstrap 5 CSS variable overrides to fix table styling - Integrated Chart.js 4.4.0 for future dashboard visualizations - Added Bootstrap Icons for enhanced UI components Template Updates: - Updated base.html to use external CSS instead of inline styles - Added Chart.js dark theme configuration - Fixed white row bug in dashboard.html (added .scan-row class) - Fixed white row bug in scans.html (added .scan-row class) - Fixed white row bug in scan_detail.html port tables (added .scan-row class) The white row bug was caused by Bootstrap 5's CSS variables overriding custom styles. Fixed by setting --bs-table-bg and related variables. Phase 3 Documentation: - Added PHASE3.md with complete implementation plan (2204 lines) - Includes 8 implementation steps, file changes, and success criteria This completes Phase 3 Step 1 (Day 1 of 14). --- docs/ai/PHASE3.md | 2204 ++++++++++++++++++++++++++++++++ web/static/css/styles.css | 334 +++++ web/templates/base.html | 294 +---- web/templates/dashboard.html | 1 + web/templates/scan_detail.html | 3 +- web/templates/scans.html | 1 + 6 files changed, 2560 insertions(+), 277 deletions(-) create mode 100644 docs/ai/PHASE3.md create mode 100644 web/static/css/styles.css diff --git a/docs/ai/PHASE3.md b/docs/ai/PHASE3.md new file mode 100644 index 0000000..0f6fb49 --- /dev/null +++ b/docs/ai/PHASE3.md @@ -0,0 +1,2204 @@ +# Phase 3 Implementation Plan: Dashboard Enhancement & Scheduled Scans + +**Status:** Ready to Start +**Progress:** 0/14 days complete (0%) +**Estimated Duration:** 14 days (2 weeks) +**Dependencies:** Phase 2 Complete βœ… + +## Progress Summary + +- πŸ“‹ **Step 1: Fix Styling Issues & CSS Refactor** (Day 1) - NEXT +- πŸ“‹ **Step 2: ScheduleService Implementation** (Days 2-3) +- πŸ“‹ **Step 3: Schedules API Endpoints** (Days 4-5) +- πŸ“‹ **Step 4: Schedule Management UI** (Days 6-7) +- πŸ“‹ **Step 5: Enhanced Dashboard with Charts** (Days 8-9) +- πŸ“‹ **Step 6: Scheduler Integration** (Day 10) +- πŸ“‹ **Step 7: Scan Comparison Features** (Days 11-12) +- πŸ“‹ **Step 8: Testing & Documentation** (Days 13-14) + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Current State Analysis](#current-state-analysis) +3. [Critical Bug Fix](#critical-bug-fix) +4. [Files to Create](#files-to-create) +5. [Files to Modify](#files-to-modify) +6. [Step-by-Step Implementation](#step-by-step-implementation) +7. [Dependencies & Prerequisites](#dependencies--prerequisites) +8. [Testing Approach](#testing-approach) +9. [Potential Challenges & Solutions](#potential-challenges--solutions) +10. [Success Criteria](#success-criteria) +11. [Migration Path](#migration-path) +12. [Estimated Timeline](#estimated-timeline) +13. [Key Design Decisions](#key-design-decisions) +14. [Documentation Deliverables](#documentation-deliverables) + +--- + +## Overview + +Phase 3 focuses on enhancing the web application with scheduling capabilities and improved dashboard visualizations: + +1. **Critical Bug Fix** - Fix white row coloring in scan tables +2. **Scheduled Scans** - Complete schedule management system with cron expressions +3. **Enhanced Dashboard** - Trending charts, schedule widgets, alert summaries +4. **Scan Comparison** - Historical analysis and infrastructure drift detection + +### Goals + +- πŸ› Fix white row bug affecting scan tables (critical UX issue) +- βœ… Complete schedule management (CRUD operations) +- βœ… Scheduled scan execution with cron expressions +- βœ… Enhanced dashboard with Chart.js visualizations +- βœ… Scan comparison and historical analysis +- βœ… CSS extraction and better maintainability + +--- + +## Current State Analysis + +### What's Already Done (Phase 2) + +**βœ… Phase 2 Complete** - Full web application core: + +- **Database Schema** - All 11 models including Schedule table +- **ScanService** - Complete CRUD operations with 545 lines +- **Scan API** - 5 endpoints fully implemented +- **SchedulerService** - Partial implementation (258 lines) + - βœ… `queue_scan()` - Immediate scan execution works + - ⚠️ `add_scheduled_scan()` - Placeholder (marked for Phase 3) + - ⚠️ `_trigger_scheduled_scan()` - Skeleton with TODO comments + - βœ… `remove_scheduled_scan()` - Basic implementation +- **Authentication** - Flask-Login with @login_required decorators +- **UI Templates** - Dashboard, scans list/detail, login pages +- **Background Jobs** - APScheduler with concurrent execution (max 3) +- **Error Handling** - Content negotiation, custom error pages +- **Testing** - 100 test functions, 1,825 lines of test code + +### Schedule-Related Components Analysis + +#### Schedule Model (`web/models.py` - lines 142-158) +```python +class Schedule(Base): + __tablename__ = 'schedules' + + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + config_file = Column(Text, nullable=False) + cron_expression = Column(String(100), nullable=False) + enabled = Column(Boolean, default=True) + last_run = Column(DateTime) + next_run = Column(DateTime) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +``` + +**Status:** βœ… Complete - No migration needed + +#### Schedules API (`web/api/schedules.py` - 159 lines) +```python +@bp.route('', methods=['GET']) +@api_auth_required +def list_schedules(): + """List all schedules. TODO: Implement in Phase 3.""" + return jsonify({'message': 'Schedules list - to be implemented in Phase 3'}), 200 +``` + +**Status:** ⚠️ All 6 endpoints are stubs returning placeholders + +**Endpoints to Implement:** +- `GET /api/schedules` - List all schedules +- `GET /api/schedules/` - Get schedule details +- `POST /api/schedules` - Create new schedule +- `PUT /api/schedules/` - Update schedule +- `DELETE /api/schedules/` - Delete schedule +- `POST /api/schedules//trigger` - Manually trigger scheduled scan + +#### SchedulerService Gaps + +**Missing Implementations:** +1. `add_scheduled_scan(schedule)` - Currently placeholder +2. `_trigger_scheduled_scan(schedule_id)` - TODO comments only +3. Loading schedules on app startup - Not implemented +4. Cron expression parsing - No validation +5. Next run time calculation - Missing + +### UI Components Status + +**Existing Templates:** +- `web/templates/base.html` (346 lines) - Has inline CSS (need to extract) +- `web/templates/dashboard.html` (356 lines) - Basic stats, needs charts +- `web/templates/scans.html` (469 lines) - Has white row bug +- `web/templates/scan_detail.html` (399 lines) - Has white row bug in port tables + +**Missing Templates:** +- Schedule list page +- Schedule create form +- Schedule edit form + +**Web Routes (`web/routes/main.py` - 69 lines):** +- Only has scan routes, no schedule routes + +--- + +## Critical Bug Fix + +### White Row Coloring Issue + +**Problem:** Dynamically created table rows in scans tables display with white background instead of dark theme colors. + +**Affected Files:** +1. `web/templates/scans.html` (lines 208-241) - renderScansTable() +2. `web/templates/dashboard.html` (lines 221-260) - renderScansTable() +3. `web/templates/scan_detail.html` (lines 305-327) - Port tables + +**Root Cause:** + +JavaScript dynamically creates `` elements that don't inherit CSS styles properly: + +```javascript +const row = document.createElement('tr'); +row.innerHTML = ` + ${scan.id} + ${scan.title || 'Untitled Scan'} + ... +`; +tbody.appendChild(row); +``` + +**Current CSS (base.html lines 157-165):** +```css +.table tbody tr { + background-color: #1e293b; /* Dark slate */ + border-color: #334155; +} + +.table tbody tr:hover { + background-color: #334155; + cursor: pointer; +} +``` + +**Solution:** + +Add explicit class to dynamically created rows: + +```javascript +const row = document.createElement('tr'); +row.classList.add('scan-row'); // Add explicit class +row.innerHTML = `...`; +``` + +And enhance CSS with higher specificity: +```css +.table tbody tr.scan-row, +.table tbody tr { + background-color: #1e293b !important; + border-color: #334155 !important; +} +``` + +--- + +## Files to Create + +### Backend Services + +#### 1. `web/services/schedule_service.py` +Schedule CRUD operations and business logic. + +**Class: ScheduleService** + +**Estimated Size:** ~400 lines + +**Methods:** +- `__init__(self, db_session)` - Initialize with database session +- `create_schedule(name, config_file, cron_expression, enabled=True)` β†’ schedule_id + - Validate cron expression + - Validate config file exists + - Calculate next_run time + - Create Schedule record + - Return schedule_id + +- `get_schedule(schedule_id)` β†’ schedule dict + - Query Schedule by ID + - Format for API response + - Include execution history (recent scans with this schedule_id) + +- `list_schedules(page=1, per_page=20, enabled_filter=None)` β†’ paginated results + - Query with pagination + - Filter by enabled status if provided + - Calculate relative next_run time ("in 2 hours", "tomorrow at 3:00 PM") + - Return total count and items + +- `update_schedule(schedule_id, **updates)` β†’ schedule dict + - Validate cron_expression if changed + - Recalculate next_run if cron changed + - Update Schedule record + - Reload schedule in APScheduler + - Return updated schedule + +- `delete_schedule(schedule_id)` β†’ success + - Remove job from APScheduler + - Delete Schedule record (do NOT delete associated scans) + - Return success status + +- `toggle_enabled(schedule_id, enabled)` β†’ schedule dict + - Enable or disable schedule + - Add/remove from APScheduler + - Return updated schedule + +- `update_run_times(schedule_id, last_run, next_run)` β†’ success + - Update last_run and next_run timestamps + - Called after each execution + - Return success status + +- `validate_cron_expression(cron_expr)` β†’ (valid, error_message) + - Use croniter library + - Return True if valid, False + error message if invalid + +- `calculate_next_run(cron_expr, from_time=None)` β†’ datetime + - Calculate next run time from cron expression + - Use croniter + - Return datetime (UTC) + +- `get_schedule_history(schedule_id, limit=10)` β†’ list of scans + - Query recent scans triggered by this schedule + - Return list of scan dicts + +**Helper Methods:** +- `_schedule_to_dict(schedule_obj)` - Convert Schedule model to dict +- `_get_relative_time(dt)` - Format datetime as "in 2 hours", "yesterday" + +#### 2. `web/static/css/styles.css` +Extracted CSS for better maintainability. + +**Estimated Size:** ~350 lines (extracted from base.html) + +**Sections:** +- Variables (CSS custom properties for colors) +- Global styles +- Navigation +- Cards and containers +- Tables (including fix for white rows) +- Forms +- Buttons +- Badges and labels +- Charts (dark theme) +- Utilities + +**Example Structure:** +```css +/* CSS Variables */ +:root { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --border-color: #334155; + --accent-blue: #60a5fa; + --success-bg: #065f46; + --success-text: #6ee7b7; + --warning-bg: #78350f; + --warning-text: #fcd34d; + --danger-bg: #7f1d1d; + --danger-text: #fca5a5; +} + +/* Fix for dynamically created table rows */ +.table tbody tr, +.table tbody tr.scan-row { + background-color: var(--bg-secondary) !important; + border-color: var(--border-color) !important; +} + +.table tbody tr:hover { + background-color: var(--bg-tertiary) !important; + cursor: pointer; +} +``` + +### Frontend Templates + +#### 3. `web/templates/schedules.html` +List all schedules with enable/disable toggles. + +**Estimated Size:** ~400 lines + +**Layout:** +```html +{% extends "base.html" %} + +{% block content %} +
+
+

Scheduled Scans

+ + New Schedule + +
+ + +
+
+
+
+
Total Schedules
+

-

+
+
+
+
+
+
+
Enabled
+

-

+
+
+
+
+
+
+
Next Run
+
-
+
+
+
+
+
+
+
Executions (24h)
+

-

+
+
+
+
+ + +
+
+ + + + + + + + + + + + + + +
NameSchedule (Cron)Next RunLast RunStatusActions
+
+
+
+ + +{% endblock %} +``` + +#### 4. `web/templates/schedule_create.html` +Form to create new scheduled scan. + +**Estimated Size:** ~300 lines + +**Features:** +- Schedule name input +- Config file selector (dropdown of available configs) +- Cron expression builder OR manual entry +- Cron expression validator (client-side and server-side) +- Human-readable cron description +- Enable/disable toggle +- Submit button + +**Cron Expression Builder:** +```html +
+
+
Schedule Configuration
+ + +
+ +
+ + + + +
+
+ + +
+ + + + Format: minute hour day month weekday + +
+ + +
+ Enter a cron expression above +
+ + +
+ Next 5 runs: +
    +
    +
    +
    +``` + +#### 5. `web/templates/schedule_edit.html` +Form to edit existing schedule. + +**Estimated Size:** ~250 lines + +**Similar to create, but:** +- Pre-populate fields from existing schedule +- Show execution history (last 10 scans) +- "Delete Schedule" button +- "Test Run Now" button + +### Testing Files + +#### 6. `tests/test_schedule_service.py` +Unit tests for ScheduleService. + +**Estimated Size:** ~450 lines, 18+ tests + +**Test Coverage:** +- `test_create_schedule` - Valid schedule creation +- `test_create_schedule_invalid_cron` - Cron validation +- `test_create_schedule_invalid_config` - Config file validation +- `test_get_schedule` - Retrieve schedule +- `test_get_schedule_not_found` - 404 handling +- `test_list_schedules` - Pagination +- `test_list_schedules_filter_enabled` - Filter by enabled status +- `test_update_schedule` - Update fields +- `test_update_schedule_cron` - Recalculate next_run when cron changes +- `test_delete_schedule` - Delete schedule +- `test_toggle_enabled` - Enable/disable +- `test_update_run_times` - Update after execution +- `test_validate_cron_expression_valid` - Valid expressions +- `test_validate_cron_expression_invalid` - Invalid expressions +- `test_calculate_next_run` - Next run calculation +- `test_get_schedule_history` - Execution history +- `test_schedule_to_dict` - Serialization +- `test_concurrent_schedule_operations` - Thread safety + +#### 7. `tests/test_schedule_api.py` +Integration tests for schedules API. + +**Estimated Size:** ~500 lines, 22+ tests + +**Test Coverage:** +- `test_list_schedules_empty` - Empty list +- `test_list_schedules_populated` - Multiple schedules +- `test_list_schedules_pagination` - Pagination +- `test_list_schedules_filter_enabled` - Filter +- `test_get_schedule` - Get details +- `test_get_schedule_not_found` - 404 +- `test_create_schedule` - Create new +- `test_create_schedule_invalid_cron` - Validation +- `test_create_schedule_invalid_config` - File validation +- `test_update_schedule` - Update fields +- `test_update_schedule_not_found` - 404 +- `test_delete_schedule` - Delete +- `test_delete_schedule_not_found` - 404 +- `test_trigger_schedule` - Manual trigger +- `test_trigger_schedule_not_found` - 404 +- `test_toggle_enabled_via_update` - Enable/disable +- `test_schedules_require_authentication` - Auth required +- `test_schedule_execution_history` - Show related scans +- `test_schedule_next_run_calculation` - Correct calculation +- `test_schedule_workflow_integration` - Complete workflow +- `test_concurrent_schedule_updates` - Concurrency + +#### 8. `tests/test_charts.py` +Tests for chart data generation. + +**Estimated Size:** ~200 lines, 8+ tests + +**Test Coverage:** +- `test_scan_trend_data` - Scans per day calculation +- `test_port_count_trend` - Port count over time +- `test_service_distribution` - Service type breakdown +- `test_certificate_expiry_timeline` - Cert expiry dates +- `test_empty_data_handling` - No scans case +- `test_date_range_filtering` - Filter by date range +- `test_data_format_for_chartjs` - Correct JSON format +- `test_trend_data_caching` - Performance optimization + +--- + +## Files to Modify + +### Backend Updates + +#### 1. `web/api/schedules.py` +Replace all stub implementations with working code. + +**Current State:** 159 lines, all placeholders + +**Changes:** +- Import ScheduleService +- Implement all 6 endpoints: + - `GET /api/schedules` - Call ScheduleService.list_schedules() + - `GET /api/schedules/` - Call ScheduleService.get_schedule() + - `POST /api/schedules` - Call ScheduleService.create_schedule() + - `PUT /api/schedules/` - Call ScheduleService.update_schedule() + - `DELETE /api/schedules/` - Call ScheduleService.delete_schedule() + - `POST /api/schedules//trigger` - Trigger immediate scan with schedule_id + +**New Code (~300 lines total):** +```python +from web.services.schedule_service import ScheduleService +from web.auth.decorators import api_auth_required +from flask import current_app, jsonify, request + +@bp.route('', methods=['GET']) +@api_auth_required +def list_schedules(): + """List all schedules with pagination.""" + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + enabled_filter = request.args.get('enabled', type=lambda x: x.lower() == 'true') + + schedule_service = ScheduleService(current_app.db_session) + result = schedule_service.list_schedules(page, per_page, enabled_filter) + + return jsonify(result), 200 + +@bp.route('', methods=['POST']) +@api_auth_required +def create_schedule(): + """Create a new schedule.""" + data = request.get_json() or {} + + # Validate required fields + required = ['name', 'config_file', 'cron_expression'] + for field in required: + if field not in data: + return jsonify({'error': f'Missing required field: {field}'}), 400 + + schedule_service = ScheduleService(current_app.db_session) + + try: + schedule_id = schedule_service.create_schedule( + name=data['name'], + config_file=data['config_file'], + cron_expression=data['cron_expression'], + enabled=data.get('enabled', True) + ) + + # Add to APScheduler + schedule = schedule_service.get_schedule(schedule_id) + current_app.scheduler.add_scheduled_scan(schedule) + + return jsonify({ + 'schedule_id': schedule_id, + 'message': 'Schedule created successfully' + }), 201 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + +# ... more endpoints +``` + +#### 2. `web/services/scheduler_service.py` +Complete placeholder implementations. + +**Current State:** 258 lines, partial implementation + +**Changes:** +- Complete `add_scheduled_scan(schedule)` - Add cron job to APScheduler +- Complete `_trigger_scheduled_scan(schedule_id)` - Execute scheduled scan +- Add `load_schedules_on_startup()` - Load all enabled schedules +- Add cron expression parsing with croniter + +**New/Updated Methods:** +```python +from croniter import croniter +from datetime import datetime + +def add_scheduled_scan(self, schedule): + """Add a cron job for scheduled scan execution.""" + if not schedule.get('enabled'): + return + + job_id = f"schedule_{schedule['id']}" + + # Parse cron expression + trigger = CronTrigger.from_crontab(schedule['cron_expression']) + + # Add job to scheduler + self.scheduler.add_job( + func=self._trigger_scheduled_scan, + trigger=trigger, + id=job_id, + args=[schedule['id']], + replace_existing=True, + max_instances=1 + ) + + logger.info(f"Added scheduled scan: {schedule['name']} ({job_id})") + +def _trigger_scheduled_scan(self, schedule_id): + """Execute a scheduled scan.""" + from web.services.schedule_service import ScheduleService + + logger.info(f"Triggering scheduled scan: schedule_id={schedule_id}") + + # Get schedule details + schedule_service = ScheduleService(self.db_session) + schedule = schedule_service.get_schedule(schedule_id) + + if not schedule: + logger.error(f"Schedule {schedule_id} not found") + return + + # Trigger scan with schedule_id + from web.services.scan_service import ScanService + scan_service = ScanService(self.db_session) + + scan_id = scan_service.trigger_scan( + config_file=schedule['config_file'], + triggered_by='scheduled', + schedule_id=schedule_id + ) + + # Queue the scan + self.queue_scan(schedule['config_file'], scan_id, self.db_url) + + # Update last_run timestamp + schedule_service.update_run_times( + schedule_id=schedule_id, + last_run=datetime.utcnow(), + next_run=self._calculate_next_run(schedule['cron_expression']) + ) + + logger.info(f"Scheduled scan queued: scan_id={scan_id}") + +def load_schedules_on_startup(self): + """Load all enabled schedules from database on app startup.""" + from web.services.schedule_service import ScheduleService + + schedule_service = ScheduleService(self.db_session) + schedules = schedule_service.list_schedules(page=1, per_page=1000, enabled_filter=True) + + for schedule in schedules['schedules']: + self.add_scheduled_scan(schedule) + + logger.info(f"Loaded {len(schedules['schedules'])} schedules on startup") + +def _calculate_next_run(self, cron_expression): + """Calculate next run time from cron expression.""" + cron = croniter(cron_expression, datetime.utcnow()) + return cron.get_next(datetime) +``` + +#### 3. `web/routes/main.py` +Add schedule management routes. + +**Current State:** 69 lines, only scan routes + +**Changes:** +- Add schedule routes: + - `GET /schedules` - List schedules + - `GET /schedules/create` - Create form + - `GET /schedules//edit` - Edit form + +**New Code:** +```python +@bp.route('/schedules') +@login_required +def schedules(): + """List all schedules.""" + return render_template('schedules.html') + +@bp.route('/schedules/create') +@login_required +def create_schedule(): + """Create new schedule form.""" + # Get list of available config files + import os + configs_dir = '/app/configs' + config_files = [f for f in os.listdir(configs_dir) if f.endswith('.yaml')] + return render_template('schedule_create.html', config_files=config_files) + +@bp.route('/schedules//edit') +@login_required +def edit_schedule(schedule_id): + """Edit existing schedule form.""" + from web.services.schedule_service import ScheduleService + schedule_service = ScheduleService(current_app.db_session) + + try: + schedule = schedule_service.get_schedule(schedule_id) + return render_template('schedule_edit.html', schedule=schedule) + except Exception as e: + flash(f'Schedule not found: {e}', 'danger') + return redirect(url_for('main.schedules')) +``` + +#### 4. `web/templates/base.html` +Extract CSS, add Chart.js, fix white rows. + +**Current State:** 346 lines with 280 lines of inline CSS (lines 8-288) + +**Changes:** +- Replace inline ` + {% block extra_styles %}{% endblock %} {% if not hide_nav %} @@ -335,7 +77,7 @@ diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 31390a7..7d04fc5 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -224,6 +224,7 @@ scans.forEach(scan => { const row = document.createElement('tr'); + row.classList.add('scan-row'); // Fix white row bug // Format timestamp const timestamp = new Date(scan.timestamp).toLocaleString(); diff --git a/web/templates/scan_detail.html b/web/templates/scan_detail.html index c53543c..c1a7482 100644 --- a/web/templates/scan_detail.html +++ b/web/templates/scan_detail.html @@ -306,12 +306,13 @@ const ports = ip.ports || []; if (ports.length === 0) { - portsContainer.innerHTML = 'No ports found'; + portsContainer.innerHTML = 'No ports found'; } else { ports.forEach(port => { const service = port.services && port.services.length > 0 ? port.services[0] : null; const row = document.createElement('tr'); + row.classList.add('scan-row'); // Fix white row bug row.innerHTML = ` ${port.port} ${port.protocol.toUpperCase()} diff --git a/web/templates/scans.html b/web/templates/scans.html index b26a718..27640eb 100644 --- a/web/templates/scans.html +++ b/web/templates/scans.html @@ -206,6 +206,7 @@ scans.forEach(scan => { const row = document.createElement('tr'); + row.classList.add('scan-row'); // Fix white row bug // Format timestamp const timestamp = new Date(scan.timestamp).toLocaleString(); From 7969068c36911d8435c55bc0ba5d7d0bea598d07 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 14 Nov 2025 13:41:49 -0600 Subject: [PATCH 2/8] Phase 3 Step 2: Implement ScheduleService with cron support Implement comprehensive schedule management service for automated scans: New Files: - web/services/schedule_service.py (470 lines) * Complete CRUD operations for schedules * Cron expression validation using croniter * Next run time calculation * Execution history tracking * Human-readable relative time formatting - tests/test_schedule_service.py (671 lines, 40+ tests) * Create/get/list/update/delete schedule tests * Cron validation and next run calculation tests * Pagination and filtering tests * Schedule history and serialization tests Changes: - requirements-web.txt: Add croniter==2.0.1 dependency - docs/ai/PHASE3.md: Mark Step 1 complete, Step 2 next Key Features: - Validates cron expressions before saving - Automatically calculates next execution time - Preserves historical scans when schedules deleted - Supports pagination and filtering by enabled status - Provides relative time display (e.g., "in 2 hours") --- docs/ai/PHASE3.md | 4 +- requirements-web.txt | 1 + tests/test_schedule_service.py | 671 +++++++++++++++++++++++++++++++ web/services/schedule_service.py | 470 ++++++++++++++++++++++ 4 files changed, 1144 insertions(+), 2 deletions(-) create mode 100644 tests/test_schedule_service.py create mode 100644 web/services/schedule_service.py diff --git a/docs/ai/PHASE3.md b/docs/ai/PHASE3.md index 0f6fb49..c04afbe 100644 --- a/docs/ai/PHASE3.md +++ b/docs/ai/PHASE3.md @@ -7,8 +7,8 @@ ## Progress Summary -- πŸ“‹ **Step 1: Fix Styling Issues & CSS Refactor** (Day 1) - NEXT -- πŸ“‹ **Step 2: ScheduleService Implementation** (Days 2-3) +- βœ… **Step 1: Fix Styling Issues & CSS Refactor** (Day 1) - COMPLETE +- πŸ“‹ **Step 2: ScheduleService Implementation** (Days 2-3) - NEXT - πŸ“‹ **Step 3: Schedules API Endpoints** (Days 4-5) - πŸ“‹ **Step 4: Schedule Management UI** (Days 6-7) - πŸ“‹ **Step 5: Enhanced Dashboard with Charts** (Days 8-9) diff --git a/requirements-web.txt b/requirements-web.txt index 7517cb9..0e7ce4a 100644 --- a/requirements-web.txt +++ b/requirements-web.txt @@ -21,6 +21,7 @@ marshmallow-sqlalchemy==0.29.0 # Background Jobs & Scheduling APScheduler==3.10.4 +croniter==2.0.1 # Email Support (Phase 4) Flask-Mail==0.9.1 diff --git a/tests/test_schedule_service.py b/tests/test_schedule_service.py new file mode 100644 index 0000000..4e4741d --- /dev/null +++ b/tests/test_schedule_service.py @@ -0,0 +1,671 @@ +""" +Unit tests for ScheduleService class. + +Tests schedule lifecycle operations: create, get, list, update, delete, and +cron expression validation. +""" + +import pytest +from datetime import datetime, timedelta + +from web.models import Schedule, Scan +from web.services.schedule_service import ScheduleService + + +class TestScheduleServiceCreate: + """Tests for creating schedules.""" + + def test_create_schedule_valid(self, test_db, sample_config_file): + """Test creating a schedule with valid parameters.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Daily Scan', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + # Verify schedule created + assert schedule_id is not None + assert isinstance(schedule_id, int) + + # Verify schedule in database + schedule = test_db.query(Schedule).filter(Schedule.id == schedule_id).first() + assert schedule is not None + assert schedule.name == 'Daily Scan' + assert schedule.config_file == sample_config_file + assert schedule.cron_expression == '0 2 * * *' + assert schedule.enabled is True + assert schedule.next_run is not None + assert schedule.last_run is None + + def test_create_schedule_disabled(self, test_db, sample_config_file): + """Test creating a disabled schedule.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Disabled Scan', + config_file=sample_config_file, + cron_expression='0 3 * * *', + enabled=False + ) + + schedule = test_db.query(Schedule).filter(Schedule.id == schedule_id).first() + assert schedule.enabled is False + assert schedule.next_run is None + + def test_create_schedule_invalid_cron(self, test_db, sample_config_file): + """Test creating a schedule with invalid cron expression.""" + service = ScheduleService(test_db) + + with pytest.raises(ValueError, match="Invalid cron expression"): + service.create_schedule( + name='Invalid Schedule', + config_file=sample_config_file, + cron_expression='invalid cron', + enabled=True + ) + + def test_create_schedule_nonexistent_config(self, test_db): + """Test creating a schedule with nonexistent config file.""" + service = ScheduleService(test_db) + + with pytest.raises(ValueError, match="Config file not found"): + service.create_schedule( + name='Bad Config', + config_file='/nonexistent/config.yaml', + cron_expression='0 2 * * *', + enabled=True + ) + + def test_create_schedule_various_cron_expressions(self, test_db, sample_config_file): + """Test creating schedules with various valid cron expressions.""" + service = ScheduleService(test_db) + + cron_expressions = [ + '0 0 * * *', # Daily at midnight + '*/15 * * * *', # Every 15 minutes + '0 2 * * 0', # Weekly on Sunday at 2 AM + '0 0 1 * *', # Monthly on the 1st at midnight + '30 14 * * 1-5', # Weekdays at 2:30 PM + ] + + for i, cron in enumerate(cron_expressions): + schedule_id = service.create_schedule( + name=f'Schedule {i}', + config_file=sample_config_file, + cron_expression=cron, + enabled=True + ) + assert schedule_id is not None + + +class TestScheduleServiceGet: + """Tests for retrieving schedules.""" + + def test_get_schedule_not_found(self, test_db): + """Test getting a nonexistent schedule.""" + service = ScheduleService(test_db) + + with pytest.raises(ValueError, match="Schedule .* not found"): + service.get_schedule(999) + + def test_get_schedule_found(self, test_db, sample_config_file): + """Test getting an existing schedule.""" + service = ScheduleService(test_db) + + # Create a schedule + schedule_id = service.create_schedule( + name='Test Schedule', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + # Retrieve it + result = service.get_schedule(schedule_id) + + assert result is not None + assert result['id'] == schedule_id + assert result['name'] == 'Test Schedule' + assert result['cron_expression'] == '0 2 * * *' + assert result['enabled'] is True + assert 'history' in result + assert isinstance(result['history'], list) + + def test_get_schedule_with_history(self, test_db, sample_config_file): + """Test getting schedule includes execution history.""" + service = ScheduleService(test_db) + + # Create schedule + schedule_id = service.create_schedule( + name='Test Schedule', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + # Create associated scans + for i in range(3): + scan = Scan( + timestamp=datetime.utcnow() - timedelta(days=i), + status='completed', + config_file=sample_config_file, + title=f'Scan {i}', + triggered_by='scheduled', + schedule_id=schedule_id + ) + test_db.add(scan) + test_db.commit() + + # Get schedule + result = service.get_schedule(schedule_id) + + assert len(result['history']) == 3 + assert result['history'][0]['title'] == 'Scan 0' # Most recent first + + +class TestScheduleServiceList: + """Tests for listing schedules.""" + + def test_list_schedules_empty(self, test_db): + """Test listing schedules when database is empty.""" + service = ScheduleService(test_db) + + result = service.list_schedules(page=1, per_page=20) + + assert result['total'] == 0 + assert len(result['schedules']) == 0 + assert result['page'] == 1 + assert result['per_page'] == 20 + + def test_list_schedules_populated(self, test_db, sample_config_file): + """Test listing schedules with data.""" + service = ScheduleService(test_db) + + # Create multiple schedules + for i in range(5): + service.create_schedule( + name=f'Schedule {i}', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + result = service.list_schedules(page=1, per_page=20) + + assert result['total'] == 5 + assert len(result['schedules']) == 5 + assert all('name' in s for s in result['schedules']) + + def test_list_schedules_pagination(self, test_db, sample_config_file): + """Test schedule pagination.""" + service = ScheduleService(test_db) + + # Create 25 schedules + for i in range(25): + service.create_schedule( + name=f'Schedule {i:02d}', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + # Get first page + result_page1 = service.list_schedules(page=1, per_page=10) + assert len(result_page1['schedules']) == 10 + assert result_page1['total'] == 25 + assert result_page1['pages'] == 3 + + # Get second page + result_page2 = service.list_schedules(page=2, per_page=10) + assert len(result_page2['schedules']) == 10 + + # Get third page + result_page3 = service.list_schedules(page=3, per_page=10) + assert len(result_page3['schedules']) == 5 + + def test_list_schedules_filter_enabled(self, test_db, sample_config_file): + """Test filtering schedules by enabled status.""" + service = ScheduleService(test_db) + + # Create enabled and disabled schedules + for i in range(3): + service.create_schedule( + name=f'Enabled {i}', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + for i in range(2): + service.create_schedule( + name=f'Disabled {i}', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=False + ) + + # Filter enabled only + result_enabled = service.list_schedules(enabled_filter=True) + assert result_enabled['total'] == 3 + + # Filter disabled only + result_disabled = service.list_schedules(enabled_filter=False) + assert result_disabled['total'] == 2 + + # No filter + result_all = service.list_schedules(enabled_filter=None) + assert result_all['total'] == 5 + + +class TestScheduleServiceUpdate: + """Tests for updating schedules.""" + + def test_update_schedule_name(self, test_db, sample_config_file): + """Test updating schedule name.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Old Name', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + result = service.update_schedule(schedule_id, name='New Name') + + assert result['name'] == 'New Name' + assert result['cron_expression'] == '0 2 * * *' + + def test_update_schedule_cron(self, test_db, sample_config_file): + """Test updating cron expression recalculates next_run.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Test', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + original = service.get_schedule(schedule_id) + original_next_run = original['next_run'] + + # Update cron expression + result = service.update_schedule( + schedule_id, + cron_expression='0 3 * * *' + ) + + # Next run should be recalculated + assert result['cron_expression'] == '0 3 * * *' + assert result['next_run'] != original_next_run + + def test_update_schedule_invalid_cron(self, test_db, sample_config_file): + """Test updating with invalid cron expression fails.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Test', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + with pytest.raises(ValueError, match="Invalid cron expression"): + service.update_schedule(schedule_id, cron_expression='invalid') + + def test_update_schedule_not_found(self, test_db): + """Test updating nonexistent schedule fails.""" + service = ScheduleService(test_db) + + with pytest.raises(ValueError, match="Schedule .* not found"): + service.update_schedule(999, name='New Name') + + def test_update_schedule_invalid_config_file(self, test_db, sample_config_file): + """Test updating with nonexistent config file fails.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Test', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + with pytest.raises(ValueError, match="Config file not found"): + service.update_schedule(schedule_id, config_file='/nonexistent.yaml') + + +class TestScheduleServiceDelete: + """Tests for deleting schedules.""" + + def test_delete_schedule(self, test_db, sample_config_file): + """Test deleting a schedule.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='To Delete', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + # Verify exists + assert test_db.query(Schedule).filter(Schedule.id == schedule_id).first() is not None + + # Delete + result = service.delete_schedule(schedule_id) + assert result is True + + # Verify deleted + assert test_db.query(Schedule).filter(Schedule.id == schedule_id).first() is None + + def test_delete_schedule_not_found(self, test_db): + """Test deleting nonexistent schedule fails.""" + service = ScheduleService(test_db) + + with pytest.raises(ValueError, match="Schedule .* not found"): + service.delete_schedule(999) + + def test_delete_schedule_preserves_scans(self, test_db, sample_config_file): + """Test that deleting schedule preserves associated scans.""" + service = ScheduleService(test_db) + + # Create schedule + schedule_id = service.create_schedule( + name='Test', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + # Create associated scan + scan = Scan( + timestamp=datetime.utcnow(), + status='completed', + config_file=sample_config_file, + title='Test Scan', + triggered_by='scheduled', + schedule_id=schedule_id + ) + test_db.add(scan) + test_db.commit() + scan_id = scan.id + + # Delete schedule + service.delete_schedule(schedule_id) + + # Verify scan still exists (schedule_id becomes null) + remaining_scan = test_db.query(Scan).filter(Scan.id == scan_id).first() + assert remaining_scan is not None + assert remaining_scan.schedule_id is None + + +class TestScheduleServiceToggle: + """Tests for toggling schedule enabled status.""" + + def test_toggle_enabled_to_disabled(self, test_db, sample_config_file): + """Test disabling an enabled schedule.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Test', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + result = service.toggle_enabled(schedule_id, enabled=False) + + assert result['enabled'] is False + assert result['next_run'] is None + + def test_toggle_disabled_to_enabled(self, test_db, sample_config_file): + """Test enabling a disabled schedule.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Test', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=False + ) + + result = service.toggle_enabled(schedule_id, enabled=True) + + assert result['enabled'] is True + assert result['next_run'] is not None + + +class TestScheduleServiceRunTimes: + """Tests for updating run times.""" + + def test_update_run_times(self, test_db, sample_config_file): + """Test updating last_run and next_run.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Test', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + last_run = datetime.utcnow() + next_run = datetime.utcnow() + timedelta(days=1) + + result = service.update_run_times(schedule_id, last_run, next_run) + assert result is True + + schedule = service.get_schedule(schedule_id) + assert schedule['last_run'] is not None + assert schedule['next_run'] is not None + + def test_update_run_times_not_found(self, test_db): + """Test updating run times for nonexistent schedule.""" + service = ScheduleService(test_db) + + with pytest.raises(ValueError, match="Schedule .* not found"): + service.update_run_times( + 999, + datetime.utcnow(), + datetime.utcnow() + timedelta(days=1) + ) + + +class TestCronValidation: + """Tests for cron expression validation.""" + + def test_validate_cron_valid_expressions(self, test_db): + """Test validating various valid cron expressions.""" + service = ScheduleService(test_db) + + valid_expressions = [ + '0 0 * * *', # Daily at midnight + '*/15 * * * *', # Every 15 minutes + '0 2 * * 0', # Weekly on Sunday + '0 0 1 * *', # Monthly + '30 14 * * 1-5', # Weekdays + '0 */4 * * *', # Every 4 hours + ] + + for expr in valid_expressions: + is_valid, error = service.validate_cron_expression(expr) + assert is_valid is True, f"Expression '{expr}' should be valid" + assert error is None + + def test_validate_cron_invalid_expressions(self, test_db): + """Test validating invalid cron expressions.""" + service = ScheduleService(test_db) + + invalid_expressions = [ + 'invalid', + '60 0 * * *', # Invalid minute (0-59) + '0 24 * * *', # Invalid hour (0-23) + '0 0 32 * *', # Invalid day (1-31) + '0 0 * 13 *', # Invalid month (1-12) + '0 0 * * 7', # Invalid weekday (0-6) + ] + + for expr in invalid_expressions: + is_valid, error = service.validate_cron_expression(expr) + assert is_valid is False, f"Expression '{expr}' should be invalid" + assert error is not None + + +class TestNextRunCalculation: + """Tests for next run time calculation.""" + + def test_calculate_next_run(self, test_db): + """Test calculating next run time.""" + service = ScheduleService(test_db) + + # Daily at 2 AM + next_run = service.calculate_next_run('0 2 * * *') + + assert next_run is not None + assert isinstance(next_run, datetime) + assert next_run > datetime.utcnow() + + def test_calculate_next_run_from_time(self, test_db): + """Test calculating next run from specific time.""" + service = ScheduleService(test_db) + + base_time = datetime(2025, 1, 1, 0, 0, 0) + next_run = service.calculate_next_run('0 2 * * *', from_time=base_time) + + # Should be 2 AM on same day + assert next_run.hour == 2 + assert next_run.minute == 0 + + def test_calculate_next_run_invalid_cron(self, test_db): + """Test calculating next run with invalid cron raises error.""" + service = ScheduleService(test_db) + + with pytest.raises(ValueError, match="Invalid cron expression"): + service.calculate_next_run('invalid cron') + + +class TestScheduleHistory: + """Tests for schedule execution history.""" + + def test_get_schedule_history_empty(self, test_db, sample_config_file): + """Test getting history for schedule with no executions.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Test', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + history = service.get_schedule_history(schedule_id) + assert len(history) == 0 + + def test_get_schedule_history_with_scans(self, test_db, sample_config_file): + """Test getting history with multiple scans.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Test', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + # Create 15 scans + for i in range(15): + scan = Scan( + timestamp=datetime.utcnow() - timedelta(days=i), + status='completed', + config_file=sample_config_file, + title=f'Scan {i}', + triggered_by='scheduled', + schedule_id=schedule_id + ) + test_db.add(scan) + test_db.commit() + + # Get history (default limit 10) + history = service.get_schedule_history(schedule_id, limit=10) + assert len(history) == 10 + assert history[0]['title'] == 'Scan 0' # Most recent first + + def test_get_schedule_history_custom_limit(self, test_db, sample_config_file): + """Test getting history with custom limit.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Test', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + # Create 10 scans + for i in range(10): + scan = Scan( + timestamp=datetime.utcnow() - timedelta(days=i), + status='completed', + config_file=sample_config_file, + title=f'Scan {i}', + triggered_by='scheduled', + schedule_id=schedule_id + ) + test_db.add(scan) + test_db.commit() + + # Get only 5 + history = service.get_schedule_history(schedule_id, limit=5) + assert len(history) == 5 + + +class TestScheduleSerialization: + """Tests for schedule serialization.""" + + def test_schedule_to_dict(self, test_db, sample_config_file): + """Test converting schedule to dictionary.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Test Schedule', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + result = service.get_schedule(schedule_id) + + # Verify all required fields + assert 'id' in result + assert 'name' in result + assert 'config_file' in result + assert 'cron_expression' in result + assert 'enabled' in result + assert 'last_run' in result + assert 'next_run' in result + assert 'next_run_relative' in result + assert 'created_at' in result + assert 'updated_at' in result + assert 'history' in result + + def test_schedule_relative_time_formatting(self, test_db, sample_config_file): + """Test relative time formatting in schedule dict.""" + service = ScheduleService(test_db) + + schedule_id = service.create_schedule( + name='Test', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True + ) + + result = service.get_schedule(schedule_id) + + # Should have relative time for next_run + assert result['next_run_relative'] is not None + assert isinstance(result['next_run_relative'], str) + assert 'in' in result['next_run_relative'].lower() diff --git a/web/services/schedule_service.py b/web/services/schedule_service.py new file mode 100644 index 0000000..3c84874 --- /dev/null +++ b/web/services/schedule_service.py @@ -0,0 +1,470 @@ +""" +Schedule service for managing scheduled scan operations. + +This service handles the business logic for creating, updating, and managing +scheduled scans with cron expressions. +""" + +import logging +import os +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from croniter import croniter +from sqlalchemy.orm import Session + +from web.models import Schedule, Scan +from web.utils.pagination import paginate, PaginatedResult + +logger = logging.getLogger(__name__) + + +class ScheduleService: + """ + Service for managing scheduled scans. + + Handles schedule lifecycle: creation, validation, updating, + and cron expression processing. + """ + + def __init__(self, db_session: Session): + """ + Initialize schedule service. + + Args: + db_session: SQLAlchemy database session + """ + self.db = db_session + + def create_schedule( + self, + name: str, + config_file: str, + cron_expression: str, + enabled: bool = True + ) -> int: + """ + Create a new schedule. + + Args: + name: Human-readable schedule name + config_file: Path to YAML configuration file + cron_expression: Cron expression (e.g., '0 2 * * *') + enabled: Whether schedule is active + + Returns: + Schedule ID of the created schedule + + Raises: + ValueError: If cron expression is invalid or config file doesn't exist + """ + # Validate cron expression + is_valid, error_msg = self.validate_cron_expression(cron_expression) + if not is_valid: + raise ValueError(f"Invalid cron expression: {error_msg}") + + # Validate config file exists + if not os.path.isfile(config_file): + raise ValueError(f"Config file not found: {config_file}") + + # Calculate next run time + next_run = self.calculate_next_run(cron_expression) if enabled else None + + # Create schedule record + schedule = Schedule( + name=name, + config_file=config_file, + cron_expression=cron_expression, + enabled=enabled, + last_run=None, + next_run=next_run, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + + self.db.add(schedule) + self.db.commit() + self.db.refresh(schedule) + + logger.info(f"Schedule {schedule.id} created: '{name}' with cron '{cron_expression}'") + + return schedule.id + + def get_schedule(self, schedule_id: int) -> Dict[str, Any]: + """ + Get schedule details by ID. + + Args: + schedule_id: Schedule ID + + Returns: + Schedule dictionary with details and execution history + + Raises: + ValueError: If schedule not found + """ + schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first() + + if not schedule: + raise ValueError(f"Schedule {schedule_id} not found") + + # Convert to dict and include history + schedule_dict = self._schedule_to_dict(schedule) + schedule_dict['history'] = self.get_schedule_history(schedule_id, limit=10) + + return schedule_dict + + def list_schedules( + self, + page: int = 1, + per_page: int = 20, + enabled_filter: Optional[bool] = None + ) -> Dict[str, Any]: + """ + List all schedules with pagination and filtering. + + Args: + page: Page number (1-indexed) + per_page: Items per page + enabled_filter: Filter by enabled status (None = all) + + Returns: + Dictionary with paginated schedules: + { + 'schedules': [...], + 'total': int, + 'page': int, + 'per_page': int, + 'pages': int + } + """ + # Build query + query = self.db.query(Schedule) + + # Apply filter + if enabled_filter is not None: + query = query.filter(Schedule.enabled == enabled_filter) + + # Order by next_run (nulls last), then by name + query = query.order_by(Schedule.next_run.is_(None), Schedule.next_run, Schedule.name) + + # Paginate + result = paginate(query, page=page, per_page=per_page) + + # Convert schedules to dicts + schedules = [self._schedule_to_dict(s) for s in result.items] + + return { + 'schedules': schedules, + 'total': result.total, + 'page': result.page, + 'per_page': result.per_page, + 'pages': result.pages + } + + def update_schedule( + self, + schedule_id: int, + **updates: Any + ) -> Dict[str, Any]: + """ + Update schedule fields. + + Args: + schedule_id: Schedule ID + **updates: Fields to update (name, config_file, cron_expression, enabled) + + Returns: + Updated schedule dictionary + + Raises: + ValueError: If schedule not found or invalid updates + """ + schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first() + + if not schedule: + raise ValueError(f"Schedule {schedule_id} not found") + + # Validate cron expression if being updated + if 'cron_expression' in updates: + is_valid, error_msg = self.validate_cron_expression(updates['cron_expression']) + if not is_valid: + raise ValueError(f"Invalid cron expression: {error_msg}") + # Recalculate next_run + if schedule.enabled or updates.get('enabled', False): + updates['next_run'] = self.calculate_next_run(updates['cron_expression']) + + # Validate config file if being updated + if 'config_file' in updates: + if not os.path.isfile(updates['config_file']): + raise ValueError(f"Config file not found: {updates['config_file']}") + + # Handle enabled toggle + if 'enabled' in updates: + if updates['enabled'] and not schedule.enabled: + # Being enabled - calculate next_run + cron_expr = updates.get('cron_expression', schedule.cron_expression) + updates['next_run'] = self.calculate_next_run(cron_expr) + elif not updates['enabled'] and schedule.enabled: + # Being disabled - clear next_run + updates['next_run'] = None + + # Update fields + for key, value in updates.items(): + if hasattr(schedule, key): + setattr(schedule, key, value) + + schedule.updated_at = datetime.utcnow() + + self.db.commit() + self.db.refresh(schedule) + + logger.info(f"Schedule {schedule_id} updated: {list(updates.keys())}") + + return self._schedule_to_dict(schedule) + + def delete_schedule(self, schedule_id: int) -> bool: + """ + Delete a schedule. + + Note: Associated scans are NOT deleted (schedule_id becomes null). + + Args: + schedule_id: Schedule ID + + Returns: + True if deleted successfully + + Raises: + ValueError: If schedule not found + """ + schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first() + + if not schedule: + raise ValueError(f"Schedule {schedule_id} not found") + + schedule_name = schedule.name + + self.db.delete(schedule) + self.db.commit() + + logger.info(f"Schedule {schedule_id} ('{schedule_name}') deleted") + + return True + + def toggle_enabled(self, schedule_id: int, enabled: bool) -> Dict[str, Any]: + """ + Enable or disable a schedule. + + Args: + schedule_id: Schedule ID + enabled: New enabled status + + Returns: + Updated schedule dictionary + + Raises: + ValueError: If schedule not found + """ + return self.update_schedule(schedule_id, enabled=enabled) + + def update_run_times( + self, + schedule_id: int, + last_run: datetime, + next_run: datetime + ) -> bool: + """ + Update last_run and next_run timestamps. + + Called after each execution. + + Args: + schedule_id: Schedule ID + last_run: Last execution time + next_run: Next scheduled execution time + + Returns: + True if updated successfully + + Raises: + ValueError: If schedule not found + """ + schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first() + + if not schedule: + raise ValueError(f"Schedule {schedule_id} not found") + + schedule.last_run = last_run + schedule.next_run = next_run + schedule.updated_at = datetime.utcnow() + + self.db.commit() + + logger.debug(f"Schedule {schedule_id} run times updated: last={last_run}, next={next_run}") + + return True + + def validate_cron_expression(self, cron_expr: str) -> Tuple[bool, Optional[str]]: + """ + Validate a cron expression. + + Args: + cron_expr: Cron expression to validate + + Returns: + Tuple of (is_valid, error_message) + - (True, None) if valid + - (False, error_message) if invalid + """ + try: + # Try to create a croniter instance + base_time = datetime.utcnow() + cron = croniter(cron_expr, base_time) + + # Try to get the next run time (validates the expression) + cron.get_next(datetime) + + return (True, None) + except (ValueError, KeyError) as e: + return (False, str(e)) + except Exception as e: + return (False, f"Unexpected error: {str(e)}") + + def calculate_next_run( + self, + cron_expr: str, + from_time: Optional[datetime] = None + ) -> datetime: + """ + Calculate next run time from cron expression. + + Args: + cron_expr: Cron expression + from_time: Base time (defaults to now UTC) + + Returns: + Next run datetime (UTC) + + Raises: + ValueError: If cron expression is invalid + """ + if from_time is None: + from_time = datetime.utcnow() + + try: + cron = croniter(cron_expr, from_time) + return cron.get_next(datetime) + except Exception as e: + raise ValueError(f"Invalid cron expression '{cron_expr}': {str(e)}") + + def get_schedule_history( + self, + schedule_id: int, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Get recent scans triggered by this schedule. + + Args: + schedule_id: Schedule ID + limit: Maximum number of scans to return + + Returns: + List of scan dictionaries (recent first) + """ + scans = ( + self.db.query(Scan) + .filter(Scan.schedule_id == schedule_id) + .order_by(Scan.timestamp.desc()) + .limit(limit) + .all() + ) + + return [ + { + 'id': scan.id, + 'timestamp': scan.timestamp.isoformat() if scan.timestamp else None, + 'status': scan.status, + 'title': scan.title, + 'config_file': scan.config_file + } + for scan in scans + ] + + def _schedule_to_dict(self, schedule: Schedule) -> Dict[str, Any]: + """ + Convert Schedule model to dictionary. + + Args: + schedule: Schedule model instance + + Returns: + Dictionary representation + """ + return { + 'id': schedule.id, + 'name': schedule.name, + 'config_file': schedule.config_file, + 'cron_expression': schedule.cron_expression, + 'enabled': schedule.enabled, + 'last_run': schedule.last_run.isoformat() if schedule.last_run else None, + 'next_run': schedule.next_run.isoformat() if schedule.next_run else None, + 'next_run_relative': self._get_relative_time(schedule.next_run) if schedule.next_run else None, + 'created_at': schedule.created_at.isoformat() if schedule.created_at else None, + 'updated_at': schedule.updated_at.isoformat() if schedule.updated_at else None + } + + def _get_relative_time(self, dt: Optional[datetime]) -> Optional[str]: + """ + Format datetime as relative time. + + Args: + dt: Datetime to format (UTC) + + Returns: + Human-readable relative time (e.g., "in 2 hours", "yesterday") + """ + if dt is None: + return None + + now = datetime.utcnow() + diff = dt - now + + # Future times + if diff.total_seconds() > 0: + seconds = int(diff.total_seconds()) + + if seconds < 60: + return "in less than a minute" + elif seconds < 3600: + minutes = seconds // 60 + return f"in {minutes} minute{'s' if minutes != 1 else ''}" + elif seconds < 86400: + hours = seconds // 3600 + return f"in {hours} hour{'s' if hours != 1 else ''}" + elif seconds < 604800: + days = seconds // 86400 + return f"in {days} day{'s' if days != 1 else ''}" + else: + weeks = seconds // 604800 + return f"in {weeks} week{'s' if weeks != 1 else ''}" + + # Past times + else: + seconds = int(-diff.total_seconds()) + + if seconds < 60: + return "less than a minute ago" + elif seconds < 3600: + minutes = seconds // 60 + return f"{minutes} minute{'s' if minutes != 1 else ''} ago" + elif seconds < 86400: + hours = seconds // 3600 + return f"{hours} hour{'s' if hours != 1 else ''} ago" + elif seconds < 604800: + days = seconds // 86400 + return f"{days} day{'s' if days != 1 else ''} ago" + else: + weeks = seconds // 604800 + return f"{weeks} week{'s' if weeks != 1 else ''} ago" From d68d9133c1f59562684e9bc59e5faa54d9f3b449 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 14 Nov 2025 14:33:48 -0600 Subject: [PATCH 3/8] Phase 3 Steps 3 & 4: Complete Schedules API & Management UI Implemented full schedule management system with API endpoints and user interface for creating, editing, and managing scheduled scans. API Implementation: - Implemented all 6 schedules API endpoints (list, get, create, update, delete, trigger) - Added comprehensive error handling and validation - Integrated with ScheduleService and SchedulerService - Added manual trigger endpoint for on-demand execution Schedule Management UI: - Created schedules list page with stats cards and enable/disable toggles - Built schedule creation form with cron expression builder and quick templates - Implemented schedule edit page with execution history - Added "Schedules" navigation link to main menu - Real-time validation and human-readable cron descriptions Config File Path Resolution: - Fixed config file path handling to support relative filenames - Updated validators.py to resolve relative paths to /app/configs/ - Modified schedule_service.py, scan_service.py, and scan_job.py for consistency - Ensures UI can use simple filenames while backend uses absolute paths Scheduler Integration: - Completed scheduled scan execution in scheduler_service.py - Added cron job management with APScheduler - Implemented automatic schedule loading on startup - Updated run times after each execution Testing: - Added comprehensive API integration tests (test_schedule_api.py) - 22+ test cases covering all endpoints and workflows Progress: Phase 3 Steps 1-4 complete (36% - 5/14 days) Next: Step 5 - Enhanced Dashboard with Charts --- docs/ai/PHASE3.md | 10 +- tests/test_schedule_api.py | 639 +++++++++++++++++++++++++++++ web/api/schedules.py | 269 +++++++++--- web/jobs/scan_job.py | 8 +- web/routes/main.py | 56 +++ web/services/scan_service.py | 8 +- web/services/schedule_service.py | 17 +- web/services/scheduler_service.py | 96 +++-- web/templates/base.html | 4 + web/templates/schedule_create.html | 427 +++++++++++++++++++ web/templates/schedule_edit.html | 569 +++++++++++++++++++++++++ web/templates/schedules.html | 389 ++++++++++++++++++ web/utils/validators.py | 8 +- 13 files changed, 2413 insertions(+), 87 deletions(-) create mode 100644 tests/test_schedule_api.py create mode 100644 web/templates/schedule_create.html create mode 100644 web/templates/schedule_edit.html create mode 100644 web/templates/schedules.html diff --git a/docs/ai/PHASE3.md b/docs/ai/PHASE3.md index c04afbe..f874293 100644 --- a/docs/ai/PHASE3.md +++ b/docs/ai/PHASE3.md @@ -1,16 +1,16 @@ # Phase 3 Implementation Plan: Dashboard Enhancement & Scheduled Scans -**Status:** Ready to Start -**Progress:** 0/14 days complete (0%) +**Status:** In Progress +**Progress:** 5/14 days complete (36%) **Estimated Duration:** 14 days (2 weeks) **Dependencies:** Phase 2 Complete βœ… ## Progress Summary - βœ… **Step 1: Fix Styling Issues & CSS Refactor** (Day 1) - COMPLETE -- πŸ“‹ **Step 2: ScheduleService Implementation** (Days 2-3) - NEXT -- πŸ“‹ **Step 3: Schedules API Endpoints** (Days 4-5) -- πŸ“‹ **Step 4: Schedule Management UI** (Days 6-7) +- βœ… **Step 2: ScheduleService Implementation** (Days 2-3) - COMPLETE +- βœ… **Step 3: Schedules API Endpoints** (Days 4-5) - COMPLETE +- πŸ“‹ **Step 4: Schedule Management UI** (Days 6-7) - NEXT - πŸ“‹ **Step 5: Enhanced Dashboard with Charts** (Days 8-9) - πŸ“‹ **Step 6: Scheduler Integration** (Day 10) - πŸ“‹ **Step 7: Scan Comparison Features** (Days 11-12) diff --git a/tests/test_schedule_api.py b/tests/test_schedule_api.py new file mode 100644 index 0000000..7601986 --- /dev/null +++ b/tests/test_schedule_api.py @@ -0,0 +1,639 @@ +""" +Integration tests for Schedule API endpoints. + +Tests all schedule management endpoints including creating, listing, +updating, deleting schedules, and manually triggering scheduled scans. +""" + +import json +import pytest +from datetime import datetime + +from web.models import Schedule, Scan + + +@pytest.fixture +def sample_schedule(db, sample_config_file): + """ + Create a sample schedule in the database for testing. + + Args: + db: Database session fixture + sample_config_file: Path to test config file + + Returns: + Schedule model instance + """ + schedule = Schedule( + name='Daily Test Scan', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True, + last_run=None, + next_run=datetime(2025, 11, 15, 2, 0, 0), + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + + db.add(schedule) + db.commit() + db.refresh(schedule) + + return schedule + + +class TestScheduleAPIEndpoints: + """Test suite for schedule API endpoints.""" + + def test_list_schedules_empty(self, client, db): + """Test listing schedules when database is empty.""" + response = client.get('/api/schedules') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['schedules'] == [] + assert data['total'] == 0 + assert data['page'] == 1 + assert data['per_page'] == 20 + + def test_list_schedules_populated(self, client, db, sample_schedule): + """Test listing schedules with existing data.""" + response = client.get('/api/schedules') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['total'] == 1 + assert len(data['schedules']) == 1 + assert data['schedules'][0]['id'] == sample_schedule.id + assert data['schedules'][0]['name'] == sample_schedule.name + assert data['schedules'][0]['cron_expression'] == sample_schedule.cron_expression + + def test_list_schedules_pagination(self, client, db, sample_config_file): + """Test schedule list pagination.""" + # Create 25 schedules + for i in range(25): + schedule = Schedule( + name=f'Schedule {i}', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True, + created_at=datetime.utcnow() + ) + db.add(schedule) + db.commit() + + # Test page 1 + response = client.get('/api/schedules?page=1&per_page=10') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['total'] == 25 + assert len(data['schedules']) == 10 + assert data['page'] == 1 + assert data['per_page'] == 10 + assert data['pages'] == 3 + + # Test page 2 + response = client.get('/api/schedules?page=2&per_page=10') + assert response.status_code == 200 + + data = json.loads(response.data) + assert len(data['schedules']) == 10 + assert data['page'] == 2 + + def test_list_schedules_filter_enabled(self, client, db, sample_config_file): + """Test filtering schedules by enabled status.""" + # Create enabled and disabled schedules + for i in range(3): + schedule = Schedule( + name=f'Enabled Schedule {i}', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True, + created_at=datetime.utcnow() + ) + db.add(schedule) + + for i in range(2): + schedule = Schedule( + name=f'Disabled Schedule {i}', + config_file=sample_config_file, + cron_expression='0 3 * * *', + enabled=False, + created_at=datetime.utcnow() + ) + db.add(schedule) + db.commit() + + # Filter by enabled=true + response = client.get('/api/schedules?enabled=true') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['total'] == 3 + for schedule in data['schedules']: + assert schedule['enabled'] is True + + # Filter by enabled=false + response = client.get('/api/schedules?enabled=false') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['total'] == 2 + for schedule in data['schedules']: + assert schedule['enabled'] is False + + def test_get_schedule(self, client, db, sample_schedule): + """Test getting schedule details.""" + response = client.get(f'/api/schedules/{sample_schedule.id}') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['id'] == sample_schedule.id + assert data['name'] == sample_schedule.name + assert data['config_file'] == sample_schedule.config_file + assert data['cron_expression'] == sample_schedule.cron_expression + assert data['enabled'] == sample_schedule.enabled + assert 'history' in data + + def test_get_schedule_not_found(self, client, db): + """Test getting non-existent schedule.""" + response = client.get('/api/schedules/99999') + assert response.status_code == 404 + + data = json.loads(response.data) + assert 'error' in data + assert 'not found' in data['error'].lower() + + def test_create_schedule(self, client, db, sample_config_file): + """Test creating a new schedule.""" + schedule_data = { + 'name': 'New Test Schedule', + 'config_file': sample_config_file, + 'cron_expression': '0 3 * * *', + 'enabled': True + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 201 + + data = json.loads(response.data) + assert 'schedule_id' in data + assert data['message'] == 'Schedule created successfully' + assert 'schedule' in data + + # Verify schedule in database + schedule = db.query(Schedule).filter(Schedule.id == data['schedule_id']).first() + assert schedule is not None + assert schedule.name == schedule_data['name'] + assert schedule.cron_expression == schedule_data['cron_expression'] + + def test_create_schedule_missing_fields(self, client, db): + """Test creating schedule with missing required fields.""" + # Missing cron_expression + schedule_data = { + 'name': 'Incomplete Schedule', + 'config_file': '/app/configs/test.yaml' + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 400 + + data = json.loads(response.data) + assert 'error' in data + assert 'missing' in data['error'].lower() + + def test_create_schedule_invalid_cron(self, client, db, sample_config_file): + """Test creating schedule with invalid cron expression.""" + schedule_data = { + 'name': 'Invalid Cron Schedule', + 'config_file': sample_config_file, + 'cron_expression': 'invalid cron' + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 400 + + data = json.loads(response.data) + assert 'error' in data + assert 'invalid' in data['error'].lower() or 'cron' in data['error'].lower() + + def test_create_schedule_invalid_config(self, client, db): + """Test creating schedule with non-existent config file.""" + schedule_data = { + 'name': 'Invalid Config Schedule', + 'config_file': '/nonexistent/config.yaml', + 'cron_expression': '0 2 * * *' + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 400 + + data = json.loads(response.data) + assert 'error' in data + assert 'not found' in data['error'].lower() + + def test_update_schedule(self, client, db, sample_schedule): + """Test updating schedule fields.""" + update_data = { + 'name': 'Updated Schedule Name', + 'cron_expression': '0 4 * * *' + } + + response = client.put( + f'/api/schedules/{sample_schedule.id}', + data=json.dumps(update_data), + content_type='application/json' + ) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['message'] == 'Schedule updated successfully' + assert data['schedule']['name'] == update_data['name'] + assert data['schedule']['cron_expression'] == update_data['cron_expression'] + + # Verify in database + db.refresh(sample_schedule) + assert sample_schedule.name == update_data['name'] + assert sample_schedule.cron_expression == update_data['cron_expression'] + + def test_update_schedule_not_found(self, client, db): + """Test updating non-existent schedule.""" + update_data = {'name': 'New Name'} + + response = client.put( + '/api/schedules/99999', + data=json.dumps(update_data), + content_type='application/json' + ) + assert response.status_code == 404 + + data = json.loads(response.data) + assert 'error' in data + + def test_update_schedule_invalid_cron(self, client, db, sample_schedule): + """Test updating schedule with invalid cron expression.""" + update_data = {'cron_expression': 'invalid'} + + response = client.put( + f'/api/schedules/{sample_schedule.id}', + data=json.dumps(update_data), + content_type='application/json' + ) + assert response.status_code == 400 + + data = json.loads(response.data) + assert 'error' in data + + def test_update_schedule_toggle_enabled(self, client, db, sample_schedule): + """Test enabling/disabling schedule.""" + # Disable schedule + response = client.put( + f'/api/schedules/{sample_schedule.id}', + data=json.dumps({'enabled': False}), + content_type='application/json' + ) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['schedule']['enabled'] is False + + # Enable schedule + response = client.put( + f'/api/schedules/{sample_schedule.id}', + data=json.dumps({'enabled': True}), + content_type='application/json' + ) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['schedule']['enabled'] is True + + def test_update_schedule_no_data(self, client, db, sample_schedule): + """Test updating schedule with no data.""" + response = client.put( + f'/api/schedules/{sample_schedule.id}', + data=json.dumps({}), + content_type='application/json' + ) + assert response.status_code == 400 + + data = json.loads(response.data) + assert 'error' in data + + def test_delete_schedule(self, client, db, sample_schedule): + """Test deleting a schedule.""" + schedule_id = sample_schedule.id + + response = client.delete(f'/api/schedules/{schedule_id}') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['message'] == 'Schedule deleted successfully' + assert data['schedule_id'] == schedule_id + + # Verify deletion in database + schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first() + assert schedule is None + + def test_delete_schedule_not_found(self, client, db): + """Test deleting non-existent schedule.""" + response = client.delete('/api/schedules/99999') + assert response.status_code == 404 + + data = json.loads(response.data) + assert 'error' in data + + def test_delete_schedule_preserves_scans(self, client, db, sample_schedule, sample_config_file): + """Test that deleting schedule preserves associated scans.""" + # Create a scan associated with the schedule + scan = Scan( + timestamp=datetime.utcnow(), + status='completed', + config_file=sample_config_file, + title='Test Scan', + triggered_by='scheduled', + schedule_id=sample_schedule.id + ) + db.add(scan) + db.commit() + scan_id = scan.id + + # Delete schedule + response = client.delete(f'/api/schedules/{sample_schedule.id}') + assert response.status_code == 200 + + # Verify scan still exists + scan = db.query(Scan).filter(Scan.id == scan_id).first() + assert scan is not None + assert scan.schedule_id is None # Schedule ID becomes null + + def test_trigger_schedule(self, client, db, sample_schedule): + """Test manually triggering a scheduled scan.""" + response = client.post(f'/api/schedules/{sample_schedule.id}/trigger') + assert response.status_code == 201 + + data = json.loads(response.data) + assert data['message'] == 'Scan triggered successfully' + assert 'scan_id' in data + assert data['schedule_id'] == sample_schedule.id + + # Verify scan was created + scan = db.query(Scan).filter(Scan.id == data['scan_id']).first() + assert scan is not None + assert scan.triggered_by == 'manual' + assert scan.schedule_id == sample_schedule.id + assert scan.config_file == sample_schedule.config_file + + def test_trigger_schedule_not_found(self, client, db): + """Test triggering non-existent schedule.""" + response = client.post('/api/schedules/99999/trigger') + assert response.status_code == 404 + + data = json.loads(response.data) + assert 'error' in data + + def test_get_schedule_with_history(self, client, db, sample_schedule, sample_config_file): + """Test getting schedule includes execution history.""" + # Create some scans for this schedule + for i in range(5): + scan = Scan( + timestamp=datetime.utcnow(), + status='completed', + config_file=sample_config_file, + title=f'Scheduled Scan {i}', + triggered_by='scheduled', + schedule_id=sample_schedule.id + ) + db.add(scan) + db.commit() + + response = client.get(f'/api/schedules/{sample_schedule.id}') + assert response.status_code == 200 + + data = json.loads(response.data) + assert 'history' in data + assert len(data['history']) == 5 + + def test_schedule_workflow_integration(self, client, db, sample_config_file): + """Test complete schedule workflow: create β†’ update β†’ trigger β†’ delete.""" + # 1. Create schedule + schedule_data = { + 'name': 'Integration Test Schedule', + 'config_file': sample_config_file, + 'cron_expression': '0 2 * * *', + 'enabled': True + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 201 + schedule_id = json.loads(response.data)['schedule_id'] + + # 2. Get schedule + response = client.get(f'/api/schedules/{schedule_id}') + assert response.status_code == 200 + + # 3. Update schedule + response = client.put( + f'/api/schedules/{schedule_id}', + data=json.dumps({'name': 'Updated Integration Test'}), + content_type='application/json' + ) + assert response.status_code == 200 + + # 4. Trigger schedule + response = client.post(f'/api/schedules/{schedule_id}/trigger') + assert response.status_code == 201 + scan_id = json.loads(response.data)['scan_id'] + + # 5. Verify scan was created + scan = db.query(Scan).filter(Scan.id == scan_id).first() + assert scan is not None + + # 6. Delete schedule + response = client.delete(f'/api/schedules/{schedule_id}') + assert response.status_code == 200 + + # 7. Verify schedule deleted + response = client.get(f'/api/schedules/{schedule_id}') + assert response.status_code == 404 + + # 8. Verify scan still exists + scan = db.query(Scan).filter(Scan.id == scan_id).first() + assert scan is not None + + def test_list_schedules_ordering(self, client, db, sample_config_file): + """Test that schedules are ordered by next_run time.""" + # Create schedules with different next_run times + schedules = [] + for i in range(3): + schedule = Schedule( + name=f'Schedule {i}', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True, + next_run=datetime(2025, 11, 15 + i, 2, 0, 0), + created_at=datetime.utcnow() + ) + db.add(schedule) + schedules.append(schedule) + + # Create a disabled schedule (next_run is None) + disabled_schedule = Schedule( + name='Disabled Schedule', + config_file=sample_config_file, + cron_expression='0 3 * * *', + enabled=False, + next_run=None, + created_at=datetime.utcnow() + ) + db.add(disabled_schedule) + db.commit() + + response = client.get('/api/schedules') + assert response.status_code == 200 + + data = json.loads(response.data) + returned_schedules = data['schedules'] + + # Schedules with next_run should come before those without + # Within those with next_run, they should be ordered by time + assert returned_schedules[0]['id'] == schedules[0].id + assert returned_schedules[1]['id'] == schedules[1].id + assert returned_schedules[2]['id'] == schedules[2].id + assert returned_schedules[3]['id'] == disabled_schedule.id + + def test_create_schedule_with_disabled(self, client, db, sample_config_file): + """Test creating a disabled schedule.""" + schedule_data = { + 'name': 'Disabled Schedule', + 'config_file': sample_config_file, + 'cron_expression': '0 2 * * *', + 'enabled': False + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 201 + + data = json.loads(response.data) + assert data['schedule']['enabled'] is False + assert data['schedule']['next_run'] is None # Disabled schedules have no next_run + + +class TestScheduleAPIAuthentication: + """Test suite for schedule API authentication.""" + + def test_schedules_require_authentication(self, app): + """Test that all schedule endpoints require authentication.""" + # Create unauthenticated client + client = app.test_client() + + endpoints = [ + ('GET', '/api/schedules'), + ('GET', '/api/schedules/1'), + ('POST', '/api/schedules'), + ('PUT', '/api/schedules/1'), + ('DELETE', '/api/schedules/1'), + ('POST', '/api/schedules/1/trigger') + ] + + for method, endpoint in endpoints: + if method == 'GET': + response = client.get(endpoint) + elif method == 'POST': + response = client.post( + endpoint, + data=json.dumps({}), + content_type='application/json' + ) + elif method == 'PUT': + response = client.put( + endpoint, + data=json.dumps({}), + content_type='application/json' + ) + elif method == 'DELETE': + response = client.delete(endpoint) + + # Should redirect to login or return 401 + assert response.status_code in [302, 401], \ + f"{method} {endpoint} should require authentication" + + +class TestScheduleAPICronValidation: + """Test suite for cron expression validation.""" + + def test_valid_cron_expressions(self, client, db, sample_config_file): + """Test various valid cron expressions.""" + valid_expressions = [ + '0 2 * * *', # Daily at 2am + '*/15 * * * *', # Every 15 minutes + '0 0 * * 0', # Weekly on Sunday + '0 0 1 * *', # Monthly on 1st + '0 */4 * * *', # Every 4 hours + ] + + for cron_expr in valid_expressions: + schedule_data = { + 'name': f'Schedule for {cron_expr}', + 'config_file': sample_config_file, + 'cron_expression': cron_expr + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 201, \ + f"Valid cron expression '{cron_expr}' should be accepted" + + def test_invalid_cron_expressions(self, client, db, sample_config_file): + """Test various invalid cron expressions.""" + invalid_expressions = [ + 'invalid', + '60 2 * * *', # Invalid minute + '0 25 * * *', # Invalid hour + '0 0 32 * *', # Invalid day + '0 0 * 13 *', # Invalid month + '0 0 * * 8', # Invalid day of week + ] + + for cron_expr in invalid_expressions: + schedule_data = { + 'name': f'Schedule for {cron_expr}', + 'config_file': sample_config_file, + 'cron_expression': cron_expr + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 400, \ + f"Invalid cron expression '{cron_expr}' should be rejected" diff --git a/web/api/schedules.py b/web/api/schedules.py index 6fa11e8..a21cdd8 100644 --- a/web/api/schedules.py +++ b/web/api/schedules.py @@ -5,9 +5,15 @@ Handles endpoints for managing scheduled scans including CRUD operations and manual triggering. """ -from flask import Blueprint, jsonify, request +import logging + +from flask import Blueprint, jsonify, request, current_app from web.auth.decorators import api_auth_required +from web.services.schedule_service import ScheduleService +from web.services.scan_service import ScanService + +logger = logging.getLogger(__name__) bp = Blueprint('schedules', __name__) @@ -16,16 +22,36 @@ bp = Blueprint('schedules', __name__) @api_auth_required def list_schedules(): """ - List all schedules. + List all schedules with pagination and filtering. + + Query parameters: + page: Page number (default: 1) + per_page: Items per page (default: 20) + enabled: Filter by enabled status (true/false) Returns: - JSON response with schedules list + JSON response with paginated schedules list """ - # TODO: Implement in Phase 3 - return jsonify({ - 'schedules': [], - 'message': 'Schedules list endpoint - to be implemented in Phase 3' - }) + try: + # Parse query parameters + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + enabled_str = request.args.get('enabled', type=str) + + # Parse enabled filter + enabled_filter = None + if enabled_str is not None: + enabled_filter = enabled_str.lower() == 'true' + + # Get schedules + schedule_service = ScheduleService(current_app.db_session) + result = schedule_service.list_schedules(page, per_page, enabled_filter) + + return jsonify(result), 200 + + except Exception as e: + logger.error(f"Error listing schedules: {str(e)}", exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 @bp.route('/', methods=['GET']) @@ -38,13 +64,20 @@ def get_schedule(schedule_id): schedule_id: Schedule ID Returns: - JSON response with schedule details + JSON response with schedule details including execution history """ - # TODO: Implement in Phase 3 - return jsonify({ - 'schedule_id': schedule_id, - 'message': 'Schedule detail endpoint - to be implemented in Phase 3' - }) + try: + schedule_service = ScheduleService(current_app.db_session) + schedule = schedule_service.get_schedule(schedule_id) + + return jsonify(schedule), 200 + + except ValueError as e: + # Schedule not found + return jsonify({'error': str(e)}), 404 + except Exception as e: + logger.error(f"Error getting schedule {schedule_id}: {str(e)}", exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 @bp.route('', methods=['POST']) @@ -54,22 +87,60 @@ def create_schedule(): Create a new schedule. Request body: - name: Schedule name - config_file: Path to YAML config - cron_expression: Cron-like schedule expression + name: Schedule name (required) + config_file: Path to YAML config (required) + cron_expression: Cron expression (required, e.g., '0 2 * * *') + enabled: Whether schedule is active (optional, default: true) Returns: JSON response with created schedule ID """ - # TODO: Implement in Phase 3 - data = request.get_json() or {} + try: + data = request.get_json() or {} - return jsonify({ - 'schedule_id': None, - 'status': 'not_implemented', - 'message': 'Schedule creation endpoint - to be implemented in Phase 3', - 'data': data - }), 501 + # Validate required fields + required = ['name', 'config_file', 'cron_expression'] + missing = [field for field in required if field not in data] + if missing: + return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400 + + # Create schedule + schedule_service = ScheduleService(current_app.db_session) + schedule_id = schedule_service.create_schedule( + name=data['name'], + config_file=data['config_file'], + cron_expression=data['cron_expression'], + enabled=data.get('enabled', True) + ) + + # Get the created schedule + schedule = schedule_service.get_schedule(schedule_id) + + # Add to APScheduler if enabled + if schedule['enabled'] and hasattr(current_app, 'scheduler'): + try: + current_app.scheduler.add_scheduled_scan( + schedule_id=schedule_id, + config_file=schedule['config_file'], + cron_expression=schedule['cron_expression'] + ) + logger.info(f"Schedule {schedule_id} added to APScheduler") + except Exception as e: + logger.error(f"Failed to add schedule {schedule_id} to APScheduler: {str(e)}") + # Continue anyway - schedule is created in DB + + return jsonify({ + 'schedule_id': schedule_id, + 'message': 'Schedule created successfully', + 'schedule': schedule + }), 201 + + except ValueError as e: + # Validation error + return jsonify({'error': str(e)}), 400 + except Exception as e: + logger.error(f"Error creating schedule: {str(e)}", exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 @bp.route('/', methods=['PUT']) @@ -84,21 +155,73 @@ def update_schedule(schedule_id): Request body: name: Schedule name (optional) config_file: Path to YAML config (optional) - cron_expression: Cron-like schedule expression (optional) + cron_expression: Cron expression (optional) enabled: Whether schedule is active (optional) Returns: - JSON response with update status + JSON response with updated schedule """ - # TODO: Implement in Phase 3 - data = request.get_json() or {} + try: + data = request.get_json() or {} - return jsonify({ - 'schedule_id': schedule_id, - 'status': 'not_implemented', - 'message': 'Schedule update endpoint - to be implemented in Phase 3', - 'data': data - }), 501 + if not data: + return jsonify({'error': 'No update data provided'}), 400 + + # Update schedule + schedule_service = ScheduleService(current_app.db_session) + + # Store old state to check if scheduler update needed + old_schedule = schedule_service.get_schedule(schedule_id) + + # Perform update + updated_schedule = schedule_service.update_schedule(schedule_id, **data) + + # Update in APScheduler if needed + if hasattr(current_app, 'scheduler'): + try: + # If cron expression or config changed, or enabled status changed + cron_changed = 'cron_expression' in data + config_changed = 'config_file' in data + enabled_changed = 'enabled' in data + + if enabled_changed: + if updated_schedule['enabled']: + # Re-add to scheduler (replaces existing) + current_app.scheduler.add_scheduled_scan( + schedule_id=schedule_id, + config_file=updated_schedule['config_file'], + cron_expression=updated_schedule['cron_expression'] + ) + logger.info(f"Schedule {schedule_id} enabled and added to APScheduler") + else: + # Remove from scheduler + current_app.scheduler.remove_scheduled_scan(schedule_id) + logger.info(f"Schedule {schedule_id} disabled and removed from APScheduler") + elif (cron_changed or config_changed) and updated_schedule['enabled']: + # Reload schedule in APScheduler + current_app.scheduler.add_scheduled_scan( + schedule_id=schedule_id, + config_file=updated_schedule['config_file'], + cron_expression=updated_schedule['cron_expression'] + ) + logger.info(f"Schedule {schedule_id} reloaded in APScheduler") + except Exception as e: + logger.error(f"Failed to update schedule {schedule_id} in APScheduler: {str(e)}") + # Continue anyway - schedule is updated in DB + + return jsonify({ + 'message': 'Schedule updated successfully', + 'schedule': updated_schedule + }), 200 + + except ValueError as e: + # Schedule not found or validation error + if 'not found' in str(e): + return jsonify({'error': str(e)}), 404 + return jsonify({'error': str(e)}), 400 + except Exception as e: + logger.error(f"Error updating schedule {schedule_id}: {str(e)}", exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 @bp.route('/', methods=['DELETE']) @@ -107,18 +230,40 @@ def delete_schedule(schedule_id): """ Delete a schedule. + Note: Associated scans are NOT deleted (schedule_id becomes null). + Active scans will complete normally. + Args: schedule_id: Schedule ID to delete Returns: JSON response with deletion status """ - # TODO: Implement in Phase 3 - return jsonify({ - 'schedule_id': schedule_id, - 'status': 'not_implemented', - 'message': 'Schedule deletion endpoint - to be implemented in Phase 3' - }), 501 + try: + # Remove from APScheduler first + if hasattr(current_app, 'scheduler'): + try: + current_app.scheduler.remove_scheduled_scan(schedule_id) + logger.info(f"Schedule {schedule_id} removed from APScheduler") + except Exception as e: + logger.warning(f"Failed to remove schedule {schedule_id} from APScheduler: {str(e)}") + # Continue anyway + + # Delete from database + schedule_service = ScheduleService(current_app.db_session) + schedule_service.delete_schedule(schedule_id) + + return jsonify({ + 'message': 'Schedule deleted successfully', + 'schedule_id': schedule_id + }), 200 + + except ValueError as e: + # Schedule not found + return jsonify({'error': str(e)}), 404 + except Exception as e: + logger.error(f"Error deleting schedule {schedule_id}: {str(e)}", exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 @bp.route('//trigger', methods=['POST']) @@ -127,19 +272,47 @@ def trigger_schedule(schedule_id): """ Manually trigger a scheduled scan. + Creates a new scan with the schedule's configuration and queues it + for immediate execution. + Args: schedule_id: Schedule ID to trigger Returns: JSON response with triggered scan ID """ - # TODO: Implement in Phase 3 - return jsonify({ - 'schedule_id': schedule_id, - 'scan_id': None, - 'status': 'not_implemented', - 'message': 'Manual schedule trigger endpoint - to be implemented in Phase 3' - }), 501 + try: + # Get schedule + schedule_service = ScheduleService(current_app.db_session) + schedule = schedule_service.get_schedule(schedule_id) + + # Trigger scan + scan_service = ScanService(current_app.db_session) + + # Get scheduler if available + scheduler = current_app.scheduler if hasattr(current_app, 'scheduler') else None + + scan_id = scan_service.trigger_scan( + config_file=schedule['config_file'], + triggered_by='manual', + schedule_id=schedule_id, + scheduler=scheduler + ) + + logger.info(f"Manual trigger of schedule {schedule_id} created scan {scan_id}") + + return jsonify({ + 'message': 'Scan triggered successfully', + 'schedule_id': schedule_id, + 'scan_id': scan_id + }), 201 + + except ValueError as e: + # Schedule not found + return jsonify({'error': str(e)}), 404 + except Exception as e: + logger.error(f"Error triggering schedule {schedule_id}: {str(e)}", exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 # Health check endpoint diff --git a/web/jobs/scan_job.py b/web/jobs/scan_job.py index e8aee3e..a01fac9 100644 --- a/web/jobs/scan_job.py +++ b/web/jobs/scan_job.py @@ -62,8 +62,14 @@ def execute_scan(scan_id: int, config_file: str, db_url: str): logger.info(f"Scan {scan_id}: Initializing scanner with config {config_file}") + # Convert config_file to full path if it's just a filename + if not config_file.startswith('/'): + config_path = f'/app/configs/{config_file}' + else: + config_path = config_file + # Initialize scanner - scanner = SneakyScanner(config_file) + scanner = SneakyScanner(config_path) # Execute scan logger.info(f"Scan {scan_id}: Running scanner...") diff --git a/web/routes/main.py b/web/routes/main.py index 34c7796..87694b9 100644 --- a/web/routes/main.py +++ b/web/routes/main.py @@ -66,3 +66,59 @@ def scan_detail(scan_id): """ # TODO: Phase 5 - Implement scan detail page return render_template('scan_detail.html', scan_id=scan_id) + + +@bp.route('/schedules') +@login_required +def schedules(): + """ + Schedules list page - shows all scheduled scans. + + Returns: + Rendered schedules list template + """ + return render_template('schedules.html') + + +@bp.route('/schedules/create') +@login_required +def create_schedule(): + """ + Create new schedule form page. + + Returns: + Rendered schedule create template with available config files + """ + import os + + # Get list of available config files + configs_dir = '/app/configs' + config_files = [] + + try: + if os.path.exists(configs_dir): + config_files = [f for f in os.listdir(configs_dir) if f.endswith('.yaml')] + config_files.sort() + except Exception as e: + logger.error(f"Error listing config files: {e}") + + return render_template('schedule_create.html', config_files=config_files) + + +@bp.route('/schedules//edit') +@login_required +def edit_schedule(schedule_id): + """ + Edit existing schedule form page. + + Args: + schedule_id: Schedule ID to edit + + Returns: + Rendered schedule edit template + """ + from flask import flash + + # 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) diff --git a/web/services/scan_service.py b/web/services/scan_service.py index 80fe6f9..85a82ce 100644 --- a/web/services/scan_service.py +++ b/web/services/scan_service.py @@ -66,9 +66,15 @@ class ScanService: if not is_valid: raise ValueError(f"Invalid config file: {error_msg}") + # Convert config_file to full path if it's just a filename + if not config_file.startswith('/'): + config_path = f'/app/configs/{config_file}' + else: + config_path = config_file + # Load config to get title import yaml - with open(config_file, 'r') as f: + with open(config_path, 'r') as f: config = yaml.safe_load(f) # Create scan record diff --git a/web/services/schedule_service.py b/web/services/schedule_service.py index 3c84874..a23d981 100644 --- a/web/services/schedule_service.py +++ b/web/services/schedule_service.py @@ -64,7 +64,13 @@ class ScheduleService: raise ValueError(f"Invalid cron expression: {error_msg}") # Validate config file exists - if not os.path.isfile(config_file): + # If config_file is just a filename, prepend the configs directory + if not config_file.startswith('/'): + config_file_path = os.path.join('/app/configs', config_file) + else: + config_file_path = config_file + + if not os.path.isfile(config_file_path): raise ValueError(f"Config file not found: {config_file}") # Calculate next run time @@ -196,7 +202,14 @@ class ScheduleService: # Validate config file if being updated if 'config_file' in updates: - if not os.path.isfile(updates['config_file']): + config_file = updates['config_file'] + # If config_file is just a filename, prepend the configs directory + if not config_file.startswith('/'): + config_file_path = os.path.join('/app/configs', config_file) + else: + config_file_path = config_file + + if not os.path.isfile(config_file_path): raise ValueError(f"Config file not found: {updates['config_file']}") # Handle enabled toggle diff --git a/web/services/scheduler_service.py b/web/services/scheduler_service.py index ce16687..e336270 100644 --- a/web/services/scheduler_service.py +++ b/web/services/scheduler_service.py @@ -136,35 +136,27 @@ class SchedulerService: Raises: RuntimeError: If scheduler not initialized ValueError: If cron expression is invalid - - Note: - This is a placeholder for Phase 3 scheduled scanning feature. - Currently not used, but structure is in place. """ if not self.scheduler: raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.") - # Parse cron expression - # Format: "minute hour day month day_of_week" - parts = cron_expression.split() - if len(parts) != 5: - raise ValueError(f"Invalid cron expression: {cron_expression}") + from apscheduler.triggers.cron import CronTrigger - minute, hour, day, month, day_of_week = parts + # Create cron trigger from expression + try: + trigger = CronTrigger.from_crontab(cron_expression) + except (ValueError, KeyError) as e: + raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}") - # Add cron job (currently placeholder - will be enhanced in Phase 3) + # Add cron job job = self.scheduler.add_job( func=self._trigger_scheduled_scan, - args=[schedule_id, config_file], - trigger='cron', - minute=minute, - hour=hour, - day=day, - month=month, - day_of_week=day_of_week, + args=[schedule_id], + trigger=trigger, id=f'schedule_{schedule_id}', name=f'Schedule {schedule_id}', - replace_existing=True + replace_existing=True, + max_instances=1 # Only one instance per schedule ) logger.info(f"Added scheduled scan {schedule_id} with cron '{cron_expression}' (job_id={job.id})") @@ -191,7 +183,7 @@ class SchedulerService: except Exception as e: logger.warning(f"Failed to remove scheduled scan job {job_id}: {str(e)}") - def _trigger_scheduled_scan(self, schedule_id: int, config_file: str): + def _trigger_scheduled_scan(self, schedule_id: int): """ Internal method to trigger a scan from a schedule. @@ -199,17 +191,63 @@ class SchedulerService: Args: schedule_id: Database ID of the schedule - config_file: Path to YAML configuration file - - Note: - This will be fully implemented in Phase 3 when scheduled - scanning is added. Currently a placeholder. """ logger.info(f"Scheduled scan triggered: schedule_id={schedule_id}") - # TODO: In Phase 3, this will: - # 1. Create a new Scan record with triggered_by='scheduled' - # 2. Call queue_scan() with the new scan_id - # 3. Update schedule's last_run and next_run timestamps + + # Import here to avoid circular imports + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from web.services.schedule_service import ScheduleService + from web.services.scan_service import ScanService + + try: + # Create database session + engine = create_engine(self.db_url) + Session = sessionmaker(bind=engine) + session = Session() + + try: + # Get schedule details + schedule_service = ScheduleService(session) + schedule = schedule_service.get_schedule(schedule_id) + + if not schedule: + logger.error(f"Schedule {schedule_id} not found") + return + + if not schedule['enabled']: + logger.warning(f"Schedule {schedule_id} is disabled, skipping execution") + return + + # Create and trigger scan + scan_service = ScanService(session) + scan_id = scan_service.trigger_scan( + config_file=schedule['config_file'], + triggered_by='scheduled', + schedule_id=schedule_id, + scheduler=None # Don't pass scheduler to avoid recursion + ) + + # Queue the scan for execution + self.queue_scan(scan_id, schedule['config_file']) + + # Update schedule's last_run and next_run + from croniter import croniter + next_run = croniter(schedule['cron_expression'], datetime.utcnow()).get_next(datetime) + + schedule_service.update_run_times( + schedule_id=schedule_id, + last_run=datetime.utcnow(), + next_run=next_run + ) + + logger.info(f"Scheduled scan completed: schedule_id={schedule_id}, scan_id={scan_id}") + + finally: + session.close() + + except Exception as e: + logger.error(f"Error triggering scheduled scan {schedule_id}: {str(e)}", exc_info=True) def get_job_status(self, job_id: str) -> Optional[dict]: """ diff --git a/web/templates/base.html b/web/templates/base.html index 4e2a999..e8c1ce3 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -49,6 +49,10 @@ Scans + -
    - Note: All times are in UTC timezone. The server is currently at - UTC. +
    + Timezone Information:
    + All cron expressions use your local system time.

    + Current local time:
    + Your timezone:

    + Schedules will run at the specified time in your local timezone.
    @@ -198,10 +203,19 @@ {% endblock %} diff --git a/web/templates/schedules.html b/web/templates/schedules.html index 434829a..ce3122b 100644 --- a/web/templates/schedules.html +++ b/web/templates/schedules.html @@ -107,6 +107,9 @@ function formatRelativeTime(timestamp) { const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); + // Get local time string for tooltip/fallback + const localStr = date.toLocaleString(); + if (diffMs < 0) { // Past time const absDiffMinutes = Math.abs(diffMinutes); @@ -120,7 +123,7 @@ function formatRelativeTime(timestamp) { if (absDiffHours < 24) return `${absDiffHours} hours ago`; if (absDiffDays === 1) return 'Yesterday'; if (absDiffDays < 7) return `${absDiffDays} days ago`; - return date.toLocaleString(); + return `${absDiffDays} days ago`; } else { // Future time if (diffMinutes < 1) return 'In less than a minute'; @@ -130,7 +133,7 @@ function formatRelativeTime(timestamp) { if (diffHours < 24) return `In ${diffHours} hours`; if (diffDays === 1) return 'Tomorrow'; if (diffDays < 7) return `In ${diffDays} days`; - return date.toLocaleString(); + return `In ${diffDays} days`; } } From 6792d69eb1cdb2e3f046613f479836eafa28b090 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 14 Nov 2025 16:15:13 -0600 Subject: [PATCH 6/8] Phase 3 Step 7: Scan Comparison Features & UX Improvements Implemented comprehensive scan comparison functionality with historical analysis and improved user experience for scan triggering. Features Added: - Scan comparison engine with ports, services, and certificates analysis - Drift score calculation (0.0-1.0 scale) for infrastructure changes - Side-by-side comparison UI with color-coded changes (added/removed/changed) - Historical trend charts showing port counts over time - "Compare with Previous" button on scan detail pages - Scan history API endpoint for trending data API Endpoints: - GET /api/scans//compare/ - Compare two scans - GET /api/stats/scan-history/ - Historical scan data for charts UI Improvements: - Replaced config file text inputs with dropdown selectors - Added config file selection to dashboard and scans pages - Improved delete scan confirmation with proper async handling - Enhanced error messages with detailed validation feedback - Added 2-second delay before redirect to ensure deletion completes Comparison Features: - Port changes: tracks added, removed, and unchanged ports - Service changes: detects version updates and service modifications - Certificate changes: monitors SSL/TLS certificate updates - Interactive historical charts with clickable data points - Automatic detection of previous scan for comparison Bug Fixes: - Fixed scan deletion UI alert appearing on successful deletion - Prevented config file path duplication (configs/configs/...) - Improved error handling for failed API responses - Added proper JSON response parsing with fallback handling Testing: - Created comprehensive test suite for comparison functionality - Tests cover comparison API, service methods, and drift scoring - Added edge case tests for identical scans and missing data --- docker-compose-web.yml | 2 +- tests/test_scan_comparison.py | 319 +++++++++++++++++++ web/api/scans.py | 54 +++- web/api/stats.py | 107 +++++++ web/routes/main.py | 48 ++- web/services/scan_service.py | 330 ++++++++++++++++++++ web/templates/dashboard.html | 22 +- web/templates/scan_compare.html | 526 ++++++++++++++++++++++++++++++++ web/templates/scan_detail.html | 189 +++++++++++- web/templates/scans.html | 20 +- 10 files changed, 1581 insertions(+), 36 deletions(-) create mode 100644 tests/test_scan_comparison.py create mode 100644 web/templates/scan_compare.html diff --git a/docker-compose-web.yml b/docker-compose-web.yml index 74dc2d7..05f9e7a 100644 --- a/docker-compose-web.yml +++ b/docker-compose-web.yml @@ -44,7 +44,7 @@ services: # Health check to ensure web service is running healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/settings/health').read()"] - interval: 30s + interval: 60s timeout: 10s retries: 3 start_period: 40s diff --git a/tests/test_scan_comparison.py b/tests/test_scan_comparison.py new file mode 100644 index 0000000..30e3f91 --- /dev/null +++ b/tests/test_scan_comparison.py @@ -0,0 +1,319 @@ +""" +Unit tests for scan comparison functionality. + +Tests scan comparison logic including port, service, and certificate comparisons, +as well as drift score calculation. +""" + +import pytest +from datetime import datetime + +from web.models import Scan, ScanSite, ScanIP, ScanPort +from web.models import ScanService as ScanServiceModel, ScanCertificate +from web.services.scan_service import ScanService + + +class TestScanComparison: + """Tests for scan comparison methods.""" + + @pytest.fixture + def scan1_data(self, test_db, sample_config_file): + """Create first scan with test data.""" + service = ScanService(test_db) + scan_id = service.trigger_scan(sample_config_file, triggered_by='manual') + + # Get scan and add some test data + scan = test_db.query(Scan).filter(Scan.id == scan_id).first() + scan.status = 'completed' + + # Create site + site = ScanSite(scan_id=scan.id, site_name='Test Site') + test_db.add(site) + test_db.flush() + + # Create IP + ip = ScanIP( + scan_id=scan.id, + site_id=site.id, + ip_address='192.168.1.100', + ping_expected=True, + ping_actual=True + ) + test_db.add(ip) + test_db.flush() + + # Create ports + port1 = ScanPort( + scan_id=scan.id, + ip_id=ip.id, + port=80, + protocol='tcp', + state='open', + expected=True + ) + port2 = ScanPort( + scan_id=scan.id, + ip_id=ip.id, + port=443, + protocol='tcp', + state='open', + expected=True + ) + test_db.add(port1) + test_db.add(port2) + test_db.flush() + + # Create service + svc1 = ScanServiceModel( + scan_id=scan.id, + port_id=port1.id, + service_name='http', + product='nginx', + version='1.18.0' + ) + test_db.add(svc1) + + test_db.commit() + return scan_id + + @pytest.fixture + def scan2_data(self, test_db, sample_config_file): + """Create second scan with modified test data.""" + service = ScanService(test_db) + scan_id = service.trigger_scan(sample_config_file, triggered_by='manual') + + # Get scan and add some test data + scan = test_db.query(Scan).filter(Scan.id == scan_id).first() + scan.status = 'completed' + + # Create site + site = ScanSite(scan_id=scan.id, site_name='Test Site') + test_db.add(site) + test_db.flush() + + # Create IP + ip = ScanIP( + scan_id=scan.id, + site_id=site.id, + ip_address='192.168.1.100', + ping_expected=True, + ping_actual=True + ) + test_db.add(ip) + test_db.flush() + + # Create ports (port 80 removed, 443 kept, 8080 added) + port2 = ScanPort( + scan_id=scan.id, + ip_id=ip.id, + port=443, + protocol='tcp', + state='open', + expected=True + ) + port3 = ScanPort( + scan_id=scan.id, + ip_id=ip.id, + port=8080, + protocol='tcp', + state='open', + expected=False + ) + test_db.add(port2) + test_db.add(port3) + test_db.flush() + + # Create service with updated version + svc2 = ScanServiceModel( + scan_id=scan.id, + port_id=port3.id, + service_name='http', + product='nginx', + version='1.20.0' # Version changed + ) + test_db.add(svc2) + + test_db.commit() + return scan_id + + def test_compare_scans_basic(self, test_db, scan1_data, scan2_data): + """Test basic scan comparison.""" + service = ScanService(test_db) + + result = service.compare_scans(scan1_data, scan2_data) + + assert result is not None + assert 'scan1' in result + assert 'scan2' in result + assert 'ports' in result + assert 'services' in result + assert 'certificates' in result + assert 'drift_score' in result + + # Verify scan metadata + assert result['scan1']['id'] == scan1_data + assert result['scan2']['id'] == scan2_data + + def test_compare_scans_not_found(self, test_db): + """Test comparison with nonexistent scan.""" + service = ScanService(test_db) + + result = service.compare_scans(999, 998) + + assert result is None + + def test_compare_ports(self, test_db, scan1_data, scan2_data): + """Test port comparison logic.""" + service = ScanService(test_db) + + result = service.compare_scans(scan1_data, scan2_data) + + # Scan1 has ports 80, 443 + # Scan2 has ports 443, 8080 + # Expected: added=[8080], removed=[80], unchanged=[443] + + ports = result['ports'] + assert len(ports['added']) == 1 + assert len(ports['removed']) == 1 + assert len(ports['unchanged']) == 1 + + # Check added port + added_port = ports['added'][0] + assert added_port['port'] == 8080 + + # Check removed port + removed_port = ports['removed'][0] + assert removed_port['port'] == 80 + + # Check unchanged port + unchanged_port = ports['unchanged'][0] + assert unchanged_port['port'] == 443 + + def test_compare_services(self, test_db, scan1_data, scan2_data): + """Test service comparison logic.""" + service = ScanService(test_db) + + result = service.compare_scans(scan1_data, scan2_data) + + services = result['services'] + + # Scan1 has nginx 1.18.0 on port 80 + # Scan2 has nginx 1.20.0 on port 8080 + # These are on different ports, so they should be added/removed, not changed + + assert len(services['added']) >= 0 + assert len(services['removed']) >= 0 + + def test_drift_score_calculation(self, test_db, scan1_data, scan2_data): + """Test drift score calculation.""" + service = ScanService(test_db) + + result = service.compare_scans(scan1_data, scan2_data) + + drift_score = result['drift_score'] + + # Drift score should be between 0.0 and 1.0 + assert 0.0 <= drift_score <= 1.0 + + # Since we have changes (1 port added, 1 removed), drift should be > 0 + assert drift_score > 0.0 + + def test_compare_identical_scans(self, test_db, scan1_data): + """Test comparing a scan with itself (should have zero drift).""" + service = ScanService(test_db) + + result = service.compare_scans(scan1_data, scan1_data) + + # Comparing scan with itself should have zero drift + assert result['drift_score'] == 0.0 + assert len(result['ports']['added']) == 0 + assert len(result['ports']['removed']) == 0 + + +class TestScanComparisonAPI: + """Tests for scan comparison API endpoint.""" + + def test_compare_scans_api(self, client, auth_headers, scan1_data, scan2_data): + """Test scan comparison API endpoint.""" + response = client.get( + f'/api/scans/{scan1_data}/compare/{scan2_data}', + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + + assert 'scan1' in data + assert 'scan2' in data + assert 'ports' in data + assert 'services' in data + assert 'drift_score' in data + + def test_compare_scans_api_not_found(self, client, auth_headers): + """Test comparison API with nonexistent scans.""" + response = client.get( + '/api/scans/999/compare/998', + headers=auth_headers + ) + + assert response.status_code == 404 + data = response.get_json() + assert 'error' in data + + def test_compare_scans_api_requires_auth(self, client, scan1_data, scan2_data): + """Test that comparison API requires authentication.""" + response = client.get(f'/api/scans/{scan1_data}/compare/{scan2_data}') + + assert response.status_code == 401 + + +class TestHistoricalChartAPI: + """Tests for historical scan chart API endpoint.""" + + def test_scan_history_api(self, client, auth_headers, scan1_data): + """Test scan history API endpoint.""" + response = client.get( + f'/api/stats/scan-history/{scan1_data}', + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + + assert 'scans' in data + assert 'labels' in data + assert 'port_counts' in data + assert 'config_file' in data + + # Should include at least the scan we created + assert len(data['scans']) >= 1 + + def test_scan_history_api_not_found(self, client, auth_headers): + """Test history API with nonexistent scan.""" + response = client.get( + '/api/stats/scan-history/999', + headers=auth_headers + ) + + assert response.status_code == 404 + data = response.get_json() + assert 'error' in data + + def test_scan_history_api_limit(self, client, auth_headers, scan1_data): + """Test scan history API with limit parameter.""" + response = client.get( + f'/api/stats/scan-history/{scan1_data}?limit=5', + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + + # Should respect limit + assert len(data['scans']) <= 5 + + def test_scan_history_api_requires_auth(self, client, scan1_data): + """Test that history API requires authentication.""" + response = client.get(f'/api/stats/scan-history/{scan1_data}') + + assert response.status_code == 401 diff --git a/web/api/scans.py b/web/api/scans.py index 9357dce..afde8d5 100644 --- a/web/api/scans.py +++ b/web/api/scans.py @@ -165,10 +165,12 @@ def trigger_scan(): except ValueError as e: # Config file validation error - logger.warning(f"Invalid config file: {str(e)}") + error_message = str(e) + logger.warning(f"Invalid config file: {error_message}") + logger.warning(f"Request data: config_file='{config_file}'") return jsonify({ 'error': 'Invalid request', - 'message': str(e) + 'message': error_message }), 400 except SQLAlchemyError as e: logger.error(f"Database error triggering scan: {str(e)}") @@ -276,20 +278,48 @@ def compare_scans(scan_id1, scan_id2): """ Compare two scans and show differences. + Compares ports, services, and certificates between two scans, + highlighting added, removed, and changed items. + Args: - scan_id1: First scan ID - scan_id2: Second scan ID + scan_id1: First (older) scan ID + scan_id2: Second (newer) scan ID Returns: - JSON response with comparison results + JSON response with comparison results including: + - scan1, scan2: Metadata for both scans + - ports: Added, removed, and unchanged ports + - services: Added, removed, and changed services + - certificates: Added, removed, and changed certificates + - drift_score: Overall drift metric (0.0-1.0) """ - # TODO: Implement in Phase 4 - return jsonify({ - 'scan_id1': scan_id1, - 'scan_id2': scan_id2, - 'diff': {}, - 'message': 'Scan comparison endpoint - to be implemented in Phase 4' - }) + try: + # Compare scans using service + scan_service = ScanService(current_app.db_session) + comparison = scan_service.compare_scans(scan_id1, scan_id2) + + if not comparison: + logger.warning(f"Scan comparison failed: one or both scans not found ({scan_id1}, {scan_id2})") + return jsonify({ + 'error': 'Not found', + 'message': 'One or both scans not found' + }), 404 + + logger.info(f"Compared scans {scan_id1} and {scan_id2}: drift_score={comparison['drift_score']}") + return jsonify(comparison), 200 + + except SQLAlchemyError as e: + logger.error(f"Database error comparing scans {scan_id1} and {scan_id2}: {str(e)}") + return jsonify({ + 'error': 'Database error', + 'message': 'Failed to compare scans' + }), 500 + except Exception as e: + logger.error(f"Unexpected error comparing scans {scan_id1} and {scan_id2}: {str(e)}", exc_info=True) + return jsonify({ + 'error': 'Internal server error', + 'message': 'An unexpected error occurred' + }), 500 # Health check endpoint diff --git a/web/api/stats.py b/web/api/stats.py index 7591197..b6e1088 100644 --- a/web/api/stats.py +++ b/web/api/stats.py @@ -149,3 +149,110 @@ def summary(): except Exception as e: logger.error(f"Error in summary: {str(e)}") return jsonify({'error': 'An error occurred'}), 500 + + +@bp.route('/scan-history/', methods=['GET']) +@api_auth_required +def scan_history(scan_id): + """ + Get historical trend data for scans with the same config file. + + Returns port counts and other metrics over time for the same + configuration/target as the specified scan. + + Args: + scan_id: Reference scan ID + + Query params: + limit: Maximum number of historical scans to include (default: 10, max: 50) + + Returns: + JSON response with historical scan data + { + "scans": [ + { + "id": 123, + "timestamp": "2025-01-01T12:00:00", + "title": "Scan title", + "port_count": 25, + "ip_count": 5 + }, + ... + ], + "labels": ["2025-01-01", ...], + "port_counts": [25, 26, 24, ...] + } + """ + try: + # Get query parameters + limit = request.args.get('limit', 10, type=int) + if limit > 50: + limit = 50 + + db_session = current_app.db_session + + # Get the reference scan to find its config file + from web.models import ScanPort + reference_scan = db_session.query(Scan).filter(Scan.id == scan_id).first() + + if not reference_scan: + return jsonify({'error': 'Scan not found'}), 404 + + config_file = reference_scan.config_file + + # Query historical scans with the same config file + historical_scans = ( + db_session.query(Scan) + .filter(Scan.config_file == config_file) + .filter(Scan.status == 'completed') + .order_by(Scan.timestamp.desc()) + .limit(limit) + .all() + ) + + # Build result data + scans_data = [] + labels = [] + port_counts = [] + + for scan in reversed(historical_scans): # Reverse to get chronological order + # Count ports for this scan + port_count = ( + db_session.query(func.count(ScanPort.id)) + .filter(ScanPort.scan_id == scan.id) + .scalar() or 0 + ) + + # Count unique IPs for this scan + from web.models import ScanIP + ip_count = ( + db_session.query(func.count(ScanIP.id)) + .filter(ScanIP.scan_id == scan.id) + .scalar() or 0 + ) + + scans_data.append({ + 'id': scan.id, + 'timestamp': scan.timestamp.isoformat() if scan.timestamp else None, + 'title': scan.title, + 'port_count': port_count, + 'ip_count': ip_count + }) + + # For chart data + labels.append(scan.timestamp.strftime('%Y-%m-%d %H:%M') if scan.timestamp else '') + port_counts.append(port_count) + + return jsonify({ + 'scans': scans_data, + 'labels': labels, + 'port_counts': port_counts, + 'config_file': config_file + }), 200 + + except SQLAlchemyError as e: + logger.error(f"Database error in scan_history: {str(e)}") + return jsonify({'error': 'Database error occurred'}), 500 + except Exception as e: + logger.error(f"Error in scan_history: {str(e)}") + return jsonify({'error': 'An error occurred'}), 500 diff --git a/web/routes/main.py b/web/routes/main.py index 87694b9..66cbfa3 100644 --- a/web/routes/main.py +++ b/web/routes/main.py @@ -35,8 +35,20 @@ def dashboard(): Returns: Rendered dashboard template """ - # TODO: Phase 5 - Add dashboard stats and recent scans - return render_template('dashboard.html') + import os + + # Get list of available config files + configs_dir = '/app/configs' + config_files = [] + + try: + if os.path.exists(configs_dir): + config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))] + config_files.sort() + except Exception as e: + logger.error(f"Error listing config files: {e}") + + return render_template('dashboard.html', config_files=config_files) @bp.route('/scans') @@ -48,8 +60,20 @@ def scans(): Returns: Rendered scans list template """ - # TODO: Phase 5 - Implement scans list page - return render_template('scans.html') + import os + + # Get list of available config files + configs_dir = '/app/configs' + config_files = [] + + try: + if os.path.exists(configs_dir): + config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))] + config_files.sort() + except Exception as e: + logger.error(f"Error listing config files: {e}") + + return render_template('scans.html', config_files=config_files) @bp.route('/scans/') @@ -68,6 +92,22 @@ def scan_detail(scan_id): return render_template('scan_detail.html', scan_id=scan_id) +@bp.route('/scans//compare/') +@login_required +def compare_scans(scan_id1, scan_id2): + """ + Scan comparison page - shows differences between two scans. + + Args: + scan_id1: First (older) scan ID + scan_id2: Second (newer) scan ID + + Returns: + Rendered comparison template + """ + return render_template('scan_compare.html', scan_id1=scan_id1, scan_id2=scan_id2) + + @bp.route('/schedules') @login_required def schedules(): diff --git a/web/services/scan_service.py b/web/services/scan_service.py index 0ec31a6..4acaa30 100644 --- a/web/services/scan_service.py +++ b/web/services/scan_service.py @@ -658,3 +658,333 @@ class ScanService: result['cipher_suites'] = [] return result + + def compare_scans(self, scan1_id: int, scan2_id: int) -> Optional[Dict[str, Any]]: + """ + Compare two scans and return the differences. + + Compares ports, services, and certificates between two scans, + highlighting added, removed, and changed items. + + Args: + scan1_id: ID of the first (older) scan + scan2_id: ID of the second (newer) scan + + Returns: + Dictionary with comparison results, or None if either scan not found + { + 'scan1': {...}, # Scan 1 summary + 'scan2': {...}, # Scan 2 summary + 'ports': { + 'added': [...], + 'removed': [...], + 'unchanged': [...] + }, + 'services': { + 'added': [...], + 'removed': [...], + 'changed': [...] + }, + 'certificates': { + 'added': [...], + 'removed': [...], + 'changed': [...] + }, + 'drift_score': 0.0-1.0 + } + """ + # Get both scans + scan1 = self.get_scan(scan1_id) + scan2 = self.get_scan(scan2_id) + + if not scan1 or not scan2: + return None + + # Extract port data + ports1 = self._extract_ports_from_scan(scan1) + ports2 = self._extract_ports_from_scan(scan2) + + # Compare ports + ports_comparison = self._compare_ports(ports1, ports2) + + # Extract service data + services1 = self._extract_services_from_scan(scan1) + services2 = self._extract_services_from_scan(scan2) + + # Compare services + services_comparison = self._compare_services(services1, services2) + + # Extract certificate data + certs1 = self._extract_certificates_from_scan(scan1) + certs2 = self._extract_certificates_from_scan(scan2) + + # Compare certificates + certificates_comparison = self._compare_certificates(certs1, certs2) + + # Calculate drift score (0.0 = identical, 1.0 = completely different) + drift_score = self._calculate_drift_score( + ports_comparison, + services_comparison, + certificates_comparison + ) + + return { + 'scan1': { + 'id': scan1['id'], + 'timestamp': scan1['timestamp'], + 'title': scan1['title'], + 'status': scan1['status'] + }, + 'scan2': { + 'id': scan2['id'], + 'timestamp': scan2['timestamp'], + 'title': scan2['title'], + 'status': scan2['status'] + }, + 'ports': ports_comparison, + 'services': services_comparison, + 'certificates': certificates_comparison, + 'drift_score': drift_score + } + + def _extract_ports_from_scan(self, scan: Dict[str, Any]) -> Dict[str, Any]: + """ + Extract port information from a scan. + + Returns: + Dictionary mapping "ip:port:protocol" to port details + """ + ports = {} + for site in scan.get('sites', []): + for ip_data in site.get('ips', []): + ip_addr = ip_data['address'] + for port_data in ip_data.get('ports', []): + key = f"{ip_addr}:{port_data['port']}:{port_data['protocol']}" + ports[key] = { + 'ip': ip_addr, + 'port': port_data['port'], + 'protocol': port_data['protocol'], + 'state': port_data.get('state', 'unknown'), + 'expected': port_data.get('expected') + } + return ports + + def _extract_services_from_scan(self, scan: Dict[str, Any]) -> Dict[str, Any]: + """ + Extract service information from a scan. + + Returns: + Dictionary mapping "ip:port:protocol" to service details + """ + services = {} + for site in scan.get('sites', []): + for ip_data in site.get('ips', []): + ip_addr = ip_data['address'] + for port_data in ip_data.get('ports', []): + port_num = port_data['port'] + protocol = port_data['protocol'] + key = f"{ip_addr}:{port_num}:{protocol}" + + # Get first service (usually only one per port) + port_services = port_data.get('services', []) + if port_services: + svc = port_services[0] + services[key] = { + 'ip': ip_addr, + 'port': port_num, + 'protocol': protocol, + 'service_name': svc.get('service_name'), + 'product': svc.get('product'), + 'version': svc.get('version'), + 'extrainfo': svc.get('extrainfo') + } + return services + + def _extract_certificates_from_scan(self, scan: Dict[str, Any]) -> Dict[str, Any]: + """ + Extract certificate information from a scan. + + Returns: + Dictionary mapping "ip:port" to certificate details + """ + certificates = {} + for site in scan.get('sites', []): + for ip_data in site.get('ips', []): + ip_addr = ip_data['address'] + for port_data in ip_data.get('ports', []): + port_num = port_data['port'] + protocol = port_data['protocol'] + + # Get certificates from services + for svc in port_data.get('services', []): + if svc.get('certificates'): + for cert in svc['certificates']: + key = f"{ip_addr}:{port_num}" + certificates[key] = { + 'ip': ip_addr, + 'port': port_num, + 'subject': cert.get('subject'), + 'issuer': cert.get('issuer'), + 'not_valid_after': cert.get('not_valid_after'), + 'days_until_expiry': cert.get('days_until_expiry'), + 'is_self_signed': cert.get('is_self_signed') + } + return certificates + + def _compare_ports(self, ports1: Dict, ports2: Dict) -> Dict[str, List]: + """ + Compare port sets between two scans. + + Returns: + Dictionary with added, removed, and unchanged ports + """ + keys1 = set(ports1.keys()) + keys2 = set(ports2.keys()) + + added_keys = keys2 - keys1 + removed_keys = keys1 - keys2 + unchanged_keys = keys1 & keys2 + + return { + 'added': [ports2[k] for k in sorted(added_keys)], + 'removed': [ports1[k] for k in sorted(removed_keys)], + 'unchanged': [ports2[k] for k in sorted(unchanged_keys)] + } + + def _compare_services(self, services1: Dict, services2: Dict) -> Dict[str, List]: + """ + Compare services between two scans. + + Returns: + Dictionary with added, removed, and changed services + """ + keys1 = set(services1.keys()) + keys2 = set(services2.keys()) + + added_keys = keys2 - keys1 + removed_keys = keys1 - keys2 + common_keys = keys1 & keys2 + + # Find changed services (same port, different version/product) + changed = [] + for key in sorted(common_keys): + svc1 = services1[key] + svc2 = services2[key] + + # Check if service details changed + if (svc1.get('product') != svc2.get('product') or + svc1.get('version') != svc2.get('version') or + svc1.get('service_name') != svc2.get('service_name')): + changed.append({ + 'ip': svc2['ip'], + 'port': svc2['port'], + 'protocol': svc2['protocol'], + 'old': { + 'service_name': svc1.get('service_name'), + 'product': svc1.get('product'), + 'version': svc1.get('version') + }, + 'new': { + 'service_name': svc2.get('service_name'), + 'product': svc2.get('product'), + 'version': svc2.get('version') + } + }) + + return { + 'added': [services2[k] for k in sorted(added_keys)], + 'removed': [services1[k] for k in sorted(removed_keys)], + 'changed': changed + } + + def _compare_certificates(self, certs1: Dict, certs2: Dict) -> Dict[str, List]: + """ + Compare certificates between two scans. + + Returns: + Dictionary with added, removed, and changed certificates + """ + keys1 = set(certs1.keys()) + keys2 = set(certs2.keys()) + + added_keys = keys2 - keys1 + removed_keys = keys1 - keys2 + common_keys = keys1 & keys2 + + # Find changed certificates (same IP:port, different cert details) + changed = [] + for key in sorted(common_keys): + cert1 = certs1[key] + cert2 = certs2[key] + + # Check if certificate changed + if (cert1.get('subject') != cert2.get('subject') or + cert1.get('issuer') != cert2.get('issuer') or + cert1.get('not_valid_after') != cert2.get('not_valid_after')): + changed.append({ + 'ip': cert2['ip'], + 'port': cert2['port'], + 'old': { + 'subject': cert1.get('subject'), + 'issuer': cert1.get('issuer'), + 'not_valid_after': cert1.get('not_valid_after'), + 'days_until_expiry': cert1.get('days_until_expiry') + }, + 'new': { + 'subject': cert2.get('subject'), + 'issuer': cert2.get('issuer'), + 'not_valid_after': cert2.get('not_valid_after'), + 'days_until_expiry': cert2.get('days_until_expiry') + } + }) + + return { + 'added': [certs2[k] for k in sorted(added_keys)], + 'removed': [certs1[k] for k in sorted(removed_keys)], + 'changed': changed + } + + def _calculate_drift_score(self, ports_comp: Dict, services_comp: Dict, + certs_comp: Dict) -> float: + """ + Calculate drift score based on comparison results. + + Returns: + Float between 0.0 (identical) and 1.0 (completely different) + """ + # Count total items in both scans + total_ports = ( + len(ports_comp['added']) + + len(ports_comp['removed']) + + len(ports_comp['unchanged']) + ) + + total_services = ( + len(services_comp['added']) + + len(services_comp['removed']) + + len(services_comp['changed']) + + max(0, len(ports_comp['unchanged']) - len(services_comp['changed'])) + ) + + # Count changed items + changed_ports = len(ports_comp['added']) + len(ports_comp['removed']) + changed_services = ( + len(services_comp['added']) + + len(services_comp['removed']) + + len(services_comp['changed']) + ) + changed_certs = ( + len(certs_comp['added']) + + len(certs_comp['removed']) + + len(certs_comp['changed']) + ) + + # Calculate weighted drift score + # Ports have 50% weight, services 30%, certificates 20% + port_drift = changed_ports / max(total_ports, 1) + service_drift = changed_services / max(total_services, 1) + cert_drift = changed_certs / max(len(certs_comp['added']) + len(certs_comp['removed']) + len(certs_comp['changed']), 1) + + drift_score = (port_drift * 0.5) + (service_drift * 0.3) + (cert_drift * 0.2) + + return round(min(drift_score, 1.0), 3) # Cap at 1.0 and round to 3 decimals diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index d60de83..20b90a5 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -154,13 +154,19 @@
    - -
    Path to YAML configuration file
    + +
    + {% if config_files %} + Select a scan configuration file + {% else %} + No config files found in /app/configs/ + {% endif %} +
    @@ -349,7 +355,7 @@ if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to trigger scan'); + throw new Error(data.message || data.error || 'Failed to trigger scan'); } const data = await response.json(); diff --git a/web/templates/scan_compare.html b/web/templates/scan_compare.html new file mode 100644 index 0000000..d0adc8c --- /dev/null +++ b/web/templates/scan_compare.html @@ -0,0 +1,526 @@ +{% extends "base.html" %} + +{% block title %}Compare Scans - SneakyScanner{% endblock %} + +{% block content %} +
    +
    +
    +
    + + ← Back to All Scans + +

    Scan Comparison

    +

    Comparing Scan #{{ scan_id1 }} vs Scan #{{ scan_id2 }}

    +
    +
    +
    +
    + + +
    +
    + Loading... +
    +

    Loading comparison...

    +
    + + + + + + + + +{% endblock %} diff --git a/web/templates/scan_detail.html b/web/templates/scan_detail.html index c1a7482..287f52a 100644 --- a/web/templates/scan_detail.html +++ b/web/templates/scan_detail.html @@ -13,7 +13,10 @@

    Scan #{{ scan_id }}

    - + @@ -117,6 +120,25 @@
    + + +
    @@ -379,21 +401,180 @@ return; } + // Disable delete button to prevent double-clicks + const deleteBtn = document.getElementById('delete-btn'); + deleteBtn.disabled = true; + deleteBtn.textContent = 'Deleting...'; + try { const response = await fetch(`/api/scans/${scanId}`, { - method: 'DELETE' + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + } }); + // Check status code first if (!response.ok) { - throw new Error('Failed to delete scan'); + // Try to get error message from response + let errorMessage = `HTTP ${response.status}: Failed to delete scan`; + try { + const data = await response.json(); + errorMessage = data.message || errorMessage; + } catch (e) { + // Ignore JSON parse errors for error responses + } + throw new Error(errorMessage); } + // For successful responses, try to parse JSON but don't fail if it doesn't work + try { + await response.json(); + } catch (e) { + console.warn('Response is not valid JSON, but deletion succeeded'); + } + + // Wait 2 seconds to ensure deletion completes fully + await new Promise(resolve => setTimeout(resolve, 2000)); + // Redirect to scans list window.location.href = '{{ url_for("main.scans") }}'; } catch (error) { console.error('Error deleting scan:', error); - alert('Failed to delete scan. Please try again.'); + alert(`Failed to delete scan: ${error.message}`); + + // Re-enable button on error + deleteBtn.disabled = false; + deleteBtn.textContent = 'Delete Scan'; } } + + // Find previous scan and show compare button + let previousScanId = null; + async function findPreviousScan() { + try { + // Get list of scans to find the previous one + const response = await fetch('/api/scans?per_page=100&status=completed'); + const data = await response.json(); + + if (data.scans && data.scans.length > 0) { + // Find scans older than current scan + const currentScanIndex = data.scans.findIndex(s => s.id === scanId); + + if (currentScanIndex !== -1 && currentScanIndex < data.scans.length - 1) { + // Get the next scan in the list (which is older due to desc order) + previousScanId = data.scans[currentScanIndex + 1].id; + + // Show the compare button + const compareBtn = document.getElementById('compare-btn'); + if (compareBtn) { + compareBtn.style.display = 'inline-block'; + } + } + } + } catch (error) { + console.error('Error finding previous scan:', error); + } + } + + // Compare with previous scan + function compareWithPrevious() { + if (previousScanId) { + window.location.href = `/scans/${previousScanId}/compare/${scanId}`; + } + } + + // Load historical trend chart + async function loadHistoricalChart() { + try { + const response = await fetch(`/api/stats/scan-history/${scanId}?limit=20`); + const data = await response.json(); + + // Only show chart if there are multiple scans + if (data.scans && data.scans.length > 1) { + document.getElementById('historical-chart-row').style.display = 'block'; + + const ctx = document.getElementById('historyChart').getContext('2d'); + new Chart(ctx, { + type: 'line', + data: { + labels: data.labels, + datasets: [{ + label: 'Open Ports', + data: data.port_counts, + borderColor: '#60a5fa', + backgroundColor: 'rgba(96, 165, 250, 0.1)', + tension: 0.3, + fill: true, + pointBackgroundColor: '#60a5fa', + pointBorderColor: '#1e293b', + pointBorderWidth: 2, + pointRadius: 4, + pointHoverRadius: 6 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + tooltip: { + backgroundColor: '#1e293b', + titleColor: '#e2e8f0', + bodyColor: '#e2e8f0', + borderColor: '#334155', + borderWidth: 1, + callbacks: { + afterLabel: function(context) { + const scan = data.scans[context.dataIndex]; + return `Scan ID: ${scan.id}\nIPs: ${scan.ip_count}`; + } + } + } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + stepSize: 1, + color: '#94a3b8' + }, + grid: { + color: '#334155' + } + }, + x: { + ticks: { + color: '#94a3b8', + maxRotation: 45, + minRotation: 45 + }, + grid: { + color: '#334155' + } + } + }, + onClick: (event, elements) => { + if (elements.length > 0) { + const index = elements[0].index; + const scan = data.scans[index]; + window.location.href = `/scans/${scan.id}`; + } + } + } + }); + } + } catch (error) { + console.error('Error loading historical chart:', error); + } + } + + // Initialize: find previous scan and load chart after loading current scan + loadScan().then(() => { + findPreviousScan(); + loadHistoricalChart(); + }); {% endblock %} diff --git a/web/templates/scans.html b/web/templates/scans.html index 27640eb..b068736 100644 --- a/web/templates/scans.html +++ b/web/templates/scans.html @@ -114,13 +114,19 @@
    - -
    Path to YAML configuration file
    + +
    + {% if config_files %} + Select a scan configuration file + {% else %} + No config files found in /app/configs/ + {% endif %} +
    From 489284bde1c371174dc0fc10326bedf0b8805580 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 14 Nov 2025 16:31:35 -0600 Subject: [PATCH 7/8] updating Phase3.md --- docs/ai/PHASE3.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ai/PHASE3.md b/docs/ai/PHASE3.md index 32f87ae..f5199e5 100644 --- a/docs/ai/PHASE3.md +++ b/docs/ai/PHASE3.md @@ -12,9 +12,9 @@ - βœ… **Step 3: Schedules API Endpoints** (Days 4-5) - COMPLETE - βœ… **Step 4: Schedule Management UI** (Days 6-7) - COMPLETE - βœ… **Step 5: Enhanced Dashboard with Charts** (Days 8-9) - COMPLETE -- πŸ“‹ **Step 6: Scheduler Integration** (Day 10) - NEXT -- πŸ“‹ **Step 7: Scan Comparison Features** (Days 11-12) -- πŸ“‹ **Step 8: Testing & Documentation** (Days 13-14) +- βœ… **Step 6: Scheduler Integration** (Day 10) - COMPLETE +- βœ… **Step 7: Scan Comparison Features** (Days 11-12) - COMPLETE +- βœ… **Step 8: Testing & Documentation** (Days 13-14) - COMPLETE --- From 6fe24c39074e3ce98b099c7a8ef03d0302cf0a85 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 17 Nov 2025 12:05:11 -0600 Subject: [PATCH 8/8] adding Phase4 --- docs/ai/Phase4.md | 1483 ++++++++++++++++++++++++++++++++++++++++++++ docs/ai/ROADMAP.md | 9 +- 2 files changed, 1488 insertions(+), 4 deletions(-) create mode 100644 docs/ai/Phase4.md diff --git a/docs/ai/Phase4.md b/docs/ai/Phase4.md new file mode 100644 index 0000000..af693f3 --- /dev/null +++ b/docs/ai/Phase4.md @@ -0,0 +1,1483 @@ +# Phase 4: Config Creator - CSV Upload & Management + +**Status:** Ready to Start +**Priority:** HIGH - Core usability feature +**Estimated Duration:** 4-5 days +**Dependencies:** Phase 2 Complete (REST API, Authentication) + +--- + +## Overview + +Phase 4 introduces a **Config Creator** feature that allows users to manage scan configurations through the web UI instead of manually creating YAML files. This dramatically improves usability by providing: + +1. **CSV Template Download** - Pre-formatted CSV template for config creation +2. **CSV Upload & Conversion** - Upload filled CSV, automatically convert to YAML +3. **YAML Upload** - Direct YAML upload for advanced users +4. **Config Management UI** - List, view, download, and delete configs +5. **Integration** - Seamlessly works with existing scan triggers and schedules + +### User Workflow + +**Current State (Manual):** +``` +1. User creates YAML file locally (requires YAML knowledge) +2. User uploads to server via Docker volume or container shell +3. User references config in scan trigger/schedule +``` + +**New Workflow (Phase 4):** +``` +1. User clicks "Download CSV Template" in web UI +2. User fills CSV with sites, IPs, expected ports in Excel/Google Sheets +3. User uploads CSV via drag-drop or file picker +4. System validates CSV and converts to YAML +5. User previews generated YAML and confirms +6. Config saved and immediately available for scans/schedules +``` + +--- + +## Design Decisions + +Based on project requirements and complexity analysis: + +### 1. CSV Scope: One CSV = One Config βœ“ +- Each CSV file creates a single YAML config file +- All rows share the same `scan_title` (first column) +- Simpler to implement and understand +- Users create multiple CSVs for multiple configs + +### 2. Creation Methods: CSV/YAML Upload Only βœ“ +- CSV upload with conversion (primary method) +- Direct YAML upload (for advanced users) +- Form-based editor deferred to future phase +- Focused scope for faster delivery + +### 3. Versioning: No Version History βœ“ +- Configs overwrite when updated (no `.bak` files) +- Simpler implementation, less storage +- Users can download existing configs before editing + +### 4. Deletion Safety: Block if Used by Schedules βœ“ +- Prevent deletion of configs referenced by active schedules +- Show error message listing which schedules use the config +- Safest approach, prevents schedule failures + +### 5. Additional Scope Decisions +- **File naming:** Auto-generated from scan title (sanitized) +- **File extensions:** Accept `.yaml`, `.yml` for uploads +- **CSV export:** Not in Phase 4 (future enhancement) +- **Config editing:** Download β†’ Edit locally β†’ Re-upload (no inline editor yet) + +--- + +## CSV Template Specification + +### CSV Format + +**Columns (in order):** +``` +scan_title,site_name,ip_address,ping_expected,tcp_ports,udp_ports,services +``` + +**Example CSV:** +```csv +scan_title,site_name,ip_address,ping_expected,tcp_ports,udp_ports,services +Sneaky Infra Scan,Production Web Servers,10.10.20.4,true,"22,53,80",53,"ssh,domain,http" +Sneaky Infra Scan,Production Web Servers,10.10.20.11,true,"22,111,3128,8006","","ssh,rpcbind,squid" +Sneaky Infra Scan,Database Servers,10.10.30.5,true,"22,3306","","" +Sneaky Infra Scan,Database Servers,10.10.30.6,false,"22,5432","","" +``` + +### Field Specifications + +| Column | Type | Required | Format | Example | Description | +|--------|------|----------|--------|---------|-------------| +| `scan_title` | string | Yes | Any text | `"Sneaky Infra Scan"` | Becomes YAML `title` field. Must be same for all rows. | +| `site_name` | string | Yes | Any text | `"Production Web Servers"` | Logical grouping. Rows with same site_name are grouped together. | +| `ip_address` | string | Yes | IPv4 or IPv6 | `"10.10.20.4"` | Target IP address to scan. | +| `ping_expected` | boolean | No | `true`/`false` (case-insensitive) | `true` | Whether host should respond to ping. Default: `false` | +| `tcp_ports` | list[int] | No | Comma-separated, quoted if multiple | `"22,80,443"` or `22` | Expected TCP ports. Empty = no expected TCP ports. | +| `udp_ports` | list[int] | No | Comma-separated, quoted if multiple | `"53,123"` or `53` | Expected UDP ports. Empty = no expected UDP ports. | +| `services` | list[str] | No | Comma-separated, quoted if multiple | `"ssh,http,https"` | Expected service names (optional). | + +### Validation Rules + +**Row-level validation:** +1. `scan_title` must be non-empty and same across all rows +2. `site_name` must be non-empty +3. `ip_address` must be valid IPv4 or IPv6 format +4. `ping_expected` must be `true`, `false`, `TRUE`, `FALSE`, or empty (default false) +5. Port numbers must be integers 1-65535 +6. Port lists must be comma-separated (spaces optional) +7. Duplicate IPs within same config are allowed (different expected values) + +**File-level validation:** +1. CSV must have exactly 7 columns with correct headers +2. Must have at least 1 data row (besides header) +3. Must have at least 1 site defined +4. Must have at least 1 IP per site + +**Filename validation:** +1. Generated filename from scan_title (lowercase, spacesβ†’hyphens, special chars removed) +2. Must not conflict with existing config files +3. Max filename length: 200 characters + +### CSV-to-YAML Conversion Logic + +**Python pseudocode:** +```python +def csv_to_yaml(csv_content: str) -> str: + rows = parse_csv(csv_content) + + # Extract scan title (same for all rows) + scan_title = rows[0]['scan_title'] + + # Group rows by site_name + sites = {} + for row in rows: + site_name = row['site_name'] + if site_name not in sites: + sites[site_name] = [] + + # Parse ports and services + tcp_ports = parse_port_list(row['tcp_ports']) + udp_ports = parse_port_list(row['udp_ports']) + services = parse_string_list(row['services']) + ping = parse_bool(row['ping_expected'], default=False) + + sites[site_name].append({ + 'address': row['ip_address'], + 'expected': { + 'ping': ping, + 'tcp_ports': tcp_ports, + 'udp_ports': udp_ports, + 'services': services # Optional, omit if empty + } + }) + + # Build YAML structure + yaml_data = { + 'title': scan_title, + 'sites': [ + { + 'name': site_name, + 'ips': ips + } + for site_name, ips in sites.items() + ] + } + + return yaml.dump(yaml_data, sort_keys=False) +``` + +**Example Conversion:** + +**Input CSV:** +```csv +scan_title,site_name,ip_address,ping_expected,tcp_ports,udp_ports,services +Prod Scan,Web Servers,10.10.20.4,true,"22,80,443",53,ssh +Prod Scan,Web Servers,10.10.20.5,true,"22,80",,"ssh,http" +``` + +**Output YAML:** +```yaml +title: Prod Scan +sites: + - name: Web Servers + ips: + - address: 10.10.20.4 + expected: + ping: true + tcp_ports: [22, 80, 443] + udp_ports: [53] + services: [ssh] + - address: 10.10.20.5 + expected: + ping: true + tcp_ports: [22, 80] + udp_ports: [] + services: [ssh, http] +``` + +--- + +## Architecture + +### Backend Components + +#### 1. API Blueprint: `web/api/configs.py` + +**New endpoints:** + +| Method | Endpoint | Description | Auth | Request Body | Response | +|--------|----------|-------------|------|--------------|----------| +| `GET` | `/api/configs` | List all config files | Required | - | `{ "configs": [{filename, title, path, created_at, used_by_schedules}] }` | +| `GET` | `/api/configs/` | Get config content (YAML) | Required | - | `{ "filename": "...", "content": "...", "parsed": {...} }` | +| `POST` | `/api/configs/upload-csv` | Upload CSV and convert to YAML | Required | `multipart/form-data` with file | `{ "filename": "...", "preview": "...", "success": true }` | +| `POST` | `/api/configs/upload-yaml` | Upload YAML directly | Required | `multipart/form-data` with file | `{ "filename": "...", "success": true }` | +| `GET` | `/api/configs/template` | Download CSV template | Required | - | CSV file download | +| `DELETE` | `/api/configs/` | Delete config file | Required | - | `{ "success": true }` or error if used by schedules | + +**Error responses:** +- `400` - Invalid CSV format, validation errors +- `404` - Config file not found +- `409` - Config filename conflict +- `422` - Cannot delete (used by schedules) +- `500` - Server error + +**Blueprint structure:** +```python +# web/api/configs.py +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 +import logging + +bp = Blueprint('configs', __name__) +logger = logging.getLogger(__name__) + +@bp.route('', methods=['GET']) +@api_auth_required +def list_configs(): + """List all config files with metadata""" + pass + +@bp.route('/', methods=['GET']) +@api_auth_required +def get_config(filename: str): + """Get config file content""" + pass + +@bp.route('/upload-csv', methods=['POST']) +@api_auth_required +def upload_csv(): + """Upload CSV and convert to YAML""" + pass + +@bp.route('/upload-yaml', methods=['POST']) +@api_auth_required +def upload_yaml(): + """Upload YAML file directly""" + pass + +@bp.route('/template', methods=['GET']) +@api_auth_required +def download_template(): + """Download CSV template""" + pass + +@bp.route('/', methods=['DELETE']) +@api_auth_required +def delete_config(filename: str): + """Delete config file""" + pass +``` + +#### 2. Service Layer: `web/services/config_service.py` + +**Class definition:** +```python +class ConfigService: + """Business logic for config management""" + + def __init__(self, configs_dir: str = '/app/configs'): + self.configs_dir = configs_dir + + def list_configs(self) -> List[Dict[str, Any]]: + """ + List all config files with metadata. + + Returns: + [ + { + "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"] + } + ] + """ + pass + + def get_config(self, filename: str) -> Dict[str, Any]: + """ + Get config file content and parsed data. + + Returns: + { + "filename": "prod-scan.yaml", + "content": "title: Prod Scan\n...", + "parsed": {"title": "Prod Scan", "sites": [...]} + } + """ + pass + + 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 + + Raises: + ValueError: If content invalid or filename conflict + """ + pass + + def create_from_csv(self, csv_file, suggested_filename: str = None) -> Tuple[str, str]: + """ + Create config from CSV file. + + Args: + csv_file: File object from request.files + suggested_filename: Optional filename (else auto-generate from title) + + Returns: + (final_filename, yaml_preview) + + Raises: + ValueError: If CSV invalid + """ + pass + + def delete_config(self, filename: str) -> None: + """ + Delete config file if not used by schedules. + + Raises: + FileNotFoundError: If config doesn't exist + ValueError: If config used by active schedules + """ + pass + + def validate_config_content(self, content: Dict) -> Tuple[bool, str]: + """ + Validate parsed YAML config structure. + + Returns: + (is_valid, error_message) + """ + pass + + def get_schedules_using_config(self, filename: str) -> List[str]: + """ + Get list of schedule names using this config. + + Returns: + ["Daily Scan", "Weekly Audit"] + """ + pass + + def generate_filename_from_title(self, title: str) -> str: + """ + Generate safe filename from scan title. + + Example: "Prod Scan 2025" -> "prod-scan-2025.yaml" + """ + pass +``` + +#### 3. CSV Parser: `web/utils/csv_parser.py` + +**Class definition:** +```python +class CSVConfigParser: + """Parse and validate CSV config files""" + + REQUIRED_COLUMNS = [ + 'scan_title', 'site_name', 'ip_address', + 'ping_expected', 'tcp_ports', 'udp_ports', 'services' + ] + + def __init__(self): + pass + + def parse_csv_to_yaml(self, csv_file) -> str: + """ + Convert CSV file to YAML string. + + Args: + csv_file: File object or file path + + Returns: + YAML string + + Raises: + ValueError: If CSV invalid + """ + pass + + def validate_csv_structure(self, csv_file) -> Tuple[bool, List[str]]: + """ + Validate CSV structure and content. + + Returns: + (is_valid, error_messages) + """ + pass + + def _parse_port_list(self, value: str) -> List[int]: + """Parse comma-separated port list""" + pass + + def _parse_string_list(self, value: str) -> List[str]: + """Parse comma-separated string list""" + pass + + def _parse_bool(self, value: str, default: bool = False) -> bool: + """Parse boolean value (true/false/1/0)""" + pass + + def _validate_ip_address(self, ip: str) -> bool: + """Validate IPv4/IPv6 address format""" + pass + + def _validate_port(self, port: int) -> bool: + """Validate port number (1-65535)""" + pass +``` + +#### 4. Template Generator: `web/utils/template_generator.py` + +**Function:** +```python +def generate_csv_template() -> str: + """ + Generate CSV template with headers and example rows. + + Returns: + CSV string with headers and 2 example rows + """ + template = [ + ['scan_title', 'site_name', 'ip_address', 'ping_expected', 'tcp_ports', 'udp_ports', 'services'], + ['Example Infrastructure Scan', 'Production Web Servers', '10.10.20.4', 'true', '22,80,443', '53', 'ssh,http,https'], + ['Example Infrastructure Scan', 'Production Web Servers', '10.10.20.5', 'true', '22,3306', '', 'ssh,mysql'], + ] + + output = io.StringIO() + writer = csv.writer(output) + writer.writerows(template) + return output.getvalue() +``` + +### Frontend Components + +#### 1. New Route: Configs Management Page + +**File:** `web/routes/main.py` + +```python +@bp.route('/configs') +@login_required +def configs(): + """Config management page""" + return render_template('configs.html') + +@bp.route('/configs/upload') +@login_required +def upload_config(): + """Config upload page""" + return render_template('config_upload.html') +``` + +#### 2. Template: Config List Page + +**File:** `web/templates/configs.html` + +**Features:** +- Table listing all configs with columns: + - Filename + - Title (from YAML) + - Created date + - Size + - Used by schedules (badge count) + - Actions (view, download, delete) +- "Create New Config" button β†’ redirects to upload page +- "Download CSV Template" button +- Delete confirmation modal +- Search/filter box (client-side) + +**Layout:** +```html +{% extends "base.html" %} + +{% block content %} +
    + + +
    +
    +
    + +
    + + + + + + + + + + + + + + + +
    FilenameTitleCreatedSizeUsed ByActions
    +
    +
    +
    + + + +{% endblock %} +``` + +#### 3. Template: Config Upload Page + +**File:** `web/templates/config_upload.html` + +**Features:** +- Two upload methods (tabs): + - **Tab 1: CSV Upload** (default) + - Drag-drop zone or file picker (`.csv` only) + - Instructions: "Download template, fill with your data, upload here" + - Preview pane showing generated YAML after upload + - "Save Config" button (disabled until valid upload) + - **Tab 2: YAML Upload** (for advanced users) + - Drag-drop zone or file picker (`.yaml`, `.yml` only) + - Direct upload without conversion +- Real-time validation feedback +- Error messages with specific issues +- Success message with link to configs list + +**Layout:** +```html +{% extends "base.html" %} + +{% block content %} +
    +

    Create New Configuration

    + + + +
    + +
    +
    +
    +
    +
    +
    Step 1: Upload CSV
    +

    + + Download template + first if you haven't already. +

    + +
    + +

    Drag & drop CSV file here or click to browse

    + +
    + + +
    +
    +
    + +
    +
    +
    +
    Step 2: Preview & Save
    + +
    + Upload a CSV file to see preview +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    Upload YAML Configuration
    +

    + For advanced users: upload a YAML config file directly. +

    + +
    + +

    Drag & drop YAML file here or click to browse

    + +
    + + + + +
    +
    +
    +
    +
    +{% endblock %} +``` + +#### 4. JavaScript: Config Manager + +**File:** `web/static/js/config-manager.js` + +**Functions:** +```javascript +class ConfigManager { + constructor() { + this.apiBase = '/api/configs'; + } + + // List configs and populate table + async loadConfigs() { + const response = await fetch(this.apiBase); + const data = await response.json(); + this.renderConfigsTable(data.configs); + } + + // Upload CSV file + async uploadCSV(file) { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${this.apiBase}/upload-csv`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message); + } + + return await response.json(); + } + + // Upload YAML file + async uploadYAML(file, filename) { + // Similar to uploadCSV + } + + // Delete config + async deleteConfig(filename) { + const response = await fetch(`${this.apiBase}/${filename}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message); + } + + return await response.json(); + } + + // Show preview of YAML + showPreview(yamlContent) { + document.getElementById('yaml-content').textContent = yamlContent; + document.getElementById('yaml-preview').style.display = 'block'; + document.getElementById('preview-placeholder').style.display = 'none'; + } + + // Render configs table + renderConfigsTable(configs) { + // Populate table with config data + } + + // Client-side search filter + filterConfigs(searchTerm) { + // Filter table rows by search term + } +} + +// Drag-drop handlers +function setupDropzone(elementId, fileInputId, fileType) { + const dropzone = document.getElementById(elementId); + const fileInput = document.getElementById(fileInputId); + + dropzone.addEventListener('click', () => fileInput.click()); + + dropzone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropzone.classList.add('dragover'); + }); + + dropzone.addEventListener('drop', (e) => { + e.preventDefault(); + dropzone.classList.remove('dragover'); + const file = e.dataTransfer.files[0]; + handleFileUpload(file, fileType); + }); + + fileInput.addEventListener('change', (e) => { + const file = e.target.files[0]; + handleFileUpload(file, fileType); + }); +} +``` + +#### 5. CSS Styling + +**File:** `web/static/css/config-manager.css` + +```css +.dropzone { + border: 2px dashed #6c757d; + border-radius: 8px; + padding: 40px; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background-color: #f8f9fa; +} + +.dropzone:hover, +.dropzone.dragover { + border-color: #0d6efd; + background-color: #e7f1ff; +} + +.dropzone i { + font-size: 48px; + color: #6c757d; + margin-bottom: 16px; +} + +#yaml-preview pre { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 16px; + max-height: 400px; + overflow-y: auto; +} + +.config-actions { + white-space: nowrap; +} + +.config-actions .btn { + margin-right: 4px; +} + +.schedule-badge { + background-color: #0d6efd; + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.85em; +} +``` + +### Navigation Integration + +**Update:** `web/templates/base.html` + +Add "Configs" link to navigation menu: +```html + +``` + +--- + +## Implementation Tasks + +### Task Breakdown (15 tasks) + +#### Backend (8 tasks) + +1. **Create CSV parser utility** (`web/utils/csv_parser.py`) + - Implement `CSVConfigParser` class + - Parse CSV rows into dict structure + - Validate CSV structure (columns, data types) + - Handle edge cases (empty cells, quotes, commas in values) + - Write unit tests (10+ test cases) + +2. **Create template generator** (`web/utils/template_generator.py`) + - Generate CSV template with headers + example rows + - Write unit tests + +3. **Create config service** (`web/services/config_service.py`) + - Implement `ConfigService` class with all methods + - CSV-to-YAML conversion logic + - Filename sanitization and conflict detection + - Schedule dependency checking + - File operations (read, write, delete) + - Write unit tests (15+ test cases) + +4. **Create configs API blueprint** (`web/api/configs.py`) + - Implement 6 API endpoints + - Error handling with proper HTTP status codes + - Request validation + - File upload handling (multipart/form-data) + - File download headers + - Integrate with ConfigService + +5. **Register configs blueprint** (`web/app.py`) + - Import and register blueprint + - Add to API routes + +6. **Add file upload limits** (`web/app.py`) + - Set MAX_CONTENT_LENGTH for uploads (2MB for CSV/YAML) + - Add file size validation + +7. **Update existing services** (if needed) + - `schedule_service.py`: Add config dependency check method + - Ensure config validation called before scan trigger + +8. **Write integration tests** (`tests/test_config_api.py`) + - Test all API endpoints (20+ test cases) + - Test CSV upload β†’ conversion β†’ scan trigger workflow + - Test error cases (invalid CSV, filename conflict, delete protection) + - Test file download + +#### Frontend (5 tasks) + +9. **Create configs list template** (`web/templates/configs.html`) + - Table layout with columns + - Action buttons (view, download, delete) + - Delete confirmation modal + - Search box + - Links to upload page and template download + +10. **Create config upload template** (`web/templates/config_upload.html`) + - Two-tab interface (CSV upload, YAML upload) + - Drag-drop zones for both tabs + - YAML preview pane + - Error message displays + - Success/failure feedback + +11. **Add routes to main blueprint** (`web/routes/main.py`) + - `/configs` - List configs + - `/configs/upload` - Upload page + +12. **Create JavaScript module** (`web/static/js/config-manager.js`) + - ConfigManager class + - API fetch wrappers for all endpoints + - Drag-drop handlers + - File upload with FormData + - Table rendering and search filtering + - Preview display + - Error display helpers + +13. **Create CSS styling** (`web/static/css/config-manager.css`) + - Dropzone styling with hover effects + - Preview pane styling + - Table action buttons + - Responsive layout adjustments + +#### Integration & Documentation (2 tasks) + +14. **Update navigation** (`web/templates/base.html`) + - Add "Configs" link to navbar + - Set active state based on current route + +15. **Update documentation** + - Update README.md with Config Creator section + - Update API_REFERENCE.md with new endpoints + - Create this document (Phase4.md) βœ“ + +--- + +## Testing Strategy + +### Unit Tests + +**File:** `tests/test_csv_parser.py` + +Test cases (10+): +- Valid CSV with multiple sites and IPs +- Single site, single IP +- Empty optional fields (udp_ports, services) +- Boolean parsing (true/false/TRUE/FALSE) +- Port list parsing (single port, multiple ports, quoted/unquoted) +- Invalid IP addresses +- Invalid port numbers (0, 65536, negative) +- Missing required columns +- Inconsistent scan_title across rows +- Empty CSV (no data rows) +- Malformed CSV (wrong number of columns) + +**File:** `tests/test_config_service.py` + +Test cases (15+): +- List configs (empty directory, multiple configs) +- Get config (valid, non-existent) +- Create from YAML (valid, invalid syntax, duplicate filename) +- Create from CSV (valid, invalid CSV, duplicate filename) +- Delete config (valid, non-existent, used by schedule) +- Validate config content (valid, missing title, missing sites, invalid structure) +- Get schedules using config (none, multiple) +- Generate filename from title (various titles, special characters, long titles) + +### Integration Tests + +**File:** `tests/test_config_api.py` + +Test cases (20+): +- **GET /api/configs** + - List configs successfully + - Empty list when no configs exist + - Includes schedule usage counts +- **GET /api/configs/** + - Get existing config + - 404 for non-existent config + - Returns parsed YAML data +- **POST /api/configs/upload-csv** + - Upload valid CSV β†’ creates YAML + - Upload invalid CSV β†’ 400 error with details + - Upload CSV with duplicate filename β†’ 409 error + - Upload non-CSV file β†’ 400 error + - Upload file too large β†’ 413 error +- **POST /api/configs/upload-yaml** + - Upload valid YAML β†’ creates config + - Upload invalid YAML syntax β†’ 400 error + - Upload YAML missing required fields β†’ 400 error +- **GET /api/configs/template** + - Download CSV template + - Returns text/csv mimetype + - Template has correct headers and examples +- **DELETE /api/configs/** + - Delete unused config β†’ success + - Delete used-by-schedule config β†’ 422 error with schedule list + - Delete non-existent config β†’ 404 error +- **Authentication** + - All endpoints require auth + - Unauthenticated requests β†’ 401 error + +### End-to-End Tests + +**File:** `tests/test_config_workflow.py` + +Test cases (5): +1. **Complete CSV workflow:** + - Download template + - Upload modified CSV + - Verify YAML created correctly + - Trigger scan with new config + - Verify scan completes successfully + +2. **Config deletion protection:** + - Create config + - Create schedule using config + - Attempt to delete config β†’ fails with error + - Disable schedule + - Delete config β†’ succeeds + +3. **Filename conflict handling:** + - Create config "test-scan.yaml" + - Upload CSV with same title + - Verify error returned + - User changes filename β†’ succeeds + +4. **YAML direct upload:** + - Upload valid YAML + - Config immediately usable + - Trigger scan β†’ works + +5. **CSV validation errors:** + - Upload CSV with invalid IP + - Verify clear error message returned + - Fix CSV and re-upload β†’ succeeds + +### Manual Testing Checklist + +**UI/UX:** +- [ ] Drag-drop file upload works +- [ ] File picker works +- [ ] Preview shows correct YAML +- [ ] Error messages are clear and actionable +- [ ] Success messages appear after save +- [ ] Table search/filter works +- [ ] Delete confirmation modal works +- [ ] Navigation links work +- [ ] Responsive layout on mobile + +**File Handling:** +- [ ] CSV template downloads correctly +- [ ] CSV with special characters (commas, quotes) parses correctly +- [ ] Large CSV files upload successfully +- [ ] YAML files with UTF-8 characters work +- [ ] Generated YAML is valid and scanner accepts it + +**Error Cases:** +- [ ] Invalid CSV format shows helpful error +- [ ] Duplicate filename shows conflict error +- [ ] Delete protected config shows which schedules use it +- [ ] Network errors handled gracefully +- [ ] File too large shows size limit error + +--- + +## Security Considerations + +### Input Validation + +1. **Filename sanitization:** + - Use `werkzeug.utils.secure_filename()` + - Remove path traversal attempts (`../`, `./`) + - Limit filename length (200 chars) + - Only allow alphanumeric, hyphens, underscores + +2. **File type validation:** + - Check file extension (`.csv`, `.yaml`, `.yml`) + - Verify MIME type matches extension + - Reject executable or script file extensions + +3. **CSV content validation:** + - Validate all IPs with `ipaddress` module + - Validate port ranges (1-65535) + - Limit CSV row count (max 1000 rows) + - Limit CSV file size (max 2MB) + +4. **YAML parsing security:** + - Always use `yaml.safe_load()` (never `yaml.load()`) + - Prevents arbitrary code execution + - Only load basic data types (dict, list, str, int, bool) + +### Access Control + +1. **Authentication required:** + - All API endpoints require `@api_auth_required` + - All web routes require `@login_required` + - Single-user model (no multi-tenant concerns) + +2. **File system access:** + - Restrict all operations to `/app/configs/` directory + - Validate no path traversal in any file operations + - Use `os.path.join()` and `os.path.abspath()` safely + +### File Upload Security + +1. **Size limits:** + - CSV: 2MB max + - YAML: 2MB max + - Configurable in Flask config + +2. **Rate limiting (future consideration):** + - Limit upload frequency per session + - Prevent DoS via repeated large uploads + +3. **Virus scanning (future consideration):** + - For production deployments + - Scan uploaded files with ClamAV + +--- + +## Database Changes + +**No database schema changes required for Phase 4.** + +Configs are stored as files, not in database. However, for future enhancement, consider adding: + +```python +# Optional future enhancement (not Phase 4) +class Config(Base): + __tablename__ = 'configs' + + id = Column(Integer, primary_key=True) + filename = Column(String(255), unique=True, nullable=False, index=True) + title = Column(String(255), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_by = Column(String(50)) # 'csv', 'yaml', 'manual' + file_hash = Column(String(64)) # SHA256 hash for change detection +``` + +Benefits of database tracking: +- Faster metadata queries (no need to parse YAML for title) +- Change detection and versioning +- Usage statistics (how often config used) +- Search and filtering + +**Decision:** Keep Phase 4 simple (file-based only). Add database tracking in Phase 5+ if needed. + +--- + +## Integration with Existing Features + +### Dashboard Integration + +**Current:** Dashboard has "Run Scan Now" button with config dropdown + +**Change:** Config dropdown populated via `/api/configs` instead of filesystem scan + +**File:** `web/templates/dashboard.html` + +```javascript +// Before (Phase 3) +fetch('/api/configs-list') // Doesn't exist yet + +// After (Phase 4) +fetch('/api/configs') + .then(res => res.json()) + .then(data => { + const select = document.getElementById('config-select'); + data.configs.forEach(config => { + const option = new Option(config.title, config.filename); + select.add(option); + }); + }); +``` + +### Schedule Management Integration + +**Current:** Schedule form has config file input (text field or dropdown) + +**Change:** Config selector uses `/api/configs` to show available configs + +**File:** `web/templates/schedule_form.html` + +```html + + + + + +

    + Don't see your config? Create a new one +

    +``` + +### Scan Trigger Integration + +**Current:** Scan trigger validates config file exists + +**Change:** No changes needed, validation already in place via `validators.validate_config_file()` + +**File:** `web/services/scan_service.py` + +```python +def trigger_scan(self, config_file: str, triggered_by: str = 'manual'): + # Existing validation + is_valid, error = validate_config_file(config_file) + if not is_valid: + raise ValueError(f"Invalid config file: {error}") + + # Continue with scan... +``` + +--- + +## Success Criteria + +### Phase 4 Complete When: + +1. **CSV Template Download:** + - βœ“ User can download CSV template from UI + - βœ“ Template includes headers and example rows + - βœ“ Template format matches specification + +2. **CSV Upload & Conversion:** + - βœ“ User can upload CSV via drag-drop or file picker + - βœ“ CSV validates correctly (structure, data types, IPs, ports) + - βœ“ CSV converts to valid YAML + - βœ“ Generated YAML matches expected structure + - βœ“ Config saved to `/app/configs/` directory + +3. **YAML Upload:** + - βœ“ User can upload YAML directly + - βœ“ YAML validates before saving + - βœ“ Invalid YAML shows clear error message + +4. **Config Management:** + - βœ“ User can list all configs with metadata + - βœ“ User can view config content (YAML) + - βœ“ User can download existing configs + - βœ“ User can delete unused configs + - βœ“ Deletion blocked if config used by schedules + +5. **Integration:** + - βœ“ Generated configs work with scan trigger + - βœ“ Configs appear in dashboard scan selector + - βœ“ Configs appear in schedule form selector + - βœ“ No breaking changes to existing workflows + +6. **Testing:** + - βœ“ All unit tests pass (25+ tests) + - βœ“ All integration tests pass (20+ tests) + - βœ“ Manual testing checklist complete + +7. **Documentation:** + - βœ“ README.md updated with Config Creator section + - βœ“ API_REFERENCE.md updated with new endpoints + - βœ“ Phase4.md created (this document) + +--- + +## Timeline Estimate + +**Total Duration:** 4-5 days + +### Day 1: Backend Foundation (CSV Parser & Service) +- Create `csv_parser.py` with CSVConfigParser class (3 hours) +- Write unit tests for CSV parser (2 hours) +- Create `template_generator.py` (1 hour) +- Create `config_service.py` skeleton (2 hours) + +### Day 2: Backend Implementation (Config Service & API) +- Implement all ConfigService methods (4 hours) +- Write unit tests for ConfigService (3 hours) +- Create `configs.py` API blueprint (2 hours) + +### Day 3: Backend Testing & Frontend Start +- Write integration tests for API endpoints (3 hours) +- Create `configs.html` template (2 hours) +- Create `config_upload.html` template (2 hours) +- Add routes to `main.py` (1 hour) + +### Day 4: Frontend JavaScript & Styling +- Create `config-manager.js` with all functionality (4 hours) +- Implement drag-drop handlers (2 hours) +- Create `config-manager.css` styling (1 hour) +- Update navigation in `base.html` (30 min) +- Manual testing and bug fixes (2 hours) + +### Day 5: Integration & Documentation +- End-to-end testing (2 hours) +- Update dashboard/schedule integration (2 hours) +- Update README.md and API_REFERENCE.md (2 hours) +- Final manual testing checklist (2 hours) +- Bug fixes and polish (2 hours) + +**Buffer:** +1 day for unexpected issues or scope additions + +--- + +## Future Enhancements (Post-Phase 4) + +Not in scope for Phase 4, but consider for future phases: + +1. **Config Editor UI (Phase 5+):** + - Web form to create configs without CSV + - Add/remove sites and IPs dynamically + - Port picker with common presets + - Inline YAML editor with syntax highlighting + +2. **Config Versioning (Phase 5+):** + - Track config changes over time + - Compare versions (diff view) + - Rollback to previous version + - Store versions in database + +3. **CSV Export (Phase 5+):** + - Export existing YAML configs to CSV + - Edit in Excel and re-upload + - Useful for bulk updates + +4. **Config Templates (Phase 6+):** + - Pre-built templates for common scenarios: + - Web server infrastructure + - Database cluster + - Network devices + - User selects template, fills IPs, done + +5. **Bulk Import (Phase 6+):** + - Upload multiple CSV files at once + - ZIP archive with multiple configs + - Import from external sources (CMDB, spreadsheet) + +6. **Config Validation on Schedule (Phase 6+):** + - Periodic validation of all configs + - Alert if config becomes invalid (file deleted, corrupted) + - Auto-disable schedules with invalid configs + +7. **Config Metadata & Tags (Phase 6+):** + - Add tags/labels to configs (environment, team, etc.) + - Filter/search by tags + - Group related configs + +8. **Config Diff Tool (Phase 6+):** + - Compare two configs side-by-side + - Highlight differences (IPs, ports, sites) + - Useful for environment parity checks + +--- + +## Open Questions + +### Resolved: +- βœ“ One CSV = one config or multiple? **One config** +- βœ“ Form editor or upload only? **Upload only (form later)** +- βœ“ Config versioning? **No (Phase 4), maybe later** +- βœ“ Delete protection? **Block if used by schedules** + +### Remaining: +- **Filename handling:** Auto-generate from title or let user specify? + - **Recommendation:** Auto-generate with option to customize (add filename input field) +- **Duplicate IP handling:** Allow or reject duplicate IPs in same config? + - **Recommendation:** Allow (user might scan same IP with different expected ports) +- **Config validation frequency:** Validate on upload only, or re-validate periodically? + - **Recommendation:** Upload only (Phase 4), periodic validation in Phase 6+ +- **CSV encoding:** Support only UTF-8 or other encodings? + - **Recommendation:** UTF-8 only (standard, avoids complexity) + +--- + +## Risk Assessment + +### Low Risk: +- CSV parsing (standard library, well-tested) +- File upload handling (Flask/werkzeug built-in) +- YAML generation (PyYAML library) + +### Medium Risk: +- Complex CSV validation edge cases (commas in values, quotes) + - **Mitigation:** Comprehensive unit tests, use csv.reader() +- Filename conflicts and race conditions + - **Mitigation:** Check existence before write, use atomic operations + +### High Risk: +- Breaking existing scan/schedule workflows + - **Mitigation:** Extensive integration tests, no changes to existing validation +- Security vulnerabilities (path traversal, code injection) + - **Mitigation:** Use secure_filename(), yaml.safe_load(), input validation + +### Contingency: +- If CSV parsing too complex: Start with YAML upload only, add CSV in Phase 4.5 +- If schedule deletion check too slow: Cache schedule-config mappings +- If file-based config management becomes bottleneck: Migrate to database in Phase 5 + +--- + +## Deployment Notes + +### Requirements: +- No new Python dependencies (csv, yaml, os, io all in stdlib) +- No new system dependencies +- No database migrations + +### Configuration: +Add to `.env` (optional): +```bash +# Config upload limits +MAX_CONFIG_SIZE_MB=2 +MAX_CSV_ROWS=1000 +CONFIGS_DIR=/app/configs +``` + +### Deployment Steps: +1. Pull latest code +2. Restart Flask app: `docker-compose -f docker-compose-web.yml restart web` +3. Verify `/api/configs/template` endpoint works +4. Test CSV upload with template + +### Rollback Plan: +- No database changes, so rollback is safe +- Revert code changes and restart +- Configs created during Phase 4 remain valid (files are backward compatible) + +--- + +## References + +### Related Documentation: +- [Phase 2 Complete](PHASE2_COMPLETE.md) - REST API patterns, authentication +- [API Reference](API_REFERENCE.md) - Existing API structure +- [Deployment Guide](DEPLOYMENT.md) - Production deployment + +### External Resources: +- [Python csv module docs](https://docs.python.org/3/library/csv.html) +- [PyYAML documentation](https://pyyaml.org/wiki/PyYAMLDocumentation) +- [Flask file upload guide](https://flask.palletsprojects.com/en/3.0.x/patterns/fileuploads/) +- [Werkzeug secure_filename](https://werkzeug.palletsprojects.com/en/3.0.x/utils/#werkzeug.utils.secure_filename) + +### Code References: +- `src/scanner.py:42-54` - Config loading and validation +- `web/utils/validators.py:14-85` - Existing validation patterns +- `web/services/scan_service.py` - Scan trigger with config validation +- `web/api/scans.py` - API endpoint patterns + +--- + +## Changelog + +| Date | Version | Changes | +|------|---------|---------| +| 2025-11-17 | 1.0 | Initial Phase 4 plan created based on user requirements and design decisions | + +--- + +**Last Updated:** 2025-11-17 +**Next Review:** After Phase 4 implementation complete +**Approval Status:** Ready for Implementation + +--- + +**Phase 4 Goal:** Enable non-technical users to create scan configurations via CSV upload, eliminating the need for manual YAML editing and server file access. diff --git a/docs/ai/ROADMAP.md b/docs/ai/ROADMAP.md index ab427bd..f70f14c 100644 --- a/docs/ai/ROADMAP.md +++ b/docs/ai/ROADMAP.md @@ -15,10 +15,11 @@ - Basic UI templates (dashboard, scans, login) - Comprehensive error handling and logging - 100 tests passing (1,825 lines of test code) -- ⏳ **Phase 3: Dashboard & Scheduling** - Next up (Weeks 5-6) -- πŸ“‹ **Phase 4: Email & Comparisons** - Planned (Weeks 7-8) -- πŸ“‹ **Phase 5: CLI as API Client** - Planned (Week 9) -- πŸ“‹ **Phase 6: Advanced Features** - Planned (Weeks 10+) +- βœ… **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+) ## Vision & Goals