Compare commits

..

96 Commits

Author SHA1 Message Date
b802779faa Merge branch 'nightly' 2025-12-24 08:39:09 -06:00
52378eaaf4 database fixes / simplification 2025-12-23 20:22:00 -06:00
7667d80d2f fixing migrations 2025-12-23 20:09:47 -06:00
9a0b7c7920 adding password init to setup.sh 2025-12-23 20:01:13 -06:00
d02a065bde adding force flag to setup for db things 2025-12-23 19:59:33 -06:00
4c22948ea2 fixing setup.sh to init the db 2025-12-23 19:58:01 -06:00
51fa4caaf5 podman updates 2025-12-23 19:53:13 -06:00
8c34f8b2eb more podman fixes 2025-12-23 19:25:36 -06:00
136276497d updating docker compose to be podman compliant
All checks were successful
Build and Push Docker Image / build (push) Successful in 6s
2025-12-23 19:14:54 -06:00
6bc733fefd adding actions
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m47s
2025-12-23 18:38:16 -06:00
4b197e0b3d Merge pull request 'beta' (#10) from beta into master
Reviewed-on: #10
2025-11-25 20:49:46 +00:00
30f0987a99 Merge pull request 'nightly' (#9) from nightly into beta
Reviewed-on: #9
2025-11-25 20:49:25 +00:00
9e2fc348b7 Merge branch 'bug/long-scans-break' into nightly 2025-11-25 14:48:00 -06:00
847e05abbe Changes Made
1. app/web/utils/validators.py - Added 'finalizing' to valid_statuses list
  2. app/web/models.py - Updated status field comment to document all valid statuses
  3. app/web/jobs/scan_job.py
  - Added transition to 'finalizing' status before output file generation
  - Sets current_phase = 'generating_outputs' during this phase
  - Wrapped output generation in try-except with proper error handling
  - If output generation fails, scan is marked 'completed' with warning message (scan data is still valid)

  4. app/web/api/scans.py
  - Added _recover_orphaned_scan() helper function for smart recovery
  - Modified stop_running_scan() to:
    - Allow stopping scans with status 'running' OR 'finalizing'
    - When scanner not in registry, perform smart recovery instead of returning 404
    - Smart recovery checks for output files and marks as 'completed' if found, 'cancelled' if not

  5. app/web/services/scan_service.py
  - Enhanced cleanup_orphaned_scans() with smart recovery logic
  - Now finds scans in both 'running' and 'finalizing' status
  - Returns dict with stats: {'recovered': N, 'failed': N, 'total': N}

  6. app/web/app.py - Updated caller to handle new dict return type from cleanup_orphaned_scans()

  Expected Behavior Now

  1. Normal scan flow: running → finalizing → completed
  2. Stop on active scan: Sends cancel signal, becomes 'cancelled'
  3. Stop on orphaned scan with files: Smart recovery → 'completed'
  4. Stop on orphaned scan without files: → 'cancelled'
  5. App restart with orphans: Startup cleanup uses smart recovery
2025-11-25 14:47:36 -06:00
07c2bcfd11 Merge branch 'beta' 2025-11-24 12:54:58 -06:00
a560bae800 Merge branch 'nightly' into beta 2025-11-24 12:54:33 -06:00
56828e4184 Merge branch 'feat/fix-cron-schedules' into nightly 2025-11-24 12:53:44 -06:00
5e3a70f837 Fix schedule management and update documentation for database-backed configs
This commit addresses multiple issues with schedule management and updates
  documentation to reflect the transition from YAML-based to database-backed
  configuration system.

  **Documentation Updates:**
  - Update DEPLOYMENT.md to remove all references to YAML config files
  - Document that all configurations are now stored in SQLite database
  - Update API examples to use config IDs instead of YAML filenames
  - Remove configs directory from backup/restore procedures
  - Update volume management section to reflect database-only storage

  **Cron Expression Handling:**
  - Add comprehensive documentation for APScheduler cron format conversion
  - Document that from_crontab() accepts standard format (Sunday=0) and converts automatically
  - Add validate_cron_expression() helper method with detailed error messages
  - Include helpful hints for day-of-week field errors in validation
  - Fix all deprecated datetime.utcnow() calls, replace with datetime.now(timezone.utc)

  **Timezone-Aware DateTime Fixes:**
  - Fix "can't subtract offset-naive and offset-aware datetimes" error
  - Add timezone awareness to croniter.get_next() return values
  - Make _get_relative_time() defensive to handle both naive and aware datetimes
  - Ensure all datetime comparisons use timezone-aware objects

  **Schedule Edit UI Fixes:**
  - Fix JavaScript error "Cannot set properties of null (setting 'value')"
  - Change reference from non-existent 'config-id' to correct 'config-file' element
  - Add config_name field to schedule API responses for better UX
  - Eagerly load Schedule.config relationship using joinedload()
  - Fix AttributeError: use schedule.config.title instead of .name
  - Display config title and ID in schedule edit form

  **Technical Details:**
  - app/web/services/schedule_service.py: 6 datetime.utcnow() fixes, validation enhancements
  - app/web/services/scheduler_service.py: Documentation, validation, timezone fixes
  - app/web/templates/schedule_edit.html: JavaScript element reference fix
  - docs/DEPLOYMENT.md: Complete rewrite of config management sections

  Fixes scheduling for Sunday at midnight (cron: 0 0 * * 0)
  Fixes schedule edit page JavaScript errors
  Improves user experience with config title display
2025-11-24 12:53:06 -06:00
451c7e92ff Merge pull request 'Merging beta into master' (#8) from beta into master
Reviewed-on: #8
2025-11-21 22:07:06 +00:00
8b89fd506d Merge pull request 'nightly merge into beta' (#7) from nightly into beta
Reviewed-on: #7
2025-11-21 22:05:43 +00:00
f24bd11dfd Add unique IP count and duplicate detection to sites page
The sites page previously showed total IP count which included duplicates
across multiple sites, leading to inflated numbers. Now displays unique
IP count as the primary metric with duplicate count shown when present.

- Add get_global_ip_stats() method to SiteService for unique/duplicate counts
- Update /api/sites?all=true endpoint to include IP statistics
- Update sites.html to display unique IPs with optional duplicate indicator
- Update API documentation with new response fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 16:03:53 -06:00
9bd2f67150 Add quick button to mark unexpected ports as expected
Allow users to add ports to expected list directly from scan results page
instead of navigating through site config pages. The button appears next
to unexpected ports and updates the site IP configuration via the API.

- Add site_id and site_ip_id to scan result data for linking to config
- Add "Mark Expected" button next to unexpected ports in scan detail view
- Implement markPortExpected() JS function to update site IP settings
2025-11-21 15:40:37 -06:00
3058c69c39 Add scan cancellation feature
- Replace subprocess.run() with Popen for cancellable processes
- Add cancel() method to SneakyScanner with process termination
- Track running scanners in registry for stop signal delivery
- Handle ScanCancelledError to set scan status to 'cancelled'
- Add POST /api/scans/<id>/stop endpoint
- Add 'cancelled' as valid scan status
- Add Stop button to scans list and detail views
- Show cancelled status with warning badge in UI
2025-11-21 14:17:26 -06:00
04dc238aea Add configurable UDP scanning and numeric IP sorting
- Add UDP_SCAN_ENABLED and UDP_PORTS environment variables to control UDP scanning
- UDP scanning disabled by default for faster scans
- Support port ranges (100-200), lists (53,67,68), or mixed formats
- Sort IPs numerically by octets in site management modal
2025-11-21 13:33:38 -06:00
c592000c96 Add real-time scan progress tracking
- Add ScanProgress model and progress fields to Scan model
- Implement progress callback in scanner to report phase completion
- Update scan_job to write per-IP results to database during execution
- Add /api/scans/<id>/progress endpoint for progress polling
- Add progress section to scan detail page with live updates
- Progress table shows current phase, completion bar, and per-IP results
- Poll every 3 seconds during active scans
- Sort IPs numerically for proper ordering
- Add database migration for new tables/columns
2025-11-21 12:49:27 -06:00
4c6b4bf35d Add IP address search feature with global search box
- Add API endpoint GET /api/scans/by-ip/{ip_address} to retrieve
  last 10 scans containing a specific IP
- Add ScanService.get_scans_by_ip() method with ScanIP join query
- Add search box to global navigation header
- Create dedicated search results page at /search/ip
- Update API documentation with new endpoint
2025-11-21 11:29:03 -06:00
3adb51ece2 Add configurable nmap host timeout setting
Move nmap host timeout from hardcoded 5m to configurable setting
in app/web/config.py with a default of 2m for faster scans.
2025-11-21 11:11:37 -06:00
c4cbbee280 Bump version to 1.0.0-beta 2025-11-20 14:43:04 -06:00
889e1eaac3 updating release.sh to use correct branch names 2025-11-20 14:42:44 -06:00
a682e5233c Reorganize roadmap with versioned planned features
Condensed completed phases into concise summaries and categorized
planned features into version milestones:
- v1.1.0: Communication & Automation (CLI, Email, CSV)
- v1.2.0: Reporting & Analysis (Scan Comparison, Enhanced Reports)
- v1.3.0: Visualization (Timeline View, Advanced Charts)
- v2.0.0: Security Intelligence (Vulnerability Detection)
2025-11-20 14:39:14 -06:00
7a14f1602b updating docs 2025-11-20 14:00:10 -06:00
949bccf644 updating readme to align with new config layout 2025-11-20 13:05:41 -06:00
801ddc8d81 removing standalone docker compose, no longer using that, api usage is fully implimented now 2025-11-20 12:59:27 -06:00
db5c828b5f adding release script 2025-11-20 12:34:15 -06:00
a044c19a46 Merge branch 'beta' 2025-11-20 11:40:27 -06:00
a5e2b43944 Merge branch 'master' into nightly 2025-11-20 11:39:39 -06:00
3219f8a861 Merge branch 'master' into beta 2025-11-20 11:39:07 -06:00
480065ed14 Fix screenshot directory deletion and update SSL dependencies
Save screenshot_dir to database when scans complete so the directory
is properly cleaned up on scan deletion. Previously the field was never
populated, causing screenshots to remain after deleting scans.

Update sslyze to 6.2.0 and cryptography to 46.0.0 to fix certificate
handling issues with negative serial numbers (RFC 5280 compliance).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 11:35:37 -06:00
73a3b95834 Add certificate details modal and fix SSL/TLS data processing
- Add certificate details modal to scan detail page with subject, issuer,
  validity dates, serial number, self-signed indicator, SANs, and TLS
  version support with expandable cipher suites
- Fix bug where certificate data was not being saved to database due to
  incorrect path lookup (was checking http_info['certificate'] instead of
  http_info['ssl_tls']['certificate'])
- Update requirements: add sslyze 6.0.0 and upgrade cryptography to >=42.0.0
  to fix 'No module named cryptography.x509.verification' error
2025-11-20 11:35:37 -06:00
8d8e53c903 Add screenshot viewing button to scan detail page
Display screenshot button in port table when a service has a captured
screenshot. Button opens screenshot in new tab with correct path
including the screenshot directory.
2025-11-20 11:35:37 -06:00
12d5aff7a5 Add help page with user documentation
Create comprehensive help page covering:
- Getting started workflow
- Sites and IP management
- Scan configuration
- Running scans manually
- Scheduling automated scans
- Scan comparisons
- Alerts and alert rules
- Webhook configuration

Add Help link with icon to navigation bar.
2025-11-20 11:35:37 -06:00
cc3758f92d Add acknowledge all alerts feature
Add POST /api/alerts/acknowledge-all endpoint to bulk acknowledge all
unacknowledged alerts. Add "Ack All" button to alerts page header with
confirmation dialog for quick dismissal of all pending alerts.
2025-11-20 11:35:37 -06:00
9804f9c032 Add route to serve scan output files
Output files (JSON, HTML, ZIP) are stored outside the static directory,
so download links in scan_detail.html were broken. This adds a /output/
route that serves files from the output directory using send_from_directory
for secure file access. Route requires authentication.
2025-11-20 11:35:37 -06:00
e3b647521e Fix scan output file paths and improve notification system
- Save JSON/HTML/ZIP paths to database when scans complete
- Remove orphaned scan-config-id reference causing JS errors
- Add showAlert function to scan_detail.html and scans.html
- Increase notification z-index to 9999 for modal visibility
- Replace inline alert creation with consistent toast notifications
2025-11-20 11:35:37 -06:00
7460c9e23e Merge branch 'nightly' into beta 2025-11-20 11:34:34 -06:00
66b02edc84 Fix screenshot directory deletion and update SSL dependencies
Save screenshot_dir to database when scans complete so the directory
is properly cleaned up on scan deletion. Previously the field was never
populated, causing screenshots to remain after deleting scans.

Update sslyze to 6.2.0 and cryptography to 46.0.0 to fix certificate
handling issues with negative serial numbers (RFC 5280 compliance).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 11:33:12 -06:00
f8b89c46c2 Add certificate details modal and fix SSL/TLS data processing
- Add certificate details modal to scan detail page with subject, issuer,
  validity dates, serial number, self-signed indicator, SANs, and TLS
  version support with expandable cipher suites
- Fix bug where certificate data was not being saved to database due to
  incorrect path lookup (was checking http_info['certificate'] instead of
  http_info['ssl_tls']['certificate'])
- Update requirements: add sslyze 6.0.0 and upgrade cryptography to >=42.0.0
  to fix 'No module named cryptography.x509.verification' error
2025-11-20 10:38:02 -06:00
6d5005403c Add screenshot viewing button to scan detail page
Display screenshot button in port table when a service has a captured
screenshot. Button opens screenshot in new tab with correct path
including the screenshot directory.
2025-11-20 10:07:24 -06:00
05f846809e Add help page with user documentation
Create comprehensive help page covering:
- Getting started workflow
- Sites and IP management
- Scan configuration
- Running scans manually
- Scheduling automated scans
- Scan comparisons
- Alerts and alert rules
- Webhook configuration

Add Help link with icon to navigation bar.
2025-11-20 09:59:35 -06:00
7c26824aa1 Add acknowledge all alerts feature
Add POST /api/alerts/acknowledge-all endpoint to bulk acknowledge all
unacknowledged alerts. Add "Ack All" button to alerts page header with
confirmation dialog for quick dismissal of all pending alerts.
2025-11-20 09:35:13 -06:00
91507cc8f8 Add route to serve scan output files
Output files (JSON, HTML, ZIP) are stored outside the static directory,
so download links in scan_detail.html were broken. This adds a /output/
route that serves files from the output directory using send_from_directory
for secure file access. Route requires authentication.
2025-11-20 09:32:28 -06:00
7437716613 Fix scan output file paths and improve notification system
- Save JSON/HTML/ZIP paths to database when scans complete
- Remove orphaned scan-config-id reference causing JS errors
- Add showAlert function to scan_detail.html and scans.html
- Increase notification z-index to 9999 for modal visibility
- Replace inline alert creation with consistent toast notifications
2025-11-20 08:41:02 -06:00
657f4784bf Merge pull request 'Update API documentation for database-based configuration' (#5) from nightly into master
Reviewed-on: #5
2025-11-20 04:07:46 +00:00
73d04cae5e Update API documentation for database-based configuration
- Fix config_id references to use integers instead of file paths
- Update scan delete response format to include scan_id field
- Add missing read_only field to Settings API responses
- Add missing template fields to Webhook responses
- Correct endpoint count from 80+ to 65+

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 22:06:38 -06:00
b8c3e4e2d8 Merge pull request 'beta' (#4) from beta into master
Reviewed-on: #4
2025-11-20 03:47:16 +00:00
aa7c32381c Merge pull request 'nightly' (#3) from nightly into beta
Reviewed-on: #3
2025-11-20 03:46:49 +00:00
0fc51eb032 Improve UI design system and fix notification positioning
- Overhaul CSS with comprehensive design tokens (shadows, transitions, radii)
- Add hover effects and smooth transitions to cards, buttons, tables
- Improve typography hierarchy and color consistency
- Remove inline styles from 10 template files for better maintainability
- Add global notification container to ensure toasts appear above modals
- Update showNotification/showAlert functions to use centralized container
- Add accessibility improvements (focus states, reduced motion support)
- Improve responsive design and mobile styling
- Add print styles
2025-11-19 21:45:36 -06:00
fdf689316f code cleanup, UI change to menu to make it cleaner 2025-11-19 21:27:05 -06:00
41ba4c47b5 refactor to remove config_files in favor of db 2025-11-19 20:29:14 -06:00
b2e6efb4b3 config file remove 2025-11-19 20:01:35 -06:00
e7dd207a62 Fix AlertRule initialization to use config_id instead of config_file
Updated init_db.py to use config_id field after database migration,
fixing container startup error on new systems.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 19:56:28 -06:00
30a29142a0 Fix password not being set when regenerating .env in setup.sh
Remove the database init marker when regenerating .env file so that
the docker entrypoint will re-run password initialization with the
new INITIAL_PASSWORD value on next container start.
2025-11-19 19:53:40 -06:00
0ec338e252 Migrate from file-based configs to database with per-IP site configuration
Major architectural changes:
   - Replace YAML config files with database-stored ScanConfig model
   - Remove CIDR block support in favor of individual IP addresses per site
   - Each IP now has its own expected_ping, expected_tcp_ports, expected_udp_ports
   - AlertRule now uses config_id FK instead of config_file string

   API changes:
   - POST /api/scans now requires config_id instead of config_file
   - Alert rules API uses config_id with validation
   - All config dropdowns fetch from /api/configs dynamically

   Template updates:
   - scans.html, dashboard.html, alert_rules.html load configs via API
   - Display format: Config Title (X sites) in dropdowns
   - Removed Jinja2 config_files loops

   Migrations:
   - 008: Expand CIDRs to individual IPs with per-IP port configs
   - 009: Remove CIDR-related columns
   - 010: Add config_id to alert_rules, remove config_file
2025-11-19 19:40:34 -06:00
034f146fa1 stage 1 of doing new cidrs/ site setup 2025-11-19 13:39:27 -06:00
4a4c33a10b doc changes 2025-11-19 10:42:49 -06:00
21254c3522 added webhooks and templates to alerting, email is next 2025-11-18 19:26:12 -06:00
230094d7b2 webhook templates 2025-11-18 15:29:23 -06:00
28b32a2049 added webhooks, moved app name and verison to simple config file 2025-11-18 15:05:57 -06:00
1d076a467a added webhooks, moved app name and verison to simple config file 2025-11-18 15:05:39 -06:00
3c740268c4 updated API docs 2025-11-18 13:23:06 -06:00
131e1f5a61 adding phase 5 init framework, added deployment ease scripts 2025-11-18 13:10:53 -06:00
b2a3fc7832 license 2025-11-17 16:32:02 -06:00
cd840cb8ca restructure of dirs, huge docs update 2025-11-17 16:29:14 -06:00
456e052389 updating docs 2025-11-17 15:50:15 -06:00
72c4f3d29b hot fixes for several UI and logic issues 2025-11-17 15:41:51 -06:00
5f2314a532 phase 4 complete 2025-11-17 14:54:31 -06:00
5301b07f37 Merge pull request 'phase3' (#2) from phase3 into master
Reviewed-on: #2
2025-11-17 18:06:56 +00:00
6fe24c3907 adding Phase4 2025-11-17 12:05:11 -06:00
489284bde1 updating Phase3.md 2025-11-14 16:31:35 -06:00
6792d69eb1 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/<id1>/compare/<id2> - Compare two scans
- GET /api/stats/scan-history/<id> - 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
2025-11-14 16:15:13 -06:00
9b88f42297 Phase 3 Step 6: Complete Scheduler Integration with Bug Fixes
Implemented complete scheduler integration with automatic schedule loading,
orphaned scan cleanup, and conversion to local timezone for better UX.

Backend Changes:
- Added load_schedules_on_startup() to load enabled schedules on app start
- Implemented cleanup_orphaned_scans() to handle crashed/interrupted scans
- Converted scheduler from UTC to local system timezone throughout
- Enhanced scheduler service with robust error handling and logging

Frontend Changes:
- Updated all schedule UI templates to display local time instead of UTC
- Improved timezone indicators and user messaging
- Removed confusing timezone converter (no longer needed)
- Updated quick templates and help text for local time

Bug Fixes:
- Fixed critical timezone bug causing cron expressions to run at wrong times
- Fixed orphaned scans stuck in 'running' status after system crashes
- Improved time display clarity across all schedule pages

All schedules now use local system time for intuitive scheduling.
2025-11-14 15:44:13 -06:00
effce42f21 Phase 3 Step 5: Enhanced Dashboard with Charts & Analytics
Implemented dashboard visualizations and statistics API endpoints:

New Features:
- Stats API endpoints (/api/stats/scan-trend, /api/stats/summary)
- Chart.js trending chart showing 30-day scan activity
- Schedules widget displaying next 3 upcoming scheduled scans
- Enhanced Quick Actions with Manage Schedules button

Stats API (web/api/stats.py):
- scan-trend endpoint with configurable days (1-365)
- Summary endpoint for dashboard statistics
- Automatic date range filling with zeros for missing days
- Proper authentication and validation

Dashboard Enhancements (web/templates/dashboard.html):
- Chart.js line chart with dark theme styling
- Real-time schedules widget with human-readable time display
- Auto-refresh for schedules every 30 seconds
- Responsive 8-4 column layout for chart and schedules

Tests (tests/test_stats_api.py):
- 18 comprehensive test cases for stats API
- Coverage for date validation, authentication, edge cases
- Tests for empty data handling and date formatting

Progress: 64% complete (9/14 days)
Next: Step 6 - Scheduler Integration
2025-11-14 14:50:20 -06:00
d68d9133c1 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
2025-11-14 14:33:48 -06:00
7969068c36 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")
2025-11-14 13:41:49 -06:00
cbc3ff0f51 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).
2025-11-14 13:21:48 -06:00
4406f545fa Merge pull request 'phase2-step3-background-job-queue' (#1) from phase2-step3-background-job-queue into master
Reviewed-on: #1
2025-11-14 18:40:23 +00:00
4febdd23a5 Phase 2 Step 8: Testing & Documentation
Complete Phase 2 with comprehensive testing and documentation suite.

Testing:
- Reviewed existing test suite: 100 test functions, 1,825 lines of test code
- All tests passing across 6 test files
- Coverage: service layer, API endpoints, authentication, background jobs, error handling

Documentation Created:
- API_REFERENCE.md (17KB): Complete REST API documentation with examples
  * All 5 scan endpoints documented
  * Settings API reference
  * Authentication flow examples
  * Request/response examples with curl commands
  * Error handling and status codes

- PHASE2_COMPLETE.md (29KB): Comprehensive Phase 2 summary
  * All success criteria met (100%)
  * Deliverables by step (7 steps completed)
  * Code metrics: 34 files created, ~7,500+ lines
  * Technical implementation details
  * Lessons learned and key accomplishments

- MANUAL_TESTING.md (24KB): Manual testing checklist
  * 38 comprehensive tests across 10 categories
  * Step-by-step test procedures
  * Expected results for each test
  * Critical tests highlighted

- README.md: Major update with Phase 2 features
  * Quick start for web application
  * Complete web application section
  * API endpoints reference
  * Deployment instructions
  * Development section with testing guide

- ROADMAP.md: Updated with Phase 2 completion
  * Marked Phase 2 as COMPLETE 
  * Updated progress overview
  * Phase 2 success criteria achieved
  * Changelog updated

Phase 2 Final Metrics:
- Files Created: 34
- Lines of Code: ~7,500+
- Test Functions: 100 (all passing)
- Documentation: 2,000+ lines across 5 documents

Features Delivered:
- REST API (5 scan endpoints, 3 settings endpoints)
- Background job queue with APScheduler
- Session-based authentication
- Web UI (dashboard, scans, login, error pages)
- Comprehensive error handling and logging
- Docker deployment with healthcheck
- Complete documentation suite

Status: Phase 2 COMPLETE  - Production ready
Next: Phase 3 - Dashboard & Scheduling

🤖 Generated with SneakyScanner Development Tools
2025-11-14 12:38:58 -06:00
167ab803a6 Phase 2 Step 7: Implement Error Handling & Logging
Comprehensive error handling and logging system with production-ready
features for monitoring, debugging, and user experience.

Enhanced Logging System:
- Implemented RotatingFileHandler (10MB per file, 10 backups, 100MB total)
- Separate error log file for ERROR level messages with detailed tracebacks
- Structured logging with request IDs, timestamps, and module names
- RequestIDLogFilter for automatic request context injection
- Console logging in debug mode with simplified format

Request/Response Middleware:
- Request ID generation using UUID (8-character prefix for readability)
- Request timing with millisecond precision
- User authentication context in all logs
- Response duration tracking and headers (X-Request-ID, X-Request-Duration-Ms)
- Security headers: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection

Database Error Handling:
- Enabled SQLite WAL mode for better concurrency with background jobs
- Busy timeout configuration (15 seconds) for lock handling
- Automatic rollback on request exceptions via teardown handler
- Dedicated SQLAlchemyError handler with explicit rollback
- Connection pooling with pre-ping validation

Comprehensive Error Handlers:
- Content negotiation: JSON responses for API, HTML for web requests
- Error handlers for 400, 401, 403, 404, 405, 500
- Database rollback in all error handlers
- Full exception logging with traceback for debugging

Custom Error Templates:
- Created web/templates/errors/ directory with 6 templates
- Dark theme matching application design (slate colors)
- User-friendly error messages with navigation
- Templates: 400, 401, 403, 404, 405, 500

Testing:
- Comprehensive test suite (320+ lines) in tests/test_error_handling.py
- Tests for JSON vs HTML error responses
- Request ID and duration header verification
- Security header validation
- Log rotation configuration tests
- Structured logging tests

Bug Fix:
- Fixed pagination bug in scans API endpoint
- Changed paginated_result.total_pages to paginated_result.pages
- Resolves AttributeError when listing scans

Files Added:
- tests/test_error_handling.py
- web/templates/errors/400.html
- web/templates/errors/401.html
- web/templates/errors/403.html
- web/templates/errors/404.html
- web/templates/errors/405.html
- web/templates/errors/500.html

Files Modified:
- web/app.py (logging, error handlers, request handlers, database config)
- web/api/scans.py (pagination bug fix)
- docs/ai/PHASE2.md (mark Step 7 complete, update progress to 86%)

Phase 2 Progress: 12/14 days complete (86%)
2025-11-14 12:19:07 -06:00
ebfefa9df3 Phase 2 Step 6: Docker & Deployment Configuration
Implement production-ready Docker deployment with comprehensive configuration
and documentation for SneakyScanner web application.

Changes:
- Update docker-compose-web.yml with production configuration
  - Add scheduler environment variables (SCHEDULER_EXECUTORS, SCHEDULER_JOB_DEFAULTS_MAX_INSTANCES)
  - Enable privileged mode and host networking for scanner operations
  - Configure health check endpoint monitoring (30s interval, 40s start period)
  - Set production defaults (FLASK_ENV=production, FLASK_DEBUG=false)
  - Add SNEAKYSCANNER_ENCRYPTION_KEY support

- Create .env.example configuration template
  - Flask, database, and security settings
  - Scheduler configuration options
  - Detailed comments with key generation examples
  - Production deployment guidance

- Create comprehensive deployment documentation (docs/ai/DEPLOYMENT.md)
  - Quick start guide and prerequisites
  - Detailed configuration instructions
  - Volume management and backup procedures
  - Health monitoring and troubleshooting
  - Security considerations and best practices
  - Upgrade/rollback and backup/restore procedures

- Update PHASE2.md progress tracker
  - Mark Step 6 as complete
  - Update progress to 11/14 days (79%)
  - Document deliverables and implementation details

Deployment is now production-ready with proper security defaults, health
monitoring, and comprehensive documentation for system administrators.
2025-11-14 12:01:21 -06:00
19a64b0cbe removing Claude from repo 2025-11-14 11:53:43 -06:00
a64096ece3 Phase 2 Step 5: Implement Basic UI Templates
Implement comprehensive web UI with dark slate theme matching HTML reports:

Templates:
- Create base.html with navigation, dark theme (#0f172a background)
- Update dashboard.html with stats cards and recent scans table
- Update scans.html with pagination, filtering, and status badges
- Update scan_detail.html with comprehensive scan results display
- Update login.html to extend base template with centered design

Features:
- AJAX-powered dynamic data loading from API endpoints
- Auto-refresh for running scans (10-15 second intervals)
- Responsive Bootstrap 5 grid layout
- Color scheme matches report_mockup.html (slate dark theme)
- Status badges (success/danger/warning/info) with proper colors
- Modal dialogs for triggering scans
- Pagination with ellipsis for large result sets
- Delete confirmation dialogs
- Loading spinners for async operations

Bug Fixes:
- Fix scanner.py imports to use 'src.' prefix for module imports
- Fix scans.py to import validate_page_params from pagination module

All templates use consistent color palette:
- Background: #0f172a, Cards: #1e293b, Accent: #60a5fa
- Success: #065f46/#6ee7b7, Danger: #7f1d1d/#fca5a5
- Warning: #78350f/#fcd34d, Info: #1e3a8a/#93c5fd
2025-11-14 11:51:27 -06:00
0791c60f60 Fix duplicate line in PHASE2.md 2025-11-14 11:24:14 -06:00
ebe0a08b24 Update PHASE2.md: Mark Step 4 (Authentication System) as complete
Progress: 8/14 days (57%)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 11:24:06 -06:00
abc682a634 Phase 2 Step 4: Implement Authentication System
Implemented comprehensive Flask-Login authentication with single-user support.

New Features:
- Flask-Login integration with User model
- Bcrypt password hashing via PasswordManager
- Login, logout, and initial password setup routes
- @login_required and @api_auth_required decorators
- All API endpoints now require authentication
- Bootstrap 5 dark theme UI templates
- Dashboard with navigation
- Remember me and next parameter redirect support

Files Created (12):
- web/auth/__init__.py, models.py, decorators.py, routes.py
- web/routes/__init__.py, main.py
- web/templates/login.html, setup.html, dashboard.html, scans.html, scan_detail.html
- tests/test_authentication.py (30+ tests)

Files Modified (6):
- web/app.py: Added Flask-Login initialization and main routes
- web/api/scans.py: Protected all endpoints with @api_auth_required
- web/api/settings.py: Protected all endpoints with @api_auth_required
- web/api/schedules.py: Protected all endpoints with @api_auth_required
- web/api/alerts.py: Protected all endpoints with @api_auth_required
- tests/conftest.py: Added authentication test fixtures

Security:
- Session-based authentication for both web UI and API
- Secure password storage with bcrypt
- Protected routes redirect to login page
- Protected API endpoints return 401 Unauthorized
- Health check endpoints remain accessible for monitoring

Testing:
- User model authentication and properties
- Login success/failure flows
- Logout and session management
- Password setup workflow
- API endpoint authentication requirements
- Session persistence and remember me functionality
- Next parameter redirect behavior

Total: ~1,200 lines of code added

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 11:23:46 -06:00
ee0c5a2c3c Phase 2 Step 3: Implement Background Job Queue
Implemented APScheduler integration for background scan execution,
enabling async job processing without blocking HTTP requests.

## Changes

### Background Jobs (web/jobs/)
- scan_job.py - Execute scans in background threads
  - execute_scan() with isolated database sessions
  - Comprehensive error handling and logging
  - Scan status lifecycle tracking
  - Timing and error message storage

### Scheduler Service (web/services/scheduler_service.py)
- SchedulerService class for job management
- APScheduler BackgroundScheduler integration
- ThreadPoolExecutor for concurrent jobs (max 3 workers)
- queue_scan() - Immediate job execution
- Job monitoring: list_jobs(), get_job_status()
- Graceful shutdown handling

### Flask Integration (web/app.py)
- init_scheduler() function
- Scheduler initialization in app factory
- Stored scheduler in app context (app.scheduler)

### Database Schema (migration 003)
- Added scan timing fields:
  - started_at - Scan execution start time
  - completed_at - Scan execution completion time
  - error_message - Error details for failed scans

### Service Layer Updates (web/services/scan_service.py)
- trigger_scan() accepts scheduler parameter
- Queues background jobs after creating scan record
- get_scan_status() includes new timing and error fields
- _save_scan_to_db() sets completed_at timestamp

### API Updates (web/api/scans.py)
- POST /api/scans passes scheduler to trigger_scan()
- Scans now execute in background automatically

### Model Updates (web/models.py)
- Added started_at, completed_at, error_message to Scan model

### Testing (tests/test_background_jobs.py)
- 13 unit tests for background job execution
- Scheduler initialization and configuration tests
- Job queuing and status tracking tests
- Scan timing field tests
- Error handling and storage tests
- Integration test for full workflow (skipped by default)

## Features

- Async scan execution without blocking HTTP requests
- Concurrent scan support (configurable max workers)
- Isolated database sessions per background thread
- Scan lifecycle tracking: created → running → completed/failed
- Error messages captured and stored in database
- Job monitoring and management capabilities
- Graceful shutdown waits for running jobs

## Implementation Notes

- Scanner runs in subprocess from background thread
- Docker provides necessary privileges (--privileged, --network host)
- Each job gets isolated SQLAlchemy session (avoid locking)
- Job IDs follow pattern: scan_{scan_id}
- Background jobs survive across requests
- Failed jobs store error messages in database

## Documentation (docs/ai/PHASE2.md)
- Updated progress: 6/14 days complete (43%)
- Marked Step 3 as complete
- Added detailed implementation notes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 09:24:00 -06:00
6c4905d6c1 Phase 2 Step 2: Implement Scan API Endpoints
Implemented all 5 scan management endpoints with comprehensive error
handling, logging, and integration tests.

## Changes

### API Endpoints (web/api/scans.py)
- POST /api/scans - Trigger new scan with config file validation
- GET /api/scans - List scans with pagination and status filtering
- GET /api/scans/<id> - Retrieve scan details with all relationships
- DELETE /api/scans/<id> - Delete scan and associated files
- GET /api/scans/<id>/status - Poll scan status for long-running scans

### Features
- Comprehensive error handling (400, 404, 500)
- Structured logging with appropriate levels
- Input validation via validators
- Consistent JSON error format
- SQLAlchemy error handling with graceful degradation
- HTTP status codes following REST conventions

### Testing (tests/test_scan_api.py)
- 24 integration tests covering all endpoints
- Empty/populated scan lists
- Pagination with multiple pages
- Status filtering
- Error scenarios (invalid input, not found, etc.)
- Complete workflow integration test

### Test Infrastructure (tests/conftest.py)
- Flask app fixture with test database
- Flask test client fixture
- Database session fixture compatible with app context
- Sample scan fixture for testing

### Documentation (docs/ai/PHASE2.md)
- Updated progress: 4/14 days complete (29%)
- Marked Step 2 as complete
- Added implementation details and testing results

## Implementation Notes

- All endpoints use ScanService for business logic separation
- Scan triggering returns immediately; client polls status endpoint
- Background job execution will be added in Step 3
- Authentication will be added in Step 4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 09:13:30 -06:00
128 changed files with 33357 additions and 7707 deletions

82
.env.example Normal file

File diff suppressed because one or more lines are too long

8
.gitignore vendored
View File

@@ -9,6 +9,11 @@ output/
data/ data/
logs/ logs/
# Environment and secrets
.env
admin_password.txt
logs/admin_password.txt
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
@@ -37,3 +42,6 @@ Thumbs.db
# Docker # Docker
.dockerignore .dockerignore
#mounted dirs
configs/

617
CLAUDE.md
View File

@@ -1,617 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
SneakyScanner is a dockerized network scanning tool that uses a five-phase approach: masscan for fast port discovery, nmap for service detection, sslyze for HTTP/HTTPS and SSL/TLS analysis, and Playwright for webpage screenshots. It accepts YAML configuration files defining scan targets and expected network behavior, then produces comprehensive JSON reports with service information, SSL certificates, TLS versions, cipher suites, and webpage screenshots - comparing expected vs. actual results.
## Essential Commands
### Building and Running
```bash
# Build the Docker image
docker build -t sneakyscanner .
# Run with docker-compose (easiest method)
docker-compose build
docker-compose up
# Run directly with Docker
docker run --rm --privileged --network host \
-v $(pwd)/configs:/app/configs:ro \
-v $(pwd)/output:/app/output \
sneakyscanner /app/configs/your-config.yaml
```
### Development
```bash
# Test the Python script locally (requires masscan and nmap installed)
python3 src/scanner.py configs/example-site.yaml -o ./output
# Validate YAML config
python3 -c "import yaml; yaml.safe_load(open('configs/example-site.yaml'))"
```
## Architecture
### Core Components
1. **src/scanner.py** - Main application
- `SneakyScanner` class: Orchestrates scanning workflow
- `_load_config()`: Parses and validates YAML config
- `_run_masscan()`: Executes masscan for TCP/UDP scanning
- `_run_ping_scan()`: Executes masscan ICMP ping scanning
- `_run_nmap_service_detection()`: Executes nmap service detection on discovered TCP ports
- `_parse_nmap_xml()`: Parses nmap XML output to extract service information
- `_is_likely_web_service()`: Identifies web services based on nmap results
- `_detect_http_https()`: Detects HTTP vs HTTPS using socket connections
- `_analyze_ssl_tls()`: Analyzes SSL/TLS certificates and supported versions using sslyze
- `_run_http_analysis()`: Orchestrates HTTP/HTTPS and SSL/TLS analysis phase
- `scan()`: Main workflow - collects IPs, runs scans, performs service detection, HTTP/HTTPS analysis, compiles results and returns report with timestamp
- `save_report()`: Writes JSON output using provided timestamp
- `generate_outputs()`: Generates all output formats (JSON, HTML, ZIP) with graceful error handling
2. **src/screenshot_capture.py** - Screenshot capture module
- `ScreenshotCapture` class: Handles webpage screenshot capture
- `capture()`: Captures screenshot of a web service (HTTP/HTTPS)
- `_launch_browser()`: Initializes Playwright with Chromium in headless mode
- `_close_browser()`: Cleanup browser resources
- `_get_screenshot_dir()`: Creates screenshots subdirectory
- `_generate_filename()`: Generates filename for screenshot (IP_PORT.png)
3. **src/report_generator.py** - HTML report generator
- `HTMLReportGenerator` class: Generates comprehensive HTML reports from JSON scan data
- `generate_report()`: Main method to generate HTML report with summary dashboard and service details
- `_load_json_report()`: Loads and parses JSON scan report
- `_calculate_summary_stats()`: Calculates scan statistics for dashboard (IPs, ports, services, screenshots)
- `_identify_drift_alerts()`: Identifies unexpected/missing ports and services
- `_identify_security_warnings()`: Identifies security issues (expiring certs, weak TLS, self-signed certs)
- `_format_date()`: Helper to format ISO date strings
- `_format_duration()`: Helper to format scan duration
4. **configs/** - YAML configuration files
- Define scan title, sites, IPs, and expected network behavior
- Each IP includes expected ping response and TCP/UDP ports
5. **output/** - Scan outputs (automatically generated)
- Timestamped JSON files: `scan_report_YYYYMMDD_HHMMSS.json`
- Timestamped HTML reports: `scan_report_YYYYMMDD_HHMMSS.html`
- Timestamped ZIP archives: `scan_report_YYYYMMDD_HHMMSS.zip`
- Screenshot directory: `scan_report_YYYYMMDD_HHMMSS_screenshots/`
- All outputs share the same timestamp for easy correlation
- ZIP contains JSON, HTML, and all screenshots
### Scan Workflow
1. Parse YAML config and extract all unique IPs
2. Create scan timestamp (shared across all outputs)
3. Run ping scan on all IPs using `masscan --ping`
4. Run TCP scan on all IPs for ports 0-65535
5. Run UDP scan on all IPs for ports 0-65535
6. Run service detection on discovered TCP ports using `nmap -sV`
7. Run HTTP/HTTPS analysis on web services identified by nmap:
- Detect HTTP vs HTTPS using socket connections
- Capture webpage screenshot using Playwright (viewport 1280x720, 15s timeout)
- For HTTPS: Extract certificate details (subject, issuer, expiry, SANs)
- Test TLS version support (TLS 1.0, 1.1, 1.2, 1.3)
- List accepted cipher suites for each TLS version
8. Aggregate results by IP and site
9. Return scan report and timestamp from `scan()` method
10. Automatically generate all output formats using `generate_outputs()`:
- Save JSON report with timestamp
- Generate HTML report (graceful error handling - continues if fails)
- Create ZIP archive containing JSON, HTML, and screenshots
- All outputs use the same timestamp for correlation
### Why Dockerized
- Masscan and nmap require raw socket access (root/CAP_NET_RAW)
- Isolates privileged operations in container
- Ensures consistent masscan and nmap versions and dependencies
- Uses `--privileged` and `--network host` for network access
### Masscan Integration
- Masscan is built from source in Dockerfile
- Writes output to temporary JSON files
- Results parsed line-by-line (masscan uses comma-separated JSON lines)
- Temporary files cleaned up after each scan
### Nmap Integration
- Nmap installed via apt package in Dockerfile
- Runs service detection (`-sV`) with intensity level 5 (balanced speed/accuracy)
- Outputs XML format for structured parsing
- XML parsed using Python's ElementTree library (xml.etree.ElementTree)
- Extracts service name, product, version, extrainfo, and ostype
- Runs sequentially per IP to avoid overwhelming the target
- 10-minute timeout per host, 5-minute host timeout
### HTTP/HTTPS and SSL/TLS Analysis
- Uses sslyze library for comprehensive SSL/TLS scanning
- HTTP/HTTPS detection using Python's built-in socket and ssl modules
- Analyzes services based on:
- Nmap service identification (http, https, ssl, http-proxy, etc.)
- Common web ports (80, 443, 8000, 8006, 8008, 8080, 8081, 8443, 8888, 9443)
- This ensures non-standard ports (like Proxmox 8006) are analyzed even if nmap misidentifies them
- For HTTPS services:
- Extracts certificate information using cryptography library
- Tests TLS versions: 1.0, 1.1, 1.2, 1.3
- Lists all accepted cipher suites for each supported TLS version
- Calculates days until certificate expiration
- Extracts SANs (Subject Alternative Names) from certificate
- Graceful error handling: if SSL analysis fails, still reports HTTP/HTTPS detection
- 5-second timeout per HTTP/HTTPS detection
- Results merged into service data structure under `http_info` key
- **Note**: Uses sslyze 6.0 API which accesses scan results as attributes (e.g., `certificate_info`, `tls_1_2_cipher_suites`) rather than through `.scan_commands_results.get()`
### Webpage Screenshot Capture
**Implementation**: `src/screenshot_capture.py` - Separate module for code organization
**Technology Stack**:
- Playwright 1.40.0 with Chromium in headless mode
- System Chromium and chromium-driver installed via apt (Dockerfile)
- Python's pathlib for cross-platform file path handling
**Screenshot Process**:
1. Screenshots captured for all successfully detected HTTP/HTTPS services
2. Services identified by:
- Nmap service names: http, https, ssl, http-proxy, http-alt, etc.
- Common web ports: 80, 443, 8000, 8006, 8008, 8080, 8081, 8443, 8888, 9443
3. Browser lifecycle managed via context manager pattern (`__enter__`, `__exit__`)
**Configuration** (default values):
- **Viewport size**: 1280x720 pixels (viewport only, not full page)
- **Timeout**: 15 seconds per screenshot (15000ms in Playwright)
- **Wait strategy**: `wait_until='networkidle'` - waits for network activity to settle
- **SSL handling**: `ignore_https_errors=True` - handles self-signed certs
- **User agent**: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
- **Browser args**: `--no-sandbox`, `--disable-setuid-sandbox`, `--disable-dev-shm-usage`, `--disable-gpu`
**Storage Architecture**:
- Screenshots saved as PNG files in subdirectory: `scan_report_YYYYMMDD_HHMMSS_screenshots/`
- Filename format: `{ip}_{port}.png` (dots in IP replaced with underscores)
- Example: `192_168_1_10_443.png` for 192.168.1.10:443
- Path stored in JSON as relative reference: `http_info.screenshot` field
- Relative paths ensure portability of output directory
**Error Handling** (graceful degradation):
- If screenshot fails (timeout, connection error, etc.), scan continues
- Failed screenshots logged as warnings, not errors
- Services without screenshots simply omit the `screenshot` field in JSON output
- Browser launch failure disables all screenshots for the scan
**Browser Lifecycle** (optimized for performance):
1. Browser launched once at scan start (in `scan()` method)
2. Reused for all screenshots via single browser instance
3. New context + page created per screenshot (isolated state)
4. Context and page closed after each screenshot
5. Browser closed at scan completion (cleanup in `scan()` method)
**Integration Points**:
- Initialized in `scanner.py:scan()` with scan timestamp
- Called from `scanner.py:_run_http_analysis()` after protocol detection
- Cleanup called in `scanner.py:scan()` after all analysis complete
**Code Reference Locations**:
- `src/screenshot_capture.py`: Complete screenshot module (lines 1-202)
- `src/scanner.py:scan()`: Browser initialization and cleanup
- `src/scanner.py:_run_http_analysis()`: Screenshot capture invocation
## Configuration Schema
```yaml
title: string # Report title (required)
sites: # List of sites (required)
- name: string # Site name
ips: # List of IPs for this site
- address: string # IP address (IPv4)
expected: # Expected network behavior
ping: boolean # Should respond to ping
tcp_ports: [int] # Expected TCP ports
udp_ports: [int] # Expected UDP ports
services: [string] # Expected services (optional)
```
## Key Design Decisions
1. **Five-phase scanning**: Masscan for fast port discovery (10,000 pps), nmap for service detection, then HTTP/HTTPS and SSL/TLS analysis for web services
2. **All-port scanning**: TCP and UDP scans cover entire port range (0-65535) to detect unexpected services
3. **Selective web analysis**: Only analyze services identified by nmap as web-related to optimize scan time
4. **Multi-format output**: Automatically generates JSON (machine-readable), HTML (human-readable), and ZIP (archival) for every scan
5. **Expected vs. Actual**: Config includes expected behavior to identify infrastructure drift
6. **Site grouping**: IPs organized by logical site for better reporting
7. **Temporary files**: Masscan and nmap output written to temp files to avoid conflicts in parallel scans
8. **Service details**: Extract product name, version, and additional info for each discovered service
9. **SSL/TLS security**: Comprehensive certificate analysis and TLS version testing with cipher suite enumeration
10. **Unified timestamp**: All outputs (JSON, HTML, ZIP, screenshots) share the same timestamp for easy correlation
11. **Graceful degradation**: If HTML or ZIP generation fails, scan continues and JSON is still saved
## Testing Strategy
When testing changes:
1. Use a controlled test environment with known services (including HTTP/HTTPS)
2. Create a test config with 1-2 IPs
3. Verify all three outputs are generated automatically:
- JSON report (`scan_report_YYYYMMDD_HHMMSS.json`)
- HTML report (`scan_report_YYYYMMDD_HHMMSS.html`)
- ZIP archive (`scan_report_YYYYMMDD_HHMMSS.zip`)
4. Verify all outputs share the same timestamp
5. Check that ping, TCP, and UDP results are captured in JSON
6. Verify service detection results include service name, product, and version
7. For web services, verify http_info includes:
- Correct protocol detection (http vs https)
- Screenshot path reference (relative to output directory)
- Verify screenshot PNG file exists at the referenced path
- Certificate details for HTTPS (subject, issuer, expiry, SANs)
- TLS version support (1.0-1.3) with cipher suites
8. Verify HTML report opens in browser and displays correctly
9. Verify ZIP archive contains:
- JSON report file
- HTML report file
- Screenshot directory with all PNG files
10. Ensure temp files are cleaned up (masscan JSON, nmap XML)
11. Test screenshot capture with HTTP, HTTPS, and self-signed certificate services
12. Test graceful degradation: If HTML generation fails, JSON and ZIP should still be created
## Common Tasks
### Modifying Scan Parameters
**Masscan rate limiting:**
- `--rate`: Currently set to 10000 packets/second in src/scanner.py:80, 132
- `--wait`: Set to 0 (don't wait for late responses)
- Adjust these in `_run_masscan()` and `_run_ping_scan()` methods
**Nmap service detection intensity:**
- `--version-intensity`: Currently set to 5 (balanced) in src/scanner.py:201
- Range: 0-9 (0=light, 9=comprehensive)
- Lower values are faster but less accurate
- Adjust in `_run_nmap_service_detection()` method
**Nmap timeouts:**
- `--host-timeout`: Currently 5 minutes in src/scanner.py:204
- Overall subprocess timeout: 600 seconds (10 minutes) in src/scanner.py:208
- Adjust based on network conditions and number of ports
### Adding New Scan Types
To add additional scan functionality (e.g., OS detection, vulnerability scanning):
1. Add new method to `SneakyScanner` class (follow pattern of `_run_nmap_service_detection()`)
2. Update `scan()` workflow to call new method
3. Add results to `actual` section of output JSON
4. Update YAML schema if expected values needed
5. Update documentation (README.md, CLAUDE.md)
### Changing Output Format
JSON structure defined in src/scanner.py:365+. To modify:
1. Update the report dictionary structure
2. Ensure backward compatibility or version the schema
3. Update README.md output format documentation
4. Update example output in both README.md and CLAUDE.md
### Generating HTML Reports
**Note**: HTML reports are automatically generated after every scan. The commands below are for manual generation from existing JSON data only.
**Basic usage:**
```bash
# Manually generate HTML report from existing JSON scan
python3 src/report_generator.py output/scan_report_20251113_175235.json
```
**Custom output location:**
```bash
# Specify output filename
python3 src/report_generator.py output/scan.json reports/my_report.html
```
**Programmatic usage:**
```python
from src.report_generator import HTMLReportGenerator
generator = HTMLReportGenerator('output/scan_report.json')
html_path = generator.generate_report('custom_output.html')
print(f"Report generated: {html_path}")
```
**Customizing the HTML template:**
- Edit `templates/report_template.html` to modify layout, colors, or content
- Template uses Jinja2 syntax with variables like `{{ title }}`, `{% for site in sites %}`
- CSS is embedded in the template for portability (single file output)
- Test design changes with `templates/report_mockup.html` first
**Output:**
- Standalone HTML file (no external dependencies)
- Can be opened directly in any browser
- Dark theme optimized for readability
- Screenshot links are relative paths (keep output directory structure intact)
### Customizing Screenshot Capture
**Change viewport size** (src/screenshot_capture.py:35):
```python
self.viewport = viewport or {'width': 1920, 'height': 1080} # Full HD
```
**Change timeout** (src/screenshot_capture.py:34):
```python
self.timeout = timeout * 1000 # Default is 15 seconds
# Pass different value when initializing: ScreenshotCapture(..., timeout=30)
```
**Capture full-page screenshots** (src/screenshot_capture.py:173):
```python
page.screenshot(path=str(screenshot_path), type='png', full_page=True)
```
**Change wait strategy** (src/screenshot_capture.py:170):
```python
# Options: 'load', 'domcontentloaded', 'networkidle', 'commit'
page.goto(url, wait_until='load', timeout=self.timeout)
```
**Add custom request headers** (src/screenshot_capture.py:157-161):
```python
context = self.browser.new_context(
viewport=self.viewport,
ignore_https_errors=True,
user_agent='CustomUserAgent/1.0',
extra_http_headers={'Authorization': 'Bearer token'}
)
```
**Disable screenshot capture entirely**:
In src/scanner.py:scan(), comment out or skip initialization:
```python
# self.screenshot_capture = ScreenshotCapture(...)
self.screenshot_capture = None # This disables all screenshots
```
**Add authentication** (for services requiring login):
In src/screenshot_capture.py:capture(), before taking screenshot:
```python
# Navigate to login page first
page.goto(f"{protocol}://{ip}:{port}/login")
page.fill('#username', 'admin')
page.fill('#password', 'password')
page.click('#login-button')
page.wait_for_url(f"{protocol}://{ip}:{port}/dashboard")
# Then take screenshot
page.screenshot(path=str(screenshot_path), type='png')
```
### Performance Optimization
Current bottlenecks:
1. **Port scanning**: ~30 seconds for 2 IPs (65535 ports each at 10k pps)
2. **Service detection**: ~20-60 seconds per IP with open ports
3. **HTTP/HTTPS analysis**: ~5-10 seconds per web service (includes SSL/TLS analysis)
4. **Screenshot capture**: ~5-15 seconds per web service (depends on page load time)
Optimization strategies:
- Parallelize nmap scans across IPs (currently sequential)
- Parallelize HTTP/HTTPS analysis and screenshot capture across services using ThreadPoolExecutor
- Reduce port range for faster scanning (if full range not needed)
- Lower nmap intensity (trade accuracy for speed)
- Skip service detection on high ports (>1024) if desired
- Reduce SSL/TLS analysis scope (e.g., test only TLS 1.2+ if legacy support not needed)
- Adjust HTTP/HTTPS detection timeout (currently 5 seconds in src/scanner.py:510)
- Adjust screenshot timeout (currently 15 seconds in src/screenshot_capture.py:34)
- Disable screenshot capture for faster scans (set screenshot_capture to None)
## HTML Report Generation (✅ Implemented)
SneakyScanner automatically generates comprehensive HTML reports after every scan, along with JSON reports and ZIP archives.
**Automatic Generation:**
- HTML reports are created automatically by `generate_outputs()` method after scan completes
- All outputs (JSON, HTML, ZIP) share the same timestamp for correlation
- Graceful error handling: If HTML generation fails, scan continues with JSON output
**Manual Generation (Optional):**
```bash
# Manually generate HTML report from existing JSON scan output
python3 src/report_generator.py output/scan_report_20251113_175235.json
# Specify custom output path
python3 src/report_generator.py output/scan.json custom_report.html
```
**Implemented Features:**
- ✅ Dark theme with slate/grey color scheme for easy reading
- ✅ Summary dashboard with scan statistics, drift alerts, and security warnings
- ✅ Site-by-site breakdown with IP grouping
- ✅ Service details with expandable cards (click to expand)
- ✅ Visual badges for expected vs. unexpected services (green/red/yellow)
- ✅ SSL/TLS certificate details with expandable sections
- ✅ TLS version support display with cipher suites
- ✅ Certificate expiration warnings (highlighted for <30 days)
- ✅ Weak TLS version detection (TLS 1.0/1.1)
- ✅ Self-signed certificate identification
- ✅ Screenshot links (referenced, not embedded)
- ✅ Missing expected services clearly marked
- ✅ UDP port handling:
- Expected UDP ports shown with green "Expected" badge
- Unexpected UDP ports shown with red "Unexpected" badge
- Missing expected UDP ports shown with yellow "Missing" badge
- Note displayed that service detection not available for UDP
- ✅ Responsive layout for different screen sizes
- ✅ No JavaScript dependencies - pure HTML/CSS with minimal vanilla JS
- ✅ Optimized hover effects for table rows (lighter background + blue left border)
**Template Architecture:**
- `templates/report_template.html` - Jinja2 template for dynamic report generation
- `templates/report_mockup.html` - Static mockup for design testing
- Custom CSS with dark slate theme (#0f172a background, #60a5fa accents)
- Expandable service details and SSL/TLS sections
- Color-coded status badges (expected=green, unexpected=red, missing=yellow)
**HTMLReportGenerator Class** (`src/report_generator.py`):
- Loads JSON scan reports
- Calculates summary statistics (IPs, ports, services, screenshots)
- Identifies drift alerts (unexpected/missing ports and services)
- Identifies security warnings (expiring certs, weak TLS, self-signed certs, high ports)
- Renders Jinja2 template with context data
- Outputs standalone HTML file (no server required)
**Future Enhancements for HTML Reports:**
- Sortable/filterable tables with JavaScript
- Timeline view of scan history
- Scan comparison (diff between two reports)
- Export to PDF capability
- Interactive charts/graphs for trends
- Embedded screenshot thumbnails (currently links only)
## Planned Features (Future Development)
The following features are planned for future implementation:
### 1. Comparison Reports (Scan Diffs)
Generate reports showing changes between scans over time.
**Features:**
- Compare two scan reports
- Highlight new/removed services
- Track certificate changes
- Detect TLS configuration drift
- Show port changes
### 3. Additional Enhancements
- **Email Notifications**: Alert on unexpected changes or certificate expirations
- **Scheduled Scanning**: Automated periodic scans with cron integration
- **Vulnerability Detection**: Integration with CVE databases for known vulnerabilities
- **API Mode**: REST API for triggering scans and retrieving results
- **Multi-threading**: Parallel scanning of multiple IPs for better performance
## Development Notes
### Current Dependencies
- PyYAML==6.0.1 (YAML parsing)
- python-libnmap==0.7.3 (nmap XML parsing)
- sslyze==6.0.0 (SSL/TLS analysis)
- playwright==1.40.0 (webpage screenshot capture)
- Jinja2==3.1.2 (HTML report template engine)
- Built-in: socket, ssl, subprocess, xml.etree.ElementTree, logging, json, pathlib, datetime, zipfile
- System: chromium, chromium-driver (installed via Dockerfile)
### For Future Enhancements, May Need:
- weasyprint or pdfkit for PDF export
- Chart.js or Plotly for interactive visualizations
### Key Files to Modify for New Features:
1. **src/scanner.py** - Core scanning logic (add new phases/methods)
2. **src/screenshot_capture.py** - ✅ Implemented: Webpage screenshot capture module
3. **src/report_generator.py** - ✅ Implemented: HTML report generation with Jinja2 templates
4. **templates/** - ✅ Implemented: Jinja2 templates for HTML reports
- `report_template.html` - Main report template with dark theme
- `report_mockup.html` - Static mockup for design testing
5. **requirements.txt** - Add new dependencies
6. **Dockerfile** - Install additional system dependencies (browsers, etc.)
### Testing Strategy for New Features:
**Screenshot Capture Testing** (✅ Implemented):
1. Test with HTTP services (port 80, 8080, etc.)
2. Test with HTTPS services with valid certificates (port 443, 8443)
3. Test with HTTPS services with self-signed certificates
4. Test with non-standard web ports (e.g., Proxmox on 8006)
5. Test with slow-loading pages (verify 15s timeout works)
6. Test with services that return errors (404, 500, etc.)
7. Verify screenshot files are created with correct naming
8. Verify JSON references point to correct screenshot files
9. Verify browser cleanup occurs properly (no zombie processes)
10. Test with multiple IPs and services to ensure browser reuse works
**HTML Report Testing** (Planned):
1. Validate HTML report rendering across browsers
2. Ensure large scans don't cause memory issues with screenshots
3. Test report generation with missing/incomplete data
4. Verify all URLs and links work in generated reports
5. Test embedded screenshots display correctly
## Troubleshooting
### Screenshot Capture Issues
**Problem**: Screenshots not being captured
- **Check**: Verify Chromium installed: `chromium --version` in container
- **Check**: Verify Playwright browsers installed: `playwright install --dry-run chromium`
- **Check**: Look for browser launch errors in stderr output
- **Solution**: Rebuild Docker image ensuring Dockerfile steps complete
**Problem**: "Failed to launch browser" error
- **Check**: Ensure container has sufficient memory (Chromium needs ~200MB)
- **Check**: Docker runs with `--privileged` or appropriate capabilities
- **Solution**: Add `--shm-size=2gb` to docker run command if `/dev/shm` is too small
**Problem**: Screenshots timing out
- **Check**: Network connectivity to target services
- **Check**: Services actually serve webpages (not just open ports)
- **Solution**: Increase timeout in `src/screenshot_capture.py:34` if needed
- **Solution**: Check service responds to HTTP requests: `curl -I http://IP:PORT`
**Problem**: Screenshots are blank/empty
- **Check**: Service returns valid HTML (not just TCP banner)
- **Check**: Page requires JavaScript (may need longer wait time)
- **Solution**: Change `wait_until` strategy from `'networkidle'` to `'load'` or `'domcontentloaded'`
**Problem**: HTTPS certificate errors despite `ignore_https_errors=True`
- **Check**: System certificates up to date in container
- **Solution**: This should not happen; file an issue if it does
### HTML Report Generation Issues
**Problem**: "Template not found" error
- **Check**: Verify `templates/report_template.html` exists
- **Check**: Running script from correct directory (project root)
- **Solution**: Ensure template directory structure is intact: `templates/report_template.html`
**Problem**: Report generated but looks broken/unstyled
- **Check**: Browser opens HTML file correctly (not viewing source)
- **Check**: CSS is embedded in template (should be in `<style>` tags)
- **Solution**: Try different browser (Chrome, Firefox, Edge)
**Problem**: UDP ports not showing in report
- **Check**: JSON report contains UDP port data in `actual.udp_ports`
- **Solution**: Regenerate report with updated template (template was fixed to show UDP ports)
- **Note**: Expected behavior - UDP ports show with note that service detection unavailable
**Problem**: Screenshot links broken in HTML report
- **Check**: Screenshot directory exists in same parent directory as HTML file
- **Check**: Screenshot paths in JSON are relative (not absolute)
- **Solution**: Keep HTML report and screenshot directory together (don't move HTML file alone)
- **Example**: If HTML is at `output/scan_report.html`, screenshots should be at `output/scan_report_screenshots/`
**Problem**: "Invalid JSON" error when generating report
- **Check**: JSON report file is valid: `python3 -m json.tool output/scan_report.json`
- **Check**: JSON file not truncated or corrupted
- **Solution**: Re-run scan to generate fresh JSON report
**Problem**: Expandable sections not working (click doesn't expand)
- **Check**: JavaScript is enabled in browser
- **Check**: Browser console for JavaScript errors (F12 → Console)
- **Solution**: This indicates template corruption; re-generate from `templates/report_template.html`
### Nmap/Masscan Issues
**Problem**: No ports discovered
- **Check**: Firewall rules allow scanning
- **Check**: Targets are actually online (`ping` test)
- **Solution**: Run manual masscan: `masscan -p80,443 192.168.1.10 --rate 1000`
**Problem**: "Operation not permitted" error
- **Check**: Container runs with `--privileged` or `CAP_NET_RAW`
- **Solution**: Add `--privileged` flag to docker run command
**Problem**: Service detection not working
- **Check**: Nmap can connect to ports: `nmap -p 80 192.168.1.10`
- **Check**: Services actually respond to nmap probes (some firewall/IPS block)
- **Solution**: Adjust nmap intensity or timeout values

View File

@@ -23,8 +23,8 @@ RUN git clone https://github.com/robertdavidgraham/masscan /tmp/masscan && \
WORKDIR /app WORKDIR /app
# Copy requirements and install Python dependencies # Copy requirements and install Python dependencies
COPY requirements.txt . COPY app/requirements.txt .
COPY requirements-web.txt . COPY app/requirements-web.txt .
RUN pip install --no-cache-dir -r requirements.txt && \ RUN pip install --no-cache-dir -r requirements.txt && \
pip install --no-cache-dir -r requirements-web.txt pip install --no-cache-dir -r requirements-web.txt
@@ -33,12 +33,12 @@ RUN pip install --no-cache-dir -r requirements.txt && \
RUN playwright install chromium RUN playwright install chromium
# Copy application code # Copy application code
COPY src/ ./src/ COPY app/src/ ./src/
COPY templates/ ./templates/ COPY app/templates/ ./templates/
COPY web/ ./web/ COPY app/web/ ./web/
COPY migrations/ ./migrations/ COPY app/migrations/ ./migrations/
COPY alembic.ini . COPY app/alembic.ini .
COPY init_db.py . COPY app/init_db.py .
# Create required directories # Create required directories
RUN mkdir -p /app/output /app/logs RUN mkdir -p /app/output /app/logs

7
LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2025 Phillip Tarrant
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

516
README.md
View File

@@ -1,404 +1,202 @@
# SneakyScanner # SneakyScanner
A dockerized network scanning tool that uses masscan for fast port discovery, nmap for service detection, and Playwright for webpage screenshots to perform comprehensive infrastructure audits. SneakyScanner accepts YAML-based configuration files to define sites, IPs, and expected network behavior, then generates machine-readable JSON reports with detailed service information and webpage screenshots. A comprehensive network scanning and infrastructure monitoring platform with web interface and CLI scanner. SneakyScanner uses masscan for fast port discovery, nmap for service detection, sslyze for SSL/TLS analysis, and Playwright for webpage screenshots to perform comprehensive infrastructure audits.
## Features **Primary Interface**: Web Application (Flask-based GUI)
**Scripting/Automation**: REST API (see [API Reference](docs/API_REFERENCE.md))
### Network Discovery & Port Scanning ---
- **YAML-based configuration** for defining scan targets and expectations
- **Comprehensive scanning using masscan**:
- Ping/ICMP echo detection (masscan --ping)
- TCP port scanning (all 65535 ports at 10,000 pps)
- UDP port scanning (all 65535 ports at 10,000 pps)
- Fast network-wide discovery in seconds
### Service Detection & Enumeration ## Key Features
- **Service detection using nmap**:
- Identifies services running on discovered TCP ports
- Extracts product names and versions (e.g., "OpenSSH 8.2p1", "nginx 1.18.0")
- Provides detailed service information including extra attributes
- Balanced intensity level (5) for accuracy and speed
### Security Assessment - 🌐 **Web Dashboard** - Modern web UI for scan management, scheduling, and historical analysis
- **HTTP/HTTPS analysis and SSL/TLS security assessment**: - 📊 **Database Storage** - SQLite-based scan history with trend analysis and comparison
- Detects HTTP vs HTTPS on web services - **Scheduled Scans** - Cron-based automated scanning with APScheduler
- Extracts SSL certificate details (subject, issuer, expiration, SANs) - 🔧 **Config Creator** - Web-based target configuration builder for quick setup
- Calculates days until certificate expiration for monitoring - 🔍 **Network Discovery** - Fast port scanning with masscan (all 65535 ports, TCP/UDP)
- Tests TLS version support (TLS 1.0, 1.1, 1.2, 1.3) - 🎯 **Service Detection** - Nmap-based service enumeration with version detection
- Lists all accepted cipher suites for each supported TLS version - 🔒 **SSL/TLS Analysis** - Certificate extraction, TLS version testing, cipher suite analysis
- Identifies weak cryptographic configurations - 📸 **Screenshot Capture** - Automated webpage screenshots for all discovered web services
- 📈 **Drift Detection** - Expected vs. actual infrastructure comparison
- 📋 **Multi-Format Reports** - JSON, HTML, and ZIP archives with visual reports
- 🔐 **Authentication** - Session-based login for single-user deployments
- 🔔 **Webhook Alerts** - Real-time notifications via Slack, Discord, PagerDuty, and custom integrations
- ⚠️ **Alert Rules** - Automated detection of infrastructure misconfigurations and anomalies
### Visual Documentation ---
- **Webpage screenshot capture** (NEW):
- Automatically captures screenshots of all discovered web services (HTTP/HTTPS)
- Uses Playwright with headless Chromium browser
- Viewport screenshots (1280x720) for consistent sizing
- 15-second timeout per page with graceful error handling
- Handles self-signed certificates without errors
- Saves screenshots as PNG files with references in JSON reports
- Screenshots organized in timestamped directories
- Browser reuse for optimal performance
### Reporting & Output
- **Automatic multi-format output** after each scan:
- Machine-readable JSON reports for post-processing
- Human-readable HTML reports with dark theme
- ZIP archives containing all outputs for easy sharing
- **HTML report features**:
- Comprehensive reports with dark theme for easy reading
- Summary dashboard with scan statistics, drift alerts, and security warnings
- Site-by-site breakdown with expandable service details
- Visual badges for expected vs. unexpected services
- SSL/TLS certificate details with expiration warnings
- Automatically generated after every scan
- **Dockerized** for consistent execution environment and root privilege isolation
- **Expected vs. Actual comparison** to identify infrastructure drift
- Timestamped reports with complete scan duration metrics
## Requirements
- Docker
- Docker Compose (optional, for easier usage)
## Quick Start ## Quick Start
### Using Docker Compose ### Web Application
1. Create or modify a configuration file in `configs/`: **Easy Setup (One Command):**
```yaml
title: "My Infrastructure Scan"
sites:
- name: "Web Servers"
ips:
- address: "192.168.1.10"
expected:
ping: true
tcp_ports: [22, 80, 443]
udp_ports: []
```
2. Build and run:
```bash ```bash
docker-compose build # 1. Clone repository
docker-compose up git clone <repository-url>
cd SneakyScan
# 2. Run setup script
./setup.sh
# 3. Access web interface at http://localhost:5000
``` ```
3. Check results in the `output/` directory: The setup script will:
- `scan_report_YYYYMMDD_HHMMSS.json` - JSON report - Generate secure keys automatically
- `scan_report_YYYYMMDD_HHMMSS.html` - HTML report - Create required directories
- `scan_report_YYYYMMDD_HHMMSS.zip` - ZIP archive - Build and start the Docker containers
- `scan_report_YYYYMMDD_HHMMSS_screenshots/` - Screenshots directory - Initialize the database on first run
- Display your login credentials
## Scan Performance **Manual Setup (Alternative):**
SneakyScanner uses a five-phase approach for comprehensive scanning:
1. **Ping Scan** (masscan): ICMP echo detection - ~1-2 seconds
2. **TCP Port Discovery** (masscan): Scans all 65535 TCP ports at 10,000 packets/second - ~13 seconds per 2 IPs
3. **UDP Port Discovery** (masscan): Scans all 65535 UDP ports at 10,000 packets/second - ~13 seconds per 2 IPs
4. **Service Detection** (nmap): Identifies services on discovered TCP ports - ~20-60 seconds per IP with open ports
5. **HTTP/HTTPS Analysis** (Playwright, SSL/TLS): Detects web protocols, captures screenshots, and analyzes certificates - ~10-20 seconds per web service
**Example**: Scanning 2 IPs with 10 open ports each (including 2-3 web services) typically takes 2-3 minutes total.
### Using Docker Directly
1. Build the image:
```bash ```bash
docker build -t sneakyscanner . # 1. Clone repository
git clone <repository-url>
cd SneakyScan
# 2. Configure environment
cp .env.example .env
# Edit .env and set SECRET_KEY, SNEAKYSCANNER_ENCRYPTION_KEY, and INITIAL_PASSWORD
# 3. Build and start (database auto-initializes on first run)
docker compose up --build -d
# 4. Access web interface
# Open http://localhost:5000
``` ```
2. Run a scan: **See [Deployment Guide](docs/DEPLOYMENT.md) for detailed setup instructions.**
```bash ---
docker run --rm --privileged --network host \
-v $(pwd)/configs:/app/configs:ro \
-v $(pwd)/output:/app/output \
sneakyscanner /app/configs/your-config.yaml
```
## Configuration File Format ## Documentation
The YAML configuration file defines the scan parameters: ### User Guides
- **[Deployment Guide](docs/DEPLOYMENT.md)** - Installation, configuration, and production deployment
- **[API Reference](docs/API_REFERENCE.md)** - Complete REST API documentation for scripting and automation
```yaml ### Developer Resources
title: "Scan Title" # Required: Report title - **[Roadmap](docs/ROADMAP.md)** - Project roadmap, architecture, and planned features
sites: # Required: List of sites to scan
- name: "Site Name"
ips:
- address: "192.168.1.10"
expected:
ping: true # Expected ping response
tcp_ports: [22, 80] # Expected TCP ports
udp_ports: [53] # Expected UDP ports
```
See `configs/example-site.yaml` for a complete example. ---
## Output Format ## Current Status
After each scan completes, SneakyScanner automatically generates three output formats: **Latest Version**: Phase 5 Complete ✅
**Last Updated**: 2025-11-19
1. **JSON Report** (`scan_report_YYYYMMDD_HHMMSS.json`): Machine-readable scan data with all discovered services, ports, and SSL/TLS information ### Completed Phases
2. **HTML Report** (`scan_report_YYYYMMDD_HHMMSS.html`): Human-readable report with dark theme, summary dashboard, and detailed service breakdown
3. **ZIP Archive** (`scan_report_YYYYMMDD_HHMMSS.zip`): Contains JSON report, HTML report, and all screenshots for easy sharing and archival
All files share the same timestamp for easy correlation. Screenshots are saved in a subdirectory (`scan_report_YYYYMMDD_HHMMSS_screenshots/`) and included in the ZIP archive. The report includes the total scan duration (in seconds) covering all phases: ping scan, TCP/UDP port discovery, service detection, screenshot capture, and report generation. -**Phase 1**: Database schema, SQLAlchemy models, settings system
-**Phase 2**: REST API, background jobs, authentication, web UI
-**Phase 3**: Dashboard, scheduling, trend charts
-**Phase 4**: Config creator, target editor, config management UI
-**Phase 5**: Webhooks & alerting, notification templates, alert rules
```json ### Next Up: Phase 6 - CLI as API Client
{
"title": "Sneaky Infra Scan",
"scan_time": "2024-01-15T10:30:00Z",
"scan_duration": 95.3,
"config_file": "/app/configs/example-site.yaml",
"sites": [
{
"name": "Production Web Servers",
"ips": [
{
"address": "192.168.1.10",
"expected": {
"ping": true,
"tcp_ports": [22, 80, 443],
"udp_ports": [53]
},
"actual": {
"ping": true,
"tcp_ports": [22, 80, 443, 3000],
"udp_ports": [53],
"services": [
{
"port": 22,
"protocol": "tcp",
"service": "ssh",
"product": "OpenSSH",
"version": "8.2p1"
},
{
"port": 80,
"protocol": "tcp",
"service": "http",
"product": "nginx",
"version": "1.18.0",
"http_info": {
"protocol": "http",
"screenshot": "scan_report_20250115_103000_screenshots/192_168_1_10_80.png"
}
},
{
"port": 443,
"protocol": "tcp",
"service": "https",
"product": "nginx",
"http_info": {
"protocol": "https",
"screenshot": "scan_report_20250115_103000_screenshots/192_168_1_10_443.png",
"ssl_tls": {
"certificate": {
"subject": "CN=example.com",
"issuer": "CN=Let's Encrypt Authority X3,O=Let's Encrypt,C=US",
"serial_number": "123456789012345678901234567890",
"not_valid_before": "2025-01-01T00:00:00+00:00",
"not_valid_after": "2025-04-01T23:59:59+00:00",
"days_until_expiry": 89,
"sans": ["example.com", "www.example.com"]
},
"tls_versions": {
"TLS 1.0": {
"supported": false,
"cipher_suites": []
},
"TLS 1.1": {
"supported": false,
"cipher_suites": []
},
"TLS 1.2": {
"supported": true,
"cipher_suites": [
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
]
},
"TLS 1.3": {
"supported": true,
"cipher_suites": [
"TLS_AES_256_GCM_SHA384",
"TLS_AES_128_GCM_SHA256"
]
}
}
}
}
},
{
"port": 3000,
"protocol": "tcp",
"service": "http",
"product": "Node.js",
"http_info": {
"protocol": "http"
}
}
]
}
}
]
}
]
}
```
## Screenshot Capture Details **Goal**: Create a thin CLI client that calls the Flask API for scan operations, enabling scripting and automation workflows while leveraging centralized database storage and web dashboard features.
SneakyScanner automatically captures webpage screenshots for all discovered HTTP and HTTPS services, providing visual documentation of your infrastructure. **Planned Features**:
- API token authentication for CLI access
- Remote scan triggering and status polling
- Centralized scan history accessible via web dashboard
- Scriptable automation workflows
### How It Works See [Roadmap](docs/ROADMAP.md) for complete feature timeline and future phases.
1. **Automatic Detection**: During the HTTP/HTTPS analysis phase, SneakyScanner identifies web services based on: ---
- Nmap service detection results (http, https, ssl, http-proxy)
- Common web ports (80, 443, 8000, 8006, 8080, 8081, 8443, 8888, 9443)
2. **Screenshot Capture**: For each web service: ## Architecture
- Launches headless Chromium browser (once per scan, reused for all screenshots)
- Navigates to the service URL (HTTP or HTTPS)
- Waits for network to be idle (up to 15 seconds)
- Captures viewport screenshot (1280x720 pixels)
- Handles SSL certificate errors gracefully (e.g., self-signed certificates)
3. **Storage**: Screenshots are saved as PNG files:
- Directory: `output/scan_report_YYYYMMDD_HHMMSS_screenshots/`
- Filename format: `{ip}_{port}.png` (e.g., `192_168_1_10_443.png`)
- Referenced in JSON report under `http_info.screenshot`
### Screenshot Configuration
Default settings (configured in `src/screenshot_capture.py`):
- **Viewport size**: 1280x720 (captures visible area only, not full page)
- **Timeout**: 15 seconds per page load
- **Browser**: Chromium (headless mode)
- **SSL handling**: Ignores HTTPS errors (works with self-signed certificates)
- **User agent**: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
### Error Handling
Screenshots are captured on a best-effort basis:
- If a screenshot fails (timeout, connection error, etc.), the scan continues
- Failed screenshots are logged but don't stop the scan
- Services without screenshots simply omit the `screenshot` field in JSON output
## HTML Report Generation
SneakyScanner automatically generates comprehensive HTML reports after each scan, providing an easy-to-read visual interface for analyzing scan results.
### Automatic Generation
HTML reports are automatically created after every scan completes, along with JSON reports and ZIP archives. All three outputs share the same timestamp and are saved to the `output/` directory.
### Manual Generation (Optional)
You can also manually generate HTML reports from existing JSON scan data:
```bash
# Generate HTML report (creates report in same directory as JSON)
python3 src/report_generator.py output/scan_report_20251113_175235.json
# Specify custom output path
python3 src/report_generator.py output/scan_report.json /path/to/custom_report.html
```
### Report Features
The generated HTML report includes:
**Summary Dashboard**:
- **Scan Statistics**: Total IPs scanned, TCP/UDP ports found, services identified, web services, screenshots captured
- **Drift Alerts**: Unexpected TCP/UDP ports, missing expected services, new services detected
- **Security Warnings**: Expiring certificates (<30 days), weak TLS versions (1.0/1.1), self-signed certificates, high port services (>10000)
**Site-by-Site Breakdown**:
- Organized by logical site grouping from configuration
- Per-IP sections with status badges (ping, port drift summary)
- Service tables with expandable details (click any row to expand)
- Visual badges: green (expected), red (unexpected), yellow (missing/warning)
**Service Details** (click to expand):
- Product name, version, extra information, OS type
- HTTP/HTTPS protocol detection
- Screenshot links for web services
- SSL/TLS certificate details (expandable):
- Subject, issuer, validity dates, serial number
- Days until expiration with color-coded warnings
- Subject Alternative Names (SANs)
- TLS version support (1.0, 1.1, 1.2, 1.3) with cipher suites
- Weak TLS and self-signed certificate warnings
**UDP Port Handling**:
- Expected UDP ports shown with green "Expected" badge
- Unexpected UDP ports shown with red "Unexpected" badge
- Missing expected UDP ports shown with yellow "Missing" badge
- Note: Service detection not available for UDP (nmap limitation)
**Design**:
- Dark theme with slate/grey color scheme for comfortable reading
- Responsive layout works on different screen sizes
- No external dependencies - single HTML file
- Minimal JavaScript for expand/collapse functionality
- Optimized hover effects for table rows
### Report Output
The HTML report is a standalone file that can be:
- Opened directly in any web browser (Chrome, Firefox, Safari, Edge)
- Shared via email or file transfer
- Archived for compliance or historical comparison
- Viewed without an internet connection or web server
Screenshot links in the report are relative paths, so keep the report and screenshot directory together.
## Project Structure
``` ```
SneakyScanner/ ┌─────────────────────────────────────────────────────────────┐
├── src/ │ Flask Web Application │
├── scanner.py # Main scanner application ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
├── screenshot_capture.py # Webpage screenshot capture module │ Web UI │ │ REST API │ │ Scheduler │ │
└── report_generator.py # HTML report generation module │ (Dashboard) │ │ (JSON/CRUD) │ │ (APScheduler) │ │
├── templates/ │ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
├── report_template.html # Jinja2 template for HTML reports │ │ │ │
└── report_mockup.html # Static mockup for design testing └─────────────────┴────────────────────┘ │
├── configs/ │ │ │
└── example-site.yaml # Example configuration ┌────────▼────────┐ │
├── output/ # Scan results │ SQLAlchemy │ │
├── scan_report_*.json # JSON reports with timestamps │ (ORM Layer) │ │
├── scan_report_*.html # HTML reports (generated from JSON) └────────┬────────┘ │
└── scan_report_*_screenshots/ # Screenshot directories │ │
├── Dockerfile │ ┌────────▼────────┐ │
├── docker-compose.yml │ │ SQLite3 DB │ │
├── requirements.txt │ │ (scan history) │ │
├── CLAUDE.md # Developer documentation │ └─────────────────┘ │
└── README.md └───────────────────────────┬─────────────────────────────────┘
┌──────────▼──────────┐
│ Scanner Engine │
│ (scanner.py) │
│ ┌────────────────┐ │
│ │ Masscan/Nmap │ │
│ │ Playwright │ │
│ │ sslyze │ │
│ └────────────────┘ │
└─────────────────────┘
``` ```
**Technology Stack**:
- **Backend**: Flask 3.x, SQLAlchemy 2.x, SQLite3, APScheduler 3.x
- **Frontend**: Jinja2, Bootstrap 5, Chart.js, Vanilla JavaScript
- **Scanner**: Masscan, Nmap, Playwright (Chromium), sslyze
- **Deployment**: Docker Compose, Gunicorn
---
## Security Notice ## Security Notice
This tool requires: ⚠️ **Important**: This tool requires:
- `--privileged` flag or `CAP_NET_RAW` capability for masscan and nmap raw socket access - `--privileged` flag or `CAP_NET_RAW` capability for raw socket access (masscan/nmap)
- `--network host` for direct network access - `--network host` for direct network access
Only use this tool on networks you own or have explicit authorization to scan. Unauthorized network scanning may be illegal in your jurisdiction. **Only use this tool on networks you own or have explicit authorization to scan.** Unauthorized network scanning may be illegal in your jurisdiction.
## Future Enhancements ### Security Best Practices
- **Enhanced HTML Reports**: 1. Run on dedicated scan server (not production systems)
- Sortable/filterable service tables with JavaScript 2. Restrict network access with firewall rules
- Interactive charts and graphs for trends 3. Use strong passwords and encryption keys
- Timeline view of scan history 4. Enable HTTPS in production (reverse proxy recommended)
- Embedded screenshot thumbnails (currently links only) 5. Regularly update Docker images and dependencies
- Export to PDF capability
- **Comparison Reports**: Generate diff reports showing changes between scans See [Deployment Guide](docs/DEPLOYMENT.md) for production security checklist.
- **Email Notifications**: Alert on unexpected changes or certificate expirations
- **Scheduled Scanning**: Automated periodic scans with cron integration ---
- **Vulnerability Detection**: Integration with CVE databases for known vulnerabilities
## Contributing
This is a personal project. For bugs or feature requests:
1. Check existing issues
2. Create detailed bug reports with reproduction steps
3. Submit pull requests with tests
---
## License
MIT License - See LICENSE file for details
---
## Support
**Documentation**:
- [Deployment Guide](docs/DEPLOYMENT.md)
- [API Reference](docs/API_REFERENCE.md)
- [Roadmap](docs/ROADMAP.md)
**Issues**: email me ptarrant at gmail dot com
---
**Version**: 1.0.0-beta
**Last Updated**: 2025-11-19

80
app/docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,80 @@
#!/bin/bash
set -e
# SneakyScanner Docker Entrypoint Script
# This script ensures the database is initialized before starting the Flask app
DB_PATH="${DATABASE_URL#sqlite:///}" # Extract path from sqlite:////app/data/sneakyscanner.db
DB_DIR=$(dirname "$DB_PATH")
INIT_MARKER="$DB_DIR/.db_initialized"
PASSWORD_FILE="/app/logs/admin_password.txt" # Save to logs dir (mounted, no permission issues)
echo "=== SneakyScanner Startup ==="
echo "Database path: $DB_PATH"
echo "Database directory: $DB_DIR"
# Ensure database directory exists
mkdir -p "$DB_DIR"
# Check if this is the first run (database doesn't exist or not initialized)
if [ ! -f "$DB_PATH" ] || [ ! -f "$INIT_MARKER" ]; then
echo ""
echo "=== First Run Detected ==="
echo "Initializing database..."
# Set default password from environment or generate a random one
if [ -z "$INITIAL_PASSWORD" ]; then
echo "INITIAL_PASSWORD not set, generating random password..."
# Generate a 32-character alphanumeric password
INITIAL_PASSWORD=$(cat /dev/urandom | tr -dc 'A-Za-z0-9' | head -c 32)
# Ensure logs directory exists
mkdir -p /app/logs
echo "$INITIAL_PASSWORD" > "$PASSWORD_FILE"
echo "✓ Random password generated and saved to: ./logs/admin_password.txt"
SAVE_PASSWORD_MESSAGE=true
fi
# Run database initialization
python3 /app/init_db.py \
--db-url "$DATABASE_URL" \
--password "$INITIAL_PASSWORD" \
--no-migrations \
--force
# Create marker file to indicate successful initialization
if [ $? -eq 0 ]; then
touch "$INIT_MARKER"
echo "✓ Database initialized successfully"
echo ""
echo "=== IMPORTANT ==="
if [ "$SAVE_PASSWORD_MESSAGE" = "true" ]; then
echo "Login password saved to: ./logs/admin_password.txt"
echo "Password: $INITIAL_PASSWORD"
else
echo "Login password: $INITIAL_PASSWORD"
fi
echo "Please change this password after logging in!"
echo "=================="
echo ""
else
echo "✗ Database initialization failed!"
exit 1
fi
else
echo "Database already initialized, skipping init..."
fi
# Apply any pending migrations (if using migrations in future)
if [ -f "/app/alembic.ini" ]; then
echo "Checking for pending migrations..."
# Uncomment when ready to use migrations:
# alembic upgrade head
fi
echo ""
echo "=== Starting Flask Application ==="
echo "Flask will be available at http://localhost:5000"
echo ""
# Execute the main application
exec "$@"

View File

@@ -23,11 +23,112 @@ from alembic import command
from alembic.config import Config from alembic.config import Config
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from datetime import datetime, timezone
from web.models import Base from web.models import Base, AlertRule
from web.utils.settings import PasswordManager, SettingsManager from web.utils.settings import PasswordManager, SettingsManager
def init_default_alert_rules(session):
"""
Create default alert rules for Phase 5.
Args:
session: Database session
"""
print("Initializing default alert rules...")
# Check if alert rules already exist
existing_rules = session.query(AlertRule).count()
if existing_rules > 0:
print(f" Alert rules already exist ({existing_rules} rules), skipping...")
return
default_rules = [
{
'name': 'Unexpected Port Detection',
'rule_type': 'unexpected_port',
'enabled': True,
'threshold': None,
'email_enabled': False,
'webhook_enabled': False,
'severity': 'warning',
'filter_conditions': None,
'config_id': None
},
{
'name': 'Drift Detection',
'rule_type': 'drift_detection',
'enabled': True,
'threshold': None, # No threshold means alert on any drift
'email_enabled': False,
'webhook_enabled': False,
'severity': 'info',
'filter_conditions': None,
'config_id': None
},
{
'name': 'Certificate Expiry Warning',
'rule_type': 'cert_expiry',
'enabled': True,
'threshold': 30, # Alert when certs expire in 30 days
'email_enabled': False,
'webhook_enabled': False,
'severity': 'warning',
'filter_conditions': None,
'config_id': None
},
{
'name': 'Weak TLS Detection',
'rule_type': 'weak_tls',
'enabled': True,
'threshold': None,
'email_enabled': False,
'webhook_enabled': False,
'severity': 'warning',
'filter_conditions': None,
'config_id': None
},
{
'name': 'Host Down Detection',
'rule_type': 'ping_failed',
'enabled': True,
'threshold': None,
'email_enabled': False,
'webhook_enabled': False,
'severity': 'critical',
'filter_conditions': None,
'config_id': None
}
]
try:
for rule_data in default_rules:
rule = AlertRule(
name=rule_data['name'],
rule_type=rule_data['rule_type'],
enabled=rule_data['enabled'],
threshold=rule_data['threshold'],
email_enabled=rule_data['email_enabled'],
webhook_enabled=rule_data['webhook_enabled'],
severity=rule_data['severity'],
filter_conditions=rule_data['filter_conditions'],
config_id=rule_data['config_id'],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
session.add(rule)
print(f" ✓ Created rule: {rule.name}")
session.commit()
print(f"✓ Created {len(default_rules)} default alert rules")
except Exception as e:
print(f"✗ Failed to create default alert rules: {e}")
session.rollback()
raise
def init_database(db_url: str = "sqlite:///./sneakyscanner.db", run_migrations: bool = True): def init_database(db_url: str = "sqlite:///./sneakyscanner.db", run_migrations: bool = True):
""" """
Initialize the database schema and settings. Initialize the database schema and settings.
@@ -78,6 +179,10 @@ def init_database(db_url: str = "sqlite:///./sneakyscanner.db", run_migrations:
settings_manager = SettingsManager(session) settings_manager = SettingsManager(session)
settings_manager.init_defaults() settings_manager.init_defaults()
print("✓ Default settings initialized") print("✓ Default settings initialized")
# Initialize default alert rules
init_default_alert_rules(session)
except Exception as e: except Exception as e:
print(f"✗ Failed to initialize settings: {e}") print(f"✗ Failed to initialize settings: {e}")
session.rollback() session.rollback()
@@ -164,6 +269,9 @@ Examples:
# Use custom database URL # Use custom database URL
python3 init_db.py --db-url postgresql://user:pass@localhost/sneakyscanner python3 init_db.py --db-url postgresql://user:pass@localhost/sneakyscanner
# Force initialization without prompting (for Docker/scripts)
python3 init_db.py --force --password mysecret
# Verify existing database # Verify existing database
python3 init_db.py --verify-only python3 init_db.py --verify-only
""" """
@@ -192,6 +300,12 @@ Examples:
help='Create tables directly instead of using migrations' help='Create tables directly instead of using migrations'
) )
parser.add_argument(
'--force',
action='store_true',
help='Force initialization without prompting (for non-interactive environments)'
)
args = parser.parse_args() args = parser.parse_args()
# Check if database already exists # Check if database already exists
@@ -200,7 +314,7 @@ Examples:
db_path = args.db_url.replace('sqlite:///', '') db_path = args.db_url.replace('sqlite:///', '')
db_exists = Path(db_path).exists() db_exists = Path(db_path).exists()
if db_exists and not args.verify_only: if db_exists and not args.verify_only and not args.force:
response = input(f"\nDatabase already exists at {db_path}. Reinitialize? (y/N): ") response = input(f"\nDatabase already exists at {db_path}. Reinitialize? (y/N): ")
if response.lower() != 'y': if response.lower() != 'y':
print("Aborting.") print("Aborting.")

View File

@@ -69,8 +69,12 @@ def run_migrations_online() -> None:
) )
with connectable.connect() as connection: with connectable.connect() as connection:
# Enable batch mode for SQLite to support ALTER TABLE operations
# like DROP COLUMN which SQLite doesn't natively support
context.configure( context.configure(
connection=connection, target_metadata=target_metadata connection=connection,
target_metadata=target_metadata,
render_as_batch=True
) )
with context.begin_transaction(): with context.begin_transaction():

View File

@@ -0,0 +1,392 @@
"""Initial schema for SneakyScanner
Revision ID: 001
Revises: None
Create Date: 2025-12-24
This is the complete initial schema for SneakyScanner. All tables are created
in the correct order to satisfy foreign key constraints.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
"""Create all tables for SneakyScanner."""
# =========================================================================
# Settings Table (no dependencies)
# =========================================================================
op.create_table(
'settings',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('key', sa.String(length=255), nullable=False, comment='Setting key'),
sa.Column('value', sa.Text(), nullable=True, comment='Setting value (JSON for complex values)'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='Last modification time'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key')
)
op.create_index('ix_settings_key', 'settings', ['key'], unique=True)
# =========================================================================
# Reusable Site Definition Tables
# =========================================================================
op.create_table(
'sites',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=255), nullable=False, comment='Unique site name'),
sa.Column('description', sa.Text(), nullable=True, comment='Site description'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Site creation time'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='Last modification time'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index('ix_sites_name', 'sites', ['name'], unique=True)
op.create_table(
'site_ips',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('site_id', sa.Integer(), nullable=False, comment='FK to sites'),
sa.Column('ip_address', sa.String(length=45), nullable=False, comment='IPv4 or IPv6 address'),
sa.Column('expected_ping', sa.Boolean(), nullable=True, comment='Expected ping response'),
sa.Column('expected_tcp_ports', sa.Text(), nullable=True, comment='JSON array of expected TCP ports'),
sa.Column('expected_udp_ports', sa.Text(), nullable=True, comment='JSON array of expected UDP ports'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='IP creation time'),
sa.ForeignKeyConstraint(['site_id'], ['sites.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('site_id', 'ip_address', name='uix_site_ip_address')
)
op.create_index('ix_site_ips_site_id', 'site_ips', ['site_id'])
# =========================================================================
# Scan Configuration Tables
# =========================================================================
op.create_table(
'scan_configs',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('title', sa.String(length=255), nullable=False, comment='Configuration title'),
sa.Column('description', sa.Text(), nullable=True, comment='Configuration description'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Config creation time'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='Last modification time'),
sa.PrimaryKeyConstraint('id')
)
op.create_table(
'scan_config_sites',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('config_id', sa.Integer(), nullable=False),
sa.Column('site_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Association creation time'),
sa.ForeignKeyConstraint(['config_id'], ['scan_configs.id']),
sa.ForeignKeyConstraint(['site_id'], ['sites.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('config_id', 'site_id', name='uix_config_site')
)
op.create_index('ix_scan_config_sites_config_id', 'scan_config_sites', ['config_id'])
op.create_index('ix_scan_config_sites_site_id', 'scan_config_sites', ['site_id'])
# =========================================================================
# Scheduling Tables
# =========================================================================
op.create_table(
'schedules',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=255), nullable=False, comment='Schedule name'),
sa.Column('config_id', sa.Integer(), nullable=True, comment='FK to scan_configs table'),
sa.Column('cron_expression', sa.String(length=100), nullable=False, comment='Cron-like schedule'),
sa.Column('enabled', sa.Boolean(), nullable=False, default=True, comment='Is schedule active?'),
sa.Column('last_run', sa.DateTime(), nullable=True, comment='Last execution time'),
sa.Column('next_run', sa.DateTime(), nullable=True, comment='Next scheduled execution'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Schedule creation time'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='Last modification time'),
sa.ForeignKeyConstraint(['config_id'], ['scan_configs.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_schedules_config_id', 'schedules', ['config_id'])
# =========================================================================
# Core Scan Tables
# =========================================================================
op.create_table(
'scans',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=False, comment='Scan start time (UTC)'),
sa.Column('duration', sa.Float(), nullable=True, comment='Total scan duration in seconds'),
sa.Column('status', sa.String(length=20), nullable=False, default='running', comment='running, finalizing, completed, failed, cancelled'),
sa.Column('config_id', sa.Integer(), nullable=True, comment='FK to scan_configs table'),
sa.Column('title', sa.Text(), nullable=True, comment='Scan title from config'),
sa.Column('json_path', sa.Text(), nullable=True, comment='Path to JSON report'),
sa.Column('html_path', sa.Text(), nullable=True, comment='Path to HTML report'),
sa.Column('zip_path', sa.Text(), nullable=True, comment='Path to ZIP archive'),
sa.Column('screenshot_dir', sa.Text(), nullable=True, comment='Path to screenshot directory'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Record creation time'),
sa.Column('triggered_by', sa.String(length=50), nullable=False, default='manual', comment='manual, scheduled, api'),
sa.Column('schedule_id', sa.Integer(), nullable=True, comment='FK to schedules if triggered by schedule'),
sa.Column('started_at', sa.DateTime(), nullable=True, comment='Scan execution start time'),
sa.Column('completed_at', sa.DateTime(), nullable=True, comment='Scan execution completion time'),
sa.Column('error_message', sa.Text(), nullable=True, comment='Error message if scan failed'),
sa.Column('current_phase', sa.String(length=50), nullable=True, comment='Current scan phase'),
sa.Column('total_ips', sa.Integer(), nullable=True, comment='Total number of IPs to scan'),
sa.Column('completed_ips', sa.Integer(), nullable=True, default=0, comment='Number of IPs completed'),
sa.ForeignKeyConstraint(['config_id'], ['scan_configs.id']),
sa.ForeignKeyConstraint(['schedule_id'], ['schedules.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_scans_timestamp', 'scans', ['timestamp'])
op.create_index('ix_scans_config_id', 'scans', ['config_id'])
op.create_table(
'scan_sites',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False),
sa.Column('site_name', sa.String(length=255), nullable=False, comment='Site name from config'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_scan_sites_scan_id', 'scan_sites', ['scan_id'])
op.create_table(
'scan_ips',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False),
sa.Column('site_id', sa.Integer(), nullable=False),
sa.Column('ip_address', sa.String(length=45), nullable=False, comment='IPv4 or IPv6 address'),
sa.Column('ping_expected', sa.Boolean(), nullable=True, comment='Expected ping response'),
sa.Column('ping_actual', sa.Boolean(), nullable=True, comment='Actual ping response'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id']),
sa.ForeignKeyConstraint(['site_id'], ['scan_sites.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scan_id', 'ip_address', name='uix_scan_ip')
)
op.create_index('ix_scan_ips_scan_id', 'scan_ips', ['scan_id'])
op.create_index('ix_scan_ips_site_id', 'scan_ips', ['site_id'])
op.create_table(
'scan_ports',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False),
sa.Column('ip_id', sa.Integer(), nullable=False),
sa.Column('port', sa.Integer(), nullable=False, comment='Port number (1-65535)'),
sa.Column('protocol', sa.String(length=10), nullable=False, comment='tcp or udp'),
sa.Column('expected', sa.Boolean(), nullable=True, comment='Was this port expected?'),
sa.Column('state', sa.String(length=20), nullable=False, default='open', comment='open, closed, filtered'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id']),
sa.ForeignKeyConstraint(['ip_id'], ['scan_ips.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scan_id', 'ip_id', 'port', 'protocol', name='uix_scan_ip_port')
)
op.create_index('ix_scan_ports_scan_id', 'scan_ports', ['scan_id'])
op.create_index('ix_scan_ports_ip_id', 'scan_ports', ['ip_id'])
op.create_table(
'scan_services',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False),
sa.Column('port_id', sa.Integer(), nullable=False),
sa.Column('service_name', sa.String(length=100), nullable=True, comment='Service name'),
sa.Column('product', sa.String(length=255), nullable=True, comment='Product name'),
sa.Column('version', sa.String(length=100), nullable=True, comment='Version string'),
sa.Column('extrainfo', sa.Text(), nullable=True, comment='Additional nmap info'),
sa.Column('ostype', sa.String(length=100), nullable=True, comment='OS type if detected'),
sa.Column('http_protocol', sa.String(length=10), nullable=True, comment='http or https'),
sa.Column('screenshot_path', sa.Text(), nullable=True, comment='Relative path to screenshot'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id']),
sa.ForeignKeyConstraint(['port_id'], ['scan_ports.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_scan_services_scan_id', 'scan_services', ['scan_id'])
op.create_index('ix_scan_services_port_id', 'scan_services', ['port_id'])
op.create_table(
'scan_certificates',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False),
sa.Column('service_id', sa.Integer(), nullable=False),
sa.Column('subject', sa.Text(), nullable=True, comment='Certificate subject (CN)'),
sa.Column('issuer', sa.Text(), nullable=True, comment='Certificate issuer'),
sa.Column('serial_number', sa.Text(), nullable=True, comment='Serial number'),
sa.Column('not_valid_before', sa.DateTime(), nullable=True, comment='Validity start date'),
sa.Column('not_valid_after', sa.DateTime(), nullable=True, comment='Validity end date'),
sa.Column('days_until_expiry', sa.Integer(), nullable=True, comment='Days until expiration'),
sa.Column('sans', sa.Text(), nullable=True, comment='JSON array of SANs'),
sa.Column('is_self_signed', sa.Boolean(), nullable=True, default=False, comment='Self-signed flag'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id']),
sa.ForeignKeyConstraint(['service_id'], ['scan_services.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_scan_certificates_scan_id', 'scan_certificates', ['scan_id'])
op.create_index('ix_scan_certificates_service_id', 'scan_certificates', ['service_id'])
op.create_table(
'scan_tls_versions',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False),
sa.Column('certificate_id', sa.Integer(), nullable=False),
sa.Column('tls_version', sa.String(length=20), nullable=False, comment='TLS 1.0, 1.1, 1.2, 1.3'),
sa.Column('supported', sa.Boolean(), nullable=False, comment='Is this version supported?'),
sa.Column('cipher_suites', sa.Text(), nullable=True, comment='JSON array of cipher suites'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id']),
sa.ForeignKeyConstraint(['certificate_id'], ['scan_certificates.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_scan_tls_versions_scan_id', 'scan_tls_versions', ['scan_id'])
op.create_index('ix_scan_tls_versions_certificate_id', 'scan_tls_versions', ['certificate_id'])
op.create_table(
'scan_progress',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False),
sa.Column('ip_address', sa.String(length=45), nullable=False, comment='IP address being scanned'),
sa.Column('site_name', sa.String(length=255), nullable=True, comment='Site name'),
sa.Column('phase', sa.String(length=50), nullable=False, comment='Phase: ping, tcp_scan, etc.'),
sa.Column('status', sa.String(length=20), nullable=False, default='pending', comment='pending, in_progress, completed, failed'),
sa.Column('ping_result', sa.Boolean(), nullable=True, comment='Ping response result'),
sa.Column('tcp_ports', sa.Text(), nullable=True, comment='JSON array of TCP ports'),
sa.Column('udp_ports', sa.Text(), nullable=True, comment='JSON array of UDP ports'),
sa.Column('services', sa.Text(), nullable=True, comment='JSON array of services'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Entry creation time'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='Last update time'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scan_id', 'ip_address', name='uix_scan_progress_ip')
)
op.create_index('ix_scan_progress_scan_id', 'scan_progress', ['scan_id'])
op.create_table(
'scan_site_associations',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False),
sa.Column('site_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Association creation time'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id']),
sa.ForeignKeyConstraint(['site_id'], ['sites.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scan_id', 'site_id', name='uix_scan_site')
)
op.create_index('ix_scan_site_associations_scan_id', 'scan_site_associations', ['scan_id'])
op.create_index('ix_scan_site_associations_site_id', 'scan_site_associations', ['site_id'])
# =========================================================================
# Alert Tables
# =========================================================================
op.create_table(
'alert_rules',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=255), nullable=True, comment='User-friendly rule name'),
sa.Column('rule_type', sa.String(length=50), nullable=False, comment='unexpected_port, cert_expiry, etc.'),
sa.Column('enabled', sa.Boolean(), nullable=False, default=True, comment='Is rule active?'),
sa.Column('threshold', sa.Integer(), nullable=True, comment='Threshold value'),
sa.Column('email_enabled', sa.Boolean(), nullable=False, default=False, comment='Send email?'),
sa.Column('webhook_enabled', sa.Boolean(), nullable=False, default=False, comment='Send webhook?'),
sa.Column('severity', sa.String(length=20), nullable=True, comment='critical, warning, info'),
sa.Column('filter_conditions', sa.Text(), nullable=True, comment='JSON filter conditions'),
sa.Column('config_id', sa.Integer(), nullable=True, comment='Optional: specific config this rule applies to'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Rule creation time'),
sa.Column('updated_at', sa.DateTime(), nullable=True, comment='Last update time'),
sa.ForeignKeyConstraint(['config_id'], ['scan_configs.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_alert_rules_config_id', 'alert_rules', ['config_id'])
op.create_table(
'alerts',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False),
sa.Column('rule_id', sa.Integer(), nullable=True, comment='Associated alert rule'),
sa.Column('alert_type', sa.String(length=50), nullable=False, comment='Alert type'),
sa.Column('severity', sa.String(length=20), nullable=False, comment='info, warning, critical'),
sa.Column('message', sa.Text(), nullable=False, comment='Human-readable message'),
sa.Column('ip_address', sa.String(length=45), nullable=True, comment='Related IP'),
sa.Column('port', sa.Integer(), nullable=True, comment='Related port'),
sa.Column('email_sent', sa.Boolean(), nullable=False, default=False, comment='Was email sent?'),
sa.Column('email_sent_at', sa.DateTime(), nullable=True, comment='Email send timestamp'),
sa.Column('webhook_sent', sa.Boolean(), nullable=False, default=False, comment='Was webhook sent?'),
sa.Column('webhook_sent_at', sa.DateTime(), nullable=True, comment='Webhook send timestamp'),
sa.Column('acknowledged', sa.Boolean(), nullable=False, default=False, comment='Was alert acknowledged?'),
sa.Column('acknowledged_at', sa.DateTime(), nullable=True, comment='Acknowledgment timestamp'),
sa.Column('acknowledged_by', sa.String(length=255), nullable=True, comment='User who acknowledged'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Alert creation time'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id']),
sa.ForeignKeyConstraint(['rule_id'], ['alert_rules.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_alerts_scan_id', 'alerts', ['scan_id'])
op.create_index('ix_alerts_rule_id', 'alerts', ['rule_id'])
op.create_index('ix_alerts_acknowledged', 'alerts', ['acknowledged'])
# =========================================================================
# Webhook Tables
# =========================================================================
op.create_table(
'webhooks',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=255), nullable=False, comment='Webhook name'),
sa.Column('url', sa.Text(), nullable=False, comment='Webhook URL'),
sa.Column('enabled', sa.Boolean(), nullable=False, default=True, comment='Is webhook enabled?'),
sa.Column('auth_type', sa.String(length=20), nullable=True, comment='none, bearer, basic, custom'),
sa.Column('auth_token', sa.Text(), nullable=True, comment='Encrypted auth token'),
sa.Column('custom_headers', sa.Text(), nullable=True, comment='JSON custom headers'),
sa.Column('alert_types', sa.Text(), nullable=True, comment='JSON array of alert types'),
sa.Column('severity_filter', sa.Text(), nullable=True, comment='JSON array of severities'),
sa.Column('timeout', sa.Integer(), nullable=True, default=10, comment='Request timeout'),
sa.Column('retry_count', sa.Integer(), nullable=True, default=3, comment='Retry attempts'),
sa.Column('template', sa.Text(), nullable=True, comment='Jinja2 template for payload'),
sa.Column('template_format', sa.String(length=20), nullable=True, default='json', comment='json, text'),
sa.Column('content_type_override', sa.String(length=100), nullable=True, comment='Custom Content-Type'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Creation time'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='Last update time'),
sa.PrimaryKeyConstraint('id')
)
op.create_table(
'webhook_delivery_log',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('webhook_id', sa.Integer(), nullable=False, comment='Associated webhook'),
sa.Column('alert_id', sa.Integer(), nullable=False, comment='Associated alert'),
sa.Column('status', sa.String(length=20), nullable=True, comment='success, failed, retrying'),
sa.Column('response_code', sa.Integer(), nullable=True, comment='HTTP response code'),
sa.Column('response_body', sa.Text(), nullable=True, comment='Response body'),
sa.Column('error_message', sa.Text(), nullable=True, comment='Error message if failed'),
sa.Column('attempt_number', sa.Integer(), nullable=True, comment='Which attempt'),
sa.Column('delivered_at', sa.DateTime(), nullable=False, comment='Delivery timestamp'),
sa.ForeignKeyConstraint(['webhook_id'], ['webhooks.id']),
sa.ForeignKeyConstraint(['alert_id'], ['alerts.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_webhook_delivery_log_webhook_id', 'webhook_delivery_log', ['webhook_id'])
op.create_index('ix_webhook_delivery_log_alert_id', 'webhook_delivery_log', ['alert_id'])
op.create_index('ix_webhook_delivery_log_status', 'webhook_delivery_log', ['status'])
print("\n✓ Initial schema created successfully")
def downgrade():
"""Drop all tables in reverse order."""
op.drop_table('webhook_delivery_log')
op.drop_table('webhooks')
op.drop_table('alerts')
op.drop_table('alert_rules')
op.drop_table('scan_site_associations')
op.drop_table('scan_progress')
op.drop_table('scan_tls_versions')
op.drop_table('scan_certificates')
op.drop_table('scan_services')
op.drop_table('scan_ports')
op.drop_table('scan_ips')
op.drop_table('scan_sites')
op.drop_table('scans')
op.drop_table('schedules')
op.drop_table('scan_config_sites')
op.drop_table('scan_configs')
op.drop_table('site_ips')
op.drop_table('sites')
op.drop_table('settings')
print("\n✓ All tables dropped")

View File

@@ -12,7 +12,7 @@ alembic==1.13.0
# Authentication & Security # Authentication & Security
Flask-Login==0.6.3 Flask-Login==0.6.3
bcrypt==4.1.2 bcrypt==4.1.2
cryptography==41.0.7 cryptography>=46.0.0
# API & Serialization # API & Serialization
Flask-CORS==4.0.0 Flask-CORS==4.0.0
@@ -21,10 +21,14 @@ marshmallow-sqlalchemy==0.29.0
# Background Jobs & Scheduling # Background Jobs & Scheduling
APScheduler==3.10.4 APScheduler==3.10.4
croniter==2.0.1
# Email Support (Phase 4) # Email Support (Phase 4)
Flask-Mail==0.9.1 Flask-Mail==0.9.1
# Webhook Support (Phase 5)
requests==2.31.0
# Configuration Management # Configuration Management
python-dotenv==1.0.0 python-dotenv==1.0.0

View File

@@ -1,5 +1,5 @@
PyYAML==6.0.1 PyYAML==6.0.1
python-libnmap==0.7.3 python-libnmap==0.7.3
sslyze==6.0.0 sslyze==6.2.0
playwright==1.40.0 playwright==1.40.0
Jinja2==3.1.2 Jinja2==3.1.2

View File

@@ -78,7 +78,7 @@ class HTMLReportGenerator:
'title': self.report_data.get('title', 'SneakyScanner Report'), 'title': self.report_data.get('title', 'SneakyScanner Report'),
'scan_time': self.report_data.get('scan_time'), 'scan_time': self.report_data.get('scan_time'),
'scan_duration': self.report_data.get('scan_duration'), 'scan_duration': self.report_data.get('scan_duration'),
'config_file': self.report_data.get('config_file'), 'config_id': self.report_data.get('config_id'),
'sites': self.report_data.get('sites', []), 'sites': self.report_data.get('sites', []),
'summary_stats': summary_stats, 'summary_stats': summary_stats,
'drift_alerts': drift_alerts, 'drift_alerts': drift_alerts,

View File

@@ -6,40 +6,112 @@ SneakyScanner - Masscan-based network scanner with YAML configuration
import argparse import argparse
import json import json
import logging import logging
import os
import signal
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import threading
import time import time
import zipfile import zipfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, List, Any from typing import Dict, List, Any, Callable, Optional
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import yaml import yaml
from libnmap.process import NmapProcess from libnmap.process import NmapProcess
from libnmap.parser import NmapParser from libnmap.parser import NmapParser
from screenshot_capture import ScreenshotCapture from src.screenshot_capture import ScreenshotCapture
from report_generator import HTMLReportGenerator from src.report_generator import HTMLReportGenerator
from web.config import NMAP_HOST_TIMEOUT
# Force unbuffered output for Docker # Force unbuffered output for Docker
sys.stdout.reconfigure(line_buffering=True) sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True) sys.stderr.reconfigure(line_buffering=True)
class SneakyScanner: class ScanCancelledError(Exception):
"""Wrapper for masscan to perform network scans based on YAML config""" """Raised when a scan is cancelled by the user."""
pass
def __init__(self, config_path: str, output_dir: str = "/app/output"):
self.config_path = Path(config_path) class SneakyScanner:
"""Wrapper for masscan to perform network scans based on YAML config or database config"""
def __init__(self, config_path: str = None, config_id: int = None, config_dict: Dict = None, output_dir: str = "/app/output"):
"""
Initialize scanner with configuration.
Args:
config_path: Path to YAML config file (legacy)
config_id: Database config ID (preferred)
config_dict: Config dictionary (for direct use)
output_dir: Output directory for scan results
Note: Provide exactly one of config_path, config_id, or config_dict
"""
if sum([config_path is not None, config_id is not None, config_dict is not None]) != 1:
raise ValueError("Must provide exactly one of: config_path, config_id, or config_dict")
self.config_path = Path(config_path) if config_path else None
self.config_id = config_id
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True) self.output_dir.mkdir(parents=True, exist_ok=True)
self.config = self._load_config()
if config_dict:
self.config = config_dict
# Process sites: resolve references and expand CIDRs
if 'sites' in self.config:
self.config['sites'] = self._resolve_sites(self.config['sites'])
else:
self.config = self._load_config()
self.screenshot_capture = None self.screenshot_capture = None
# Cancellation support
self._cancelled = False
self._cancel_lock = threading.Lock()
self._active_process = None
self._process_lock = threading.Lock()
def cancel(self):
"""
Cancel the running scan.
Terminates any active subprocess and sets cancellation flag.
"""
with self._cancel_lock:
self._cancelled = True
with self._process_lock:
if self._active_process and self._active_process.poll() is None:
try:
# Terminate the process group
os.killpg(os.getpgid(self._active_process.pid), signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
def is_cancelled(self) -> bool:
"""Check if scan has been cancelled."""
with self._cancel_lock:
return self._cancelled
def _load_config(self) -> Dict[str, Any]: def _load_config(self) -> Dict[str, Any]:
"""Load and validate YAML configuration""" """
Load and validate configuration from file or database.
Supports three formats:
1. Legacy: Sites with explicit IP lists
2. Site references: Sites referencing database-stored sites
3. Inline CIDRs: Sites with CIDR ranges
"""
# Load from database if config_id provided
if self.config_id:
return self._load_config_from_database(self.config_id)
# Load from YAML file
if not self.config_path.exists(): if not self.config_path.exists():
raise FileNotFoundError(f"Config file not found: {self.config_path}") raise FileNotFoundError(f"Config file not found: {self.config_path}")
@@ -51,8 +123,256 @@ class SneakyScanner:
if not config.get('sites'): if not config.get('sites'):
raise ValueError("Config must include 'sites' field") raise ValueError("Config must include 'sites' field")
# Process sites: resolve references and expand CIDRs
config['sites'] = self._resolve_sites(config['sites'])
return config return config
def _load_config_from_database(self, config_id: int) -> Dict[str, Any]:
"""
Load configuration from database by ID.
Args:
config_id: Database config ID
Returns:
Config dictionary with expanded sites
Raises:
ValueError: If config not found or invalid
"""
try:
# Import here to avoid circular dependencies and allow scanner to work standalone
import os
import sys
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from web.models import ScanConfig
# Create database session
db_url = os.environ.get('DATABASE_URL', 'sqlite:////app/data/sneakyscanner.db')
engine = create_engine(db_url)
Session = sessionmaker(bind=engine)
session = Session()
try:
# Load config from database
db_config = session.query(ScanConfig).filter_by(id=config_id).first()
if not db_config:
raise ValueError(f"Config with ID {config_id} not found in database")
# Build config dict with site references
config = {
'title': db_config.title,
'sites': []
}
# Add each site as a site_ref
for assoc in db_config.site_associations:
site = assoc.site
config['sites'].append({
'site_ref': site.name
})
# Process sites: resolve references and expand CIDRs
config['sites'] = self._resolve_sites(config['sites'])
return config
finally:
session.close()
except ImportError as e:
raise ValueError(f"Failed to load config from database (import error): {str(e)}")
except Exception as e:
raise ValueError(f"Failed to load config from database: {str(e)}")
def _resolve_sites(self, sites: List[Dict]) -> List[Dict]:
"""
Resolve site references and expand CIDRs to IP lists.
Converts all site formats into the legacy format (with explicit IPs)
for compatibility with the existing scan logic.
Args:
sites: List of site definitions from config
Returns:
List of sites with expanded IP lists
"""
import ipaddress
resolved_sites = []
for site_def in sites:
# Handle site references
if 'site_ref' in site_def:
site_ref = site_def['site_ref']
# Load site from database
site_data = self._load_site_from_database(site_ref)
if site_data:
resolved_sites.append(site_data)
else:
print(f"WARNING: Site reference '{site_ref}' not found in database", file=sys.stderr)
continue
# Handle inline CIDR definitions
if 'cidrs' in site_def:
site_name = site_def.get('name', 'Unknown Site')
expanded_ips = []
for cidr_def in site_def['cidrs']:
cidr = cidr_def['cidr']
expected_ping = cidr_def.get('expected_ping', False)
expected_tcp_ports = cidr_def.get('expected_tcp_ports', [])
expected_udp_ports = cidr_def.get('expected_udp_ports', [])
# Check if there are IP-level overrides (from database sites)
ip_overrides = cidr_def.get('ip_overrides', [])
override_map = {
override['ip_address']: override
for override in ip_overrides
}
# Expand CIDR to IP list
try:
network = ipaddress.ip_network(cidr, strict=False)
ip_list = [str(ip) for ip in network.hosts()]
# If network has only 1 address (like /32), hosts() returns empty
if not ip_list:
ip_list = [str(network.network_address)]
# Create IP config for each IP in the CIDR
for ip_address in ip_list:
# Check if this IP has an override
if ip_address in override_map:
override = override_map[ip_address]
ip_config = {
'address': ip_address,
'expected': {
'ping': override.get('expected_ping', expected_ping),
'tcp_ports': override.get('expected_tcp_ports', expected_tcp_ports),
'udp_ports': override.get('expected_udp_ports', expected_udp_ports)
}
}
else:
# Use CIDR-level defaults
ip_config = {
'address': ip_address,
'expected': {
'ping': expected_ping,
'tcp_ports': expected_tcp_ports,
'udp_ports': expected_udp_ports
}
}
expanded_ips.append(ip_config)
except ValueError as e:
print(f"WARNING: Invalid CIDR '{cidr}': {e}", file=sys.stderr)
continue
# Add expanded site
resolved_sites.append({
'name': site_name,
'ips': expanded_ips
})
continue
# Legacy format: already has 'ips' list
if 'ips' in site_def:
resolved_sites.append(site_def)
continue
print(f"WARNING: Site definition missing required fields: {site_def}", file=sys.stderr)
return resolved_sites
def _load_site_from_database(self, site_name: str) -> Dict[str, Any]:
"""
Load a site definition from the database.
IPs are pre-expanded in the database, so we just load them directly.
Args:
site_name: Name of the site to load
Returns:
Site definition dict with IPs, or None if not found
"""
try:
# Import database modules
import os
import sys
# Add parent directory to path if needed
parent_dir = str(Path(__file__).parent.parent)
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, joinedload
from web.models import Site
# Get database URL from environment
database_url = os.environ.get('DATABASE_URL', 'sqlite:///./sneakyscanner.db')
# Create engine and session
engine = create_engine(database_url)
Session = sessionmaker(bind=engine)
session = Session()
# Query site with all IPs (CIDRs are already expanded)
site = (
session.query(Site)
.options(joinedload(Site.ips))
.filter(Site.name == site_name)
.first()
)
if not site:
session.close()
return None
# Load all IPs directly from database (already expanded)
expanded_ips = []
for ip_obj in site.ips:
# Get settings from IP (no need to merge with CIDR defaults)
expected_ping = ip_obj.expected_ping if ip_obj.expected_ping is not None else False
expected_tcp_ports = json.loads(ip_obj.expected_tcp_ports) if ip_obj.expected_tcp_ports else []
expected_udp_ports = json.loads(ip_obj.expected_udp_ports) if ip_obj.expected_udp_ports else []
ip_config = {
'address': ip_obj.ip_address,
'expected': {
'ping': expected_ping,
'tcp_ports': expected_tcp_ports,
'udp_ports': expected_udp_ports
}
}
expanded_ips.append(ip_config)
session.close()
return {
'name': site.name,
'ips': expanded_ips
}
except Exception as e:
print(f"ERROR: Failed to load site '{site_name}' from database: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
return None
def _run_masscan(self, targets: List[str], ports: str, protocol: str) -> List[Dict]: def _run_masscan(self, targets: List[str], ports: str, protocol: str) -> List[Dict]:
""" """
Run masscan and return parsed results Run masscan and return parsed results
@@ -98,11 +418,31 @@ class SneakyScanner:
raise ValueError(f"Invalid protocol: {protocol}") raise ValueError(f"Invalid protocol: {protocol}")
print(f"Running: {' '.join(cmd)}", flush=True) print(f"Running: {' '.join(cmd)}", flush=True)
result = subprocess.run(cmd, capture_output=True, text=True)
# Use Popen for cancellation support
with self._process_lock:
self._active_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
start_new_session=True
)
stdout, stderr = self._active_process.communicate()
returncode = self._active_process.returncode
with self._process_lock:
self._active_process = None
# Check if cancelled
if self.is_cancelled():
return []
print(f"Masscan {protocol.upper()} scan completed", flush=True) print(f"Masscan {protocol.upper()} scan completed", flush=True)
if result.returncode != 0: if returncode != 0:
print(f"Masscan stderr: {result.stderr}", file=sys.stderr) print(f"Masscan stderr: {stderr}", file=sys.stderr)
# Parse masscan JSON output # Parse masscan JSON output
results = [] results = []
@@ -150,11 +490,31 @@ class SneakyScanner:
] ]
print(f"Running: {' '.join(cmd)}", flush=True) print(f"Running: {' '.join(cmd)}", flush=True)
result = subprocess.run(cmd, capture_output=True, text=True)
# Use Popen for cancellation support
with self._process_lock:
self._active_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
start_new_session=True
)
stdout, stderr = self._active_process.communicate()
returncode = self._active_process.returncode
with self._process_lock:
self._active_process = None
# Check if cancelled
if self.is_cancelled():
return {}
print(f"Masscan PING scan completed", flush=True) print(f"Masscan PING scan completed", flush=True)
if result.returncode != 0: if returncode != 0:
print(f"Masscan stderr: {result.stderr}", file=sys.stderr, flush=True) print(f"Masscan stderr: {stderr}", file=sys.stderr, flush=True)
# Parse results # Parse results
responding_ips = set() responding_ips = set()
@@ -192,6 +552,10 @@ class SneakyScanner:
all_services = {} all_services = {}
for ip, ports in ip_ports.items(): for ip, ports in ip_ports.items():
# Check if cancelled before each host
if self.is_cancelled():
break
if not ports: if not ports:
all_services[ip] = [] all_services[ip] = []
continue continue
@@ -213,14 +577,33 @@ class SneakyScanner:
'--version-intensity', '5', # Balanced speed/accuracy '--version-intensity', '5', # Balanced speed/accuracy
'-p', port_list, '-p', port_list,
'-oX', xml_output, # XML output '-oX', xml_output, # XML output
'--host-timeout', '5m', # Timeout per host '--host-timeout', NMAP_HOST_TIMEOUT, # Timeout per host
ip ip
] ]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) # Use Popen for cancellation support
with self._process_lock:
self._active_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
start_new_session=True
)
if result.returncode != 0: stdout, stderr = self._active_process.communicate(timeout=600)
print(f" Nmap warning for {ip}: {result.stderr}", file=sys.stderr, flush=True) returncode = self._active_process.returncode
with self._process_lock:
self._active_process = None
# Check if cancelled
if self.is_cancelled():
Path(xml_output).unlink(missing_ok=True)
break
if returncode != 0:
print(f" Nmap warning for {ip}: {stderr}", file=sys.stderr, flush=True)
# Parse XML output # Parse XML output
services = self._parse_nmap_xml(xml_output) services = self._parse_nmap_xml(xml_output)
@@ -293,29 +676,57 @@ class SneakyScanner:
return services return services
def _is_likely_web_service(self, service: Dict) -> bool: def _is_likely_web_service(self, service: Dict, ip: str = None) -> bool:
""" """
Check if a service is likely HTTP/HTTPS based on nmap detection or common web ports Check if a service is a web server by actually making an HTTP request
Args: Args:
service: Service dictionary from nmap results service: Service dictionary from nmap results
ip: IP address to test (required for HTTP probe)
Returns: Returns:
True if service appears to be web-related True if service responds to HTTP/HTTPS requests
""" """
# Check service name import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Quick check for known web service names first
web_services = ['http', 'https', 'ssl', 'http-proxy', 'https-alt', web_services = ['http', 'https', 'ssl', 'http-proxy', 'https-alt',
'http-alt', 'ssl/http', 'ssl/https'] 'http-alt', 'ssl/http', 'ssl/https']
service_name = service.get('service', '').lower() service_name = service.get('service', '').lower()
if service_name in web_services: # If no IP provided, can't do HTTP probe
return True
# Check common non-standard web ports
web_ports = [80, 443, 8000, 8006, 8008, 8080, 8081, 8443, 8888, 9443]
port = service.get('port') port = service.get('port')
if not ip or not port:
# check just the service if no IP - honestly shouldn't get here, but just incase...
if service_name in web_services:
return True
return False
return port in web_ports # Actually try to connect - this is the definitive test
# Try HTTPS first, then HTTP
for protocol in ['https', 'http']:
url = f"{protocol}://{ip}:{port}/"
try:
response = requests.get(
url,
timeout=3,
verify=False,
allow_redirects=False
)
# Any status code means it's a web server
# (including 404, 500, etc. - still a web server)
return True
except requests.exceptions.SSLError:
# SSL error on HTTPS, try HTTP next
continue
except (requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.RequestException):
continue
return False
def _detect_http_https(self, ip: str, port: int, timeout: int = 5) -> str: def _detect_http_https(self, ip: str, port: int, timeout: int = 5) -> str:
""" """
@@ -503,7 +914,7 @@ class SneakyScanner:
ip_results = {} ip_results = {}
for service in services: for service in services:
if not self._is_likely_web_service(service): if not self._is_likely_web_service(service, ip):
continue continue
port = service['port'] port = service['port']
@@ -549,15 +960,25 @@ class SneakyScanner:
return all_results return all_results
def scan(self) -> Dict[str, Any]: def scan(self, progress_callback: Optional[Callable] = None) -> Dict[str, Any]:
""" """
Perform complete scan based on configuration Perform complete scan based on configuration
Args:
progress_callback: Optional callback function for progress updates.
Called with (phase, ip, data) where:
- phase: 'init', 'ping', 'tcp_scan', 'udp_scan', 'service_detection', 'http_analysis'
- ip: IP address being processed (or None for phase start)
- data: Dict with progress data (results, counts, etc.)
Returns: Returns:
Dictionary containing scan results Dictionary containing scan results
""" """
print(f"Starting scan: {self.config['title']}", flush=True) print(f"Starting scan: {self.config['title']}", flush=True)
print(f"Config: {self.config_path}", flush=True) if self.config_id:
print(f"Config ID: {self.config_id}", flush=True)
elif self.config_path:
print(f"Config: {self.config_path}", flush=True)
# Record start time # Record start time
start_time = time.time() start_time = time.time()
@@ -586,17 +1007,61 @@ class SneakyScanner:
all_ips = sorted(list(all_ips)) all_ips = sorted(list(all_ips))
print(f"Total IPs to scan: {len(all_ips)}", flush=True) print(f"Total IPs to scan: {len(all_ips)}", flush=True)
# Report initialization with total IP count
if progress_callback:
progress_callback('init', None, {
'total_ips': len(all_ips),
'ip_to_site': ip_to_site
})
# Perform ping scan # Perform ping scan
print(f"\n[1/5] Performing ping scan on {len(all_ips)} IPs...", flush=True) print(f"\n[1/5] Performing ping scan on {len(all_ips)} IPs...", flush=True)
if progress_callback:
progress_callback('ping', None, {'status': 'starting'})
ping_results = self._run_ping_scan(all_ips) ping_results = self._run_ping_scan(all_ips)
# Check for cancellation
if self.is_cancelled():
print("\nScan cancelled by user", flush=True)
raise ScanCancelledError("Scan cancelled by user")
# Report ping results
if progress_callback:
progress_callback('ping', None, {
'status': 'completed',
'results': ping_results
})
# Perform TCP scan (all ports) # Perform TCP scan (all ports)
print(f"\n[2/5] Performing TCP scan on {len(all_ips)} IPs (ports 0-65535)...", flush=True) print(f"\n[2/5] Performing TCP scan on {len(all_ips)} IPs (ports 0-65535)...", flush=True)
if progress_callback:
progress_callback('tcp_scan', None, {'status': 'starting'})
tcp_results = self._run_masscan(all_ips, '0-65535', 'tcp') tcp_results = self._run_masscan(all_ips, '0-65535', 'tcp')
# Perform UDP scan (all ports) # Check for cancellation
print(f"\n[3/5] Performing UDP scan on {len(all_ips)} IPs (ports 0-65535)...", flush=True) if self.is_cancelled():
udp_results = self._run_masscan(all_ips, '0-65535', 'udp') print("\nScan cancelled by user", flush=True)
raise ScanCancelledError("Scan cancelled by user")
# Perform UDP scan (if enabled)
udp_enabled = os.environ.get('UDP_SCAN_ENABLED', 'false').lower() == 'true'
udp_ports = os.environ.get('UDP_PORTS', '53,67,68,69,123,161,500,514,1900')
if udp_enabled:
print(f"\n[3/5] Performing UDP scan on {len(all_ips)} IPs (ports {udp_ports})...", flush=True)
if progress_callback:
progress_callback('udp_scan', None, {'status': 'starting'})
udp_results = self._run_masscan(all_ips, udp_ports, 'udp')
# Check for cancellation
if self.is_cancelled():
print("\nScan cancelled by user", flush=True)
raise ScanCancelledError("Scan cancelled by user")
else:
print(f"\n[3/5] Skipping UDP scan (disabled)...", flush=True)
if progress_callback:
progress_callback('udp_scan', None, {'status': 'skipped'})
udp_results = []
# Organize results by IP # Organize results by IP
results_by_ip = {} results_by_ip = {}
@@ -631,20 +1096,56 @@ class SneakyScanner:
results_by_ip[ip]['actual']['tcp_ports'].sort() results_by_ip[ip]['actual']['tcp_ports'].sort()
results_by_ip[ip]['actual']['udp_ports'].sort() results_by_ip[ip]['actual']['udp_ports'].sort()
# Report TCP/UDP scan results with discovered ports per IP
if progress_callback:
tcp_udp_results = {}
for ip in all_ips:
tcp_udp_results[ip] = {
'tcp_ports': results_by_ip[ip]['actual']['tcp_ports'],
'udp_ports': results_by_ip[ip]['actual']['udp_ports']
}
progress_callback('tcp_scan', None, {
'status': 'completed',
'results': tcp_udp_results
})
# Perform service detection on TCP ports # Perform service detection on TCP ports
print(f"\n[4/5] Performing service detection on discovered TCP ports...", flush=True) print(f"\n[4/5] Performing service detection on discovered TCP ports...", flush=True)
if progress_callback:
progress_callback('service_detection', None, {'status': 'starting'})
ip_ports = {ip: results_by_ip[ip]['actual']['tcp_ports'] for ip in all_ips} ip_ports = {ip: results_by_ip[ip]['actual']['tcp_ports'] for ip in all_ips}
service_results = self._run_nmap_service_detection(ip_ports) service_results = self._run_nmap_service_detection(ip_ports)
# Check for cancellation
if self.is_cancelled():
print("\nScan cancelled by user", flush=True)
raise ScanCancelledError("Scan cancelled by user")
# Add service information to results # Add service information to results
for ip, services in service_results.items(): for ip, services in service_results.items():
if ip in results_by_ip: if ip in results_by_ip:
results_by_ip[ip]['actual']['services'] = services results_by_ip[ip]['actual']['services'] = services
# Report service detection results
if progress_callback:
progress_callback('service_detection', None, {
'status': 'completed',
'results': service_results
})
# Perform HTTP/HTTPS analysis on web services # Perform HTTP/HTTPS analysis on web services
print(f"\n[5/5] Analyzing HTTP/HTTPS services and SSL/TLS configuration...", flush=True) print(f"\n[5/5] Analyzing HTTP/HTTPS services and SSL/TLS configuration...", flush=True)
if progress_callback:
progress_callback('http_analysis', None, {'status': 'starting'})
http_results = self._run_http_analysis(service_results) http_results = self._run_http_analysis(service_results)
# Report HTTP analysis completion
if progress_callback:
progress_callback('http_analysis', None, {
'status': 'completed',
'results': http_results
})
# Merge HTTP analysis into service results # Merge HTTP analysis into service results
for ip, port_results in http_results.items(): for ip, port_results in http_results.items():
if ip in results_by_ip: if ip in results_by_ip:
@@ -662,7 +1163,7 @@ class SneakyScanner:
'title': self.config['title'], 'title': self.config['title'],
'scan_time': datetime.utcnow().isoformat() + 'Z', 'scan_time': datetime.utcnow().isoformat() + 'Z',
'scan_duration': scan_duration, 'scan_duration': scan_duration,
'config_file': str(self.config_path), 'config_id': self.config_id,
'sites': [] 'sites': []
} }
@@ -768,6 +1269,8 @@ class SneakyScanner:
# Preserve directory structure in ZIP # Preserve directory structure in ZIP
arcname = f"{screenshot_dir.name}/{screenshot_file.name}" arcname = f"{screenshot_dir.name}/{screenshot_file.name}"
zipf.write(screenshot_file, arcname) zipf.write(screenshot_file, arcname)
# Track screenshot directory for database storage
output_paths['screenshots'] = screenshot_dir
output_paths['zip'] = zip_path output_paths['zip'] = zip_path
print(f"ZIP archive saved to: {zip_path}", flush=True) print(f"ZIP archive saved to: {zip_path}", flush=True)

View File

@@ -490,8 +490,8 @@
<div class="header-meta"> <div class="header-meta">
<span>📅 <strong>Scan Time:</strong> {{ scan_time | format_date }}</span> <span>📅 <strong>Scan Time:</strong> {{ scan_time | format_date }}</span>
<span>⏱️ <strong>Duration:</strong> {{ scan_duration | format_duration }}</span> <span>⏱️ <strong>Duration:</strong> {{ scan_duration | format_duration }}</span>
{% if config_file %} {% if config_id %}
<span>📄 <strong>Config:</strong> {{ config_file }}</span> <span>📄 <strong>Config ID:</strong> {{ config_id }}</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -4,6 +4,7 @@ Pytest configuration and fixtures for SneakyScanner tests.
import os import os
import tempfile import tempfile
from datetime import datetime
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -11,7 +12,9 @@ import yaml
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from web.models import Base from web.app import create_app
from web.models import Base, Scan, ScanConfig
from web.utils.settings import PasswordManager, SettingsManager
@pytest.fixture(scope='function') @pytest.fixture(scope='function')
@@ -50,7 +53,7 @@ def sample_scan_report():
'title': 'Test Scan', 'title': 'Test Scan',
'scan_time': '2025-11-14T10:30:00Z', 'scan_time': '2025-11-14T10:30:00Z',
'scan_duration': 125.5, 'scan_duration': 125.5,
'config_file': '/app/configs/test.yaml', 'config_id': 1,
'sites': [ 'sites': [
{ {
'name': 'Test Site', 'name': 'Test Site',
@@ -194,3 +197,235 @@ def sample_invalid_config_file(tmp_path):
f.write("invalid: yaml: content: [missing closing bracket") f.write("invalid: yaml: content: [missing closing bracket")
return str(config_file) return str(config_file)
@pytest.fixture
def sample_db_config(db):
"""
Create a sample database config for testing.
Args:
db: Database session fixture
Returns:
ScanConfig model instance with ID
"""
import json
config_data = {
'title': 'Test Scan',
'sites': [
{
'name': 'Test Site',
'ips': [
{
'address': '192.168.1.10',
'expected': {
'ping': True,
'tcp_ports': [22, 80, 443],
'udp_ports': [53],
'services': ['ssh', 'http', 'https']
}
}
]
}
]
}
scan_config = ScanConfig(
title='Test Scan',
config_data=json.dumps(config_data),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(scan_config)
db.commit()
db.refresh(scan_config)
return scan_config
@pytest.fixture(scope='function')
def app():
"""
Create Flask application for testing.
Returns:
Configured Flask app instance with test database
"""
# Create temporary database
db_fd, db_path = tempfile.mkstemp(suffix='.db')
# Create app with test config
test_config = {
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{db_path}',
'SECRET_KEY': 'test-secret-key'
}
app = create_app(test_config)
yield app
# Cleanup
os.close(db_fd)
os.unlink(db_path)
@pytest.fixture(scope='function')
def client(app):
"""
Create Flask test client.
Args:
app: Flask application fixture
Returns:
Flask test client for making API requests
"""
return app.test_client()
@pytest.fixture(scope='function')
def db(app):
"""
Alias for database session that works with Flask app context.
Args:
app: Flask application fixture
Returns:
SQLAlchemy session
"""
with app.app_context():
yield app.db_session
@pytest.fixture
def sample_scan(db):
"""
Create a sample scan in the database for testing.
Args:
db: Database session fixture
Returns:
Scan model instance
"""
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_id=1,
title='Test Scan',
duration=125.5,
triggered_by='test',
json_path='/app/output/scan_report_20251114_103000.json',
html_path='/app/output/scan_report_20251114_103000.html',
zip_path='/app/output/scan_report_20251114_103000.zip',
screenshot_dir='/app/output/scan_report_20251114_103000_screenshots'
)
db.add(scan)
db.commit()
db.refresh(scan)
return scan
# Authentication Fixtures
@pytest.fixture
def app_password():
"""
Test password for authentication tests.
Returns:
Test password string
"""
return 'testpassword123'
@pytest.fixture
def db_with_password(db, app_password):
"""
Database session with application password set.
Args:
db: Database session fixture
app_password: Test password fixture
Returns:
Database session with password configured
"""
settings_manager = SettingsManager(db)
PasswordManager.set_app_password(settings_manager, app_password)
return db
@pytest.fixture
def db_no_password(app):
"""
Database session without application password set.
Args:
app: Flask application fixture
Returns:
Database session without password
"""
with app.app_context():
# Clear any password that might be set
settings_manager = SettingsManager(app.db_session)
settings_manager.delete('app_password')
yield app.db_session
@pytest.fixture
def authenticated_client(client, db_with_password, app_password):
"""
Flask test client with authenticated session.
Args:
client: Flask test client fixture
db_with_password: Database with password set
app_password: Test password fixture
Returns:
Test client with active session
"""
# Log in
client.post('/auth/login', data={
'password': app_password
})
return client
@pytest.fixture
def client_no_password(app):
"""
Flask test client with no password set (for setup testing).
Args:
app: Flask application fixture
Returns:
Test client for testing setup flow
"""
# Create temporary database without password
db_fd, db_path = tempfile.mkstemp(suffix='.db')
test_config = {
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{db_path}',
'SECRET_KEY': 'test-secret-key'
}
test_app = create_app(test_config)
test_client = test_app.test_client()
yield test_client
# Cleanup
os.close(db_fd)
os.unlink(db_path)

View File

@@ -0,0 +1,279 @@
"""
Tests for authentication system.
Tests login, logout, session management, and API authentication.
"""
import pytest
from flask import url_for
from web.auth.models import User
from web.utils.settings import PasswordManager, SettingsManager
class TestUserModel:
"""Tests for User model."""
def test_user_get_valid_id(self, db):
"""Test getting user with valid ID."""
user = User.get('1', db)
assert user is not None
assert user.id == '1'
def test_user_get_invalid_id(self, db):
"""Test getting user with invalid ID."""
user = User.get('invalid', db)
assert user is None
def test_user_properties(self):
"""Test user properties."""
user = User('1')
assert user.is_authenticated is True
assert user.is_active is True
assert user.is_anonymous is False
assert user.get_id() == '1'
def test_user_authenticate_success(self, db, app_password):
"""Test successful authentication."""
user = User.authenticate(app_password, db)
assert user is not None
assert user.id == '1'
def test_user_authenticate_failure(self, db):
"""Test failed authentication with wrong password."""
user = User.authenticate('wrongpassword', db)
assert user is None
def test_user_has_password_set(self, db, app_password):
"""Test checking if password is set."""
# Password is set in fixture
assert User.has_password_set(db) is True
def test_user_has_password_not_set(self, db_no_password):
"""Test checking if password is not set."""
assert User.has_password_set(db_no_password) is False
class TestAuthRoutes:
"""Tests for authentication routes."""
def test_login_page_renders(self, client):
"""Test that login page renders correctly."""
response = client.get('/auth/login')
assert response.status_code == 200
# Note: This will fail until templates are created
# assert b'login' in response.data.lower()
def test_login_success(self, client, app_password):
"""Test successful login."""
response = client.post('/auth/login', data={
'password': app_password
}, follow_redirects=False)
# Should redirect to dashboard (or main.dashboard)
assert response.status_code == 302
def test_login_failure(self, client):
"""Test failed login with wrong password."""
response = client.post('/auth/login', data={
'password': 'wrongpassword'
}, follow_redirects=True)
# Should stay on login page
assert response.status_code == 200
def test_login_redirect_when_authenticated(self, authenticated_client):
"""Test that login page redirects when already logged in."""
response = authenticated_client.get('/auth/login', follow_redirects=False)
# Should redirect to dashboard
assert response.status_code == 302
def test_logout(self, authenticated_client):
"""Test logout functionality."""
response = authenticated_client.get('/auth/logout', follow_redirects=False)
# Should redirect to login page
assert response.status_code == 302
assert '/auth/login' in response.location
def test_logout_when_not_authenticated(self, client):
"""Test logout when not authenticated."""
response = client.get('/auth/logout', follow_redirects=False)
# Should redirect to login page anyway
assert response.status_code == 302
def test_setup_page_renders_when_no_password(self, client_no_password):
"""Test that setup page renders when no password is set."""
response = client_no_password.get('/auth/setup')
assert response.status_code == 200
def test_setup_redirects_when_password_set(self, client):
"""Test that setup page redirects when password already set."""
response = client.get('/auth/setup', follow_redirects=False)
assert response.status_code == 302
assert '/auth/login' in response.location
def test_setup_password_success(self, client_no_password):
"""Test setting password via setup page."""
response = client_no_password.post('/auth/setup', data={
'password': 'newpassword123',
'confirm_password': 'newpassword123'
}, follow_redirects=False)
# Should redirect to login
assert response.status_code == 302
assert '/auth/login' in response.location
def test_setup_password_too_short(self, client_no_password):
"""Test that setup rejects password that's too short."""
response = client_no_password.post('/auth/setup', data={
'password': 'short',
'confirm_password': 'short'
}, follow_redirects=True)
# Should stay on setup page
assert response.status_code == 200
def test_setup_passwords_dont_match(self, client_no_password):
"""Test that setup rejects mismatched passwords."""
response = client_no_password.post('/auth/setup', data={
'password': 'password123',
'confirm_password': 'different123'
}, follow_redirects=True)
# Should stay on setup page
assert response.status_code == 200
class TestAPIAuthentication:
"""Tests for API endpoint authentication."""
def test_scans_list_requires_auth(self, client):
"""Test that listing scans requires authentication."""
response = client.get('/api/scans')
assert response.status_code == 401
data = response.get_json()
assert 'error' in data
assert data['error'] == 'Authentication required'
def test_scans_list_with_auth(self, authenticated_client):
"""Test that listing scans works when authenticated."""
response = authenticated_client.get('/api/scans')
# Should succeed (200) even if empty
assert response.status_code == 200
data = response.get_json()
assert 'scans' in data
def test_scan_trigger_requires_auth(self, client):
"""Test that triggering scan requires authentication."""
response = client.post('/api/scans', json={
'config_file': '/app/configs/test.yaml'
})
assert response.status_code == 401
def test_scan_get_requires_auth(self, client):
"""Test that getting scan details requires authentication."""
response = client.get('/api/scans/1')
assert response.status_code == 401
def test_scan_delete_requires_auth(self, client):
"""Test that deleting scan requires authentication."""
response = client.delete('/api/scans/1')
assert response.status_code == 401
def test_scan_status_requires_auth(self, client):
"""Test that getting scan status requires authentication."""
response = client.get('/api/scans/1/status')
assert response.status_code == 401
def test_settings_get_requires_auth(self, client):
"""Test that getting settings requires authentication."""
response = client.get('/api/settings')
assert response.status_code == 401
def test_settings_update_requires_auth(self, client):
"""Test that updating settings requires authentication."""
response = client.put('/api/settings', json={
'settings': {'test_key': 'test_value'}
})
assert response.status_code == 401
def test_settings_get_with_auth(self, authenticated_client):
"""Test that getting settings works when authenticated."""
response = authenticated_client.get('/api/settings')
assert response.status_code == 200
data = response.get_json()
assert 'settings' in data
def test_schedules_list_requires_auth(self, client):
"""Test that listing schedules requires authentication."""
response = client.get('/api/schedules')
assert response.status_code == 401
def test_alerts_list_requires_auth(self, client):
"""Test that listing alerts requires authentication."""
response = client.get('/api/alerts')
assert response.status_code == 401
def test_health_check_no_auth_required(self, client):
"""Test that health check endpoints don't require authentication."""
# Health checks should be accessible without authentication
response = client.get('/api/scans/health')
assert response.status_code == 200
response = client.get('/api/settings/health')
assert response.status_code == 200
response = client.get('/api/schedules/health')
assert response.status_code == 200
response = client.get('/api/alerts/health')
assert response.status_code == 200
class TestSessionManagement:
"""Tests for session management."""
def test_session_persists_across_requests(self, authenticated_client):
"""Test that session persists across multiple requests."""
# First request - should succeed
response1 = authenticated_client.get('/api/scans')
assert response1.status_code == 200
# Second request - should also succeed (session persists)
response2 = authenticated_client.get('/api/settings')
assert response2.status_code == 200
def test_remember_me_cookie(self, client, app_password):
"""Test remember me functionality."""
response = client.post('/auth/login', data={
'password': app_password,
'remember': 'on'
}, follow_redirects=False)
# Should set remember_me cookie
assert response.status_code == 302
# Note: Actual cookie checking would require inspecting response.headers
class TestNextRedirect:
"""Tests for 'next' parameter redirect."""
def test_login_redirects_to_next(self, client, app_password):
"""Test that login redirects to 'next' parameter."""
response = client.post('/auth/login?next=/api/scans', data={
'password': app_password
}, follow_redirects=False)
assert response.status_code == 302
assert '/api/scans' in response.location
def test_login_without_next_redirects_to_dashboard(self, client, app_password):
"""Test that login without 'next' redirects to dashboard."""
response = client.post('/auth/login', data={
'password': app_password
}, follow_redirects=False)
assert response.status_code == 302
# Should redirect to dashboard
assert 'dashboard' in response.location or response.location == '/'

View File

@@ -0,0 +1,225 @@
"""
Tests for background job execution and scheduler integration.
Tests the APScheduler integration, job queuing, and background scan execution.
"""
import pytest
import time
from datetime import datetime
from web.models import Scan
from web.services.scan_service import ScanService
from web.services.scheduler_service import SchedulerService
class TestBackgroundJobs:
"""Test suite for background job execution."""
def test_scheduler_initialization(self, app):
"""Test that scheduler is initialized with Flask app."""
assert hasattr(app, 'scheduler')
assert app.scheduler is not None
assert app.scheduler.scheduler is not None
assert app.scheduler.scheduler.running
def test_queue_scan_job(self, app, db, sample_db_config):
"""Test queuing a scan for background execution."""
# Create a scan via service
scan_service = ScanService(db)
scan_id = scan_service.trigger_scan(
config_id=sample_db_config.id,
triggered_by='test',
scheduler=app.scheduler
)
# Verify scan was created
scan = db.query(Scan).filter_by(id=scan_id).first()
assert scan is not None
assert scan.status == 'running'
# Verify job was queued (check scheduler has the job)
job = app.scheduler.scheduler.get_job(f'scan_{scan_id}')
assert job is not None
assert job.id == f'scan_{scan_id}'
def test_trigger_scan_without_scheduler(self, db, sample_db_config):
"""Test triggering scan without scheduler logs warning."""
# Create scan without scheduler
scan_service = ScanService(db)
scan_id = scan_service.trigger_scan(
config_id=sample_db_config.id,
triggered_by='test',
scheduler=None # No scheduler
)
# Verify scan was created but not queued
scan = db.query(Scan).filter_by(id=scan_id).first()
assert scan is not None
assert scan.status == 'running'
def test_scheduler_service_queue_scan(self, app, db, sample_db_config):
"""Test SchedulerService.queue_scan directly."""
# Create scan record first
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_id=sample_db_config.id,
title='Test Scan',
triggered_by='test'
)
db.add(scan)
db.commit()
# Queue the scan
job_id = app.scheduler.queue_scan(scan.id, sample_db_config)
# Verify job was queued
assert job_id == f'scan_{scan.id}'
job = app.scheduler.scheduler.get_job(job_id)
assert job is not None
def test_scheduler_list_jobs(self, app, db, sample_db_config):
"""Test listing scheduled jobs."""
# Queue a few scans
for i in range(3):
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_id=sample_db_config.id,
title=f'Test Scan {i}',
triggered_by='test'
)
db.add(scan)
db.commit()
app.scheduler.queue_scan(scan.id, sample_db_config)
# List jobs
jobs = app.scheduler.list_jobs()
# Should have at least 3 jobs (might have more from other tests)
assert len(jobs) >= 3
# Each job should have required fields
for job in jobs:
assert 'id' in job
assert 'name' in job
assert 'trigger' in job
def test_scheduler_get_job_status(self, app, db, sample_db_config):
"""Test getting status of a specific job."""
# Create and queue a scan
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_id=sample_db_config.id,
title='Test Scan',
triggered_by='test'
)
db.add(scan)
db.commit()
job_id = app.scheduler.queue_scan(scan.id, sample_db_config)
# Get job status
status = app.scheduler.get_job_status(job_id)
assert status is not None
assert status['id'] == job_id
assert status['name'] == f'Scan {scan.id}'
def test_scheduler_get_nonexistent_job(self, app):
"""Test getting status of non-existent job."""
status = app.scheduler.get_job_status('nonexistent_job_id')
assert status is None
def test_scan_timing_fields(self, db, sample_db_config):
"""Test that scan timing fields are properly set."""
# Create scan with started_at
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_id=sample_db_config.id,
title='Test Scan',
triggered_by='test',
started_at=datetime.utcnow()
)
db.add(scan)
db.commit()
# Verify fields exist
assert scan.started_at is not None
assert scan.completed_at is None
assert scan.error_message is None
# Update to completed
scan.status = 'completed'
scan.completed_at = datetime.utcnow()
db.commit()
# Verify fields updated
assert scan.completed_at is not None
assert (scan.completed_at - scan.started_at).total_seconds() >= 0
def test_scan_error_handling(self, db, sample_db_config):
"""Test that error messages are stored correctly."""
# Create failed scan
scan = Scan(
timestamp=datetime.utcnow(),
status='failed',
config_id=sample_db_config.id,
title='Failed Scan',
triggered_by='test',
started_at=datetime.utcnow(),
completed_at=datetime.utcnow(),
error_message='Test error message'
)
db.add(scan)
db.commit()
# Verify error message stored
assert scan.error_message == 'Test error message'
# Verify status query works
scan_service = ScanService(db)
status = scan_service.get_scan_status(scan.id)
assert status['status'] == 'failed'
assert status['error_message'] == 'Test error message'
@pytest.mark.skip(reason="Requires actual scanner execution - slow test")
def test_background_scan_execution(self, app, db, sample_db_config):
"""
Integration test for actual background scan execution.
This test is skipped by default because it actually runs the scanner,
which requires privileged operations and takes time.
To run: pytest -v -k test_background_scan_execution --run-slow
"""
# Trigger scan
scan_service = ScanService(db)
scan_id = scan_service.trigger_scan(
config_id=sample_db_config.id,
triggered_by='test',
scheduler=app.scheduler
)
# Wait for scan to complete (with timeout)
max_wait = 300 # 5 minutes
start_time = time.time()
while time.time() - start_time < max_wait:
scan = db.query(Scan).filter_by(id=scan_id).first()
if scan.status in ['completed', 'failed']:
break
time.sleep(5)
# Verify scan completed
scan = db.query(Scan).filter_by(id=scan_id).first()
assert scan.status in ['completed', 'failed']
if scan.status == 'completed':
assert scan.duration is not None
assert scan.json_path is not None
else:
assert scan.error_message is not None

View File

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

View File

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

View File

@@ -0,0 +1,267 @@
"""
Tests for error handling and logging functionality.
Tests error handlers, request/response logging, database rollback on errors,
and proper error responses (JSON vs HTML).
"""
import json
import logging
import pytest
from flask import Flask
from sqlalchemy.exc import SQLAlchemyError
from web.app import create_app
@pytest.fixture
def app():
"""Create test Flask app."""
test_config = {
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
'SECRET_KEY': 'test-secret-key',
'WTF_CSRF_ENABLED': False
}
app = create_app(test_config)
return app
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
class TestErrorHandlers:
"""Test error handler functionality."""
def test_404_json_response(self, client):
"""Test 404 error returns JSON for API requests."""
response = client.get('/api/nonexistent')
assert response.status_code == 404
assert response.content_type == 'application/json'
data = json.loads(response.data)
assert 'error' in data
assert data['error'] == 'Not Found'
assert 'message' in data
def test_404_html_response(self, client):
"""Test 404 error returns HTML for web requests."""
response = client.get('/nonexistent')
assert response.status_code == 404
assert 'text/html' in response.content_type
assert b'404' in response.data
def test_400_json_response(self, client):
"""Test 400 error returns JSON for API requests."""
# Trigger 400 by sending invalid JSON
response = client.post(
'/api/scans',
data='invalid json',
content_type='application/json'
)
assert response.status_code in [400, 401] # 401 if auth required
def test_405_method_not_allowed(self, client):
"""Test 405 error for method not allowed."""
# Try POST to health check (only GET allowed)
response = client.post('/api/scans/health')
assert response.status_code == 405
data = json.loads(response.data)
assert 'error' in data
assert data['error'] == 'Method Not Allowed'
def test_json_accept_header(self, client):
"""Test JSON response when Accept header specifies JSON."""
response = client.get(
'/nonexistent',
headers={'Accept': 'application/json'}
)
assert response.status_code == 404
assert response.content_type == 'application/json'
class TestLogging:
"""Test logging functionality."""
def test_request_logging(self, client, caplog):
"""Test that requests are logged."""
with caplog.at_level(logging.INFO):
response = client.get('/api/scans/health')
# Check log messages
log_messages = [record.message for record in caplog.records]
# Should log incoming request and response
assert any('GET /api/scans/health' in msg for msg in log_messages)
def test_error_logging(self, client, caplog):
"""Test that errors are logged with full context."""
with caplog.at_level(logging.INFO):
client.get('/api/nonexistent')
# Check that 404 was logged
log_messages = [record.message for record in caplog.records]
assert any('not found' in msg.lower() or '404' in msg for msg in log_messages)
def test_request_id_in_logs(self, client, caplog):
"""Test that request ID is included in log records."""
with caplog.at_level(logging.INFO):
client.get('/api/scans/health')
# Check that log records have request_id attribute
for record in caplog.records:
assert hasattr(record, 'request_id')
assert record.request_id # Should not be empty
class TestRequestResponseHandlers:
"""Test request and response handler middleware."""
def test_request_id_header(self, client):
"""Test that response includes X-Request-ID header for API requests."""
response = client.get('/api/scans/health')
assert 'X-Request-ID' in response.headers
def test_request_duration_header(self, client):
"""Test that response includes X-Request-Duration-Ms header."""
response = client.get('/api/scans/health')
assert 'X-Request-Duration-Ms' in response.headers
duration = float(response.headers['X-Request-Duration-Ms'])
assert duration >= 0 # Should be non-negative
def test_security_headers(self, client):
"""Test that security headers are added to API responses."""
response = client.get('/api/scans/health')
# Check security headers
assert response.headers.get('X-Content-Type-Options') == 'nosniff'
assert response.headers.get('X-Frame-Options') == 'DENY'
assert response.headers.get('X-XSS-Protection') == '1; mode=block'
def test_request_timing(self, client):
"""Test that request timing is calculated correctly."""
response = client.get('/api/scans/health')
duration_header = response.headers.get('X-Request-Duration-Ms')
assert duration_header is not None
duration = float(duration_header)
# Should complete in reasonable time (less than 5 seconds)
assert duration < 5000
class TestDatabaseErrorHandling:
"""Test database error handling and rollback."""
def test_database_rollback_on_error(self, app):
"""Test that database session is rolled back on error."""
# This test would require triggering a database error
# For now, just verify the error handler is registered
from sqlalchemy.exc import SQLAlchemyError
# Check that SQLAlchemyError handler is registered
assert SQLAlchemyError in app.error_handler_spec[None]
class TestLogRotation:
"""Test log rotation configuration."""
def test_log_files_created(self, app, tmp_path):
"""Test that log files are created in logs directory."""
import os
from pathlib import Path
# Check that logs directory exists
log_dir = Path('logs')
# Note: In test environment, logs may not be created immediately
# Just verify the configuration is set up
# Verify app logger has handlers
assert len(app.logger.handlers) > 0
# Verify at least one handler is a RotatingFileHandler
from logging.handlers import RotatingFileHandler
has_rotating_handler = any(
isinstance(h, RotatingFileHandler)
for h in app.logger.handlers
)
assert has_rotating_handler, "Should have RotatingFileHandler configured"
def test_log_handler_configuration(self, app):
"""Test that log handlers are configured correctly."""
from logging.handlers import RotatingFileHandler
# Find RotatingFileHandler
rotating_handlers = [
h for h in app.logger.handlers
if isinstance(h, RotatingFileHandler)
]
assert len(rotating_handlers) > 0, "Should have rotating file handlers"
# Check handler configuration
for handler in rotating_handlers:
# Should have max size configured
assert handler.maxBytes > 0
# Should have backup count configured
assert handler.backupCount > 0
class TestStructuredLogging:
"""Test structured logging features."""
def test_log_format_includes_request_id(self, client, caplog):
"""Test that log format includes request ID."""
with caplog.at_level(logging.INFO):
client.get('/api/scans/health')
# Verify log records have request_id
for record in caplog.records:
assert hasattr(record, 'request_id')
def test_error_log_includes_traceback(self, app, caplog):
"""Test that errors are logged with traceback."""
with app.test_request_context('/api/test'):
with caplog.at_level(logging.ERROR):
try:
raise ValueError("Test error")
except ValueError as e:
app.logger.error("Test error occurred", exc_info=True)
# Check that traceback is in logs
log_output = caplog.text
assert 'Test error' in log_output
assert 'Traceback' in log_output or 'ValueError' in log_output
class TestErrorTemplates:
"""Test error template rendering."""
def test_404_template_exists(self, client):
"""Test that 404 error template is rendered."""
response = client.get('/nonexistent')
assert response.status_code == 404
assert b'404' in response.data
assert b'Page Not Found' in response.data or b'Not Found' in response.data
def test_500_template_exists(self, app):
"""Test that 500 error template can be rendered."""
# We can't easily trigger a 500 without breaking the app
# Just verify the template file exists
from pathlib import Path
template_path = Path('web/templates/errors/500.html')
assert template_path.exists(), "500 error template should exist"
def test_error_template_styling(self, client):
"""Test that error templates include styling."""
response = client.get('/nonexistent')
# Should include CSS styling
assert b'style' in response.data or b'css' in response.data.lower()
if __name__ == '__main__':
pytest.main([__file__, '-v'])

267
app/tests/test_scan_api.py Normal file
View File

@@ -0,0 +1,267 @@
"""
Integration tests for Scan API endpoints.
Tests all scan management endpoints including triggering scans,
listing, retrieving details, deleting, and status polling.
"""
import json
import pytest
from pathlib import Path
from datetime import datetime
from web.models import Scan
class TestScanAPIEndpoints:
"""Test suite for scan API endpoints."""
def test_list_scans_empty(self, client, db):
"""Test listing scans when database is empty."""
response = client.get('/api/scans')
assert response.status_code == 200
data = json.loads(response.data)
assert data['scans'] == []
assert data['total'] == 0
assert data['page'] == 1
assert data['per_page'] == 20
def test_list_scans_with_data(self, client, db, sample_scan):
"""Test listing scans with existing data."""
response = client.get('/api/scans')
assert response.status_code == 200
data = json.loads(response.data)
assert data['total'] == 1
assert len(data['scans']) == 1
assert data['scans'][0]['id'] == sample_scan.id
def test_list_scans_pagination(self, client, db, sample_db_config):
"""Test scan list pagination."""
# Create 25 scans
for i in range(25):
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_id=sample_db_config.id,
title=f'Test Scan {i}',
triggered_by='test'
)
db.add(scan)
db.commit()
# Test page 1
response = client.get('/api/scans?page=1&per_page=10')
assert response.status_code == 200
data = json.loads(response.data)
assert data['total'] == 25
assert len(data['scans']) == 10
assert data['page'] == 1
assert data['per_page'] == 10
assert data['total_pages'] == 3
assert data['has_next'] is True
assert data['has_prev'] is False
# Test page 2
response = client.get('/api/scans?page=2&per_page=10')
assert response.status_code == 200
data = json.loads(response.data)
assert len(data['scans']) == 10
assert data['page'] == 2
assert data['has_next'] is True
assert data['has_prev'] is True
def test_list_scans_status_filter(self, client, db):
"""Test filtering scans by status."""
# Create scans with different statuses
for status in ['running', 'completed', 'failed']:
scan = Scan(
timestamp=datetime.utcnow(),
status=status,
config_id=1,
title=f'{status.capitalize()} Scan',
triggered_by='test'
)
db.add(scan)
db.commit()
# Filter by completed
response = client.get('/api/scans?status=completed')
assert response.status_code == 200
data = json.loads(response.data)
assert data['total'] == 1
assert data['scans'][0]['status'] == 'completed'
def test_list_scans_invalid_page(self, client, db):
"""Test listing scans with invalid page parameter."""
response = client.get('/api/scans?page=0')
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
def test_get_scan_success(self, client, db, sample_scan):
"""Test retrieving a specific scan."""
response = client.get(f'/api/scans/{sample_scan.id}')
assert response.status_code == 200
data = json.loads(response.data)
assert data['id'] == sample_scan.id
assert data['title'] == sample_scan.title
assert data['status'] == sample_scan.status
def test_get_scan_not_found(self, client, db):
"""Test retrieving a non-existent scan."""
response = client.get('/api/scans/99999')
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
assert data['error'] == 'Not found'
def test_trigger_scan_success(self, client, db, sample_db_config):
"""Test triggering a new scan."""
response = client.post('/api/scans',
json={'config_id': sample_db_config.id},
content_type='application/json'
)
assert response.status_code == 201
data = json.loads(response.data)
assert 'scan_id' in data
assert data['status'] == 'running'
assert data['message'] == 'Scan queued successfully'
# Verify scan was created in database
scan = db.query(Scan).filter_by(id=data['scan_id']).first()
assert scan is not None
assert scan.status == 'running'
assert scan.triggered_by == 'api'
def test_trigger_scan_missing_config_id(self, client, db):
"""Test triggering scan without config_id."""
response = client.post('/api/scans',
json={},
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
assert 'config_id is required' in data['message']
def test_trigger_scan_invalid_config_id(self, client, db):
"""Test triggering scan with non-existent config."""
response = client.post('/api/scans',
json={'config_id': 99999},
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
def test_delete_scan_success(self, client, db, sample_scan):
"""Test deleting a scan."""
scan_id = sample_scan.id
response = client.delete(f'/api/scans/{scan_id}')
assert response.status_code == 200
data = json.loads(response.data)
assert data['scan_id'] == scan_id
assert 'deleted successfully' in data['message']
# Verify scan was deleted from database
scan = db.query(Scan).filter_by(id=scan_id).first()
assert scan is None
def test_delete_scan_not_found(self, client, db):
"""Test deleting a non-existent scan."""
response = client.delete('/api/scans/99999')
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
def test_get_scan_status_success(self, client, db, sample_scan):
"""Test getting scan status."""
response = client.get(f'/api/scans/{sample_scan.id}/status')
assert response.status_code == 200
data = json.loads(response.data)
assert data['scan_id'] == sample_scan.id
assert data['status'] == sample_scan.status
assert 'timestamp' in data
def test_get_scan_status_not_found(self, client, db):
"""Test getting status for non-existent scan."""
response = client.get('/api/scans/99999/status')
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
def test_api_error_handling(self, client, db):
"""Test API error responses are properly formatted."""
# Test 404
response = client.get('/api/scans/99999')
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
assert 'message' in data
# Test 400
response = client.post('/api/scans', json={})
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
assert 'message' in data
def test_scan_workflow_integration(self, client, db, sample_db_config):
"""
Test complete scan workflow: trigger → status → retrieve → delete.
This integration test verifies the entire scan lifecycle through
the API endpoints.
"""
# Step 1: Trigger scan
response = client.post('/api/scans',
json={'config_id': sample_db_config.id},
content_type='application/json'
)
assert response.status_code == 201
data = json.loads(response.data)
scan_id = data['scan_id']
# Step 2: Check status
response = client.get(f'/api/scans/{scan_id}/status')
assert response.status_code == 200
data = json.loads(response.data)
assert data['scan_id'] == scan_id
assert data['status'] == 'running'
# Step 3: List scans (verify it appears)
response = client.get('/api/scans')
assert response.status_code == 200
data = json.loads(response.data)
assert data['total'] == 1
assert data['scans'][0]['id'] == scan_id
# Step 4: Get scan details
response = client.get(f'/api/scans/{scan_id}')
assert response.status_code == 200
data = json.loads(response.data)
assert data['id'] == scan_id
# Step 5: Delete scan
response = client.delete(f'/api/scans/{scan_id}')
assert response.status_code == 200
# Step 6: Verify deletion
response = client.get(f'/api/scans/{scan_id}')
assert response.status_code == 404

View File

@@ -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_db_config):
"""Create first scan with test data."""
service = ScanService(test_db)
scan_id = service.trigger_scan(sample_db_config, 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_db_config):
"""Create second scan with modified test data."""
service = ScanService(test_db)
scan_id = service.trigger_scan(sample_db_config, 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

View File

@@ -13,49 +13,42 @@ from web.services.scan_service import ScanService
class TestScanServiceTrigger: class TestScanServiceTrigger:
"""Tests for triggering scans.""" """Tests for triggering scans."""
def test_trigger_scan_valid_config(self, test_db, sample_config_file): def test_trigger_scan_valid_config(self, db, sample_db_config):
"""Test triggering a scan with valid config file.""" """Test triggering a scan with valid config."""
service = ScanService(test_db) service = ScanService(db)
scan_id = service.trigger_scan(sample_config_file, triggered_by='manual') scan_id = service.trigger_scan(config_id=sample_db_config.id, triggered_by='manual')
# Verify scan created # Verify scan created
assert scan_id is not None assert scan_id is not None
assert isinstance(scan_id, int) assert isinstance(scan_id, int)
# Verify scan in database # Verify scan in database
scan = test_db.query(Scan).filter(Scan.id == scan_id).first() scan = db.query(Scan).filter(Scan.id == scan_id).first()
assert scan is not None assert scan is not None
assert scan.status == 'running' assert scan.status == 'running'
assert scan.title == 'Test Scan' assert scan.title == 'Test Scan'
assert scan.triggered_by == 'manual' assert scan.triggered_by == 'manual'
assert scan.config_file == sample_config_file assert scan.config_id == sample_db_config.id
def test_trigger_scan_invalid_config(self, test_db, sample_invalid_config_file): def test_trigger_scan_invalid_config(self, db):
"""Test triggering a scan with invalid config file.""" """Test triggering a scan with invalid config ID."""
service = ScanService(test_db) service = ScanService(db)
with pytest.raises(ValueError, match="Invalid config file"): with pytest.raises(ValueError, match="not found"):
service.trigger_scan(sample_invalid_config_file) service.trigger_scan(config_id=99999)
def test_trigger_scan_nonexistent_file(self, test_db): def test_trigger_scan_with_schedule(self, db, sample_db_config):
"""Test triggering a scan with nonexistent config file."""
service = ScanService(test_db)
with pytest.raises(ValueError, match="does not exist"):
service.trigger_scan('/nonexistent/config.yaml')
def test_trigger_scan_with_schedule(self, test_db, sample_config_file):
"""Test triggering a scan via schedule.""" """Test triggering a scan via schedule."""
service = ScanService(test_db) service = ScanService(db)
scan_id = service.trigger_scan( scan_id = service.trigger_scan(
sample_config_file, config_id=sample_db_config.id,
triggered_by='scheduled', triggered_by='scheduled',
schedule_id=42 schedule_id=42
) )
scan = test_db.query(Scan).filter(Scan.id == scan_id).first() scan = db.query(Scan).filter(Scan.id == scan_id).first()
assert scan.triggered_by == 'scheduled' assert scan.triggered_by == 'scheduled'
assert scan.schedule_id == 42 assert scan.schedule_id == 42
@@ -63,19 +56,19 @@ class TestScanServiceTrigger:
class TestScanServiceGet: class TestScanServiceGet:
"""Tests for retrieving scans.""" """Tests for retrieving scans."""
def test_get_scan_not_found(self, test_db): def test_get_scan_not_found(self, db):
"""Test getting a nonexistent scan.""" """Test getting a nonexistent scan."""
service = ScanService(test_db) service = ScanService(db)
result = service.get_scan(999) result = service.get_scan(999)
assert result is None assert result is None
def test_get_scan_found(self, test_db, sample_config_file): def test_get_scan_found(self, db, sample_db_config):
"""Test getting an existing scan.""" """Test getting an existing scan."""
service = ScanService(test_db) service = ScanService(db)
# Create a scan # Create a scan
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
# Retrieve it # Retrieve it
result = service.get_scan(scan_id) result = service.get_scan(scan_id)
@@ -90,9 +83,9 @@ class TestScanServiceGet:
class TestScanServiceList: class TestScanServiceList:
"""Tests for listing scans.""" """Tests for listing scans."""
def test_list_scans_empty(self, test_db): def test_list_scans_empty(self, db):
"""Test listing scans when database is empty.""" """Test listing scans when database is empty."""
service = ScanService(test_db) service = ScanService(db)
result = service.list_scans(page=1, per_page=20) result = service.list_scans(page=1, per_page=20)
@@ -100,13 +93,13 @@ class TestScanServiceList:
assert len(result.items) == 0 assert len(result.items) == 0
assert result.pages == 0 assert result.pages == 0
def test_list_scans_with_data(self, test_db, sample_config_file): def test_list_scans_with_data(self, db, sample_db_config):
"""Test listing scans with multiple scans.""" """Test listing scans with multiple scans."""
service = ScanService(test_db) service = ScanService(db)
# Create 3 scans # Create 3 scans
for i in range(3): for i in range(3):
service.trigger_scan(sample_config_file, triggered_by='api') service.trigger_scan(config_id=sample_db_config.id, triggered_by='api')
# List all scans # List all scans
result = service.list_scans(page=1, per_page=20) result = service.list_scans(page=1, per_page=20)
@@ -115,13 +108,13 @@ class TestScanServiceList:
assert len(result.items) == 3 assert len(result.items) == 3
assert result.pages == 1 assert result.pages == 1
def test_list_scans_pagination(self, test_db, sample_config_file): def test_list_scans_pagination(self, db, sample_db_config):
"""Test pagination.""" """Test pagination."""
service = ScanService(test_db) service = ScanService(db)
# Create 5 scans # Create 5 scans
for i in range(5): for i in range(5):
service.trigger_scan(sample_config_file) service.trigger_scan(config_id=sample_db_config.id)
# Get page 1 (2 items per page) # Get page 1 (2 items per page)
result = service.list_scans(page=1, per_page=2) result = service.list_scans(page=1, per_page=2)
@@ -141,18 +134,18 @@ class TestScanServiceList:
assert len(result.items) == 1 assert len(result.items) == 1
assert result.has_next is False assert result.has_next is False
def test_list_scans_filter_by_status(self, test_db, sample_config_file): def test_list_scans_filter_by_status(self, db, sample_db_config):
"""Test filtering scans by status.""" """Test filtering scans by status."""
service = ScanService(test_db) service = ScanService(db)
# Create scans with different statuses # Create scans with different statuses
scan_id_1 = service.trigger_scan(sample_config_file) scan_id_1 = service.trigger_scan(config_id=sample_db_config.id)
scan_id_2 = service.trigger_scan(sample_config_file) scan_id_2 = service.trigger_scan(config_id=sample_db_config.id)
# Mark one as completed # Mark one as completed
scan = test_db.query(Scan).filter(Scan.id == scan_id_1).first() scan = db.query(Scan).filter(Scan.id == scan_id_1).first()
scan.status = 'completed' scan.status = 'completed'
test_db.commit() db.commit()
# Filter by running # Filter by running
result = service.list_scans(status_filter='running') result = service.list_scans(status_filter='running')
@@ -162,9 +155,9 @@ class TestScanServiceList:
result = service.list_scans(status_filter='completed') result = service.list_scans(status_filter='completed')
assert result.total == 1 assert result.total == 1
def test_list_scans_invalid_status_filter(self, test_db): def test_list_scans_invalid_status_filter(self, db):
"""Test filtering with invalid status.""" """Test filtering with invalid status."""
service = ScanService(test_db) service = ScanService(db)
with pytest.raises(ValueError, match="Invalid status"): with pytest.raises(ValueError, match="Invalid status"):
service.list_scans(status_filter='invalid_status') service.list_scans(status_filter='invalid_status')
@@ -173,46 +166,46 @@ class TestScanServiceList:
class TestScanServiceDelete: class TestScanServiceDelete:
"""Tests for deleting scans.""" """Tests for deleting scans."""
def test_delete_scan_not_found(self, test_db): def test_delete_scan_not_found(self, db):
"""Test deleting a nonexistent scan.""" """Test deleting a nonexistent scan."""
service = ScanService(test_db) service = ScanService(db)
with pytest.raises(ValueError, match="not found"): with pytest.raises(ValueError, match="not found"):
service.delete_scan(999) service.delete_scan(999)
def test_delete_scan_success(self, test_db, sample_config_file): def test_delete_scan_success(self, db, sample_db_config):
"""Test successful scan deletion.""" """Test successful scan deletion."""
service = ScanService(test_db) service = ScanService(db)
# Create a scan # Create a scan
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
# Verify it exists # Verify it exists
assert test_db.query(Scan).filter(Scan.id == scan_id).first() is not None assert db.query(Scan).filter(Scan.id == scan_id).first() is not None
# Delete it # Delete it
result = service.delete_scan(scan_id) result = service.delete_scan(scan_id)
assert result is True assert result is True
# Verify it's gone # Verify it's gone
assert test_db.query(Scan).filter(Scan.id == scan_id).first() is None assert db.query(Scan).filter(Scan.id == scan_id).first() is None
class TestScanServiceStatus: class TestScanServiceStatus:
"""Tests for scan status retrieval.""" """Tests for scan status retrieval."""
def test_get_scan_status_not_found(self, test_db): def test_get_scan_status_not_found(self, db):
"""Test getting status of nonexistent scan.""" """Test getting status of nonexistent scan."""
service = ScanService(test_db) service = ScanService(db)
result = service.get_scan_status(999) result = service.get_scan_status(999)
assert result is None assert result is None
def test_get_scan_status_running(self, test_db, sample_config_file): def test_get_scan_status_running(self, db, sample_db_config):
"""Test getting status of running scan.""" """Test getting status of running scan."""
service = ScanService(test_db) service = ScanService(db)
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
status = service.get_scan_status(scan_id) status = service.get_scan_status(scan_id)
assert status is not None assert status is not None
@@ -221,16 +214,16 @@ class TestScanServiceStatus:
assert status['progress'] == 'In progress' assert status['progress'] == 'In progress'
assert status['title'] == 'Test Scan' assert status['title'] == 'Test Scan'
def test_get_scan_status_completed(self, test_db, sample_config_file): def test_get_scan_status_completed(self, db, sample_db_config):
"""Test getting status of completed scan.""" """Test getting status of completed scan."""
service = ScanService(test_db) service = ScanService(db)
# Create and mark as completed # Create and mark as completed
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
scan = test_db.query(Scan).filter(Scan.id == scan_id).first() scan = db.query(Scan).filter(Scan.id == scan_id).first()
scan.status = 'completed' scan.status = 'completed'
scan.duration = 125.5 scan.duration = 125.5
test_db.commit() db.commit()
status = service.get_scan_status(scan_id) status = service.get_scan_status(scan_id)
@@ -242,35 +235,35 @@ class TestScanServiceStatus:
class TestScanServiceDatabaseMapping: class TestScanServiceDatabaseMapping:
"""Tests for mapping scan reports to database models.""" """Tests for mapping scan reports to database models."""
def test_save_scan_to_db(self, test_db, sample_config_file, sample_scan_report): def test_save_scan_to_db(self, db, sample_db_config, sample_scan_report):
"""Test saving a complete scan report to database.""" """Test saving a complete scan report to database."""
service = ScanService(test_db) service = ScanService(db)
# Create a scan # Create a scan
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
# Save report to database # Save report to database
service._save_scan_to_db(sample_scan_report, scan_id, status='completed') service._save_scan_to_db(sample_scan_report, scan_id, status='completed')
# Verify scan updated # Verify scan updated
scan = test_db.query(Scan).filter(Scan.id == scan_id).first() scan = db.query(Scan).filter(Scan.id == scan_id).first()
assert scan.status == 'completed' assert scan.status == 'completed'
assert scan.duration == 125.5 assert scan.duration == 125.5
# Verify sites created # Verify sites created
sites = test_db.query(ScanSite).filter(ScanSite.scan_id == scan_id).all() sites = db.query(ScanSite).filter(ScanSite.scan_id == scan_id).all()
assert len(sites) == 1 assert len(sites) == 1
assert sites[0].site_name == 'Test Site' assert sites[0].site_name == 'Test Site'
# Verify IPs created # Verify IPs created
ips = test_db.query(ScanIP).filter(ScanIP.scan_id == scan_id).all() ips = db.query(ScanIP).filter(ScanIP.scan_id == scan_id).all()
assert len(ips) == 1 assert len(ips) == 1
assert ips[0].ip_address == '192.168.1.10' assert ips[0].ip_address == '192.168.1.10'
assert ips[0].ping_expected is True assert ips[0].ping_expected is True
assert ips[0].ping_actual is True assert ips[0].ping_actual is True
# Verify ports created (TCP: 22, 80, 443, 8080 | UDP: 53) # Verify ports created (TCP: 22, 80, 443, 8080 | UDP: 53)
ports = test_db.query(ScanPort).filter(ScanPort.scan_id == scan_id).all() ports = db.query(ScanPort).filter(ScanPort.scan_id == scan_id).all()
assert len(ports) == 5 # 4 TCP + 1 UDP assert len(ports) == 5 # 4 TCP + 1 UDP
# Verify TCP ports # Verify TCP ports
@@ -285,7 +278,7 @@ class TestScanServiceDatabaseMapping:
assert udp_ports[0].port == 53 assert udp_ports[0].port == 53
# Verify services created # Verify services created
services = test_db.query(ScanServiceModel).filter( services = db.query(ScanServiceModel).filter(
ScanServiceModel.scan_id == scan_id ScanServiceModel.scan_id == scan_id
).all() ).all()
assert len(services) == 4 # SSH, HTTP (80), HTTPS, HTTP (8080) assert len(services) == 4 # SSH, HTTP (80), HTTPS, HTTP (8080)
@@ -300,15 +293,15 @@ class TestScanServiceDatabaseMapping:
assert https_service.http_protocol == 'https' assert https_service.http_protocol == 'https'
assert https_service.screenshot_path == 'screenshots/192_168_1_10_443.png' assert https_service.screenshot_path == 'screenshots/192_168_1_10_443.png'
def test_map_port_expected_vs_actual(self, test_db, sample_config_file, sample_scan_report): def test_map_port_expected_vs_actual(self, db, sample_db_config, sample_scan_report):
"""Test that expected vs actual ports are correctly flagged.""" """Test that expected vs actual ports are correctly flagged."""
service = ScanService(test_db) service = ScanService(db)
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
service._save_scan_to_db(sample_scan_report, scan_id) service._save_scan_to_db(sample_scan_report, scan_id)
# Check TCP ports # Check TCP ports
tcp_ports = test_db.query(ScanPort).filter( tcp_ports = db.query(ScanPort).filter(
ScanPort.scan_id == scan_id, ScanPort.scan_id == scan_id,
ScanPort.protocol == 'tcp' ScanPort.protocol == 'tcp'
).all() ).all()
@@ -322,15 +315,15 @@ class TestScanServiceDatabaseMapping:
# Port 8080 was not expected # Port 8080 was not expected
assert port.expected is False, f"Port {port.port} should not be expected" assert port.expected is False, f"Port {port.port} should not be expected"
def test_map_certificate_and_tls(self, test_db, sample_config_file, sample_scan_report): def test_map_certificate_and_tls(self, db, sample_db_config, sample_scan_report):
"""Test that certificate and TLS data are correctly mapped.""" """Test that certificate and TLS data are correctly mapped."""
service = ScanService(test_db) service = ScanService(db)
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
service._save_scan_to_db(sample_scan_report, scan_id) service._save_scan_to_db(sample_scan_report, scan_id)
# Find HTTPS service # Find HTTPS service
https_service = test_db.query(ScanServiceModel).filter( https_service = db.query(ScanServiceModel).filter(
ScanServiceModel.scan_id == scan_id, ScanServiceModel.scan_id == scan_id,
ScanServiceModel.service_name == 'https' ScanServiceModel.service_name == 'https'
).first() ).first()
@@ -363,11 +356,11 @@ class TestScanServiceDatabaseMapping:
assert tls_13 is not None assert tls_13 is not None
assert tls_13.supported is True assert tls_13.supported is True
def test_get_scan_with_full_details(self, test_db, sample_config_file, sample_scan_report): def test_get_scan_with_full_details(self, db, sample_db_config, sample_scan_report):
"""Test retrieving scan with all nested relationships.""" """Test retrieving scan with all nested relationships."""
service = ScanService(test_db) service = ScanService(db)
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
service._save_scan_to_db(sample_scan_report, scan_id) service._save_scan_to_db(sample_scan_report, scan_id)
# Get full scan details # Get full scan details

View File

@@ -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_db_config):
"""
Create a sample schedule in the database for testing.
Args:
db: Database session fixture
sample_db_config: Path to test config file
Returns:
Schedule model instance
"""
schedule = Schedule(
name='Daily Test Scan',
config_id=sample_db_config.id,
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_db_config):
"""Test schedule list pagination."""
# Create 25 schedules
for i in range(25):
schedule = Schedule(
name=f'Schedule {i}',
config_id=sample_db_config.id,
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_db_config):
"""Test filtering schedules by enabled status."""
# Create enabled and disabled schedules
for i in range(3):
schedule = Schedule(
name=f'Enabled Schedule {i}',
config_id=sample_db_config.id,
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_id=sample_db_config.id,
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_id'] == sample_schedule.config_id
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_db_config):
"""Test creating a new schedule."""
schedule_data = {
'name': 'New Test Schedule',
'config_id': sample_db_config.id,
'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_id': 1
}
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_db_config):
"""Test creating schedule with invalid cron expression."""
schedule_data = {
'name': 'Invalid Cron Schedule',
'config_id': sample_db_config.id,
'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."""
schedule_data = {
'name': 'Invalid Config Schedule',
'config_id': 99999,
'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_db_config):
"""Test that deleting schedule preserves associated scans."""
# Create a scan associated with the schedule
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_id=sample_db_config.id,
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_id == sample_schedule.config_id
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_db_config):
"""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_id=sample_db_config.id,
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_db_config):
"""Test complete schedule workflow: create → update → trigger → delete."""
# 1. Create schedule
schedule_data = {
'name': 'Integration Test Schedule',
'config_id': sample_db_config.id,
'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_db_config):
"""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_id=sample_db_config.id,
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_id=sample_db_config.id,
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_db_config):
"""Test creating a disabled schedule."""
schedule_data = {
'name': 'Disabled Schedule',
'config_id': sample_db_config.id,
'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_db_config):
"""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_id': sample_db_config.id,
'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_db_config):
"""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_id': sample_db_config.id,
'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"

View File

@@ -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, db, sample_db_config):
"""Test creating a schedule with valid parameters."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Daily Scan',
config_id=sample_db_config.id,
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 = db.query(Schedule).filter(Schedule.id == schedule_id).first()
assert schedule is not None
assert schedule.name == 'Daily Scan'
assert schedule.config_id == sample_db_config.id
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, db, sample_db_config):
"""Test creating a disabled schedule."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Disabled Scan',
config_id=sample_db_config.id,
cron_expression='0 3 * * *',
enabled=False
)
schedule = 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, db, sample_db_config):
"""Test creating a schedule with invalid cron expression."""
service = ScheduleService(db)
with pytest.raises(ValueError, match="Invalid cron expression"):
service.create_schedule(
name='Invalid Schedule',
config_id=sample_db_config.id,
cron_expression='invalid cron',
enabled=True
)
def test_create_schedule_nonexistent_config(self, db):
"""Test creating a schedule with nonexistent config."""
service = ScheduleService(db)
with pytest.raises(ValueError, match="not found"):
service.create_schedule(
name='Bad Config',
config_id=99999,
cron_expression='0 2 * * *',
enabled=True
)
def test_create_schedule_various_cron_expressions(self, db, sample_db_config):
"""Test creating schedules with various valid cron expressions."""
service = ScheduleService(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_id=sample_db_config.id,
cron_expression=cron,
enabled=True
)
assert schedule_id is not None
class TestScheduleServiceGet:
"""Tests for retrieving schedules."""
def test_get_schedule_not_found(self, db):
"""Test getting a nonexistent schedule."""
service = ScheduleService(db)
with pytest.raises(ValueError, match="Schedule .* not found"):
service.get_schedule(999)
def test_get_schedule_found(self, db, sample_db_config):
"""Test getting an existing schedule."""
service = ScheduleService(db)
# Create a schedule
schedule_id = service.create_schedule(
name='Test Schedule',
config_id=sample_db_config.id,
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, db, sample_db_config):
"""Test getting schedule includes execution history."""
service = ScheduleService(db)
# Create schedule
schedule_id = service.create_schedule(
name='Test Schedule',
config_id=sample_db_config.id,
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_id=sample_db_config.id,
title=f'Scan {i}',
triggered_by='scheduled',
schedule_id=schedule_id
)
db.add(scan)
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, db):
"""Test listing schedules when database is empty."""
service = ScheduleService(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, db, sample_db_config):
"""Test listing schedules with data."""
service = ScheduleService(db)
# Create multiple schedules
for i in range(5):
service.create_schedule(
name=f'Schedule {i}',
config_id=sample_db_config.id,
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, db, sample_db_config):
"""Test schedule pagination."""
service = ScheduleService(db)
# Create 25 schedules
for i in range(25):
service.create_schedule(
name=f'Schedule {i:02d}',
config_id=sample_db_config.id,
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, db, sample_db_config):
"""Test filtering schedules by enabled status."""
service = ScheduleService(db)
# Create enabled and disabled schedules
for i in range(3):
service.create_schedule(
name=f'Enabled {i}',
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
for i in range(2):
service.create_schedule(
name=f'Disabled {i}',
config_id=sample_db_config.id,
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, db, sample_db_config):
"""Test updating schedule name."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Old Name',
config_id=sample_db_config.id,
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, db, sample_db_config):
"""Test updating cron expression recalculates next_run."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_id=sample_db_config.id,
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, db, sample_db_config):
"""Test updating with invalid cron expression fails."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_id=sample_db_config.id,
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, db):
"""Test updating nonexistent schedule fails."""
service = ScheduleService(db)
with pytest.raises(ValueError, match="Schedule .* not found"):
service.update_schedule(999, name='New Name')
def test_update_schedule_invalid_config_id(self, db, sample_db_config):
"""Test updating with nonexistent config ID fails."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
with pytest.raises(ValueError, match="not found"):
service.update_schedule(schedule_id, config_id=99999)
class TestScheduleServiceDelete:
"""Tests for deleting schedules."""
def test_delete_schedule(self, db, sample_db_config):
"""Test deleting a schedule."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='To Delete',
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
# Verify exists
assert 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 db.query(Schedule).filter(Schedule.id == schedule_id).first() is None
def test_delete_schedule_not_found(self, db):
"""Test deleting nonexistent schedule fails."""
service = ScheduleService(db)
with pytest.raises(ValueError, match="Schedule .* not found"):
service.delete_schedule(999)
def test_delete_schedule_preserves_scans(self, db, sample_db_config):
"""Test that deleting schedule preserves associated scans."""
service = ScheduleService(db)
# Create schedule
schedule_id = service.create_schedule(
name='Test',
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
# Create associated scan
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_id=sample_db_config.id,
title='Test Scan',
triggered_by='scheduled',
schedule_id=schedule_id
)
db.add(scan)
db.commit()
scan_id = scan.id
# Delete schedule
service.delete_schedule(schedule_id)
# Verify scan still exists (schedule_id becomes null)
remaining_scan = 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, db, sample_db_config):
"""Test disabling an enabled schedule."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_id=sample_db_config.id,
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, db, sample_db_config):
"""Test enabling a disabled schedule."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_id=sample_db_config.id,
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, db, sample_db_config):
"""Test updating last_run and next_run."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_id=sample_db_config.id,
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, db):
"""Test updating run times for nonexistent schedule."""
service = ScheduleService(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, db):
"""Test validating various valid cron expressions."""
service = ScheduleService(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, db):
"""Test validating invalid cron expressions."""
service = ScheduleService(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, db):
"""Test calculating next run time."""
service = ScheduleService(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, db):
"""Test calculating next run from specific time."""
service = ScheduleService(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, db):
"""Test calculating next run with invalid cron raises error."""
service = ScheduleService(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, db, sample_db_config):
"""Test getting history for schedule with no executions."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_id=sample_db_config.id,
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, db, sample_db_config):
"""Test getting history with multiple scans."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_id=sample_db_config.id,
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_id=sample_db_config.id,
title=f'Scan {i}',
triggered_by='scheduled',
schedule_id=schedule_id
)
db.add(scan)
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, db, sample_db_config):
"""Test getting history with custom limit."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_id=sample_db_config.id,
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_id=sample_db_config.id,
title=f'Scan {i}',
triggered_by='scheduled',
schedule_id=schedule_id
)
db.add(scan)
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, db, sample_db_config):
"""Test converting schedule to dictionary."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test Schedule',
config_id=sample_db_config.id,
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_id' 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, db, sample_db_config):
"""Test relative time formatting in schedule dict."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_id=sample_db_config.id,
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()

325
app/tests/test_stats_api.py Normal file
View File

@@ -0,0 +1,325 @@
"""
Tests for stats API endpoints.
Tests dashboard statistics and trending data endpoints.
"""
import pytest
from datetime import datetime, timedelta
from web.models import Scan
class TestStatsAPI:
"""Test suite for stats API endpoints."""
def test_scan_trend_default_30_days(self, client, auth_headers, db_session):
"""Test scan trend endpoint with default 30 days."""
# Create test scans over multiple days
today = datetime.utcnow()
for i in range(5):
scan_date = today - timedelta(days=i)
for j in range(i + 1): # Create 1, 2, 3, 4, 5 scans per day
scan = Scan(
config_id=1,
timestamp=scan_date,
status='completed',
duration=10.5
)
db_session.add(scan)
db_session.commit()
# Request trend data
response = client.get('/api/stats/scan-trend', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert 'labels' in data
assert 'values' in data
assert 'start_date' in data
assert 'end_date' in data
assert 'total_scans' in data
# Should have 30 days of data
assert len(data['labels']) == 30
assert len(data['values']) == 30
# Total scans should match (1+2+3+4+5 = 15)
assert data['total_scans'] == 15
# Values should be non-negative integers
assert all(isinstance(v, int) for v in data['values'])
assert all(v >= 0 for v in data['values'])
def test_scan_trend_custom_days(self, client, auth_headers, db_session):
"""Test scan trend endpoint with custom number of days."""
# Create test scans
today = datetime.utcnow()
for i in range(10):
scan = Scan(
config_id=1,
timestamp=today - timedelta(days=i),
status='completed',
duration=10.5
)
db_session.add(scan)
db_session.commit()
# Request 7 days of data
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert len(data['labels']) == 7
assert len(data['values']) == 7
assert data['total_scans'] == 7
def test_scan_trend_max_days_365(self, client, auth_headers):
"""Test scan trend endpoint accepts maximum 365 days."""
response = client.get('/api/stats/scan-trend?days=365', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert len(data['labels']) == 365
def test_scan_trend_rejects_days_over_365(self, client, auth_headers):
"""Test scan trend endpoint rejects more than 365 days."""
response = client.get('/api/stats/scan-trend?days=366', headers=auth_headers)
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
assert '365' in data['error']
def test_scan_trend_rejects_days_less_than_1(self, client, auth_headers):
"""Test scan trend endpoint rejects days less than 1."""
response = client.get('/api/stats/scan-trend?days=0', headers=auth_headers)
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
def test_scan_trend_fills_missing_days_with_zero(self, client, auth_headers, db_session):
"""Test scan trend fills days with no scans as zero."""
# Create scans only on specific days
today = datetime.utcnow()
# Create scan 5 days ago
scan1 = Scan(
config_id=1,
timestamp=today - timedelta(days=5),
status='completed',
duration=10.5
)
db_session.add(scan1)
# Create scan 10 days ago
scan2 = Scan(
config_id=1,
timestamp=today - timedelta(days=10),
status='completed',
duration=10.5
)
db_session.add(scan2)
db_session.commit()
# Request 15 days
response = client.get('/api/stats/scan-trend?days=15', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should have 15 days of data
assert len(data['values']) == 15
# Most days should be zero
zero_days = sum(1 for v in data['values'] if v == 0)
assert zero_days >= 13 # At least 13 days with no scans
def test_scan_trend_requires_authentication(self, client):
"""Test scan trend endpoint requires authentication."""
response = client.get('/api/stats/scan-trend')
assert response.status_code == 401
def test_summary_endpoint(self, client, auth_headers, db_session):
"""Test summary statistics endpoint."""
# Create test scans with different statuses
today = datetime.utcnow()
# 5 completed scans
for i in range(5):
scan = Scan(
config_id=1,
timestamp=today - timedelta(days=i),
status='completed',
duration=10.5
)
db_session.add(scan)
# 2 failed scans
for i in range(2):
scan = Scan(
config_id=1,
timestamp=today - timedelta(days=i),
status='failed',
duration=5.0
)
db_session.add(scan)
# 1 running scan
scan = Scan(
config_id=1,
timestamp=today,
status='running',
duration=None
)
db_session.add(scan)
db_session.commit()
# Request summary
response = client.get('/api/stats/summary', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert 'total_scans' in data
assert 'completed_scans' in data
assert 'failed_scans' in data
assert 'running_scans' in data
assert 'scans_today' in data
assert 'scans_this_week' in data
# Verify counts
assert data['total_scans'] == 8
assert data['completed_scans'] == 5
assert data['failed_scans'] == 2
assert data['running_scans'] == 1
assert data['scans_today'] >= 1
assert data['scans_this_week'] >= 1
def test_summary_with_no_scans(self, client, auth_headers):
"""Test summary endpoint with no scans."""
response = client.get('/api/stats/summary', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data['total_scans'] == 0
assert data['completed_scans'] == 0
assert data['failed_scans'] == 0
assert data['running_scans'] == 0
assert data['scans_today'] == 0
assert data['scans_this_week'] == 0
def test_summary_scans_today(self, client, auth_headers, db_session):
"""Test summary counts scans today correctly."""
today = datetime.utcnow()
yesterday = today - timedelta(days=1)
# Create 3 scans today
for i in range(3):
scan = Scan(
config_id=1,
timestamp=today,
status='completed',
duration=10.5
)
db_session.add(scan)
# Create 2 scans yesterday
for i in range(2):
scan = Scan(
config_id=1,
timestamp=yesterday,
status='completed',
duration=10.5
)
db_session.add(scan)
db_session.commit()
response = client.get('/api/stats/summary', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data['scans_today'] == 3
assert data['scans_this_week'] >= 3
def test_summary_scans_this_week(self, client, auth_headers, db_session):
"""Test summary counts scans this week correctly."""
today = datetime.utcnow()
# Create scans over the last 10 days
for i in range(10):
scan = Scan(
config_id=1,
timestamp=today - timedelta(days=i),
status='completed',
duration=10.5
)
db_session.add(scan)
db_session.commit()
response = client.get('/api/stats/summary', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Last 7 days (0-6) = 7 scans
assert data['scans_this_week'] == 7
def test_summary_requires_authentication(self, client):
"""Test summary endpoint requires authentication."""
response = client.get('/api/stats/summary')
assert response.status_code == 401
def test_scan_trend_date_format(self, client, auth_headers, db_session):
"""Test scan trend returns dates in correct format."""
# Create a scan
scan = Scan(
config_id=1,
timestamp=datetime.utcnow(),
status='completed',
duration=10.5
)
db_session.add(scan)
db_session.commit()
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Check date format (YYYY-MM-DD)
for label in data['labels']:
assert len(label) == 10
assert label[4] == '-'
assert label[7] == '-'
# Try parsing to ensure valid date
datetime.strptime(label, '%Y-%m-%d')
def test_scan_trend_consecutive_dates(self, client, auth_headers):
"""Test scan trend returns consecutive dates."""
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
labels = data['labels']
# Convert to datetime objects
dates = [datetime.strptime(label, '%Y-%m-%d') for label in labels]
# Check dates are consecutive
for i in range(len(dates) - 1):
diff = dates[i + 1] - dates[i]
assert diff.days == 1, f"Dates not consecutive: {dates[i]} to {dates[i+1]}"
def test_scan_trend_ends_with_today(self, client, auth_headers):
"""Test scan trend ends with today's date."""
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Last date should be today
today = datetime.utcnow().date()
last_date = datetime.strptime(data['labels'][-1], '%Y-%m-%d').date()
assert last_date == today

535
app/web/api/alerts.py Normal file
View File

@@ -0,0 +1,535 @@
"""
Alerts API blueprint.
Handles endpoints for viewing alert history and managing alert rules.
"""
import json
from datetime import datetime, timedelta, timezone
from flask import Blueprint, jsonify, request, current_app
from web.auth.decorators import api_auth_required
from web.models import Alert, AlertRule, Scan
from web.services.alert_service import AlertService
bp = Blueprint('alerts', __name__)
@bp.route('', methods=['GET'])
@api_auth_required
def list_alerts():
"""
List recent alerts.
Query params:
page: Page number (default: 1)
per_page: Items per page (default: 20)
alert_type: Filter by alert type
severity: Filter by severity (info, warning, critical)
acknowledged: Filter by acknowledgment status (true/false)
scan_id: Filter by specific scan
start_date: Filter alerts after this date (ISO format)
end_date: Filter alerts before this date (ISO format)
Returns:
JSON response with alerts list
"""
# Get query parameters
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100) # Max 100 items
alert_type = request.args.get('alert_type')
severity = request.args.get('severity')
acknowledged = request.args.get('acknowledged')
scan_id = request.args.get('scan_id', type=int)
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
# Build query
query = current_app.db_session.query(Alert)
# Apply filters
if alert_type:
query = query.filter(Alert.alert_type == alert_type)
if severity:
query = query.filter(Alert.severity == severity)
if acknowledged is not None:
ack_bool = acknowledged.lower() == 'true'
query = query.filter(Alert.acknowledged == ack_bool)
if scan_id:
query = query.filter(Alert.scan_id == scan_id)
if start_date:
try:
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
query = query.filter(Alert.created_at >= start_dt)
except ValueError:
pass # Ignore invalid date format
if end_date:
try:
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
query = query.filter(Alert.created_at <= end_dt)
except ValueError:
pass # Ignore invalid date format
# Order by severity and date
query = query.order_by(
Alert.severity.desc(), # Critical first, then warning, then info
Alert.created_at.desc() # Most recent first
)
# Paginate
total = query.count()
alerts = query.offset((page - 1) * per_page).limit(per_page).all()
# Format response
alerts_data = []
for alert in alerts:
# Get scan info
scan = current_app.db_session.query(Scan).filter(Scan.id == alert.scan_id).first()
alerts_data.append({
'id': alert.id,
'scan_id': alert.scan_id,
'scan_title': scan.title if scan else None,
'rule_id': alert.rule_id,
'alert_type': alert.alert_type,
'severity': alert.severity,
'message': alert.message,
'ip_address': alert.ip_address,
'port': alert.port,
'acknowledged': alert.acknowledged,
'acknowledged_at': alert.acknowledged_at.isoformat() if alert.acknowledged_at else None,
'acknowledged_by': alert.acknowledged_by,
'email_sent': alert.email_sent,
'email_sent_at': alert.email_sent_at.isoformat() if alert.email_sent_at else None,
'webhook_sent': alert.webhook_sent,
'webhook_sent_at': alert.webhook_sent_at.isoformat() if alert.webhook_sent_at else None,
'created_at': alert.created_at.isoformat()
})
return jsonify({
'alerts': alerts_data,
'total': total,
'page': page,
'per_page': per_page,
'pages': (total + per_page - 1) // per_page # Ceiling division
})
@bp.route('/<int:alert_id>/acknowledge', methods=['POST'])
@api_auth_required
def acknowledge_alert(alert_id):
"""
Acknowledge an alert.
Args:
alert_id: Alert ID to acknowledge
Returns:
JSON response with acknowledgment status
"""
# Get username from auth context or default to 'api'
acknowledged_by = request.json.get('acknowledged_by', 'api') if request.json else 'api'
alert_service = AlertService(current_app.db_session)
success = alert_service.acknowledge_alert(alert_id, acknowledged_by)
if success:
return jsonify({
'status': 'success',
'message': f'Alert {alert_id} acknowledged',
'acknowledged_by': acknowledged_by
})
else:
return jsonify({
'status': 'error',
'message': f'Failed to acknowledge alert {alert_id}'
}), 400
@bp.route('/acknowledge-all', methods=['POST'])
@api_auth_required
def acknowledge_all_alerts():
"""
Acknowledge all unacknowledged alerts.
Returns:
JSON response with count of acknowledged alerts
"""
acknowledged_by = request.json.get('acknowledged_by', 'api') if request.json else 'api'
try:
# Get all unacknowledged alerts
unacked_alerts = current_app.db_session.query(Alert).filter(
Alert.acknowledged == False
).all()
count = 0
for alert in unacked_alerts:
alert.acknowledged = True
alert.acknowledged_at = datetime.now(timezone.utc)
alert.acknowledged_by = acknowledged_by
count += 1
current_app.db_session.commit()
return jsonify({
'status': 'success',
'message': f'Acknowledged {count} alerts',
'count': count,
'acknowledged_by': acknowledged_by
})
except Exception as e:
current_app.db_session.rollback()
return jsonify({
'status': 'error',
'message': f'Failed to acknowledge alerts: {str(e)}'
}), 500
@bp.route('/rules', methods=['GET'])
@api_auth_required
def list_alert_rules():
"""
List all alert rules.
Returns:
JSON response with alert rules
"""
rules = current_app.db_session.query(AlertRule).order_by(AlertRule.name, AlertRule.rule_type).all()
rules_data = []
for rule in rules:
rules_data.append({
'id': rule.id,
'name': rule.name,
'rule_type': rule.rule_type,
'enabled': rule.enabled,
'threshold': rule.threshold,
'email_enabled': rule.email_enabled,
'webhook_enabled': rule.webhook_enabled,
'severity': rule.severity,
'filter_conditions': json.loads(rule.filter_conditions) if rule.filter_conditions else None,
'config_id': rule.config_id,
'config_title': rule.config.title if rule.config else None,
'created_at': rule.created_at.isoformat(),
'updated_at': rule.updated_at.isoformat() if rule.updated_at else None
})
return jsonify({
'rules': rules_data,
'total': len(rules_data)
})
@bp.route('/rules', methods=['POST'])
@api_auth_required
def create_alert_rule():
"""
Create a new alert rule.
Request body:
name: User-friendly rule name
rule_type: Type of alert rule (unexpected_port, drift_detection, cert_expiry, weak_tls, ping_failed)
threshold: Threshold value (e.g., days for cert expiry, percentage for drift)
enabled: Whether rule is active (default: true)
email_enabled: Send email for this rule (default: false)
webhook_enabled: Send webhook for this rule (default: false)
severity: Alert severity (critical, warning, info)
filter_conditions: JSON object with filter conditions
config_id: Optional config ID to apply rule to
Returns:
JSON response with created rule
"""
data = request.get_json() or {}
# Validate required fields
if not data.get('rule_type'):
return jsonify({
'status': 'error',
'message': 'rule_type is required'
}), 400
# Valid rule types
valid_rule_types = ['unexpected_port', 'drift_detection', 'cert_expiry', 'weak_tls', 'ping_failed']
if data['rule_type'] not in valid_rule_types:
return jsonify({
'status': 'error',
'message': f'Invalid rule_type. Must be one of: {", ".join(valid_rule_types)}'
}), 400
# Valid severities
valid_severities = ['critical', 'warning', 'info']
if data.get('severity') and data['severity'] not in valid_severities:
return jsonify({
'status': 'error',
'message': f'Invalid severity. Must be one of: {", ".join(valid_severities)}'
}), 400
try:
# Validate config_id if provided
config_id = data.get('config_id')
if config_id:
from web.models import ScanConfig
config = current_app.db_session.query(ScanConfig).filter_by(id=config_id).first()
if not config:
return jsonify({
'status': 'error',
'message': f'Config with ID {config_id} not found'
}), 400
# Create new rule
rule = AlertRule(
name=data.get('name', f"{data['rule_type']} rule"),
rule_type=data['rule_type'],
enabled=data.get('enabled', True),
threshold=data.get('threshold'),
email_enabled=data.get('email_enabled', False),
webhook_enabled=data.get('webhook_enabled', False),
severity=data.get('severity', 'warning'),
filter_conditions=json.dumps(data['filter_conditions']) if data.get('filter_conditions') else None,
config_id=config_id,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
current_app.db_session.add(rule)
current_app.db_session.commit()
return jsonify({
'status': 'success',
'message': 'Alert rule created successfully',
'rule': {
'id': rule.id,
'name': rule.name,
'rule_type': rule.rule_type,
'enabled': rule.enabled,
'threshold': rule.threshold,
'email_enabled': rule.email_enabled,
'webhook_enabled': rule.webhook_enabled,
'severity': rule.severity,
'filter_conditions': json.loads(rule.filter_conditions) if rule.filter_conditions else None,
'config_id': rule.config_id,
'config_title': rule.config.title if rule.config else None,
'created_at': rule.created_at.isoformat(),
'updated_at': rule.updated_at.isoformat()
}
}), 201
except Exception as e:
current_app.db_session.rollback()
return jsonify({
'status': 'error',
'message': f'Failed to create alert rule: {str(e)}'
}), 500
@bp.route('/rules/<int:rule_id>', methods=['PUT'])
@api_auth_required
def update_alert_rule(rule_id):
"""
Update an existing alert rule.
Args:
rule_id: Alert rule ID to update
Request body:
name: User-friendly rule name (optional)
threshold: Threshold value (optional)
enabled: Whether rule is active (optional)
email_enabled: Send email for this rule (optional)
webhook_enabled: Send webhook for this rule (optional)
severity: Alert severity (optional)
filter_conditions: JSON object with filter conditions (optional)
config_id: Config ID to apply rule to (optional)
Returns:
JSON response with update status
"""
data = request.get_json() or {}
# Get existing rule
rule = current_app.db_session.query(AlertRule).filter(AlertRule.id == rule_id).first()
if not rule:
return jsonify({
'status': 'error',
'message': f'Alert rule {rule_id} not found'
}), 404
# Valid severities
valid_severities = ['critical', 'warning', 'info']
if data.get('severity') and data['severity'] not in valid_severities:
return jsonify({
'status': 'error',
'message': f'Invalid severity. Must be one of: {", ".join(valid_severities)}'
}), 400
try:
# Validate config_id if provided
if 'config_id' in data:
config_id = data['config_id']
if config_id:
from web.models import ScanConfig
config = current_app.db_session.query(ScanConfig).filter_by(id=config_id).first()
if not config:
return jsonify({
'status': 'error',
'message': f'Config with ID {config_id} not found'
}), 400
# Update fields if provided
if 'name' in data:
rule.name = data['name']
if 'threshold' in data:
rule.threshold = data['threshold']
if 'enabled' in data:
rule.enabled = data['enabled']
if 'email_enabled' in data:
rule.email_enabled = data['email_enabled']
if 'webhook_enabled' in data:
rule.webhook_enabled = data['webhook_enabled']
if 'severity' in data:
rule.severity = data['severity']
if 'filter_conditions' in data:
rule.filter_conditions = json.dumps(data['filter_conditions']) if data['filter_conditions'] else None
if 'config_id' in data:
rule.config_id = data['config_id']
rule.updated_at = datetime.now(timezone.utc)
current_app.db_session.commit()
return jsonify({
'status': 'success',
'message': 'Alert rule updated successfully',
'rule': {
'id': rule.id,
'name': rule.name,
'rule_type': rule.rule_type,
'enabled': rule.enabled,
'threshold': rule.threshold,
'email_enabled': rule.email_enabled,
'webhook_enabled': rule.webhook_enabled,
'severity': rule.severity,
'filter_conditions': json.loads(rule.filter_conditions) if rule.filter_conditions else None,
'config_id': rule.config_id,
'config_title': rule.config.title if rule.config else None,
'created_at': rule.created_at.isoformat(),
'updated_at': rule.updated_at.isoformat()
}
})
except Exception as e:
current_app.db_session.rollback()
return jsonify({
'status': 'error',
'message': f'Failed to update alert rule: {str(e)}'
}), 500
@bp.route('/rules/<int:rule_id>', methods=['DELETE'])
@api_auth_required
def delete_alert_rule(rule_id):
"""
Delete an alert rule.
Args:
rule_id: Alert rule ID to delete
Returns:
JSON response with deletion status
"""
# Get existing rule
rule = current_app.db_session.query(AlertRule).filter(AlertRule.id == rule_id).first()
if not rule:
return jsonify({
'status': 'error',
'message': f'Alert rule {rule_id} not found'
}), 404
try:
# Delete the rule (cascade will delete related alerts)
current_app.db_session.delete(rule)
current_app.db_session.commit()
return jsonify({
'status': 'success',
'message': f'Alert rule {rule_id} deleted successfully'
})
except Exception as e:
current_app.db_session.rollback()
return jsonify({
'status': 'error',
'message': f'Failed to delete alert rule: {str(e)}'
}), 500
@bp.route('/stats', methods=['GET'])
@api_auth_required
def alert_stats():
"""
Get alert statistics.
Query params:
days: Number of days to look back (default: 7)
Returns:
JSON response with alert statistics
"""
days = request.args.get('days', 7, type=int)
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
# Get alerts in date range
alerts = current_app.db_session.query(Alert).filter(Alert.created_at >= cutoff_date).all()
# Calculate statistics
total_alerts = len(alerts)
alerts_by_severity = {'critical': 0, 'warning': 0, 'info': 0}
alerts_by_type = {}
unacknowledged_count = 0
for alert in alerts:
# Count by severity
if alert.severity in alerts_by_severity:
alerts_by_severity[alert.severity] += 1
# Count by type
if alert.alert_type not in alerts_by_type:
alerts_by_type[alert.alert_type] = 0
alerts_by_type[alert.alert_type] += 1
# Count unacknowledged
if not alert.acknowledged:
unacknowledged_count += 1
return jsonify({
'stats': {
'total_alerts': total_alerts,
'unacknowledged_count': unacknowledged_count,
'alerts_by_severity': alerts_by_severity,
'alerts_by_type': alerts_by_type,
'date_range': {
'start': cutoff_date.isoformat(),
'end': datetime.now(timezone.utc).isoformat(),
'days': days
}
}
})
# Health check endpoint
@bp.route('/health', methods=['GET'])
def health_check():
"""
Health check endpoint for monitoring.
Returns:
JSON response with API health status
"""
return jsonify({
'status': 'healthy',
'api': 'alerts',
'version': '1.0.0-phase5'
})

461
app/web/api/configs.py Normal file
View File

@@ -0,0 +1,461 @@
"""
Configs API blueprint.
Handles endpoints for managing scan configurations stored in the database.
Provides REST API for creating, updating, and deleting configs that reference sites.
"""
import logging
from flask import Blueprint, jsonify, request, current_app
from web.auth.decorators import api_auth_required
from web.services.config_service import ConfigService
bp = Blueprint('configs', __name__)
logger = logging.getLogger(__name__)
# ============================================================================
# Database-based Config Endpoints (Primary)
# ============================================================================
@bp.route('', methods=['GET'])
@api_auth_required
def list_configs():
"""
List all scan configurations from database.
Returns:
JSON response with list of configs:
{
"configs": [
{
"id": 1,
"title": "Production Scan",
"description": "Weekly production scan",
"site_count": 3,
"sites": [
{"id": 1, "name": "Production DC"},
{"id": 2, "name": "DMZ"}
],
"created_at": "2025-11-19T10:30:00Z",
"updated_at": "2025-11-19T10:30:00Z"
}
]
}
"""
try:
config_service = ConfigService(db_session=current_app.db_session)
configs = config_service.list_configs_db()
logger.info(f"Listed {len(configs)} configs from database")
return jsonify({
'configs': configs
})
except Exception as e:
logger.error(f"Unexpected error listing configs: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('', methods=['POST'])
@api_auth_required
def create_config():
"""
Create a new scan configuration in the database.
Request:
JSON with:
{
"title": "Production Scan",
"description": "Weekly production scan (optional)",
"site_ids": [1, 2, 3]
}
Returns:
JSON response with created config:
{
"success": true,
"config": {
"id": 1,
"title": "Production Scan",
"description": "...",
"site_count": 3,
"sites": [...],
"created_at": "2025-11-19T10:30:00Z",
"updated_at": "2025-11-19T10:30:00Z"
}
}
Error responses:
- 400: Validation error or missing fields
- 500: Internal server error
"""
try:
data = request.get_json()
if not data:
return jsonify({
'error': 'Bad request',
'message': 'Request body must be JSON'
}), 400
# Validate required fields
if 'title' not in data:
return jsonify({
'error': 'Bad request',
'message': 'Missing required field: title'
}), 400
if 'site_ids' not in data:
return jsonify({
'error': 'Bad request',
'message': 'Missing required field: site_ids'
}), 400
title = data['title']
description = data.get('description', None)
site_ids = data['site_ids']
if not isinstance(site_ids, list):
return jsonify({
'error': 'Bad request',
'message': 'Field site_ids must be an array'
}), 400
# Create config
config_service = ConfigService(db_session=current_app.db_session)
config = config_service.create_config(title, description, site_ids)
logger.info(f"Created config: {config['title']} (ID: {config['id']})")
return jsonify({
'success': True,
'config': config
}), 201
except ValueError as e:
logger.warning(f"Config validation failed: {str(e)}")
return jsonify({
'error': 'Validation error',
'message': str(e)
}), 400
except Exception as e:
logger.error(f"Unexpected error creating config: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:config_id>', methods=['GET'])
@api_auth_required
def get_config(config_id: int):
"""
Get a scan configuration by ID.
Args:
config_id: Configuration ID
Returns:
JSON response with config details:
{
"id": 1,
"title": "Production Scan",
"description": "...",
"site_count": 3,
"sites": [
{
"id": 1,
"name": "Production DC",
"description": "...",
"ip_count": 5
}
],
"created_at": "2025-11-19T10:30:00Z",
"updated_at": "2025-11-19T10:30:00Z"
}
Error responses:
- 404: Config not found
- 500: Internal server error
"""
try:
config_service = ConfigService(db_session=current_app.db_session)
config = config_service.get_config_by_id(config_id)
logger.info(f"Retrieved config: {config['title']} (ID: {config_id})")
return jsonify(config)
except ValueError as e:
logger.warning(f"Config not found: {config_id}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
except Exception as e:
logger.error(f"Unexpected error getting config {config_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:config_id>', methods=['PUT'])
@api_auth_required
def update_config(config_id: int):
"""
Update an existing scan configuration.
Args:
config_id: Configuration ID
Request:
JSON with (all fields optional):
{
"title": "New Title",
"description": "New Description",
"site_ids": [1, 2, 3]
}
Returns:
JSON response with updated config:
{
"success": true,
"config": {...}
}
Error responses:
- 400: Validation error
- 404: Config not found
- 500: Internal server error
"""
try:
data = request.get_json()
if not data:
return jsonify({
'error': 'Bad request',
'message': 'Request body must be JSON'
}), 400
title = data.get('title', None)
description = data.get('description', None)
site_ids = data.get('site_ids', None)
if site_ids is not None and not isinstance(site_ids, list):
return jsonify({
'error': 'Bad request',
'message': 'Field site_ids must be an array'
}), 400
# Update config
config_service = ConfigService(db_session=current_app.db_session)
config = config_service.update_config(config_id, title, description, site_ids)
logger.info(f"Updated config: {config['title']} (ID: {config_id})")
return jsonify({
'success': True,
'config': config
})
except ValueError as e:
if 'not found' in str(e).lower():
logger.warning(f"Config not found: {config_id}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
else:
logger.warning(f"Config validation failed: {str(e)}")
return jsonify({
'error': 'Validation error',
'message': str(e)
}), 400
except Exception as e:
logger.error(f"Unexpected error updating config {config_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:config_id>', methods=['DELETE'])
@api_auth_required
def delete_config(config_id: int):
"""
Delete a scan configuration.
Args:
config_id: Configuration ID
Returns:
JSON response with success status:
{
"success": true,
"message": "Config deleted successfully"
}
Error responses:
- 404: Config not found
- 500: Internal server error
"""
try:
config_service = ConfigService(db_session=current_app.db_session)
config_service.delete_config(config_id)
logger.info(f"Deleted config (ID: {config_id})")
return jsonify({
'success': True,
'message': 'Config deleted successfully'
})
except ValueError as e:
logger.warning(f"Config not found: {config_id}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
except Exception as e:
logger.error(f"Unexpected error deleting config {config_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:config_id>/sites', methods=['POST'])
@api_auth_required
def add_site_to_config(config_id: int):
"""
Add a site to an existing config.
Args:
config_id: Configuration ID
Request:
JSON with:
{
"site_id": 5
}
Returns:
JSON response with updated config:
{
"success": true,
"config": {...}
}
Error responses:
- 400: Validation error or site already in config
- 404: Config or site not found
- 500: Internal server error
"""
try:
data = request.get_json()
if not data or 'site_id' not in data:
return jsonify({
'error': 'Bad request',
'message': 'Missing required field: site_id'
}), 400
site_id = data['site_id']
# Add site to config
config_service = ConfigService(db_session=current_app.db_session)
config = config_service.add_site_to_config(config_id, site_id)
logger.info(f"Added site {site_id} to config {config_id}")
return jsonify({
'success': True,
'config': config
})
except ValueError as e:
if 'not found' in str(e).lower():
logger.warning(f"Config or site not found: {str(e)}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
else:
logger.warning(f"Validation error: {str(e)}")
return jsonify({
'error': 'Validation error',
'message': str(e)
}), 400
except Exception as e:
logger.error(f"Unexpected error adding site to config: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:config_id>/sites/<int:site_id>', methods=['DELETE'])
@api_auth_required
def remove_site_from_config(config_id: int, site_id: int):
"""
Remove a site from a config.
Args:
config_id: Configuration ID
site_id: Site ID to remove
Returns:
JSON response with updated config:
{
"success": true,
"config": {...}
}
Error responses:
- 400: Validation error (e.g., last site cannot be removed)
- 404: Config not found or site not in config
- 500: Internal server error
"""
try:
config_service = ConfigService(db_session=current_app.db_session)
config = config_service.remove_site_from_config(config_id, site_id)
logger.info(f"Removed site {site_id} from config {config_id}")
return jsonify({
'success': True,
'config': config
})
except ValueError as e:
if 'not found' in str(e).lower() or 'not in this config' in str(e).lower():
logger.warning(f"Config or site not found: {str(e)}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
else:
logger.warning(f"Validation error: {str(e)}")
return jsonify({
'error': 'Validation error',
'message': str(e)
}), 400
except Exception as e:
logger.error(f"Unexpected error removing site from config: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500

642
app/web/api/scans.py Normal file
View File

@@ -0,0 +1,642 @@
"""
Scans API blueprint.
Handles endpoints for triggering scans, listing scan history, and retrieving
scan results.
"""
import json
import logging
from datetime import datetime
from pathlib import Path
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy.exc import SQLAlchemyError
from web.auth.decorators import api_auth_required
from web.models import Scan, ScanProgress
from web.services.scan_service import ScanService
from web.utils.pagination import validate_page_params
from web.jobs.scan_job import stop_scan
bp = Blueprint('scans', __name__)
logger = logging.getLogger(__name__)
def _recover_orphaned_scan(scan: Scan, session) -> dict:
"""
Recover an orphaned scan by checking for output files.
If output files exist: mark as 'completed' (smart recovery)
If no output files: mark as 'cancelled'
Args:
scan: The orphaned Scan object
session: Database session
Returns:
Dictionary with recovery result for API response
"""
# Check for existing output files
output_exists = False
output_files_found = []
# Check paths stored in database
if scan.json_path and Path(scan.json_path).exists():
output_exists = True
output_files_found.append('json')
if scan.html_path and Path(scan.html_path).exists():
output_files_found.append('html')
if scan.zip_path and Path(scan.zip_path).exists():
output_files_found.append('zip')
# Also check by timestamp pattern if paths not stored yet
if not output_exists and scan.started_at:
output_dir = Path('/app/output')
if output_dir.exists():
timestamp_pattern = scan.started_at.strftime('%Y%m%d')
for json_file in output_dir.glob(f'scan_report_{timestamp_pattern}*.json'):
output_exists = True
output_files_found.append('json')
# Update scan record with found paths
scan.json_path = str(json_file)
html_file = json_file.with_suffix('.html')
if html_file.exists():
scan.html_path = str(html_file)
output_files_found.append('html')
zip_file = json_file.with_suffix('.zip')
if zip_file.exists():
scan.zip_path = str(zip_file)
output_files_found.append('zip')
break
if output_exists:
# Smart recovery: outputs exist, mark as completed
scan.status = 'completed'
scan.completed_at = datetime.utcnow()
if scan.started_at:
scan.duration = (datetime.utcnow() - scan.started_at).total_seconds()
scan.error_message = None
session.commit()
logger.info(f"Scan {scan.id}: Recovered as completed (files: {output_files_found})")
return {
'scan_id': scan.id,
'status': 'completed',
'message': f'Scan recovered as completed (output files found: {", ".join(output_files_found)})',
'recovery_type': 'smart_recovery'
}
else:
# No outputs: mark as cancelled
scan.status = 'cancelled'
scan.completed_at = datetime.utcnow()
if scan.started_at:
scan.duration = (datetime.utcnow() - scan.started_at).total_seconds()
scan.error_message = 'Scan process was interrupted before completion. No output files were generated.'
session.commit()
logger.info(f"Scan {scan.id}: Marked as cancelled (orphaned, no output files)")
return {
'scan_id': scan.id,
'status': 'cancelled',
'message': 'Orphaned scan cancelled (no output files found)',
'recovery_type': 'orphan_cleanup'
}
@bp.route('', methods=['GET'])
@api_auth_required
def list_scans():
"""
List all scans with pagination.
Query params:
page: Page number (default: 1)
per_page: Items per page (default: 20, max: 100)
status: Filter by status (running, completed, failed)
Returns:
JSON response with scans list and pagination info
"""
try:
# Get and validate query parameters
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
status_filter = request.args.get('status', None, type=str)
# Validate pagination params
page, per_page = validate_page_params(page, per_page)
# Get scans from service
scan_service = ScanService(current_app.db_session)
paginated_result = scan_service.list_scans(
page=page,
per_page=per_page,
status_filter=status_filter
)
logger.info(f"Listed scans: page={page}, per_page={per_page}, status={status_filter}, total={paginated_result.total}")
return jsonify({
'scans': paginated_result.items,
'total': paginated_result.total,
'page': paginated_result.page,
'per_page': paginated_result.per_page,
'total_pages': paginated_result.pages,
'has_prev': paginated_result.has_prev,
'has_next': paginated_result.has_next
})
except ValueError as e:
logger.warning(f"Invalid request parameters: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error listing scans: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to retrieve scans'
}), 500
except Exception as e:
logger.error(f"Unexpected error listing scans: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:scan_id>', methods=['GET'])
@api_auth_required
def get_scan(scan_id):
"""
Get details for a specific scan.
Args:
scan_id: Scan ID
Returns:
JSON response with scan details
"""
try:
# Get scan from service
scan_service = ScanService(current_app.db_session)
scan = scan_service.get_scan(scan_id)
if not scan:
logger.warning(f"Scan not found: {scan_id}")
return jsonify({
'error': 'Not found',
'message': f'Scan with ID {scan_id} not found'
}), 404
logger.info(f"Retrieved scan details: {scan_id}")
return jsonify(scan)
except SQLAlchemyError as e:
logger.error(f"Database error retrieving scan {scan_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to retrieve scan'
}), 500
except Exception as e:
logger.error(f"Unexpected error retrieving scan {scan_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('', methods=['POST'])
@api_auth_required
def trigger_scan():
"""
Trigger a new scan.
Request body:
config_id: Database config ID (required)
Returns:
JSON response with scan_id and status
"""
try:
# Get request data
data = request.get_json() or {}
config_id = data.get('config_id')
# Validate required fields
if not config_id:
logger.warning("Scan trigger request missing config_id")
return jsonify({
'error': 'Invalid request',
'message': 'config_id is required'
}), 400
# Validate config_id is an integer
try:
config_id = int(config_id)
except (TypeError, ValueError):
logger.warning(f"Invalid config_id type: {config_id}")
return jsonify({
'error': 'Invalid request',
'message': 'config_id must be an integer'
}), 400
# Trigger scan via service
scan_service = ScanService(current_app.db_session)
scan_id = scan_service.trigger_scan(
config_id=config_id,
triggered_by='api',
scheduler=current_app.scheduler
)
logger.info(f"Scan {scan_id} triggered via API: config_id={config_id}")
return jsonify({
'scan_id': scan_id,
'status': 'running',
'message': 'Scan queued successfully'
}), 201
except ValueError as e:
# Config validation error
error_message = str(e)
logger.warning(f"Invalid config: {error_message}")
logger.warning(f"Request data: config_id='{config_id}'")
return jsonify({
'error': 'Invalid request',
'message': error_message
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error triggering scan: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to create scan'
}), 500
except Exception as e:
logger.error(f"Unexpected error triggering scan: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:scan_id>', methods=['DELETE'])
@api_auth_required
def delete_scan(scan_id):
"""
Delete a scan and its associated files.
Args:
scan_id: Scan ID to delete
Returns:
JSON response with deletion status
"""
try:
# Delete scan via service
scan_service = ScanService(current_app.db_session)
scan_service.delete_scan(scan_id)
logger.info(f"Scan {scan_id} deleted successfully")
return jsonify({
'scan_id': scan_id,
'message': 'Scan deleted successfully'
}), 200
except ValueError as e:
# Scan not found
logger.warning(f"Scan deletion failed: {str(e)}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
except SQLAlchemyError as e:
logger.error(f"Database error deleting scan {scan_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to delete scan'
}), 500
except Exception as e:
logger.error(f"Unexpected error deleting scan {scan_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:scan_id>/stop', methods=['POST'])
@api_auth_required
def stop_running_scan(scan_id):
"""
Stop a running scan with smart recovery for orphaned scans.
If the scan is actively running in the registry, sends a cancel signal.
If the scan shows as running/finalizing but is not in the registry (orphaned),
performs smart recovery: marks as 'completed' if output files exist,
otherwise marks as 'cancelled'.
Args:
scan_id: Scan ID to stop
Returns:
JSON response with stop status or recovery result
"""
try:
session = current_app.db_session
# Check if scan exists
scan = session.query(Scan).filter_by(id=scan_id).first()
if not scan:
logger.warning(f"Scan not found for stop request: {scan_id}")
return jsonify({
'error': 'Not found',
'message': f'Scan with ID {scan_id} not found'
}), 404
# Allow stopping scans with status 'running' or 'finalizing'
if scan.status not in ('running', 'finalizing'):
logger.warning(f"Cannot stop scan {scan_id}: status is '{scan.status}'")
return jsonify({
'error': 'Invalid state',
'message': f"Cannot stop scan: status is '{scan.status}'"
}), 400
# Get database URL from app config
db_url = current_app.config['SQLALCHEMY_DATABASE_URI']
# Attempt to stop the scan
stopped = stop_scan(scan_id, db_url)
if stopped:
logger.info(f"Stop signal sent to scan {scan_id}")
return jsonify({
'scan_id': scan_id,
'message': 'Stop signal sent to scan',
'status': 'stopping'
}), 200
else:
# Scanner not in registry - this is an orphaned scan
# Attempt smart recovery
logger.warning(f"Scan {scan_id} not in registry, attempting smart recovery")
recovery_result = _recover_orphaned_scan(scan, session)
return jsonify(recovery_result), 200
except SQLAlchemyError as e:
logger.error(f"Database error stopping scan {scan_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to stop scan'
}), 500
except Exception as e:
logger.error(f"Unexpected error stopping scan {scan_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:scan_id>/status', methods=['GET'])
@api_auth_required
def get_scan_status(scan_id):
"""
Get current status of a running scan.
Args:
scan_id: Scan ID
Returns:
JSON response with scan status and progress
"""
try:
# Get scan status from service
scan_service = ScanService(current_app.db_session)
status = scan_service.get_scan_status(scan_id)
if not status:
logger.warning(f"Scan not found for status check: {scan_id}")
return jsonify({
'error': 'Not found',
'message': f'Scan with ID {scan_id} not found'
}), 404
logger.debug(f"Retrieved status for scan {scan_id}: {status['status']}")
return jsonify(status)
except SQLAlchemyError as e:
logger.error(f"Database error retrieving scan status {scan_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to retrieve scan status'
}), 500
except Exception as e:
logger.error(f"Unexpected error retrieving scan status {scan_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:scan_id>/progress', methods=['GET'])
@api_auth_required
def get_scan_progress(scan_id):
"""
Get detailed progress for a running scan including per-IP results.
Args:
scan_id: Scan ID
Returns:
JSON response with scan progress including:
- current_phase: Current scan phase
- total_ips: Total IPs being scanned
- completed_ips: Number of IPs completed in current phase
- progress_entries: List of per-IP progress with discovered results
"""
try:
session = current_app.db_session
# Get scan record
scan = session.query(Scan).filter_by(id=scan_id).first()
if not scan:
logger.warning(f"Scan not found for progress check: {scan_id}")
return jsonify({
'error': 'Not found',
'message': f'Scan with ID {scan_id} not found'
}), 404
# Get progress entries
progress_entries = session.query(ScanProgress).filter_by(scan_id=scan_id).all()
# Build progress data
entries = []
for entry in progress_entries:
entry_data = {
'ip_address': entry.ip_address,
'site_name': entry.site_name,
'phase': entry.phase,
'status': entry.status,
'ping_result': entry.ping_result
}
# Parse JSON fields
if entry.tcp_ports:
entry_data['tcp_ports'] = json.loads(entry.tcp_ports)
else:
entry_data['tcp_ports'] = []
if entry.udp_ports:
entry_data['udp_ports'] = json.loads(entry.udp_ports)
else:
entry_data['udp_ports'] = []
if entry.services:
entry_data['services'] = json.loads(entry.services)
else:
entry_data['services'] = []
entries.append(entry_data)
# Sort entries by site name then IP (numerically)
def ip_sort_key(ip_str):
"""Convert IP to tuple of integers for proper numeric sorting."""
try:
return tuple(int(octet) for octet in ip_str.split('.'))
except (ValueError, AttributeError):
return (0, 0, 0, 0)
entries.sort(key=lambda x: (x['site_name'] or '', ip_sort_key(x['ip_address'])))
response = {
'scan_id': scan_id,
'status': scan.status,
'current_phase': scan.current_phase or 'pending',
'total_ips': scan.total_ips or 0,
'completed_ips': scan.completed_ips or 0,
'progress_entries': entries
}
logger.debug(f"Retrieved progress for scan {scan_id}: phase={scan.current_phase}, {scan.completed_ips}/{scan.total_ips} IPs")
return jsonify(response)
except SQLAlchemyError as e:
logger.error(f"Database error retrieving scan progress {scan_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to retrieve scan progress'
}), 500
except Exception as e:
logger.error(f"Unexpected error retrieving scan progress {scan_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/by-ip/<ip_address>', methods=['GET'])
@api_auth_required
def get_scans_by_ip(ip_address):
"""
Get last 10 scans containing a specific IP address.
Args:
ip_address: IP address to search for
Returns:
JSON response with list of scans containing the IP
"""
try:
# Get scans from service
scan_service = ScanService(current_app.db_session)
scans = scan_service.get_scans_by_ip(ip_address)
logger.info(f"Retrieved {len(scans)} scans for IP: {ip_address}")
return jsonify({
'ip_address': ip_address,
'scans': scans,
'count': len(scans)
})
except SQLAlchemyError as e:
logger.error(f"Database error retrieving scans for IP {ip_address}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to retrieve scans'
}), 500
except Exception as e:
logger.error(f"Unexpected error retrieving scans for IP {ip_address}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:scan_id1>/compare/<int:scan_id2>', methods=['GET'])
@api_auth_required
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 (older) scan ID
scan_id2: Second (newer) scan ID
Returns:
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)
"""
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
@bp.route('/health', methods=['GET'])
def health_check():
"""
Health check endpoint for monitoring.
Returns:
JSON response with API health status
"""
return jsonify({
'status': 'healthy',
'api': 'scans',
'version': '1.0.0-phase1'
})

331
app/web/api/schedules.py Normal file
View File

@@ -0,0 +1,331 @@
"""
Schedules API blueprint.
Handles endpoints for managing scheduled scans including CRUD operations
and manual triggering.
"""
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__)
@bp.route('', methods=['GET'])
@api_auth_required
def list_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 paginated schedules list
"""
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('/<int:schedule_id>', methods=['GET'])
@api_auth_required
def get_schedule(schedule_id):
"""
Get details for a specific schedule.
Args:
schedule_id: Schedule ID
Returns:
JSON response with schedule details including execution history
"""
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'])
@api_auth_required
def create_schedule():
"""
Create a new schedule.
Request body:
name: Schedule name (required)
config_id: Database config ID (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
"""
try:
data = request.get_json() or {}
# Validate required fields
required = ['name', 'config_id', '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_id=data['config_id'],
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_id=schedule['config_id'],
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('/<int:schedule_id>', methods=['PUT'])
@api_auth_required
def update_schedule(schedule_id):
"""
Update an existing schedule.
Args:
schedule_id: Schedule ID to update
Request body:
name: Schedule name (optional)
config_id: Database config ID (optional)
cron_expression: Cron expression (optional)
enabled: Whether schedule is active (optional)
Returns:
JSON response with updated schedule
"""
try:
data = request.get_json() or {}
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_id' 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_id=updated_schedule['config_id'],
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_id=updated_schedule['config_id'],
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('/<int:schedule_id>', methods=['DELETE'])
@api_auth_required
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
"""
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('/<int:schedule_id>/trigger', methods=['POST'])
@api_auth_required
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
"""
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_id=schedule['config_id'],
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
@bp.route('/health', methods=['GET'])
def health_check():
"""
Health check endpoint for monitoring.
Returns:
JSON response with API health status
"""
return jsonify({
'status': 'healthy',
'api': 'schedules',
'version': '1.0.0-phase1'
})

View File

@@ -7,6 +7,7 @@ authentication, and system preferences.
from flask import Blueprint, current_app, jsonify, request from flask import Blueprint, current_app, jsonify, request
from web.auth.decorators import api_auth_required
from web.utils.settings import PasswordManager, SettingsManager from web.utils.settings import PasswordManager, SettingsManager
bp = Blueprint('settings', __name__) bp = Blueprint('settings', __name__)
@@ -18,6 +19,7 @@ def get_settings_manager():
@bp.route('', methods=['GET']) @bp.route('', methods=['GET'])
@api_auth_required
def get_settings(): def get_settings():
""" """
Get all settings (sanitized - encrypted values masked). Get all settings (sanitized - encrypted values masked).
@@ -42,6 +44,7 @@ def get_settings():
@bp.route('', methods=['PUT']) @bp.route('', methods=['PUT'])
@api_auth_required
def update_settings(): def update_settings():
""" """
Update multiple settings at once. Update multiple settings at once.
@@ -52,7 +55,6 @@ def update_settings():
Returns: Returns:
JSON response with update status JSON response with update status
""" """
# TODO: Add authentication in Phase 2
data = request.get_json() or {} data = request.get_json() or {}
settings_dict = data.get('settings', {}) settings_dict = data.get('settings', {})
@@ -73,6 +75,12 @@ def update_settings():
'status': 'success', 'status': 'success',
'message': f'Updated {len(settings_dict)} settings' 'message': f'Updated {len(settings_dict)} settings'
}) })
except ValueError as e:
# Handle read-only setting attempts
return jsonify({
'status': 'error',
'message': str(e)
}), 403
except Exception as e: except Exception as e:
current_app.logger.error(f"Failed to update settings: {e}") current_app.logger.error(f"Failed to update settings: {e}")
return jsonify({ return jsonify({
@@ -82,6 +90,7 @@ def update_settings():
@bp.route('/<string:key>', methods=['GET']) @bp.route('/<string:key>', methods=['GET'])
@api_auth_required
def get_setting(key): def get_setting(key):
""" """
Get a specific setting by key. Get a specific setting by key.
@@ -109,7 +118,8 @@ def get_setting(key):
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'key': key, 'key': key,
'value': value 'value': value,
'read_only': settings_manager._is_read_only(key)
}) })
except Exception as e: except Exception as e:
current_app.logger.error(f"Failed to retrieve setting {key}: {e}") current_app.logger.error(f"Failed to retrieve setting {key}: {e}")
@@ -120,6 +130,7 @@ def get_setting(key):
@bp.route('/<string:key>', methods=['PUT']) @bp.route('/<string:key>', methods=['PUT'])
@api_auth_required
def update_setting(key): def update_setting(key):
""" """
Update a specific setting. Update a specific setting.
@@ -133,7 +144,6 @@ def update_setting(key):
Returns: Returns:
JSON response with update status JSON response with update status
""" """
# TODO: Add authentication in Phase 2
data = request.get_json() or {} data = request.get_json() or {}
value = data.get('value') value = data.get('value')
@@ -151,6 +161,12 @@ def update_setting(key):
'status': 'success', 'status': 'success',
'message': f'Setting "{key}" updated' 'message': f'Setting "{key}" updated'
}) })
except ValueError as e:
# Handle read-only setting attempts
return jsonify({
'status': 'error',
'message': str(e)
}), 403
except Exception as e: except Exception as e:
current_app.logger.error(f"Failed to update setting {key}: {e}") current_app.logger.error(f"Failed to update setting {key}: {e}")
return jsonify({ return jsonify({
@@ -160,6 +176,7 @@ def update_setting(key):
@bp.route('/<string:key>', methods=['DELETE']) @bp.route('/<string:key>', methods=['DELETE'])
@api_auth_required
def delete_setting(key): def delete_setting(key):
""" """
Delete a setting. Delete a setting.
@@ -170,9 +187,16 @@ def delete_setting(key):
Returns: Returns:
JSON response with deletion status JSON response with deletion status
""" """
# TODO: Add authentication in Phase 2
try: try:
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
# Prevent deletion of read-only settings
if settings_manager._is_read_only(key):
return jsonify({
'status': 'error',
'message': f'Setting "{key}" is read-only and cannot be deleted'
}), 403
deleted = settings_manager.delete(key) deleted = settings_manager.delete(key)
if not deleted: if not deleted:
@@ -194,6 +218,7 @@ def delete_setting(key):
@bp.route('/password', methods=['POST']) @bp.route('/password', methods=['POST'])
@api_auth_required
def set_password(): def set_password():
""" """
Set the application password. Set the application password.
@@ -204,7 +229,6 @@ def set_password():
Returns: Returns:
JSON response with status JSON response with status
""" """
# TODO: Add current password verification in Phase 2
data = request.get_json() or {} data = request.get_json() or {}
password = data.get('password') password = data.get('password')
@@ -237,6 +261,7 @@ def set_password():
@bp.route('/test-email', methods=['POST']) @bp.route('/test-email', methods=['POST'])
@api_auth_required
def test_email(): def test_email():
""" """
Test email configuration by sending a test email. Test email configuration by sending a test email.

661
app/web/api/sites.py Normal file
View File

@@ -0,0 +1,661 @@
"""
Sites API blueprint.
Handles endpoints for managing reusable site definitions, including CIDR ranges
and IP-level overrides.
"""
import logging
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy.exc import SQLAlchemyError
from web.auth.decorators import api_auth_required
from web.services.site_service import SiteService
from web.utils.pagination import validate_page_params
bp = Blueprint('sites', __name__)
logger = logging.getLogger(__name__)
@bp.route('', methods=['GET'])
@api_auth_required
def list_sites():
"""
List all sites with pagination.
Query params:
page: Page number (default: 1)
per_page: Items per page (default: 20, max: 100)
all: If 'true', returns all sites without pagination (for dropdowns)
Returns:
JSON response with sites list and pagination info
"""
try:
# Check if requesting all sites (no pagination)
if request.args.get('all', '').lower() == 'true':
site_service = SiteService(current_app.db_session)
sites = site_service.list_all_sites()
ip_stats = site_service.get_global_ip_stats()
logger.info(f"Listed all sites (count={len(sites)})")
return jsonify({
'sites': sites,
'total_ips': ip_stats['total_ips'],
'unique_ips': ip_stats['unique_ips'],
'duplicate_ips': ip_stats['duplicate_ips']
})
# Get and validate query parameters
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
# Validate pagination params
page, per_page = validate_page_params(page, per_page)
# Get sites from service
site_service = SiteService(current_app.db_session)
paginated_result = site_service.list_sites(page=page, per_page=per_page)
logger.info(f"Listed sites: page={page}, per_page={per_page}, total={paginated_result.total}")
return jsonify({
'sites': paginated_result.items,
'total': paginated_result.total,
'page': paginated_result.page,
'per_page': paginated_result.per_page,
'total_pages': paginated_result.pages,
'has_prev': paginated_result.has_prev,
'has_next': paginated_result.has_next
})
except ValueError as e:
logger.warning(f"Invalid request parameters: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error listing sites: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to retrieve sites'
}), 500
except Exception as e:
logger.error(f"Unexpected error listing sites: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>', methods=['GET'])
@api_auth_required
def get_site(site_id):
"""
Get details for a specific site.
Args:
site_id: Site ID
Returns:
JSON response with site details including CIDRs and IP overrides
"""
try:
site_service = SiteService(current_app.db_session)
site = site_service.get_site(site_id)
if not site:
logger.warning(f"Site not found: {site_id}")
return jsonify({
'error': 'Not found',
'message': f'Site with ID {site_id} not found'
}), 404
logger.info(f"Retrieved site details: {site_id}")
return jsonify(site)
except SQLAlchemyError as e:
logger.error(f"Database error retrieving site {site_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to retrieve site'
}), 500
except Exception as e:
logger.error(f"Unexpected error retrieving site {site_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('', methods=['POST'])
@api_auth_required
def create_site():
"""
Create a new site.
Request body:
name: Site name (required, must be unique)
description: Site description (optional)
cidrs: List of CIDR definitions (optional, but recommended)
[
{
"cidr": "10.0.0.0/24",
"expected_ping": true,
"expected_tcp_ports": [22, 80, 443],
"expected_udp_ports": [53]
}
]
Returns:
JSON response with created site data
"""
try:
data = request.get_json() or {}
# Validate required fields
name = data.get('name')
if not name:
logger.warning("Site creation request missing name")
return jsonify({
'error': 'Invalid request',
'message': 'name is required'
}), 400
description = data.get('description')
# Create site (empty initially)
site_service = SiteService(current_app.db_session)
site = site_service.create_site(
name=name,
description=description
)
logger.info(f"Created site '{name}' (id={site['id']})")
return jsonify(site), 201
except ValueError as e:
logger.warning(f"Invalid site creation request: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error creating site: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to create site'
}), 500
except Exception as e:
logger.error(f"Unexpected error creating site: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>', methods=['PUT'])
@api_auth_required
def update_site(site_id):
"""
Update site metadata (name and/or description).
Args:
site_id: Site ID
Request body:
name: New site name (optional, must be unique)
description: New description (optional)
Returns:
JSON response with updated site data
"""
try:
data = request.get_json() or {}
name = data.get('name')
description = data.get('description')
# Update site
site_service = SiteService(current_app.db_session)
site = site_service.update_site(
site_id=site_id,
name=name,
description=description
)
logger.info(f"Updated site {site_id}")
return jsonify(site)
except ValueError as e:
logger.warning(f"Invalid site update request: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error updating site {site_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to update site'
}), 500
except Exception as e:
logger.error(f"Unexpected error updating site {site_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>', methods=['DELETE'])
@api_auth_required
def delete_site(site_id):
"""
Delete a site.
Prevents deletion if site is used in any scan.
Args:
site_id: Site ID
Returns:
JSON response with success message
"""
try:
site_service = SiteService(current_app.db_session)
site_service.delete_site(site_id)
logger.info(f"Deleted site {site_id}")
return jsonify({
'message': f'Site {site_id} deleted successfully'
})
except ValueError as e:
logger.warning(f"Cannot delete site {site_id}: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error deleting site {site_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to delete site'
}), 500
except Exception as e:
logger.error(f"Unexpected error deleting site {site_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>/ips/bulk', methods=['POST'])
@api_auth_required
def bulk_add_ips(site_id):
"""
Bulk add IPs to a site from CIDR or list.
Args:
site_id: Site ID
Request body:
source_type: "cidr" or "list" (required)
cidr: CIDR notation if source_type="cidr" (e.g., "10.0.0.0/24")
ips: List of IP addresses if source_type="list" (e.g., ["10.0.0.1", "10.0.0.2"])
expected_ping: Expected ping response for all IPs (optional)
expected_tcp_ports: List of expected TCP ports for all IPs (optional)
expected_udp_ports: List of expected UDP ports for all IPs (optional)
Returns:
JSON response with count of IPs added and any errors
"""
try:
data = request.get_json() or {}
source_type = data.get('source_type')
if source_type not in ['cidr', 'list']:
return jsonify({
'error': 'Invalid request',
'message': 'source_type must be "cidr" or "list"'
}), 400
expected_ping = data.get('expected_ping')
expected_tcp_ports = data.get('expected_tcp_ports', [])
expected_udp_ports = data.get('expected_udp_ports', [])
site_service = SiteService(current_app.db_session)
if source_type == 'cidr':
cidr = data.get('cidr')
if not cidr:
return jsonify({
'error': 'Invalid request',
'message': 'cidr is required when source_type="cidr"'
}), 400
result = site_service.bulk_add_ips_from_cidr(
site_id=site_id,
cidr=cidr,
expected_ping=expected_ping,
expected_tcp_ports=expected_tcp_ports,
expected_udp_ports=expected_udp_ports
)
logger.info(f"Bulk added {result['ip_count']} IPs from CIDR '{cidr}' to site {site_id}")
return jsonify(result), 201
else: # source_type == 'list'
ip_list = data.get('ips', [])
if not isinstance(ip_list, list):
return jsonify({
'error': 'Invalid request',
'message': 'ips must be a list when source_type="list"'
}), 400
result = site_service.bulk_add_ips_from_list(
site_id=site_id,
ip_list=ip_list,
expected_ping=expected_ping,
expected_tcp_ports=expected_tcp_ports,
expected_udp_ports=expected_udp_ports
)
logger.info(f"Bulk added {result['ip_count']} IPs from list to site {site_id}")
return jsonify(result), 201
except ValueError as e:
logger.warning(f"Invalid bulk IP request: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error bulk adding IPs to site {site_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to add IPs'
}), 500
except Exception as e:
logger.error(f"Unexpected error bulk adding IPs to site {site_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>/ips', methods=['GET'])
@api_auth_required
def list_ips(site_id):
"""
List IPs in a site with pagination.
Query params:
page: Page number (default: 1)
per_page: Items per page (default: 50, max: 200)
Returns:
JSON response with IPs list and pagination info
"""
try:
# Get and validate query parameters
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
# Validate pagination params
page, per_page = validate_page_params(page, per_page, max_per_page=200)
# Get IPs from service
site_service = SiteService(current_app.db_session)
paginated_result = site_service.list_ips(
site_id=site_id,
page=page,
per_page=per_page
)
logger.info(f"Listed IPs for site {site_id}: page={page}, per_page={per_page}, total={paginated_result.total}")
return jsonify({
'ips': paginated_result.items,
'total': paginated_result.total,
'page': paginated_result.page,
'per_page': paginated_result.per_page,
'total_pages': paginated_result.pages,
'has_prev': paginated_result.has_prev,
'has_next': paginated_result.has_next
})
except ValueError as e:
logger.warning(f"Invalid request parameters: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error listing IPs for site {site_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to retrieve IPs'
}), 500
except Exception as e:
logger.error(f"Unexpected error listing IPs for site {site_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>/ips', methods=['POST'])
@api_auth_required
def add_standalone_ip(site_id):
"""
Add a standalone IP (without CIDR parent) to a site.
Args:
site_id: Site ID
Request body:
ip_address: IP address (required)
expected_ping: Expected ping response (optional)
expected_tcp_ports: List of expected TCP ports (optional)
expected_udp_ports: List of expected UDP ports (optional)
Returns:
JSON response with created IP data
"""
try:
data = request.get_json() or {}
# Validate required fields
ip_address = data.get('ip_address')
if not ip_address:
logger.warning("Standalone IP creation request missing ip_address")
return jsonify({
'error': 'Invalid request',
'message': 'ip_address is required'
}), 400
expected_ping = data.get('expected_ping')
expected_tcp_ports = data.get('expected_tcp_ports', [])
expected_udp_ports = data.get('expected_udp_ports', [])
# Add standalone IP
site_service = SiteService(current_app.db_session)
ip_data = site_service.add_standalone_ip(
site_id=site_id,
ip_address=ip_address,
expected_ping=expected_ping,
expected_tcp_ports=expected_tcp_ports,
expected_udp_ports=expected_udp_ports
)
logger.info(f"Added standalone IP '{ip_address}' to site {site_id}")
return jsonify(ip_data), 201
except ValueError as e:
logger.warning(f"Invalid standalone IP creation request: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error adding standalone IP to site {site_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to add IP'
}), 500
except Exception as e:
logger.error(f"Unexpected error adding standalone IP to site {site_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>/ips/<int:ip_id>', methods=['PUT'])
@api_auth_required
def update_ip_settings(site_id, ip_id):
"""
Update settings for an individual IP.
Args:
site_id: Site ID
ip_id: IP ID
Request body:
expected_ping: New ping expectation (optional)
expected_tcp_ports: New TCP ports expectation (optional)
expected_udp_ports: New UDP ports expectation (optional)
Returns:
JSON response with updated IP data
"""
try:
data = request.get_json() or {}
expected_ping = data.get('expected_ping')
expected_tcp_ports = data.get('expected_tcp_ports')
expected_udp_ports = data.get('expected_udp_ports')
# Update IP settings
site_service = SiteService(current_app.db_session)
ip_data = site_service.update_ip_settings(
site_id=site_id,
ip_id=ip_id,
expected_ping=expected_ping,
expected_tcp_ports=expected_tcp_ports,
expected_udp_ports=expected_udp_ports
)
logger.info(f"Updated IP {ip_id} in site {site_id}")
return jsonify(ip_data)
except ValueError as e:
logger.warning(f"Invalid IP update request: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error updating IP {ip_id} in site {site_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to update IP'
}), 500
except Exception as e:
logger.error(f"Unexpected error updating IP {ip_id} in site {site_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>/ips/<int:ip_id>', methods=['DELETE'])
@api_auth_required
def remove_ip(site_id, ip_id):
"""
Remove an IP from a site.
Args:
site_id: Site ID
ip_id: IP ID
Returns:
JSON response with success message
"""
try:
site_service = SiteService(current_app.db_session)
site_service.remove_ip(site_id, ip_id)
logger.info(f"Removed IP {ip_id} from site {site_id}")
return jsonify({
'message': f'IP {ip_id} removed successfully'
})
except ValueError as e:
logger.warning(f"Cannot remove IP {ip_id}: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error removing IP {ip_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to remove IP'
}), 500
except Exception as e:
logger.error(f"Unexpected error removing IP {ip_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>/usage', methods=['GET'])
@api_auth_required
def get_site_usage(site_id):
"""
Get list of scans that use this site.
Args:
site_id: Site ID
Returns:
JSON response with list of scans
"""
try:
site_service = SiteService(current_app.db_session)
# First check if site exists
site = site_service.get_site(site_id)
if not site:
logger.warning(f"Site not found: {site_id}")
return jsonify({
'error': 'Not found',
'message': f'Site with ID {site_id} not found'
}), 404
scans = site_service.get_scan_usage(site_id)
logger.info(f"Retrieved usage for site {site_id} (count={len(scans)})")
return jsonify({
'site_id': site_id,
'site_name': site['name'],
'scans': scans,
'count': len(scans)
})
except SQLAlchemyError as e:
logger.error(f"Database error retrieving site usage {site_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to retrieve site usage'
}), 500
except Exception as e:
logger.error(f"Unexpected error retrieving site usage {site_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500

258
app/web/api/stats.py Normal file
View File

@@ -0,0 +1,258 @@
"""
Stats API blueprint.
Handles endpoints for dashboard statistics, trending data, and analytics.
"""
import logging
from datetime import datetime, timedelta
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy import func, Date
from sqlalchemy.exc import SQLAlchemyError
from web.auth.decorators import api_auth_required
from web.models import Scan
bp = Blueprint('stats', __name__)
logger = logging.getLogger(__name__)
@bp.route('/scan-trend', methods=['GET'])
@api_auth_required
def scan_trend():
"""
Get scan activity trend data for charts.
Query params:
days: Number of days to include (default: 30, max: 365)
Returns:
JSON response with labels and values arrays for Chart.js
{
"labels": ["2025-01-01", "2025-01-02", ...],
"values": [5, 3, 7, 2, ...]
}
"""
try:
# Get and validate query parameters
days = request.args.get('days', 30, type=int)
# Validate days parameter
if days < 1:
return jsonify({'error': 'days parameter must be at least 1'}), 400
if days > 365:
return jsonify({'error': 'days parameter cannot exceed 365'}), 400
# Calculate date range
end_date = datetime.utcnow().date()
start_date = end_date - timedelta(days=days - 1)
# Query scan counts per day
db_session = current_app.db_session
scan_counts = (
db_session.query(
func.date(Scan.timestamp).label('scan_date'),
func.count(Scan.id).label('scan_count')
)
.filter(func.date(Scan.timestamp) >= start_date)
.filter(func.date(Scan.timestamp) <= end_date)
.group_by(func.date(Scan.timestamp))
.order_by('scan_date')
.all()
)
# Create a dictionary of date -> count
scan_dict = {str(row.scan_date): row.scan_count for row in scan_counts}
# Generate all dates in range (fill missing dates with 0)
labels = []
values = []
current_date = start_date
while current_date <= end_date:
date_str = str(current_date)
labels.append(date_str)
values.append(scan_dict.get(date_str, 0))
current_date += timedelta(days=1)
return jsonify({
'labels': labels,
'values': values,
'start_date': str(start_date),
'end_date': str(end_date),
'total_scans': sum(values)
}), 200
except SQLAlchemyError as e:
logger.error(f"Database error in scan_trend: {str(e)}")
return jsonify({'error': 'Database error occurred'}), 500
except Exception as e:
logger.error(f"Error in scan_trend: {str(e)}")
return jsonify({'error': 'An error occurred'}), 500
@bp.route('/summary', methods=['GET'])
@api_auth_required
def summary():
"""
Get dashboard summary statistics.
Returns:
JSON response with summary stats
{
"total_scans": 150,
"completed_scans": 140,
"failed_scans": 5,
"running_scans": 5,
"scans_today": 3,
"scans_this_week": 15
}
"""
try:
db_session = current_app.db_session
# Get total counts by status
total_scans = db_session.query(func.count(Scan.id)).scalar() or 0
completed_scans = db_session.query(func.count(Scan.id)).filter(
Scan.status == 'completed'
).scalar() or 0
failed_scans = db_session.query(func.count(Scan.id)).filter(
Scan.status == 'failed'
).scalar() or 0
running_scans = db_session.query(func.count(Scan.id)).filter(
Scan.status == 'running'
).scalar() or 0
# Get scans today
today = datetime.utcnow().date()
scans_today = db_session.query(func.count(Scan.id)).filter(
func.date(Scan.timestamp) == today
).scalar() or 0
# Get scans this week (last 7 days)
week_ago = today - timedelta(days=6)
scans_this_week = db_session.query(func.count(Scan.id)).filter(
func.date(Scan.timestamp) >= week_ago
).scalar() or 0
return jsonify({
'total_scans': total_scans,
'completed_scans': completed_scans,
'failed_scans': failed_scans,
'running_scans': running_scans,
'scans_today': scans_today,
'scans_this_week': scans_this_week
}), 200
except SQLAlchemyError as e:
logger.error(f"Database error in summary: {str(e)}")
return jsonify({'error': 'Database error occurred'}), 500
except Exception as e:
logger.error(f"Error in summary: {str(e)}")
return jsonify({'error': 'An error occurred'}), 500
@bp.route('/scan-history/<int:scan_id>', 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_id = reference_scan.config_id
# Query historical scans with the same config_id
historical_scans = (
db_session.query(Scan)
.filter(Scan.config_id == config_id)
.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_id': config_id
}), 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

677
app/web/api/webhooks.py Normal file
View File

@@ -0,0 +1,677 @@
"""
Webhooks API blueprint.
Handles endpoints for managing webhook configurations and viewing delivery logs.
"""
import json
from datetime import datetime, timezone
from flask import Blueprint, jsonify, request, current_app
from web.auth.decorators import api_auth_required
from web.models import Webhook, WebhookDeliveryLog, Alert
from web.services.webhook_service import WebhookService
from web.services.template_service import get_template_service
bp = Blueprint('webhooks_api', __name__)
@bp.route('', methods=['GET'])
@api_auth_required
def list_webhooks():
"""
List all webhooks with optional filtering.
Query params:
page: Page number (default: 1)
per_page: Items per page (default: 20)
enabled: Filter by enabled status (true/false)
Returns:
JSON response with webhooks list
"""
# Get query parameters
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100) # Max 100 items
enabled = request.args.get('enabled')
# Build query
query = current_app.db_session.query(Webhook)
# Apply enabled filter
if enabled is not None:
enabled_bool = enabled.lower() == 'true'
query = query.filter(Webhook.enabled == enabled_bool)
# Order by name
query = query.order_by(Webhook.name)
# Paginate
total = query.count()
webhooks = query.offset((page - 1) * per_page).limit(per_page).all()
# Format response
webhooks_data = []
for webhook in webhooks:
# Parse JSON fields
alert_types = json.loads(webhook.alert_types) if webhook.alert_types else None
severity_filter = json.loads(webhook.severity_filter) if webhook.severity_filter else None
custom_headers = json.loads(webhook.custom_headers) if webhook.custom_headers else None
webhooks_data.append({
'id': webhook.id,
'name': webhook.name,
'url': webhook.url,
'enabled': webhook.enabled,
'auth_type': webhook.auth_type,
'auth_token': '***ENCRYPTED***' if webhook.auth_token else None, # Mask sensitive data
'custom_headers': custom_headers,
'alert_types': alert_types,
'severity_filter': severity_filter,
'timeout': webhook.timeout,
'retry_count': webhook.retry_count,
'created_at': webhook.created_at.isoformat() if webhook.created_at else None,
'updated_at': webhook.updated_at.isoformat() if webhook.updated_at else None
})
return jsonify({
'webhooks': webhooks_data,
'total': total,
'page': page,
'per_page': per_page,
'pages': (total + per_page - 1) // per_page
})
@bp.route('/<int:webhook_id>', methods=['GET'])
@api_auth_required
def get_webhook(webhook_id):
"""
Get a specific webhook by ID.
Args:
webhook_id: Webhook ID
Returns:
JSON response with webhook details
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return jsonify({
'status': 'error',
'message': f'Webhook {webhook_id} not found'
}), 404
# Parse JSON fields
alert_types = json.loads(webhook.alert_types) if webhook.alert_types else None
severity_filter = json.loads(webhook.severity_filter) if webhook.severity_filter else None
custom_headers = json.loads(webhook.custom_headers) if webhook.custom_headers else None
return jsonify({
'webhook': {
'id': webhook.id,
'name': webhook.name,
'url': webhook.url,
'enabled': webhook.enabled,
'auth_type': webhook.auth_type,
'auth_token': '***ENCRYPTED***' if webhook.auth_token else None,
'custom_headers': custom_headers,
'alert_types': alert_types,
'severity_filter': severity_filter,
'timeout': webhook.timeout,
'retry_count': webhook.retry_count,
'created_at': webhook.created_at.isoformat() if webhook.created_at else None,
'updated_at': webhook.updated_at.isoformat() if webhook.updated_at else None
}
})
@bp.route('', methods=['POST'])
@api_auth_required
def create_webhook():
"""
Create a new webhook.
Request body:
name: Webhook name (required)
url: Webhook URL (required)
enabled: Whether webhook is enabled (default: true)
auth_type: Authentication type (none, bearer, basic, custom)
auth_token: Authentication token (encrypted on storage)
custom_headers: JSON object with custom headers
alert_types: Array of alert types to filter
severity_filter: Array of severities to filter
timeout: Request timeout in seconds (default: 10)
retry_count: Number of retry attempts (default: 3)
template: Jinja2 template for custom payload (optional)
template_format: Template format - 'json' or 'text' (default: json)
content_type_override: Custom Content-Type header (optional)
Returns:
JSON response with created webhook
"""
data = request.get_json() or {}
# Validate required fields
if not data.get('name'):
return jsonify({
'status': 'error',
'message': 'name is required'
}), 400
if not data.get('url'):
return jsonify({
'status': 'error',
'message': 'url is required'
}), 400
# Validate auth_type
valid_auth_types = ['none', 'bearer', 'basic', 'custom']
auth_type = data.get('auth_type', 'none')
if auth_type not in valid_auth_types:
return jsonify({
'status': 'error',
'message': f'Invalid auth_type. Must be one of: {", ".join(valid_auth_types)}'
}), 400
# Validate template_format
valid_template_formats = ['json', 'text']
template_format = data.get('template_format', 'json')
if template_format not in valid_template_formats:
return jsonify({
'status': 'error',
'message': f'Invalid template_format. Must be one of: {", ".join(valid_template_formats)}'
}), 400
# Validate template if provided
template = data.get('template')
if template:
template_service = get_template_service()
is_valid, error_msg = template_service.validate_template(template, template_format)
if not is_valid:
return jsonify({
'status': 'error',
'message': f'Invalid template: {error_msg}'
}), 400
try:
webhook_service = WebhookService(current_app.db_session)
# Encrypt auth_token if provided
auth_token = None
if data.get('auth_token'):
auth_token = webhook_service._encrypt_value(data['auth_token'])
# Serialize JSON fields
alert_types = json.dumps(data['alert_types']) if data.get('alert_types') else None
severity_filter = json.dumps(data['severity_filter']) if data.get('severity_filter') else None
custom_headers = json.dumps(data['custom_headers']) if data.get('custom_headers') else None
# Create webhook
webhook = Webhook(
name=data['name'],
url=data['url'],
enabled=data.get('enabled', True),
auth_type=auth_type,
auth_token=auth_token,
custom_headers=custom_headers,
alert_types=alert_types,
severity_filter=severity_filter,
timeout=data.get('timeout', 10),
retry_count=data.get('retry_count', 3),
template=template,
template_format=template_format,
content_type_override=data.get('content_type_override'),
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
current_app.db_session.add(webhook)
current_app.db_session.commit()
# Parse for response
alert_types_parsed = json.loads(alert_types) if alert_types else None
severity_filter_parsed = json.loads(severity_filter) if severity_filter else None
custom_headers_parsed = json.loads(custom_headers) if custom_headers else None
return jsonify({
'status': 'success',
'message': 'Webhook created successfully',
'webhook': {
'id': webhook.id,
'name': webhook.name,
'url': webhook.url,
'enabled': webhook.enabled,
'auth_type': webhook.auth_type,
'alert_types': alert_types_parsed,
'severity_filter': severity_filter_parsed,
'custom_headers': custom_headers_parsed,
'timeout': webhook.timeout,
'retry_count': webhook.retry_count,
'template': webhook.template,
'template_format': webhook.template_format,
'content_type_override': webhook.content_type_override,
'created_at': webhook.created_at.isoformat()
}
}), 201
except Exception as e:
current_app.db_session.rollback()
return jsonify({
'status': 'error',
'message': f'Failed to create webhook: {str(e)}'
}), 500
@bp.route('/<int:webhook_id>', methods=['PUT'])
@api_auth_required
def update_webhook(webhook_id):
"""
Update an existing webhook.
Args:
webhook_id: Webhook ID
Request body (all optional):
name: Webhook name
url: Webhook URL
enabled: Whether webhook is enabled
auth_type: Authentication type
auth_token: Authentication token
custom_headers: JSON object with custom headers
alert_types: Array of alert types
severity_filter: Array of severities
timeout: Request timeout
retry_count: Retry attempts
template: Jinja2 template for custom payload
template_format: Template format - 'json' or 'text'
content_type_override: Custom Content-Type header
Returns:
JSON response with update status
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return jsonify({
'status': 'error',
'message': f'Webhook {webhook_id} not found'
}), 404
data = request.get_json() or {}
# Validate auth_type if provided
if 'auth_type' in data:
valid_auth_types = ['none', 'bearer', 'basic', 'custom']
if data['auth_type'] not in valid_auth_types:
return jsonify({
'status': 'error',
'message': f'Invalid auth_type. Must be one of: {", ".join(valid_auth_types)}'
}), 400
# Validate template_format if provided
if 'template_format' in data:
valid_template_formats = ['json', 'text']
if data['template_format'] not in valid_template_formats:
return jsonify({
'status': 'error',
'message': f'Invalid template_format. Must be one of: {", ".join(valid_template_formats)}'
}), 400
# Validate template if provided
if 'template' in data and data['template']:
template_format = data.get('template_format', webhook.template_format or 'json')
template_service = get_template_service()
is_valid, error_msg = template_service.validate_template(data['template'], template_format)
if not is_valid:
return jsonify({
'status': 'error',
'message': f'Invalid template: {error_msg}'
}), 400
try:
webhook_service = WebhookService(current_app.db_session)
# Update fields if provided
if 'name' in data:
webhook.name = data['name']
if 'url' in data:
webhook.url = data['url']
if 'enabled' in data:
webhook.enabled = data['enabled']
if 'auth_type' in data:
webhook.auth_type = data['auth_type']
if 'auth_token' in data:
# Encrypt new token
webhook.auth_token = webhook_service._encrypt_value(data['auth_token'])
if 'custom_headers' in data:
webhook.custom_headers = json.dumps(data['custom_headers']) if data['custom_headers'] else None
if 'alert_types' in data:
webhook.alert_types = json.dumps(data['alert_types']) if data['alert_types'] else None
if 'severity_filter' in data:
webhook.severity_filter = json.dumps(data['severity_filter']) if data['severity_filter'] else None
if 'timeout' in data:
webhook.timeout = data['timeout']
if 'retry_count' in data:
webhook.retry_count = data['retry_count']
if 'template' in data:
webhook.template = data['template']
if 'template_format' in data:
webhook.template_format = data['template_format']
if 'content_type_override' in data:
webhook.content_type_override = data['content_type_override']
webhook.updated_at = datetime.now(timezone.utc)
current_app.db_session.commit()
# Parse for response
alert_types = json.loads(webhook.alert_types) if webhook.alert_types else None
severity_filter = json.loads(webhook.severity_filter) if webhook.severity_filter else None
custom_headers = json.loads(webhook.custom_headers) if webhook.custom_headers else None
return jsonify({
'status': 'success',
'message': 'Webhook updated successfully',
'webhook': {
'id': webhook.id,
'name': webhook.name,
'url': webhook.url,
'enabled': webhook.enabled,
'auth_type': webhook.auth_type,
'alert_types': alert_types,
'severity_filter': severity_filter,
'custom_headers': custom_headers,
'timeout': webhook.timeout,
'retry_count': webhook.retry_count,
'template': webhook.template,
'template_format': webhook.template_format,
'content_type_override': webhook.content_type_override,
'updated_at': webhook.updated_at.isoformat()
}
})
except Exception as e:
current_app.db_session.rollback()
return jsonify({
'status': 'error',
'message': f'Failed to update webhook: {str(e)}'
}), 500
@bp.route('/<int:webhook_id>', methods=['DELETE'])
@api_auth_required
def delete_webhook(webhook_id):
"""
Delete a webhook.
Args:
webhook_id: Webhook ID
Returns:
JSON response with deletion status
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return jsonify({
'status': 'error',
'message': f'Webhook {webhook_id} not found'
}), 404
try:
# Delete webhook (delivery logs will be cascade deleted)
current_app.db_session.delete(webhook)
current_app.db_session.commit()
return jsonify({
'status': 'success',
'message': f'Webhook {webhook_id} deleted successfully'
})
except Exception as e:
current_app.db_session.rollback()
return jsonify({
'status': 'error',
'message': f'Failed to delete webhook: {str(e)}'
}), 500
@bp.route('/<int:webhook_id>/test', methods=['POST'])
@api_auth_required
def test_webhook(webhook_id):
"""
Send a test payload to a webhook.
Args:
webhook_id: Webhook ID
Returns:
JSON response with test result
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return jsonify({
'status': 'error',
'message': f'Webhook {webhook_id} not found'
}), 404
# Test webhook delivery
webhook_service = WebhookService(current_app.db_session)
result = webhook_service.test_webhook(webhook_id)
return jsonify({
'status': 'success' if result['success'] else 'error',
'message': result['message'],
'status_code': result['status_code'],
'response_body': result.get('response_body')
})
@bp.route('/<int:webhook_id>/logs', methods=['GET'])
@api_auth_required
def get_webhook_logs(webhook_id):
"""
Get delivery logs for a specific webhook.
Args:
webhook_id: Webhook ID
Query params:
page: Page number (default: 1)
per_page: Items per page (default: 20)
status: Filter by status (success/failed)
Returns:
JSON response with delivery logs
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return jsonify({
'status': 'error',
'message': f'Webhook {webhook_id} not found'
}), 404
# Get query parameters
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100)
status_filter = request.args.get('status')
# Build query
query = current_app.db_session.query(WebhookDeliveryLog).filter(
WebhookDeliveryLog.webhook_id == webhook_id
)
# Apply status filter
if status_filter:
query = query.filter(WebhookDeliveryLog.status == status_filter)
# Order by most recent first
query = query.order_by(WebhookDeliveryLog.delivered_at.desc())
# Paginate
total = query.count()
logs = query.offset((page - 1) * per_page).limit(per_page).all()
# Format response
logs_data = []
for log in logs:
# Get alert info
alert = current_app.db_session.query(Alert).filter(Alert.id == log.alert_id).first()
logs_data.append({
'id': log.id,
'alert_id': log.alert_id,
'alert_type': alert.alert_type if alert else None,
'alert_message': alert.message if alert else None,
'status': log.status,
'response_code': log.response_code,
'response_body': log.response_body,
'error_message': log.error_message,
'attempt_number': log.attempt_number,
'delivered_at': log.delivered_at.isoformat() if log.delivered_at else None
})
return jsonify({
'webhook_id': webhook_id,
'webhook_name': webhook.name,
'logs': logs_data,
'total': total,
'page': page,
'per_page': per_page,
'pages': (total + per_page - 1) // per_page
})
@bp.route('/preview-template', methods=['POST'])
@api_auth_required
def preview_template():
"""
Preview a webhook template with sample data.
Request body:
template: Jinja2 template string (required)
template_format: Template format - 'json' or 'text' (default: json)
Returns:
JSON response with rendered template preview
"""
data = request.get_json() or {}
if not data.get('template'):
return jsonify({
'status': 'error',
'message': 'template is required'
}), 400
template = data['template']
template_format = data.get('template_format', 'json')
# Validate template format
if template_format not in ['json', 'text']:
return jsonify({
'status': 'error',
'message': 'Invalid template_format. Must be json or text'
}), 400
try:
template_service = get_template_service()
# Validate template
is_valid, error_msg = template_service.validate_template(template, template_format)
if not is_valid:
return jsonify({
'status': 'error',
'message': f'Template validation error: {error_msg}'
}), 400
# Render with sample data
rendered, error = template_service.render_test_payload(template, template_format)
if error:
return jsonify({
'status': 'error',
'message': f'Template rendering error: {error}'
}), 400
return jsonify({
'status': 'success',
'rendered': rendered,
'format': template_format
})
except Exception as e:
return jsonify({
'status': 'error',
'message': f'Failed to preview template: {str(e)}'
}), 500
@bp.route('/template-presets', methods=['GET'])
@api_auth_required
def get_template_presets():
"""
Get list of available webhook template presets.
Returns:
JSON response with template presets
"""
import os
try:
# Load presets manifest
presets_file = os.path.join(
os.path.dirname(__file__),
'../templates/webhook_presets/presets.json'
)
with open(presets_file, 'r') as f:
presets_manifest = json.load(f)
# Load template contents for each preset
presets_dir = os.path.join(
os.path.dirname(__file__),
'../templates/webhook_presets'
)
for preset in presets_manifest:
template_file = os.path.join(presets_dir, preset['file'])
with open(template_file, 'r') as f:
preset['template'] = f.read()
# Remove file reference from response
del preset['file']
return jsonify({
'status': 'success',
'presets': presets_manifest
})
except FileNotFoundError as e:
return jsonify({
'status': 'error',
'message': f'Template presets not found: {str(e)}'
}), 500
except Exception as e:
return jsonify({
'status': 'error',
'message': f'Failed to load template presets: {str(e)}'
}), 500
# Health check endpoint
@bp.route('/health', methods=['GET'])
def health_check():
"""
Health check endpoint for monitoring.
Returns:
JSON response with API health status
"""
return jsonify({
'status': 'healthy',
'api': 'webhooks',
'version': '1.0.0-phase5'
})

642
app/web/app.py Normal file
View File

@@ -0,0 +1,642 @@
"""
Flask application factory for SneakyScanner web interface.
This module creates and configures the Flask application with all necessary
extensions, blueprints, and middleware.
"""
import logging
import os
import uuid
from logging.handlers import RotatingFileHandler
from pathlib import Path
from flask import Flask, g, jsonify, request
from flask_cors import CORS
from flask_login import LoginManager, current_user
from sqlalchemy import create_engine, event
from sqlalchemy.orm import scoped_session, sessionmaker
from web.models import Base
class RequestIDLogFilter(logging.Filter):
"""
Logging filter that injects request ID into log records.
Adds a 'request_id' attribute to each log record. For requests within
Flask request context, uses the request ID from g.request_id. For logs
outside request context (background jobs, startup), uses 'system'.
"""
def filter(self, record):
"""Add request_id to log record."""
try:
# Try to get request ID from Flask's g object
record.request_id = g.get('request_id', 'system')
except (RuntimeError, AttributeError):
# Outside of request context
record.request_id = 'system'
return True
def create_app(config: dict = None) -> Flask:
"""
Create and configure the Flask application.
Args:
config: Optional configuration dictionary to override defaults
Returns:
Configured Flask application instance
"""
app = Flask(__name__,
instance_relative_config=True,
static_folder='static',
template_folder='templates')
# Load default configuration
app.config.from_mapping(
SECRET_KEY=os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production'),
SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', 'sqlite:///./sneakyscanner.db'),
SQLALCHEMY_TRACK_MODIFICATIONS=False,
JSON_SORT_KEYS=False, # Preserve order in JSON responses
MAX_CONTENT_LENGTH=50 * 1024 * 1024, # 50MB max upload size (supports config files up to ~2MB)
)
# Override with custom config if provided
if config:
app.config.update(config)
# Ensure instance folder exists
try:
os.makedirs(app.instance_path, exist_ok=True)
except OSError:
pass
# Configure logging
configure_logging(app)
# Initialize database
init_database(app)
# Initialize extensions
init_extensions(app)
# Initialize authentication
init_authentication(app)
# Initialize background scheduler
init_scheduler(app)
# Register blueprints
register_blueprints(app)
# Register error handlers
register_error_handlers(app)
# Register context processors
register_context_processors(app)
# Add request/response handlers
register_request_handlers(app)
app.logger.info("SneakyScanner Flask app initialized")
return app
def configure_logging(app: Flask) -> None:
"""
Configure application logging with rotation and structured format.
Args:
app: Flask application instance
"""
# Set log level from environment or default to INFO
log_level = os.environ.get('LOG_LEVEL', 'INFO').upper()
app.logger.setLevel(getattr(logging, log_level, logging.INFO))
# Create logs directory if it doesn't exist
log_dir = Path('logs')
log_dir.mkdir(exist_ok=True)
# Rotating file handler for application logs
# Max 10MB per file, keep 10 backup files (100MB total)
app_log_handler = RotatingFileHandler(
log_dir / 'sneakyscanner.log',
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=10
)
app_log_handler.setLevel(logging.INFO)
# Structured log format with more context
log_formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s [%(request_id)s] '
'%(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
app_log_handler.setFormatter(log_formatter)
# Add filter to inject request ID into log records
app_log_handler.addFilter(RequestIDLogFilter())
app.logger.addHandler(app_log_handler)
# Separate rotating file handler for errors only
error_log_handler = RotatingFileHandler(
log_dir / 'sneakyscanner_errors.log',
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5
)
error_log_handler.setLevel(logging.ERROR)
error_formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s [%(request_id)s]\n'
'Message: %(message)s\n'
'Path: %(pathname)s:%(lineno)d\n'
'%(stack_info)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
error_log_handler.setFormatter(error_formatter)
error_log_handler.addFilter(RequestIDLogFilter())
app.logger.addHandler(error_log_handler)
# Console handler for development
if app.debug:
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s [%(request_id)s] %(message)s',
datefmt='%H:%M:%S'
)
console_handler.setFormatter(console_formatter)
console_handler.addFilter(RequestIDLogFilter())
app.logger.addHandler(console_handler)
app.logger.info("Logging configured with rotation (10MB per file, 10 backups)")
def init_database(app: Flask) -> None:
"""
Initialize database connection and session management.
Args:
app: Flask application instance
"""
# Determine connect_args based on database type
connect_args = {}
if 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']:
# SQLite-specific configuration for better concurrency
connect_args = {
'timeout': 15, # 15 second timeout for database locks
'check_same_thread': False # Allow SQLite usage across threads
}
# Create engine
engine = create_engine(
app.config['SQLALCHEMY_DATABASE_URI'],
echo=app.debug, # Log SQL in debug mode
pool_pre_ping=True, # Verify connections before using
pool_recycle=3600, # Recycle connections after 1 hour
connect_args=connect_args
)
# Enable WAL mode for SQLite (better concurrency)
if 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']:
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_conn, connection_record):
"""Set SQLite pragmas for better performance and concurrency."""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL") # Write-Ahead Logging
cursor.execute("PRAGMA synchronous=NORMAL") # Faster writes
cursor.execute("PRAGMA busy_timeout=15000") # 15 second busy timeout
cursor.close()
# Create scoped session factory
db_session = scoped_session(
sessionmaker(
autocommit=False,
autoflush=False,
bind=engine
)
)
# Store session in app for use in views
app.db_session = db_session
# Create tables if they don't exist (for development)
# In production, use Alembic migrations instead
if app.debug:
Base.metadata.create_all(bind=engine)
@app.teardown_appcontext
def shutdown_session(exception=None):
"""
Remove database session at end of request.
Rollback on exception to prevent partial commits.
"""
if exception:
app.logger.warning(f"Request ended with exception, rolling back database session")
db_session.rollback()
db_session.remove()
app.logger.info(f"Database initialized: {app.config['SQLALCHEMY_DATABASE_URI']}")
def init_extensions(app: Flask) -> None:
"""
Initialize Flask extensions.
Args:
app: Flask application instance
"""
# CORS support for API
CORS(app, resources={
r"/api/*": {
"origins": os.environ.get('CORS_ORIGINS', '*').split(','),
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
"allow_headers": ["Content-Type", "Authorization"],
}
})
app.logger.info("Extensions initialized")
def init_authentication(app: Flask) -> None:
"""
Initialize Flask-Login authentication.
Args:
app: Flask application instance
"""
from web.auth.models import User
# Initialize LoginManager
login_manager = LoginManager()
login_manager.init_app(app)
# Configure login view
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'info'
# User loader callback
@login_manager.user_loader
def load_user(user_id):
"""Load user by ID for Flask-Login."""
return User.get(user_id, app.db_session)
app.logger.info("Authentication initialized")
def init_scheduler(app: Flask) -> None:
"""
Initialize background job scheduler.
Args:
app: Flask application instance
"""
from web.services.scheduler_service import SchedulerService
from web.services.scan_service import ScanService
# Create and initialize scheduler
scheduler = SchedulerService()
scheduler.init_scheduler(app)
# Perform startup tasks with app context for database access
with app.app_context():
# Clean up any orphaned scans from previous crashes/restarts
scan_service = ScanService(app.db_session)
cleanup_result = scan_service.cleanup_orphaned_scans()
if cleanup_result['total'] > 0:
app.logger.warning(
f"Cleaned up {cleanup_result['total']} orphaned scan(s) on startup: "
f"{cleanup_result['recovered']} recovered, {cleanup_result['failed']} failed"
)
# Load all enabled schedules from database
scheduler.load_schedules_on_startup()
# Store in app context for access from routes
app.scheduler = scheduler
app.logger.info("Background scheduler initialized")
def register_blueprints(app: Flask) -> None:
"""
Register Flask blueprints for different app sections.
Args:
app: Flask application instance
"""
# Import blueprints
from web.api.scans import bp as scans_bp
from web.api.schedules import bp as schedules_bp
from web.api.alerts import bp as alerts_bp
from web.api.webhooks import bp as webhooks_api_bp
from web.api.settings import bp as settings_bp
from web.api.stats import bp as stats_bp
from web.api.configs import bp as configs_bp
from web.api.sites import bp as sites_bp
from web.auth.routes import bp as auth_bp
from web.routes.main import bp as main_bp
from web.routes.webhooks import bp as webhooks_bp
# Register authentication blueprint
app.register_blueprint(auth_bp, url_prefix='/auth')
# Register main web routes blueprint
app.register_blueprint(main_bp, url_prefix='/')
# Register webhooks web routes blueprint
app.register_blueprint(webhooks_bp, url_prefix='/webhooks')
# Register API blueprints
app.register_blueprint(scans_bp, url_prefix='/api/scans')
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
app.register_blueprint(webhooks_api_bp, url_prefix='/api/webhooks')
app.register_blueprint(settings_bp, url_prefix='/api/settings')
app.register_blueprint(stats_bp, url_prefix='/api/stats')
app.register_blueprint(configs_bp, url_prefix='/api/configs')
app.register_blueprint(sites_bp, url_prefix='/api/sites')
app.logger.info("Blueprints registered")
def register_error_handlers(app: Flask) -> None:
"""
Register error handlers for common HTTP errors.
Handles errors with either JSON responses (for API requests) or
HTML templates (for web requests). Ensures database rollback on errors.
Args:
app: Flask application instance
"""
from flask import render_template
from sqlalchemy.exc import SQLAlchemyError
def wants_json():
"""Check if client wants JSON response."""
# API requests always get JSON
if request.path.startswith('/api/'):
return True
# Check Accept header
best = request.accept_mimetypes.best_match(['application/json', 'text/html'])
return best == 'application/json' and \
request.accept_mimetypes[best] > request.accept_mimetypes['text/html']
@app.errorhandler(400)
def bad_request(error):
"""Handle 400 Bad Request errors."""
app.logger.warning(f"Bad request: {request.path} - {str(error)}")
if wants_json():
return jsonify({
'error': 'Bad Request',
'message': str(error) or 'The request was invalid'
}), 400
return render_template('errors/400.html', error=error), 400
@app.errorhandler(401)
def unauthorized(error):
"""Handle 401 Unauthorized errors."""
app.logger.warning(f"Unauthorized access attempt: {request.path}")
if wants_json():
return jsonify({
'error': 'Unauthorized',
'message': 'Authentication required'
}), 401
return render_template('errors/401.html', error=error), 401
@app.errorhandler(403)
def forbidden(error):
"""Handle 403 Forbidden errors."""
app.logger.warning(f"Forbidden access: {request.path}")
if wants_json():
return jsonify({
'error': 'Forbidden',
'message': 'You do not have permission to access this resource'
}), 403
return render_template('errors/403.html', error=error), 403
@app.errorhandler(404)
def not_found(error):
"""Handle 404 Not Found errors."""
app.logger.info(f"Resource not found: {request.path}")
if wants_json():
return jsonify({
'error': 'Not Found',
'message': 'The requested resource was not found'
}), 404
return render_template('errors/404.html', error=error), 404
@app.errorhandler(405)
def method_not_allowed(error):
"""Handle 405 Method Not Allowed errors."""
app.logger.warning(f"Method not allowed: {request.method} {request.path}")
if wants_json():
return jsonify({
'error': 'Method Not Allowed',
'message': 'The HTTP method is not allowed for this endpoint'
}), 405
return render_template('errors/405.html', error=error), 405
@app.errorhandler(500)
def internal_server_error(error):
"""
Handle 500 Internal Server Error.
Rolls back database session and logs full traceback.
"""
# Rollback database session on error
try:
app.db_session.rollback()
except Exception as e:
app.logger.error(f"Failed to rollback database session: {str(e)}")
# Log error with full context
app.logger.error(
f"Internal server error: {request.method} {request.path} - {str(error)}",
exc_info=True
)
if wants_json():
return jsonify({
'error': 'Internal Server Error',
'message': 'An unexpected error occurred'
}), 500
return render_template('errors/500.html', error=error), 500
@app.errorhandler(SQLAlchemyError)
def handle_db_error(error):
"""
Handle database errors.
Rolls back transaction and returns appropriate error response.
"""
# Rollback database session
try:
app.db_session.rollback()
except Exception as e:
app.logger.error(f"Failed to rollback database session: {str(e)}")
# Log database error
app.logger.error(
f"Database error: {request.method} {request.path} - {str(error)}",
exc_info=True
)
if wants_json():
return jsonify({
'error': 'Database Error',
'message': 'A database error occurred'
}), 500
return render_template('errors/500.html', error=error), 500
def register_context_processors(app: Flask) -> None:
"""
Register template context processors.
Makes common variables available to all templates without having to
pass them explicitly in every render_template call.
Args:
app: Flask application instance
"""
@app.context_processor
def inject_app_settings():
"""
Inject application metadata into all templates.
Returns:
Dictionary of variables to add to template context
"""
from web.config import APP_NAME, APP_VERSION, REPO_URL
return {
'app_name': APP_NAME,
'app_version': APP_VERSION,
'repo_url': REPO_URL
}
app.logger.info("Context processors registered")
def register_request_handlers(app: Flask) -> None:
"""
Register request and response handlers.
Adds request ID generation, request/response logging with timing,
and security headers.
Args:
app: Flask application instance
"""
import time
@app.before_request
def before_request_handler():
"""
Generate request ID and start timing.
Sets g.request_id and g.request_start_time for use in logging
and timing calculations.
"""
# Generate unique request ID
g.request_id = str(uuid.uuid4())[:8] # Short ID for readability
g.request_start_time = time.time()
# Log incoming request with context
user_info = 'anonymous'
if current_user.is_authenticated:
user_info = f'user:{current_user.get_id()}'
# Log at INFO level for API calls, DEBUG for other requests
if request.path.startswith('/api/'):
app.logger.info(
f"{request.method} {request.path} "
f"from={request.remote_addr} user={user_info}"
)
elif app.debug:
app.logger.debug(
f"{request.method} {request.path} "
f"from={request.remote_addr}"
)
@app.after_request
def after_request_handler(response):
"""
Log response and add security headers.
Calculates request duration and logs response status.
"""
# Calculate request duration
if hasattr(g, 'request_start_time'):
duration_ms = (time.time() - g.request_start_time) * 1000
# Log response with duration
if request.path.startswith('/api/'):
# Log API responses at INFO level
app.logger.info(
f"{request.method} {request.path} "
f"status={response.status_code} "
f"duration={duration_ms:.2f}ms"
)
elif app.debug:
# Log web responses at DEBUG level in debug mode
app.logger.debug(
f"{request.method} {request.path} "
f"status={response.status_code} "
f"duration={duration_ms:.2f}ms"
)
# Add duration header for API responses
if request.path.startswith('/api/'):
response.headers['X-Request-Duration-Ms'] = f"{duration_ms:.2f}"
response.headers['X-Request-ID'] = g.request_id
# Add security headers to all responses
if request.path.startswith('/api/'):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-XSS-Protection'] = '1; mode=block'
return response
@app.teardown_request
def teardown_request_handler(exception=None):
"""
Log errors that occur during request processing.
Args:
exception: Exception that occurred, if any
"""
if exception:
app.logger.error(
f"Request failed: {request.method} {request.path} "
f"error={type(exception).__name__}: {str(exception)}",
exc_info=True
)
# Development server entry point
def main():
"""Run development server."""
app = create_app()
app.run(
host=os.environ.get('FLASK_HOST', '0.0.0.0'),
port=int(os.environ.get('FLASK_PORT', 5000)),
debug=os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
)
if __name__ == '__main__':
main()

9
app/web/auth/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
"""
Authentication package for SneakyScanner.
Provides Flask-Login based authentication with single-user support.
"""
from web.auth.models import User
__all__ = ['User']

View File

@@ -0,0 +1,65 @@
"""
Authentication decorators for SneakyScanner.
Provides decorators for protecting web routes and API endpoints.
"""
from functools import wraps
from typing import Callable
from flask import jsonify, redirect, request, url_for
from flask_login import current_user
def login_required(f: Callable) -> Callable:
"""
Decorator for web routes that require authentication.
Redirects to login page if user is not authenticated.
This is a wrapper around Flask-Login's login_required that can be
customized if needed.
Args:
f: Function to decorate
Returns:
Decorated function
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
# Redirect to login page
return redirect(url_for('auth.login', next=request.url))
return f(*args, **kwargs)
return decorated_function
def api_auth_required(f: Callable) -> Callable:
"""
Decorator for API endpoints that require authentication.
Returns 401 JSON response if user is not authenticated.
Uses Flask-Login sessions (same as web UI).
Args:
f: Function to decorate
Returns:
Decorated function
Example:
@bp.route('/api/scans', methods=['POST'])
@api_auth_required
def trigger_scan():
# Protected endpoint
pass
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return jsonify({
'error': 'Authentication required',
'message': 'Please authenticate to access this endpoint'
}), 401
return f(*args, **kwargs)
return decorated_function

107
app/web/auth/models.py Normal file
View File

@@ -0,0 +1,107 @@
"""
User model for Flask-Login authentication.
Simple single-user model that loads credentials from the settings table.
"""
from typing import Optional
from flask_login import UserMixin
from sqlalchemy.orm import Session
from web.utils.settings import PasswordManager, SettingsManager
class User(UserMixin):
"""
User class for Flask-Login.
Represents the single application user. Credentials are stored in the
settings table (app_password key).
"""
# Single user ID (always 1 for single-user app)
USER_ID = '1'
def __init__(self, user_id: str = USER_ID):
"""
Initialize user.
Args:
user_id: User ID (always '1' for single-user app)
"""
self.id = user_id
def get_id(self) -> str:
"""
Get user ID for Flask-Login.
Returns:
User ID string
"""
return self.id
@property
def is_authenticated(self) -> bool:
"""User is always authenticated if instance exists."""
return True
@property
def is_active(self) -> bool:
"""User is always active."""
return True
@property
def is_anonymous(self) -> bool:
"""User is never anonymous."""
return False
@staticmethod
def get(user_id: str, db_session: Session = None) -> Optional['User']:
"""
Get user by ID (Flask-Login user_loader).
Args:
user_id: User ID to load
db_session: Database session (unused - kept for compatibility)
Returns:
User instance if ID is valid, None otherwise
"""
if user_id == User.USER_ID:
return User(user_id)
return None
@staticmethod
def authenticate(password: str, db_session: Session) -> Optional['User']:
"""
Authenticate user with password.
Args:
password: Password to verify
db_session: Database session for accessing settings
Returns:
User instance if password is correct, None otherwise
"""
settings_manager = SettingsManager(db_session)
if PasswordManager.verify_app_password(settings_manager, password):
return User(User.USER_ID)
return None
@staticmethod
def has_password_set(db_session: Session) -> bool:
"""
Check if application password is set.
Args:
db_session: Database session for accessing settings
Returns:
True if password is set, False otherwise
"""
settings_manager = SettingsManager(db_session)
stored_hash = settings_manager.get('app_password', decrypt=False)
return bool(stored_hash)

120
app/web/auth/routes.py Normal file
View File

@@ -0,0 +1,120 @@
"""
Authentication routes for SneakyScanner.
Provides login and logout endpoints for user authentication.
"""
import logging
from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
from flask_login import login_user, logout_user, current_user
from web.auth.models import User
logger = logging.getLogger(__name__)
bp = Blueprint('auth', __name__)
@bp.route('/login', methods=['GET', 'POST'])
def login():
"""
Login page and authentication endpoint.
GET: Render login form
POST: Authenticate user and create session
Returns:
GET: Rendered login template
POST: Redirect to dashboard on success, login page with error on failure
"""
# If already logged in, redirect to dashboard
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
# Check if password is set
if not User.has_password_set(current_app.db_session):
flash('Application password not set. Please contact administrator.', 'error')
logger.warning("Login attempted but no password is set")
return render_template('login.html', password_not_set=True)
if request.method == 'POST':
password = request.form.get('password', '')
# Authenticate user
user = User.authenticate(password, current_app.db_session)
if user:
# Login successful
login_user(user, remember=request.form.get('remember', False))
logger.info(f"User logged in successfully from {request.remote_addr}")
# Redirect to next page or dashboard
next_page = request.args.get('next')
if next_page:
return redirect(next_page)
return redirect(url_for('main.dashboard'))
else:
# Login failed
flash('Invalid password', 'error')
logger.warning(f"Failed login attempt from {request.remote_addr}")
return render_template('login.html')
@bp.route('/logout')
def logout():
"""
Logout endpoint.
Destroys the user session and redirects to login page.
Returns:
Redirect to login page
"""
if current_user.is_authenticated:
logger.info(f"User logged out from {request.remote_addr}")
logout_user()
flash('You have been logged out successfully', 'info')
return redirect(url_for('auth.login'))
@bp.route('/setup', methods=['GET', 'POST'])
def setup():
"""
Initial password setup page.
Only accessible when no password is set. Allows setting the application password.
Returns:
GET: Rendered setup template
POST: Redirect to login page on success
"""
# If password already set, redirect to login
if User.has_password_set(current_app.db_session):
flash('Password already set. Please login.', 'info')
return redirect(url_for('auth.login'))
if request.method == 'POST':
password = request.form.get('password', '')
confirm_password = request.form.get('confirm_password', '')
# Validate passwords
if not password:
flash('Password is required', 'error')
elif len(password) < 8:
flash('Password must be at least 8 characters', 'error')
elif password != confirm_password:
flash('Passwords do not match', 'error')
else:
# Set password
from web.utils.settings import PasswordManager, SettingsManager
settings_manager = SettingsManager(current_app.db_session)
PasswordManager.set_app_password(settings_manager, password)
logger.info(f"Application password set from {request.remote_addr}")
flash('Password set successfully! You can now login.', 'success')
return redirect(url_for('auth.login'))
return render_template('setup.html')

16
app/web/config.py Normal file
View File

@@ -0,0 +1,16 @@
"""
Application configuration and metadata.
Contains version information and other application-level constants
that are managed by developers, not stored in the database.
"""
# Application metadata
APP_NAME = 'SneakyScanner'
APP_VERSION = '1.0.0-beta'
# Repository URL
REPO_URL = 'https://git.sneakygeek.net/sneakygeek/SneakyScan'
# Scanner settings
NMAP_HOST_TIMEOUT = '2m' # Timeout per host for nmap service detection

6
app/web/jobs/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
Background jobs package for SneakyScanner.
This package contains job definitions for background task execution,
including scan jobs and scheduled tasks.
"""

381
app/web/jobs/scan_job.py Normal file
View File

@@ -0,0 +1,381 @@
"""
Background scan job execution.
This module handles the execution of scans in background threads,
updating database status and handling errors.
"""
import json
import logging
import threading
import traceback
from datetime import datetime
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.scanner import SneakyScanner, ScanCancelledError
from web.models import Scan, ScanProgress
from web.services.scan_service import ScanService
from web.services.alert_service import AlertService
logger = logging.getLogger(__name__)
# Registry for tracking running scanners (scan_id -> SneakyScanner instance)
_running_scanners = {}
_running_scanners_lock = threading.Lock()
def get_running_scanner(scan_id: int):
"""Get a running scanner instance by scan ID."""
with _running_scanners_lock:
return _running_scanners.get(scan_id)
def stop_scan(scan_id: int, db_url: str) -> bool:
"""
Stop a running scan.
Args:
scan_id: ID of the scan to stop
db_url: Database connection URL
Returns:
True if scan was cancelled, False if not found or already stopped
"""
logger.info(f"Attempting to stop scan {scan_id}")
# Get the scanner instance
scanner = get_running_scanner(scan_id)
if not scanner:
logger.warning(f"Scanner for scan {scan_id} not found in registry")
return False
# Cancel the scanner
scanner.cancel()
logger.info(f"Cancellation signal sent to scan {scan_id}")
return True
def create_progress_callback(scan_id: int, session):
"""
Create a progress callback function for updating scan progress in database.
Args:
scan_id: ID of the scan record
session: Database session
Returns:
Callback function that accepts (phase, ip, data)
"""
ip_to_site = {}
def progress_callback(phase: str, ip: str, data: dict):
"""Update scan progress in database."""
nonlocal ip_to_site
try:
# Get scan record
scan = session.query(Scan).filter_by(id=scan_id).first()
if not scan:
return
# Handle initialization phase
if phase == 'init':
scan.total_ips = data.get('total_ips', 0)
scan.completed_ips = 0
scan.current_phase = 'ping'
ip_to_site = data.get('ip_to_site', {})
# Create progress entries for all IPs
for ip_addr, site_name in ip_to_site.items():
progress = ScanProgress(
scan_id=scan_id,
ip_address=ip_addr,
site_name=site_name,
phase='pending',
status='pending'
)
session.add(progress)
session.commit()
return
# Update current phase
if data.get('status') == 'starting':
scan.current_phase = phase
scan.completed_ips = 0
session.commit()
return
# Handle phase completion with results
if data.get('status') == 'completed':
results = data.get('results', {})
if phase == 'ping':
# Update progress entries with ping results
for ip_addr, ping_result in results.items():
progress = session.query(ScanProgress).filter_by(
scan_id=scan_id, ip_address=ip_addr
).first()
if progress:
progress.ping_result = ping_result
progress.phase = 'ping'
progress.status = 'completed'
scan.completed_ips = len(results)
elif phase == 'tcp_scan':
# Update progress entries with TCP/UDP port results
for ip_addr, port_data in results.items():
progress = session.query(ScanProgress).filter_by(
scan_id=scan_id, ip_address=ip_addr
).first()
if progress:
progress.tcp_ports = json.dumps(port_data.get('tcp_ports', []))
progress.udp_ports = json.dumps(port_data.get('udp_ports', []))
progress.phase = 'tcp_scan'
progress.status = 'completed'
scan.completed_ips = len(results)
elif phase == 'service_detection':
# Update progress entries with service detection results
for ip_addr, services in results.items():
progress = session.query(ScanProgress).filter_by(
scan_id=scan_id, ip_address=ip_addr
).first()
if progress:
# Simplify service data for storage
service_list = []
for svc in services:
service_list.append({
'port': svc.get('port'),
'service': svc.get('service', 'unknown'),
'product': svc.get('product', ''),
'version': svc.get('version', '')
})
progress.services = json.dumps(service_list)
progress.phase = 'service_detection'
progress.status = 'completed'
scan.completed_ips = len(results)
elif phase == 'http_analysis':
# Mark HTTP analysis as complete
scan.current_phase = 'completed'
scan.completed_ips = scan.total_ips
session.commit()
except Exception as e:
logger.error(f"Progress callback error for scan {scan_id}: {str(e)}")
# Don't re-raise - we don't want to break the scan
session.rollback()
return progress_callback
def execute_scan(scan_id: int, config_id: int, db_url: str = None):
"""
Execute a scan in the background.
This function is designed to run in a background thread via APScheduler.
It creates its own database session to avoid conflicts with the main
application thread.
Args:
scan_id: ID of the scan record in database
config_id: Database config ID
db_url: Database connection URL
Workflow:
1. Create new database session for this thread
2. Update scan status to 'running'
3. Execute scanner
4. Generate output files (JSON, HTML, ZIP)
5. Save results to database
6. Update status to 'completed' or 'failed'
"""
logger.info(f"Starting background scan execution: scan_id={scan_id}, config_id={config_id}")
# Create new database session for this thread
engine = create_engine(db_url, echo=False)
Session = sessionmaker(bind=engine)
session = Session()
try:
# Get scan record
scan = session.query(Scan).filter_by(id=scan_id).first()
if not scan:
logger.error(f"Scan {scan_id} not found in database")
return
# Update status to running (in case it wasn't already)
scan.status = 'running'
scan.started_at = datetime.utcnow()
session.commit()
logger.info(f"Scan {scan_id}: Initializing scanner with config_id={config_id}")
# Initialize scanner with database config
scanner = SneakyScanner(config_id=config_id)
# Register scanner in the running registry
with _running_scanners_lock:
_running_scanners[scan_id] = scanner
logger.debug(f"Scan {scan_id}: Registered in running scanners registry")
# Create progress callback
progress_callback = create_progress_callback(scan_id, session)
# Execute scan with progress tracking
logger.info(f"Scan {scan_id}: Running scanner...")
start_time = datetime.utcnow()
report, timestamp = scanner.scan(progress_callback=progress_callback)
end_time = datetime.utcnow()
scan_duration = (end_time - start_time).total_seconds()
logger.info(f"Scan {scan_id}: Scanner completed in {scan_duration:.2f} seconds")
# Transition to 'finalizing' status before output generation
try:
scan = session.query(Scan).filter_by(id=scan_id).first()
if scan:
scan.status = 'finalizing'
scan.current_phase = 'generating_outputs'
session.commit()
logger.info(f"Scan {scan_id}: Status changed to 'finalizing'")
except Exception as e:
logger.error(f"Scan {scan_id}: Failed to update status to finalizing: {e}")
session.rollback()
# Generate output files (JSON, HTML, ZIP) with error handling
output_paths = {}
output_generation_failed = False
try:
logger.info(f"Scan {scan_id}: Generating output files...")
output_paths = scanner.generate_outputs(report, timestamp)
except Exception as e:
output_generation_failed = True
logger.error(f"Scan {scan_id}: Output generation failed: {str(e)}")
logger.error(f"Scan {scan_id}: Traceback:\n{traceback.format_exc()}")
# Still mark scan as completed with warning since scan data is valid
try:
scan = session.query(Scan).filter_by(id=scan_id).first()
if scan:
scan.status = 'completed'
scan.error_message = f"Scan completed but output file generation failed: {str(e)}"
scan.completed_at = datetime.utcnow()
if scan.started_at:
scan.duration = (datetime.utcnow() - scan.started_at).total_seconds()
session.commit()
logger.info(f"Scan {scan_id}: Marked as completed with output generation warning")
except Exception as db_error:
logger.error(f"Scan {scan_id}: Failed to update status after output error: {db_error}")
# Save results to database (only if output generation succeeded)
if not output_generation_failed:
logger.info(f"Scan {scan_id}: Saving results to database...")
scan_service = ScanService(session)
scan_service._save_scan_to_db(report, scan_id, status='completed', output_paths=output_paths)
# Evaluate alert rules
logger.info(f"Scan {scan_id}: Evaluating alert rules...")
try:
alert_service = AlertService(session)
alerts_triggered = alert_service.evaluate_alert_rules(scan_id)
logger.info(f"Scan {scan_id}: {len(alerts_triggered)} alerts triggered")
except Exception as e:
# Don't fail the scan if alert evaluation fails
logger.error(f"Scan {scan_id}: Alert evaluation failed: {str(e)}")
logger.debug(f"Alert evaluation error details: {traceback.format_exc()}")
logger.info(f"Scan {scan_id}: Completed successfully")
except ScanCancelledError:
# Scan was cancelled by user
logger.info(f"Scan {scan_id}: Cancelled by user")
scan = session.query(Scan).filter_by(id=scan_id).first()
if scan:
scan.status = 'cancelled'
scan.error_message = 'Scan cancelled by user'
scan.completed_at = datetime.utcnow()
if scan.started_at:
scan.duration = (datetime.utcnow() - scan.started_at).total_seconds()
session.commit()
except FileNotFoundError as e:
# Config file not found
error_msg = f"Configuration file not found: {str(e)}"
logger.error(f"Scan {scan_id}: {error_msg}")
scan = session.query(Scan).filter_by(id=scan_id).first()
if scan:
scan.status = 'failed'
scan.error_message = error_msg
scan.completed_at = datetime.utcnow()
session.commit()
except Exception as e:
# Any other error during scan execution
error_msg = f"Scan execution failed: {str(e)}"
logger.error(f"Scan {scan_id}: {error_msg}")
logger.error(f"Scan {scan_id}: Traceback:\n{traceback.format_exc()}")
try:
scan = session.query(Scan).filter_by(id=scan_id).first()
if scan:
scan.status = 'failed'
scan.error_message = error_msg
scan.completed_at = datetime.utcnow()
session.commit()
except Exception as db_error:
logger.error(f"Scan {scan_id}: Failed to update error status in database: {str(db_error)}")
finally:
# Unregister scanner from registry
with _running_scanners_lock:
if scan_id in _running_scanners:
del _running_scanners[scan_id]
logger.debug(f"Scan {scan_id}: Unregistered from running scanners registry")
# Always close the session
session.close()
logger.info(f"Scan {scan_id}: Background job completed, session closed")
def get_scan_status_from_db(scan_id: int, db_url: str) -> dict:
"""
Helper function to get scan status directly from database.
Useful for monitoring background jobs without needing Flask app context.
Args:
scan_id: Scan ID to check
db_url: Database connection URL
Returns:
Dictionary with scan status information
"""
engine = create_engine(db_url, echo=False)
Session = sessionmaker(bind=engine)
session = Session()
try:
scan = session.query(Scan).filter_by(id=scan_id).first()
if not scan:
return None
return {
'scan_id': scan.id,
'status': scan.status,
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
'duration': scan.duration,
'error_message': scan.error_message
}
finally:
session.close()

View File

@@ -0,0 +1,59 @@
"""
Background webhook delivery job execution.
This module handles the execution of webhook deliveries in background threads,
updating delivery logs and handling errors.
"""
import logging
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from web.services.webhook_service import WebhookService
logger = logging.getLogger(__name__)
def execute_webhook_delivery(webhook_id: int, alert_id: int, db_url: str):
"""
Execute a webhook delivery in the background.
This function is designed to run in a background thread via APScheduler.
It creates its own database session to avoid conflicts with the main
application thread.
Args:
webhook_id: ID of the webhook to deliver
alert_id: ID of the alert to send
db_url: Database connection URL
Workflow:
1. Create new database session for this thread
2. Call WebhookService to deliver webhook
3. WebhookService handles retry logic and logging
4. Close session
"""
logger.info(f"Starting background webhook delivery: webhook_id={webhook_id}, alert_id={alert_id}")
# Create new database session for this thread
engine = create_engine(db_url, echo=False)
Session = sessionmaker(bind=engine)
session = Session()
try:
# Create webhook service and deliver
webhook_service = WebhookService(session)
success = webhook_service.deliver_webhook(webhook_id, alert_id)
if success:
logger.info(f"Webhook {webhook_id} delivered successfully for alert {alert_id}")
else:
logger.warning(f"Webhook {webhook_id} delivery failed for alert {alert_id}")
except Exception as e:
logger.error(f"Error during webhook delivery: {e}", exc_info=True)
finally:
session.close()
engine.dispose()
logger.info(f"Webhook delivery job completed: webhook_id={webhook_id}, alert_id={alert_id}")

View File

@@ -45,8 +45,8 @@ class Scan(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
timestamp = Column(DateTime, nullable=False, index=True, comment="Scan start time (UTC)") timestamp = Column(DateTime, nullable=False, index=True, comment="Scan start time (UTC)")
duration = Column(Float, nullable=True, comment="Total scan duration in seconds") duration = Column(Float, nullable=True, comment="Total scan duration in seconds")
status = Column(String(20), nullable=False, default='running', comment="running, completed, failed") status = Column(String(20), nullable=False, default='running', comment="running, finalizing, completed, failed, cancelled")
config_file = Column(Text, nullable=True, comment="Path to YAML config used") config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="FK to scan_configs table")
title = Column(Text, nullable=True, comment="Scan title from config") title = Column(Text, nullable=True, comment="Scan title from config")
json_path = Column(Text, nullable=True, comment="Path to JSON report") json_path = Column(Text, nullable=True, comment="Path to JSON report")
html_path = Column(Text, nullable=True, comment="Path to HTML report") html_path = Column(Text, nullable=True, comment="Path to HTML report")
@@ -55,6 +55,14 @@ class Scan(Base):
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Record creation time") created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Record creation time")
triggered_by = Column(String(50), nullable=False, default='manual', comment="manual, scheduled, api") triggered_by = Column(String(50), nullable=False, default='manual', comment="manual, scheduled, api")
schedule_id = Column(Integer, ForeignKey('schedules.id'), nullable=True, comment="FK to schedules if triggered by schedule") schedule_id = Column(Integer, ForeignKey('schedules.id'), nullable=True, comment="FK to schedules if triggered by schedule")
started_at = Column(DateTime, nullable=True, comment="Scan execution start time")
completed_at = Column(DateTime, nullable=True, comment="Scan execution completion time")
error_message = Column(Text, nullable=True, comment="Error message if scan failed")
# Progress tracking fields
current_phase = Column(String(50), nullable=True, comment="Current scan phase: ping, tcp_scan, udp_scan, service_detection, http_analysis")
total_ips = Column(Integer, nullable=True, comment="Total number of IPs to scan")
completed_ips = Column(Integer, nullable=True, default=0, comment="Number of IPs completed in current phase")
# Relationships # Relationships
sites = relationship('ScanSite', back_populates='scan', cascade='all, delete-orphan') sites = relationship('ScanSite', back_populates='scan', cascade='all, delete-orphan')
@@ -65,6 +73,9 @@ class Scan(Base):
tls_versions = relationship('ScanTLSVersion', back_populates='scan', cascade='all, delete-orphan') tls_versions = relationship('ScanTLSVersion', back_populates='scan', cascade='all, delete-orphan')
alerts = relationship('Alert', back_populates='scan', cascade='all, delete-orphan') alerts = relationship('Alert', back_populates='scan', cascade='all, delete-orphan')
schedule = relationship('Schedule', back_populates='scans') schedule = relationship('Schedule', back_populates='scans')
config = relationship('ScanConfig', back_populates='scans')
site_associations = relationship('ScanSiteAssociation', back_populates='scan', cascade='all, delete-orphan')
progress_entries = relationship('ScanProgress', back_populates='scan', cascade='all, delete-orphan')
def __repr__(self): def __repr__(self):
return f"<Scan(id={self.id}, title='{self.title}', status='{self.status}')>" return f"<Scan(id={self.id}, title='{self.title}', status='{self.status}')>"
@@ -239,6 +250,185 @@ class ScanTLSVersion(Base):
return f"<ScanTLSVersion(id={self.id}, tls_version='{self.tls_version}', supported={self.supported})>" return f"<ScanTLSVersion(id={self.id}, tls_version='{self.tls_version}', supported={self.supported})>"
class ScanProgress(Base):
"""
Real-time progress tracking for individual IPs during scan execution.
Stores intermediate results as they become available, allowing users to
see progress and results before the full scan completes.
"""
__tablename__ = 'scan_progress'
id = Column(Integer, primary_key=True, autoincrement=True)
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
ip_address = Column(String(45), nullable=False, comment="IP address being scanned")
site_name = Column(String(255), nullable=True, comment="Site name this IP belongs to")
phase = Column(String(50), nullable=False, comment="Phase: ping, tcp_scan, udp_scan, service_detection, http_analysis")
status = Column(String(20), nullable=False, default='pending', comment="pending, in_progress, completed, failed")
# Results data (stored as JSON)
ping_result = Column(Boolean, nullable=True, comment="Ping response result")
tcp_ports = Column(Text, nullable=True, comment="JSON array of discovered TCP ports")
udp_ports = Column(Text, nullable=True, comment="JSON array of discovered UDP ports")
services = Column(Text, nullable=True, comment="JSON array of detected services")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Entry creation time")
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, comment="Last update time")
# Relationships
scan = relationship('Scan', back_populates='progress_entries')
# Index for efficient lookups
__table_args__ = (
UniqueConstraint('scan_id', 'ip_address', name='uix_scan_progress_ip'),
)
def __repr__(self):
return f"<ScanProgress(id={self.id}, ip='{self.ip_address}', phase='{self.phase}', status='{self.status}')>"
# ============================================================================
# Reusable Site Definition Tables
# ============================================================================
class Site(Base):
"""
Master site definition (reusable across scans).
Sites represent logical network segments (e.g., "Production DC", "DMZ",
"Branch Office") that can be reused across multiple scans. Each site
contains one or more CIDR ranges.
"""
__tablename__ = 'sites'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, unique=True, index=True, comment="Unique site name")
description = Column(Text, nullable=True, comment="Site description")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Site creation time")
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, comment="Last modification time")
# Relationships
ips = relationship('SiteIP', back_populates='site', cascade='all, delete-orphan')
scan_associations = relationship('ScanSiteAssociation', back_populates='site')
config_associations = relationship('ScanConfigSite', back_populates='site')
def __repr__(self):
return f"<Site(id={self.id}, name='{self.name}')>"
class SiteIP(Base):
"""
Individual IP addresses with their own settings.
Each IP is directly associated with a site and has its own port and ping settings.
IPs are standalone entities - CIDRs are only used as a convenience for bulk creation.
"""
__tablename__ = 'site_ips'
id = Column(Integer, primary_key=True, autoincrement=True)
site_id = Column(Integer, ForeignKey('sites.id'), nullable=False, index=True, comment="FK to sites")
ip_address = Column(String(45), nullable=False, comment="IPv4 or IPv6 address")
expected_ping = Column(Boolean, nullable=True, comment="Expected ping response for this IP")
expected_tcp_ports = Column(Text, nullable=True, comment="JSON array of expected TCP ports")
expected_udp_ports = Column(Text, nullable=True, comment="JSON array of expected UDP ports")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="IP creation time")
# Relationships
site = relationship('Site', back_populates='ips')
# Index for efficient IP lookups - prevent duplicate IPs within a site
__table_args__ = (
UniqueConstraint('site_id', 'ip_address', name='uix_site_ip_address'),
)
def __repr__(self):
return f"<SiteIP(id={self.id}, ip_address='{self.ip_address}')>"
class ScanSiteAssociation(Base):
"""
Many-to-many relationship between scans and sites.
Tracks which sites were included in which scans. This allows sites
to be reused across multiple scans.
"""
__tablename__ = 'scan_site_associations'
id = Column(Integer, primary_key=True, autoincrement=True)
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
site_id = Column(Integer, ForeignKey('sites.id'), nullable=False, index=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Association creation time")
# Relationships
scan = relationship('Scan', back_populates='site_associations')
site = relationship('Site', back_populates='scan_associations')
# Index to prevent duplicate associations
__table_args__ = (
UniqueConstraint('scan_id', 'site_id', name='uix_scan_site'),
)
def __repr__(self):
return f"<ScanSiteAssociation(scan_id={self.scan_id}, site_id={self.site_id})>"
# ============================================================================
# Scan Configuration Tables
# ============================================================================
class ScanConfig(Base):
"""
Scan configurations stored in database (replaces YAML files).
Stores reusable scan configurations that reference sites from the
sites table. Configs define what sites to scan together.
"""
__tablename__ = 'scan_configs'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(255), nullable=False, comment="Configuration title")
description = Column(Text, nullable=True, comment="Configuration description")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Config creation time")
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, comment="Last modification time")
# Relationships
site_associations = relationship('ScanConfigSite', back_populates='config', cascade='all, delete-orphan')
scans = relationship('Scan', back_populates='config')
schedules = relationship('Schedule', back_populates='config')
def __repr__(self):
return f"<ScanConfig(id={self.id}, title='{self.title}')>"
class ScanConfigSite(Base):
"""
Many-to-many relationship between scan configs and sites.
Links scan configurations to the sites they should scan. A config
can reference multiple sites, and sites can be used in multiple configs.
"""
__tablename__ = 'scan_config_sites'
id = Column(Integer, primary_key=True, autoincrement=True)
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=False, index=True)
site_id = Column(Integer, ForeignKey('sites.id'), nullable=False, index=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Association creation time")
# Relationships
config = relationship('ScanConfig', back_populates='site_associations')
site = relationship('Site', back_populates='config_associations')
# Index to prevent duplicate associations
__table_args__ = (
UniqueConstraint('config_id', 'site_id', name='uix_config_site'),
)
def __repr__(self):
return f"<ScanConfigSite(config_id={self.config_id}, site_id={self.site_id})>"
# ============================================================================ # ============================================================================
# Scheduling & Notifications Tables # Scheduling & Notifications Tables
# ============================================================================ # ============================================================================
@@ -255,7 +445,7 @@ class Schedule(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, comment="Schedule name (e.g., 'Daily prod scan')") name = Column(String(255), nullable=False, comment="Schedule name (e.g., 'Daily prod scan')")
config_file = Column(Text, nullable=False, comment="Path to YAML config") config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="FK to scan_configs table")
cron_expression = Column(String(100), nullable=False, comment="Cron-like schedule (e.g., '0 2 * * *')") cron_expression = Column(String(100), nullable=False, comment="Cron-like schedule (e.g., '0 2 * * *')")
enabled = Column(Boolean, nullable=False, default=True, comment="Is schedule active?") enabled = Column(Boolean, nullable=False, default=True, comment="Is schedule active?")
last_run = Column(DateTime, nullable=True, comment="Last execution time") last_run = Column(DateTime, nullable=True, comment="Last execution time")
@@ -265,6 +455,7 @@ class Schedule(Base):
# Relationships # Relationships
scans = relationship('Scan', back_populates='schedule') scans = relationship('Scan', back_populates='schedule')
config = relationship('ScanConfig', back_populates='schedules')
def __repr__(self): def __repr__(self):
return f"<Schedule(id={self.id}, name='{self.name}', enabled={self.enabled})>" return f"<Schedule(id={self.id}, name='{self.name}', enabled={self.enabled})>"
@@ -281,17 +472,24 @@ class Alert(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True) scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
alert_type = Column(String(50), nullable=False, comment="new_port, cert_expiry, service_change, ping_failed") rule_id = Column(Integer, ForeignKey('alert_rules.id'), nullable=True, index=True, comment="Associated alert rule")
alert_type = Column(String(50), nullable=False, comment="unexpected_port, drift_detection, cert_expiry, service_change, ping_failed")
severity = Column(String(20), nullable=False, comment="info, warning, critical") severity = Column(String(20), nullable=False, comment="info, warning, critical")
message = Column(Text, nullable=False, comment="Human-readable alert message") message = Column(Text, nullable=False, comment="Human-readable alert message")
ip_address = Column(String(45), nullable=True, comment="Related IP (optional)") ip_address = Column(String(45), nullable=True, comment="Related IP (optional)")
port = Column(Integer, nullable=True, comment="Related port (optional)") port = Column(Integer, nullable=True, comment="Related port (optional)")
email_sent = Column(Boolean, nullable=False, default=False, comment="Was email notification sent?") email_sent = Column(Boolean, nullable=False, default=False, comment="Was email notification sent?")
email_sent_at = Column(DateTime, nullable=True, comment="Email send timestamp") email_sent_at = Column(DateTime, nullable=True, comment="Email send timestamp")
webhook_sent = Column(Boolean, nullable=False, default=False, comment="Was webhook sent?")
webhook_sent_at = Column(DateTime, nullable=True, comment="Webhook send timestamp")
acknowledged = Column(Boolean, nullable=False, default=False, index=True, comment="Was alert acknowledged?")
acknowledged_at = Column(DateTime, nullable=True, comment="Acknowledgment timestamp")
acknowledged_by = Column(String(255), nullable=True, comment="User who acknowledged")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Alert creation time") created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Alert creation time")
# Relationships # Relationships
scan = relationship('Scan', back_populates='alerts') scan = relationship('Scan', back_populates='alerts')
rule = relationship('AlertRule', back_populates='alerts')
# Index for alert queries by type and severity # Index for alert queries by type and severity
__table_args__ = ( __table_args__ = (
@@ -312,14 +510,83 @@ class AlertRule(Base):
__tablename__ = 'alert_rules' __tablename__ = 'alert_rules'
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
rule_type = Column(String(50), nullable=False, comment="unexpected_port, cert_expiry, service_down, etc.") name = Column(String(255), nullable=True, comment="User-friendly rule name")
rule_type = Column(String(50), nullable=False, comment="unexpected_port, cert_expiry, service_down, drift_detection, etc.")
enabled = Column(Boolean, nullable=False, default=True, comment="Is rule active?") enabled = Column(Boolean, nullable=False, default=True, comment="Is rule active?")
threshold = Column(Integer, nullable=True, comment="Threshold value (e.g., days for cert expiry)") threshold = Column(Integer, nullable=True, comment="Threshold value (e.g., days for cert expiry)")
email_enabled = Column(Boolean, nullable=False, default=False, comment="Send email for this rule?") email_enabled = Column(Boolean, nullable=False, default=False, comment="Send email for this rule?")
webhook_enabled = Column(Boolean, nullable=False, default=False, comment="Send webhook for this rule?")
severity = Column(String(20), nullable=True, comment="Alert severity: critical, warning, info")
filter_conditions = Column(Text, nullable=True, comment="JSON filter conditions for the rule")
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="Optional: specific config this rule applies to")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Rule creation time") created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Rule creation time")
updated_at = Column(DateTime, nullable=True, comment="Last update time")
# Relationships
alerts = relationship("Alert", back_populates="rule", cascade="all, delete-orphan")
config = relationship("ScanConfig", backref="alert_rules")
def __repr__(self): def __repr__(self):
return f"<AlertRule(id={self.id}, rule_type='{self.rule_type}', enabled={self.enabled})>" return f"<AlertRule(id={self.id}, name='{self.name}', rule_type='{self.rule_type}', enabled={self.enabled})>"
class Webhook(Base):
"""
Webhook configurations for alert notifications.
Stores webhook endpoints and authentication details for sending alert
notifications to external systems.
"""
__tablename__ = 'webhooks'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, comment="Webhook name")
url = Column(Text, nullable=False, comment="Webhook URL")
enabled = Column(Boolean, nullable=False, default=True, comment="Is webhook enabled?")
auth_type = Column(String(20), nullable=True, comment="Authentication type: none, bearer, basic, custom")
auth_token = Column(Text, nullable=True, comment="Encrypted authentication token")
custom_headers = Column(Text, nullable=True, comment="JSON custom headers")
alert_types = Column(Text, nullable=True, comment="JSON array of alert types to trigger on")
severity_filter = Column(Text, nullable=True, comment="JSON array of severities to trigger on")
timeout = Column(Integer, nullable=True, default=10, comment="Request timeout in seconds")
retry_count = Column(Integer, nullable=True, default=3, comment="Number of retry attempts")
template = Column(Text, nullable=True, comment="Jinja2 template for webhook payload")
template_format = Column(String(20), nullable=True, default='json', comment="Template output format: json, text")
content_type_override = Column(String(100), nullable=True, comment="Optional custom Content-Type header")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Creation time")
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Last update time")
# Relationships
delivery_logs = relationship("WebhookDeliveryLog", back_populates="webhook", cascade="all, delete-orphan")
def __repr__(self):
return f"<Webhook(id={self.id}, name='{self.name}', enabled={self.enabled})>"
class WebhookDeliveryLog(Base):
"""
Webhook delivery tracking.
Logs all webhook delivery attempts for auditing and debugging purposes.
"""
__tablename__ = 'webhook_delivery_log'
id = Column(Integer, primary_key=True, autoincrement=True)
webhook_id = Column(Integer, ForeignKey('webhooks.id'), nullable=False, index=True, comment="Associated webhook")
alert_id = Column(Integer, ForeignKey('alerts.id'), nullable=False, index=True, comment="Associated alert")
status = Column(String(20), nullable=True, index=True, comment="Delivery status: success, failed, retrying")
response_code = Column(Integer, nullable=True, comment="HTTP response code")
response_body = Column(Text, nullable=True, comment="Response body from webhook")
error_message = Column(Text, nullable=True, comment="Error message if failed")
attempt_number = Column(Integer, nullable=True, comment="Which attempt this was")
delivered_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Delivery timestamp")
# Relationships
webhook = relationship("Webhook", back_populates="delivery_logs")
alert = relationship("Alert")
def __repr__(self):
return f"<WebhookDeliveryLog(id={self.id}, webhook_id={self.webhook_id}, status='{self.status}')>"
# ============================================================================ # ============================================================================

View File

@@ -0,0 +1,5 @@
"""
Main web routes package for SneakyScanner.
Provides web UI routes (dashboard, scan views, etc.).
"""

288
app/web/routes/main.py Normal file
View File

@@ -0,0 +1,288 @@
"""
Main web routes for SneakyScanner.
Provides dashboard and scan viewing pages.
"""
import logging
import os
from flask import Blueprint, current_app, redirect, render_template, request, send_from_directory, url_for
from web.auth.decorators import login_required
logger = logging.getLogger(__name__)
bp = Blueprint('main', __name__)
@bp.route('/')
def index():
"""
Root route - redirect to dashboard.
Returns:
Redirect to dashboard
"""
return redirect(url_for('main.dashboard'))
@bp.route('/dashboard')
@login_required
def dashboard():
"""
Dashboard page - shows recent scans and statistics.
Returns:
Rendered dashboard template
"""
return render_template('dashboard.html')
@bp.route('/scans')
@login_required
def scans():
"""
Scans list page - shows all scans with pagination.
Returns:
Rendered scans list template
"""
return render_template('scans.html')
@bp.route('/scans/<int:scan_id>')
@login_required
def scan_detail(scan_id):
"""
Scan detail page - shows full scan results.
Args:
scan_id: Scan ID to display
Returns:
Rendered scan detail template
"""
# TODO: Phase 5 - Implement scan detail page
return render_template('scan_detail.html', scan_id=scan_id)
@bp.route('/scans/<int:scan_id1>/compare/<int:scan_id2>')
@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('/search/ip')
@login_required
def search_ip():
"""
IP search results page - shows scans containing a specific IP address.
Returns:
Rendered search results template
"""
ip_address = request.args.get('ip', '').strip()
return render_template('ip_search_results.html', ip_address=ip_address)
@bp.route('/schedules')
@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 configs
"""
from web.models import ScanConfig
# Get list of available configs from database
configs = []
try:
configs = current_app.db_session.query(ScanConfig).order_by(ScanConfig.title).all()
except Exception as e:
logger.error(f"Error listing configs: {e}")
return render_template('schedule_create.html', configs=configs)
@bp.route('/schedules/<int:schedule_id>/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
"""
# Note: Schedule data is loaded via AJAX in the template
# This just renders the page with the schedule_id in the URL
return render_template('schedule_edit.html', schedule_id=schedule_id)
@bp.route('/sites')
@login_required
def sites():
"""
Sites management page - manage reusable site definitions.
Returns:
Rendered sites template
"""
return render_template('sites.html')
@bp.route('/configs')
@login_required
def configs():
"""
Configuration files list page - shows all config files.
Returns:
Rendered configs list template
"""
return render_template('configs.html')
@bp.route('/alerts')
@login_required
def alerts():
"""
Alerts history page - shows all alerts.
Returns:
Rendered alerts template
"""
from flask import request, current_app
from web.models import Alert, AlertRule, Scan
from web.utils.pagination import paginate
# Get query parameters for filtering
page = request.args.get('page', 1, type=int)
per_page = 20
severity = request.args.get('severity')
alert_type = request.args.get('alert_type')
acknowledged = request.args.get('acknowledged')
# Build query
query = current_app.db_session.query(Alert).join(Scan, isouter=True)
# Apply filters
if severity:
query = query.filter(Alert.severity == severity)
if alert_type:
query = query.filter(Alert.alert_type == alert_type)
if acknowledged is not None:
ack_bool = acknowledged == 'true'
query = query.filter(Alert.acknowledged == ack_bool)
# Order by severity and date
query = query.order_by(Alert.severity.desc(), Alert.created_at.desc())
# Paginate using utility function
pagination = paginate(query, page=page, per_page=per_page)
alerts = pagination.items
# Get unique alert types for filter dropdown
try:
alert_types = current_app.db_session.query(Alert.alert_type).distinct().all()
alert_types = [at[0] for at in alert_types] if alert_types else []
except Exception:
alert_types = []
return render_template(
'alerts.html',
alerts=alerts,
pagination=pagination,
current_severity=severity,
current_alert_type=alert_type,
current_acknowledged=acknowledged,
alert_types=alert_types
)
@bp.route('/alerts/rules')
@login_required
def alert_rules():
"""
Alert rules management page.
Returns:
Rendered alert rules template
"""
from flask import current_app
from web.models import AlertRule
# Get all alert rules with error handling
try:
rules = current_app.db_session.query(AlertRule).order_by(
AlertRule.name.nullslast(),
AlertRule.rule_type
).all()
except Exception as e:
logger.error(f"Error fetching alert rules: {e}")
rules = []
# Ensure rules is always a list
if rules is None:
rules = []
return render_template(
'alert_rules.html',
rules=rules
)
@bp.route('/help')
@login_required
def help():
"""
Help page - explains how to use the application.
Returns:
Rendered help template
"""
return render_template('help.html')
@bp.route('/output/<path:filename>')
@login_required
def serve_output_file(filename):
"""
Serve output files (JSON, HTML, ZIP) from the output directory.
Args:
filename: Name of the file to serve
Returns:
The requested file
"""
output_dir = os.environ.get('OUTPUT_DIR', '/app/output')
return send_from_directory(output_dir, filename)

View File

@@ -0,0 +1,83 @@
"""
Webhook web routes for SneakyScanner.
Provides UI pages for managing webhooks and viewing delivery logs.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from web.auth.decorators import login_required
from web.models import Webhook
from web.services.webhook_service import WebhookService
logger = logging.getLogger(__name__)
bp = Blueprint('webhooks', __name__)
@bp.route('')
@login_required
def list_webhooks():
"""
Webhooks list page - shows all configured webhooks.
Returns:
Rendered webhooks list template
"""
return render_template('webhooks/list.html')
@bp.route('/new')
@login_required
def new_webhook():
"""
New webhook form page.
Returns:
Rendered webhook form template
"""
return render_template('webhooks/form.html', webhook=None, mode='create')
@bp.route('/<int:webhook_id>/edit')
@login_required
def edit_webhook(webhook_id):
"""
Edit webhook form page.
Args:
webhook_id: Webhook ID to edit
Returns:
Rendered webhook form template or 404
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
flash('Webhook not found', 'error')
return redirect(url_for('webhooks.list_webhooks'))
return render_template('webhooks/form.html', webhook=webhook, mode='edit')
@bp.route('/<int:webhook_id>/logs')
@login_required
def webhook_logs(webhook_id):
"""
Webhook delivery logs page.
Args:
webhook_id: Webhook ID
Returns:
Rendered webhook logs template or 404
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
flash('Webhook not found', 'error')
return redirect(url_for('webhooks.list_webhooks'))
return render_template('webhooks/logs.html', webhook=webhook)

View File

@@ -0,0 +1,521 @@
"""
Alert Service Module
Handles alert evaluation, rule processing, and notification triggering
for SneakyScan Phase 5.
"""
import logging
from datetime import datetime, timezone
from typing import List, Dict, Optional, Any
from sqlalchemy.orm import Session
from ..models import (
Alert, AlertRule, Scan, ScanPort, ScanIP, ScanService as ScanServiceModel,
ScanCertificate, ScanTLSVersion
)
from .scan_service import ScanService
logger = logging.getLogger(__name__)
class AlertService:
"""
Service for evaluating alert rules and generating alerts based on scan results.
Supports two main alert types:
1. Unexpected Port Detection - Alerts when ports marked as unexpected are found open
2. Drift Detection - Alerts when scan results differ from previous scan
"""
def __init__(self, db_session: Session):
self.db = db_session
self.scan_service = ScanService(db_session)
def evaluate_alert_rules(self, scan_id: int) -> List[Alert]:
"""
Main entry point for alert evaluation after scan completion.
Args:
scan_id: ID of the completed scan to evaluate
Returns:
List of Alert objects that were created
"""
logger.info(f"Starting alert evaluation for scan {scan_id}")
# Get the scan
scan = self.db.query(Scan).filter(Scan.id == scan_id).first()
if not scan:
logger.error(f"Scan {scan_id} not found")
return []
# Get all enabled alert rules
rules = self.db.query(AlertRule).filter(AlertRule.enabled == True).all()
logger.info(f"Found {len(rules)} enabled alert rules to evaluate")
alerts_created = []
for rule in rules:
try:
# Check if rule applies to this scan's config
if rule.config_id and scan.config_id != rule.config_id:
logger.debug(f"Skipping rule {rule.id} - config mismatch")
continue
# Evaluate based on rule type
alert_data = []
if rule.rule_type == 'unexpected_port':
alert_data = self.check_unexpected_ports(scan, rule)
elif rule.rule_type == 'drift_detection':
alert_data = self.check_drift_from_previous(scan, rule)
elif rule.rule_type == 'cert_expiry':
alert_data = self.check_certificate_expiry(scan, rule)
elif rule.rule_type == 'weak_tls':
alert_data = self.check_weak_tls(scan, rule)
elif rule.rule_type == 'ping_failed':
alert_data = self.check_ping_failures(scan, rule)
else:
logger.warning(f"Unknown rule type: {rule.rule_type}")
continue
# Create alerts for any findings
for alert_info in alert_data:
alert = self.create_alert(scan_id, rule, alert_info)
if alert:
alerts_created.append(alert)
# Trigger notifications if configured
if rule.email_enabled or rule.webhook_enabled:
self.trigger_notifications(alert, rule)
logger.info(f"Rule {rule.name or rule.id} generated {len(alert_data)} alerts")
except Exception as e:
logger.error(f"Error evaluating rule {rule.id}: {str(e)}")
continue
logger.info(f"Alert evaluation complete. Created {len(alerts_created)} alerts")
return alerts_created
def check_unexpected_ports(self, scan: Scan, rule: AlertRule) -> List[Dict[str, Any]]:
"""
Detect ports that are open but not in the expected_ports list.
Args:
scan: The scan to check
rule: The alert rule configuration
Returns:
List of alert data dictionaries
"""
alerts_to_create = []
# Get all ports where expected=False
unexpected_ports = (
self.db.query(ScanPort, ScanIP)
.join(ScanIP, ScanPort.ip_id == ScanIP.id)
.filter(ScanPort.scan_id == scan.id)
.filter(ScanPort.expected == False) # Not in config's expected_ports
.filter(ScanPort.state == 'open')
.all()
)
# High-risk ports that should trigger critical alerts
high_risk_ports = {
22, # SSH
23, # Telnet
135, # Windows RPC
139, # NetBIOS
445, # SMB
1433, # SQL Server
3306, # MySQL
3389, # RDP
5432, # PostgreSQL
5900, # VNC
6379, # Redis
9200, # Elasticsearch
27017, # MongoDB
}
for port, ip in unexpected_ports:
# Determine severity based on port number
severity = rule.severity or ('critical' if port.port in high_risk_ports else 'warning')
# Get service info if available
service = (
self.db.query(ScanServiceModel)
.filter(ScanServiceModel.port_id == port.id)
.first()
)
service_info = ""
if service:
product = service.product or "Unknown"
version = service.version or ""
service_info = f" (Service: {service.service_name}: {product} {version}".strip() + ")"
alerts_to_create.append({
'alert_type': 'unexpected_port',
'severity': severity,
'message': f"Unexpected port open on {ip.ip_address}:{port.port}/{port.protocol}{service_info}",
'ip_address': ip.ip_address,
'port': port.port
})
return alerts_to_create
def check_drift_from_previous(self, scan: Scan, rule: AlertRule) -> List[Dict[str, Any]]:
"""
Compare current scan to the last scan with the same config.
Args:
scan: The current scan
rule: The alert rule configuration
Returns:
List of alert data dictionaries
"""
alerts_to_create = []
# Find previous scan with same config_id
previous_scan = (
self.db.query(Scan)
.filter(Scan.config_id == scan.config_id)
.filter(Scan.id < scan.id)
.filter(Scan.status == 'completed')
.order_by(Scan.started_at.desc() if Scan.started_at else Scan.timestamp.desc())
.first()
)
if not previous_scan:
logger.info(f"No previous scan found for config_id {scan.config_id}")
return []
try:
# Use existing comparison logic from scan_service
comparison = self.scan_service.compare_scans(previous_scan.id, scan.id)
# Alert on new ports
for port_data in comparison.get('ports', {}).get('added', []):
severity = rule.severity or 'warning'
alerts_to_create.append({
'alert_type': 'drift_new_port',
'severity': severity,
'message': f"New port detected: {port_data['ip']}:{port_data['port']}/{port_data['protocol']}",
'ip_address': port_data['ip'],
'port': port_data['port']
})
# Alert on removed ports
for port_data in comparison.get('ports', {}).get('removed', []):
severity = rule.severity or 'info'
alerts_to_create.append({
'alert_type': 'drift_missing_port',
'severity': severity,
'message': f"Port no longer open: {port_data['ip']}:{port_data['port']}/{port_data['protocol']}",
'ip_address': port_data['ip'],
'port': port_data['port']
})
# Alert on service changes
for svc_data in comparison.get('services', {}).get('changed', []):
old_svc = svc_data.get('old', {})
new_svc = svc_data.get('new', {})
old_desc = f"{old_svc.get('product', 'Unknown')} {old_svc.get('version', '')}".strip()
new_desc = f"{new_svc.get('product', 'Unknown')} {new_svc.get('version', '')}".strip()
severity = rule.severity or 'info'
alerts_to_create.append({
'alert_type': 'drift_service_change',
'severity': severity,
'message': f"Service changed on {svc_data['ip']}:{svc_data['port']}: {old_desc}{new_desc}",
'ip_address': svc_data['ip'],
'port': svc_data['port']
})
# Alert on certificate changes
for cert_data in comparison.get('certificates', {}).get('changed', []):
old_cert = cert_data.get('old', {})
new_cert = cert_data.get('new', {})
severity = rule.severity or 'warning'
alerts_to_create.append({
'alert_type': 'drift_cert_change',
'severity': severity,
'message': f"Certificate changed on {cert_data['ip']}:{cert_data['port']} - "
f"Subject: {old_cert.get('subject', 'Unknown')}{new_cert.get('subject', 'Unknown')}",
'ip_address': cert_data['ip'],
'port': cert_data['port']
})
# Check drift score threshold if configured
if rule.threshold and comparison.get('drift_score', 0) * 100 >= rule.threshold:
alerts_to_create.append({
'alert_type': 'drift_threshold_exceeded',
'severity': rule.severity or 'warning',
'message': f"Drift score {comparison['drift_score']*100:.1f}% exceeds threshold {rule.threshold}%",
'ip_address': None,
'port': None
})
except Exception as e:
logger.error(f"Error comparing scans: {str(e)}")
return alerts_to_create
def check_certificate_expiry(self, scan: Scan, rule: AlertRule) -> List[Dict[str, Any]]:
"""
Check for certificates expiring within the threshold days.
Args:
scan: The scan to check
rule: The alert rule configuration
Returns:
List of alert data dictionaries
"""
alerts_to_create = []
threshold_days = rule.threshold or 30 # Default 30 days
# Get all certificates from the scan
certificates = (
self.db.query(ScanCertificate, ScanPort, ScanIP)
.join(ScanServiceModel, ScanCertificate.service_id == ScanServiceModel.id)
.join(ScanPort, ScanServiceModel.port_id == ScanPort.id)
.join(ScanIP, ScanPort.ip_id == ScanIP.id)
.filter(ScanPort.scan_id == scan.id)
.all()
)
for cert, port, ip in certificates:
if cert.days_until_expiry is not None and cert.days_until_expiry <= threshold_days:
if cert.days_until_expiry <= 0:
severity = 'critical'
message = f"Certificate EXPIRED on {ip.ip_address}:{port.port}"
elif cert.days_until_expiry <= 7:
severity = 'critical'
message = f"Certificate expires in {cert.days_until_expiry} days on {ip.ip_address}:{port.port}"
elif cert.days_until_expiry <= 14:
severity = 'warning'
message = f"Certificate expires in {cert.days_until_expiry} days on {ip.ip_address}:{port.port}"
else:
severity = 'info'
message = f"Certificate expires in {cert.days_until_expiry} days on {ip.ip_address}:{port.port}"
alerts_to_create.append({
'alert_type': 'cert_expiry',
'severity': severity,
'message': message,
'ip_address': ip.ip_address,
'port': port.port
})
return alerts_to_create
def check_weak_tls(self, scan: Scan, rule: AlertRule) -> List[Dict[str, Any]]:
"""
Check for weak TLS versions (1.0, 1.1).
Args:
scan: The scan to check
rule: The alert rule configuration
Returns:
List of alert data dictionaries
"""
alerts_to_create = []
# Get all TLS version data from the scan
tls_versions = (
self.db.query(ScanTLSVersion, ScanPort, ScanIP)
.join(ScanCertificate, ScanTLSVersion.certificate_id == ScanCertificate.id)
.join(ScanServiceModel, ScanCertificate.service_id == ScanServiceModel.id)
.join(ScanPort, ScanServiceModel.port_id == ScanPort.id)
.join(ScanIP, ScanPort.ip_id == ScanIP.id)
.filter(ScanPort.scan_id == scan.id)
.all()
)
# Group TLS versions by port/IP to create one alert per host
tls_by_host = {}
for tls, port, ip in tls_versions:
# Only alert on weak TLS versions that are supported
if tls.supported and tls.tls_version in ['TLS 1.0', 'TLS 1.1']:
key = (ip.ip_address, port.port)
if key not in tls_by_host:
tls_by_host[key] = {'ip': ip.ip_address, 'port': port.port, 'versions': []}
tls_by_host[key]['versions'].append(tls.tls_version)
# Create alerts for hosts with weak TLS
for host_key, host_data in tls_by_host.items():
severity = rule.severity or 'warning'
alerts_to_create.append({
'alert_type': 'weak_tls',
'severity': severity,
'message': f"Weak TLS versions supported on {host_data['ip']}:{host_data['port']}: {', '.join(host_data['versions'])}",
'ip_address': host_data['ip'],
'port': host_data['port']
})
return alerts_to_create
def check_ping_failures(self, scan: Scan, rule: AlertRule) -> List[Dict[str, Any]]:
"""
Check for hosts that were expected to respond to ping but didn't.
Args:
scan: The scan to check
rule: The alert rule configuration
Returns:
List of alert data dictionaries
"""
alerts_to_create = []
# Get all IPs where ping was expected but failed
failed_pings = (
self.db.query(ScanIP)
.filter(ScanIP.scan_id == scan.id)
.filter(ScanIP.ping_expected == True)
.filter(ScanIP.ping_actual == False)
.all()
)
for ip in failed_pings:
severity = rule.severity or 'warning'
alerts_to_create.append({
'alert_type': 'ping_failed',
'severity': severity,
'message': f"Host {ip.ip_address} did not respond to ping (expected to be up)",
'ip_address': ip.ip_address,
'port': None
})
return alerts_to_create
def create_alert(self, scan_id: int, rule: AlertRule, alert_data: Dict[str, Any]) -> Optional[Alert]:
"""
Create an alert record in the database.
Args:
scan_id: ID of the scan that triggered the alert
rule: The alert rule that was triggered
alert_data: Dictionary with alert details
Returns:
Created Alert object or None if creation failed
"""
try:
alert = Alert(
scan_id=scan_id,
rule_id=rule.id,
alert_type=alert_data['alert_type'],
severity=alert_data['severity'],
message=alert_data['message'],
ip_address=alert_data.get('ip_address'),
port=alert_data.get('port'),
created_at=datetime.now(timezone.utc)
)
self.db.add(alert)
self.db.commit()
logger.info(f"Created alert: {alert.message}")
return alert
except Exception as e:
logger.error(f"Failed to create alert: {str(e)}")
self.db.rollback()
return None
def trigger_notifications(self, alert: Alert, rule: AlertRule):
"""
Send notifications for an alert based on rule configuration.
Args:
alert: The alert to send notifications for
rule: The rule that specifies notification settings
"""
# Email notification will be implemented in email_service.py
if rule.email_enabled:
logger.info(f"Email notification would be sent for alert {alert.id}")
# TODO: Call email service
# Webhook notification - queue for delivery
if rule.webhook_enabled:
try:
from flask import current_app
from .webhook_service import WebhookService
webhook_service = WebhookService(self.db)
# Get matching webhooks for this alert
matching_webhooks = webhook_service.get_matching_webhooks(alert)
if matching_webhooks:
# Get scheduler from app context
scheduler = getattr(current_app, 'scheduler', None)
# Queue delivery for each matching webhook
for webhook in matching_webhooks:
webhook_service.queue_webhook_delivery(
webhook.id,
alert.id,
scheduler_service=scheduler
)
logger.info(f"Queued webhook {webhook.id} ({webhook.name}) for alert {alert.id}")
else:
logger.debug(f"No matching webhooks found for alert {alert.id}")
except Exception as e:
logger.error(f"Failed to queue webhook notifications for alert {alert.id}: {e}", exc_info=True)
# Don't fail alert creation if webhook queueing fails
def acknowledge_alert(self, alert_id: int, acknowledged_by: str = "system") -> bool:
"""
Acknowledge an alert.
Args:
alert_id: ID of the alert to acknowledge
acknowledged_by: Username or system identifier
Returns:
True if successful, False otherwise
"""
try:
alert = self.db.query(Alert).filter(Alert.id == alert_id).first()
if not alert:
logger.error(f"Alert {alert_id} not found")
return False
alert.acknowledged = True
alert.acknowledged_at = datetime.now(timezone.utc)
alert.acknowledged_by = acknowledged_by
self.db.commit()
logger.info(f"Alert {alert_id} acknowledged by {acknowledged_by}")
return True
except Exception as e:
logger.error(f"Failed to acknowledge alert {alert_id}: {str(e)}")
self.db.rollback()
return False
def get_alerts_for_scan(self, scan_id: int) -> List[Alert]:
"""
Get all alerts for a specific scan.
Args:
scan_id: ID of the scan
Returns:
List of Alert objects
"""
return (
self.db.query(Alert)
.filter(Alert.scan_id == scan_id)
.order_by(Alert.severity.desc(), Alert.created_at.desc())
.all()
)

View File

@@ -0,0 +1,339 @@
"""
Config Service - Business logic for config management
This service handles all operations related to scan configurations,
both database-stored (primary) and file-based (deprecated).
"""
import os
from typing import Dict, List, Any, Optional
from datetime import datetime
from sqlalchemy.orm import Session
class ConfigService:
"""Business logic for config management"""
def __init__(self, db_session: Session = None, configs_dir: str = '/app/configs'):
"""
Initialize the config service.
Args:
db_session: SQLAlchemy database session (for database operations)
configs_dir: Directory where legacy config files are stored
"""
self.db = db_session
self.configs_dir = configs_dir
# Ensure configs directory exists (for legacy YAML configs)
os.makedirs(self.configs_dir, exist_ok=True)
# ============================================================================
# Database-based Config Operations (Primary)
# ============================================================================
def create_config(self, title: str, description: Optional[str], site_ids: List[int]) -> Dict[str, Any]:
"""
Create a new scan configuration in the database.
Args:
title: Configuration title
description: Optional configuration description
site_ids: List of site IDs to include in this config
Returns:
Created config as dictionary:
{
"id": 1,
"title": "Production Scan",
"description": "...",
"site_count": 3,
"sites": [...],
"created_at": "2025-11-19T10:30:00Z",
"updated_at": "2025-11-19T10:30:00Z"
}
Raises:
ValueError: If validation fails or sites don't exist
"""
if not title or not title.strip():
raise ValueError("Title is required")
if not site_ids or len(site_ids) == 0:
raise ValueError("At least one site must be selected")
# Import models here to avoid circular imports
from web.models import ScanConfig, ScanConfigSite, Site
# Verify all sites exist
existing_sites = self.db.query(Site).filter(Site.id.in_(site_ids)).all()
if len(existing_sites) != len(site_ids):
found_ids = {s.id for s in existing_sites}
missing_ids = set(site_ids) - found_ids
raise ValueError(f"Sites not found: {missing_ids}")
# Create config
config = ScanConfig(
title=title.strip(),
description=description.strip() if description else None,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
self.db.add(config)
self.db.flush() # Get the config ID
# Create associations
for site_id in site_ids:
assoc = ScanConfigSite(
config_id=config.id,
site_id=site_id,
created_at=datetime.utcnow()
)
self.db.add(assoc)
self.db.commit()
return self.get_config_by_id(config.id)
def get_config_by_id(self, config_id: int) -> Dict[str, Any]:
"""
Get a scan configuration by ID.
Args:
config_id: Configuration ID
Returns:
Config as dictionary with sites
Raises:
ValueError: If config not found
"""
from web.models import ScanConfig
config = self.db.query(ScanConfig).filter_by(id=config_id).first()
if not config:
raise ValueError(f"Config with ID {config_id} not found")
# Get associated sites
sites = []
for assoc in config.site_associations:
site = assoc.site
sites.append({
'id': site.id,
'name': site.name,
'description': site.description,
'ip_count': len(site.ips)
})
return {
'id': config.id,
'title': config.title,
'description': config.description,
'site_count': len(sites),
'sites': sites,
'created_at': config.created_at.isoformat() + 'Z' if config.created_at else None,
'updated_at': config.updated_at.isoformat() + 'Z' if config.updated_at else None
}
def list_configs_db(self) -> List[Dict[str, Any]]:
"""
List all scan configurations from database.
Returns:
List of config dictionaries with metadata
"""
from web.models import ScanConfig
configs = self.db.query(ScanConfig).order_by(ScanConfig.updated_at.desc()).all()
result = []
for config in configs:
sites = []
for assoc in config.site_associations:
site = assoc.site
sites.append({
'id': site.id,
'name': site.name
})
result.append({
'id': config.id,
'title': config.title,
'description': config.description,
'site_count': len(sites),
'sites': sites,
'created_at': config.created_at.isoformat() + 'Z' if config.created_at else None,
'updated_at': config.updated_at.isoformat() + 'Z' if config.updated_at else None
})
return result
def update_config(self, config_id: int, title: Optional[str], description: Optional[str], site_ids: Optional[List[int]]) -> Dict[str, Any]:
"""
Update a scan configuration.
Args:
config_id: Configuration ID to update
title: New title (optional)
description: New description (optional)
site_ids: New list of site IDs (optional, replaces existing)
Returns:
Updated config dictionary
Raises:
ValueError: If config not found or validation fails
"""
from web.models import ScanConfig, ScanConfigSite, Site
config = self.db.query(ScanConfig).filter_by(id=config_id).first()
if not config:
raise ValueError(f"Config with ID {config_id} not found")
# Update fields if provided
if title is not None:
if not title.strip():
raise ValueError("Title cannot be empty")
config.title = title.strip()
if description is not None:
config.description = description.strip() if description.strip() else None
# Update sites if provided
if site_ids is not None:
if len(site_ids) == 0:
raise ValueError("At least one site must be selected")
# Verify all sites exist
existing_sites = self.db.query(Site).filter(Site.id.in_(site_ids)).all()
if len(existing_sites) != len(site_ids):
found_ids = {s.id for s in existing_sites}
missing_ids = set(site_ids) - found_ids
raise ValueError(f"Sites not found: {missing_ids}")
# Remove existing associations
self.db.query(ScanConfigSite).filter_by(config_id=config_id).delete()
# Create new associations
for site_id in site_ids:
assoc = ScanConfigSite(
config_id=config_id,
site_id=site_id,
created_at=datetime.utcnow()
)
self.db.add(assoc)
config.updated_at = datetime.utcnow()
self.db.commit()
return self.get_config_by_id(config_id)
def delete_config(self, config_id: int) -> None:
"""
Delete a scan configuration from database.
This will cascade delete associated ScanConfigSite records.
Schedules and scans referencing this config will have their
config_id set to NULL.
Args:
config_id: Configuration ID to delete
Raises:
ValueError: If config not found
"""
from web.models import ScanConfig
config = self.db.query(ScanConfig).filter_by(id=config_id).first()
if not config:
raise ValueError(f"Config with ID {config_id} not found")
self.db.delete(config)
self.db.commit()
def add_site_to_config(self, config_id: int, site_id: int) -> Dict[str, Any]:
"""
Add a site to an existing config.
Args:
config_id: Configuration ID
site_id: Site ID to add
Returns:
Updated config dictionary
Raises:
ValueError: If config or site not found, or association already exists
"""
from web.models import ScanConfig, Site, ScanConfigSite
config = self.db.query(ScanConfig).filter_by(id=config_id).first()
if not config:
raise ValueError(f"Config with ID {config_id} not found")
site = self.db.query(Site).filter_by(id=site_id).first()
if not site:
raise ValueError(f"Site with ID {site_id} not found")
# Check if association already exists
existing = self.db.query(ScanConfigSite).filter_by(
config_id=config_id, site_id=site_id
).first()
if existing:
raise ValueError(f"Site '{site.name}' is already in this config")
# Create association
assoc = ScanConfigSite(
config_id=config_id,
site_id=site_id,
created_at=datetime.utcnow()
)
self.db.add(assoc)
config.updated_at = datetime.utcnow()
self.db.commit()
return self.get_config_by_id(config_id)
def remove_site_from_config(self, config_id: int, site_id: int) -> Dict[str, Any]:
"""
Remove a site from a config.
Args:
config_id: Configuration ID
site_id: Site ID to remove
Returns:
Updated config dictionary
Raises:
ValueError: If config not found, or removing would leave config empty
"""
from web.models import ScanConfig, ScanConfigSite
config = self.db.query(ScanConfig).filter_by(id=config_id).first()
if not config:
raise ValueError(f"Config with ID {config_id} not found")
# Check if this would leave the config empty
current_site_count = len(config.site_associations)
if current_site_count <= 1:
raise ValueError("Cannot remove last site from config. Delete the config instead.")
# Remove association
deleted = self.db.query(ScanConfigSite).filter_by(
config_id=config_id, site_id=site_id
).delete()
if deleted == 0:
raise ValueError(f"Site with ID {site_id} is not in this config")
config.updated_at = datetime.utcnow()
self.db.commit()
return self.get_config_by_id(config_id)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,520 @@
"""
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
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from croniter import croniter
from sqlalchemy.orm import Session
from web.models import Schedule, Scan, ScanConfig
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_id: int,
cron_expression: str,
enabled: bool = True
) -> int:
"""
Create a new schedule.
Args:
name: Human-readable schedule name
config_id: Database config ID
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 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 exists
db_config = self.db.query(ScanConfig).filter_by(id=config_id).first()
if not db_config:
raise ValueError(f"Config with ID {config_id} not found")
# Calculate next run time
next_run = self.calculate_next_run(cron_expression) if enabled else None
# Create schedule record
now_utc = datetime.now(timezone.utc)
schedule = Schedule(
name=name,
config_id=config_id,
cron_expression=cron_expression,
enabled=enabled,
last_run=None,
next_run=next_run,
created_at=now_utc,
updated_at=now_utc
)
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
"""
from sqlalchemy.orm import joinedload
schedule = (
self.db.query(Schedule)
.options(joinedload(Schedule.config))
.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
}
"""
from sqlalchemy.orm import joinedload
# Build query and eagerly load config relationship
query = self.db.query(Schedule).options(joinedload(Schedule.config))
# 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_id if being updated
if 'config_id' in updates:
db_config = self.db.query(ScanConfig).filter_by(id=updates['config_id']).first()
if not db_config:
raise ValueError(f"Config with ID {updates['config_id']} not found")
# 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.now(timezone.utc)
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.now(timezone.utc)
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 in standard crontab format
Format: minute hour day month day_of_week
Day of week: 0=Sunday, 1=Monday, ..., 6=Saturday
(APScheduler will convert this to its internal format automatically)
Returns:
Tuple of (is_valid, error_message)
- (True, None) if valid
- (False, error_message) if invalid
Note:
This validates using croniter which uses standard crontab format.
APScheduler's from_crontab() will handle the conversion when the
schedule is registered with the scheduler.
"""
try:
# Try to create a croniter instance
# croniter uses standard crontab format (Sunday=0)
from datetime import timezone
base_time = datetime.now(timezone.utc)
cron = croniter(cron_expr, base_time)
# Try to get the next run time (validates the expression)
cron.get_next(datetime)
# Validate basic format (5 fields)
fields = cron_expr.split()
if len(fields) != 5:
return (False, f"Cron expression must have 5 fields (minute hour day month day_of_week), got {len(fields)}")
return (True, None)
except (ValueError, KeyError) as e:
error_msg = str(e)
# Add helpful hint for day_of_week errors
if "day" in error_msg.lower() and len(cron_expr.split()) >= 5:
hint = "\nNote: Use standard crontab format where 0=Sunday, 1=Monday, ..., 6=Saturday"
return (False, f"{error_msg}{hint}")
return (False, str(e))
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, timezone-aware)
Raises:
ValueError: If cron expression is invalid
"""
if from_time is None:
from_time = datetime.now(timezone.utc)
try:
cron = croniter(cron_expr, from_time)
next_run = cron.get_next(datetime)
# croniter returns naive datetime, so we need to add timezone info
# Since we're using UTC for all calculations, add UTC timezone
if next_run.tzinfo is None:
next_run = next_run.replace(tzinfo=timezone.utc)
return next_run
except Exception as e:
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_id': scan.config_id
}
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
"""
# Get config title if relationship is loaded
config_name = None
if schedule.config:
config_name = schedule.config.title
return {
'id': schedule.id,
'name': schedule.name,
'config_id': schedule.config_id,
'config_name': config_name,
'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, can be naive or aware)
Returns:
Human-readable relative time (e.g., "in 2 hours", "yesterday")
"""
if dt is None:
return None
# Ensure both datetimes are timezone-aware for comparison
now = datetime.now(timezone.utc)
# If dt is naive, assume it's UTC and add timezone info
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
diff = dt - now
# 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"

View File

@@ -0,0 +1,421 @@
"""
Scheduler service for managing background jobs and scheduled scans.
This service integrates APScheduler with Flask to enable background
scan execution and future scheduled scanning capabilities.
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.executors.pool import ThreadPoolExecutor
from flask import Flask
from web.jobs.scan_job import execute_scan
logger = logging.getLogger(__name__)
class SchedulerService:
"""
Service for managing background job scheduling.
Uses APScheduler's BackgroundScheduler to run scans asynchronously
without blocking HTTP requests.
"""
def __init__(self):
"""Initialize scheduler service (scheduler not started yet)."""
self.scheduler: Optional[BackgroundScheduler] = None
self.db_url: Optional[str] = None
def init_scheduler(self, app: Flask):
"""
Initialize and start APScheduler with Flask app.
Args:
app: Flask application instance
Configuration:
- BackgroundScheduler: Runs in separate thread
- ThreadPoolExecutor: Allows concurrent scan execution
- Max workers: 3 (configurable via SCHEDULER_MAX_WORKERS)
"""
if self.scheduler:
logger.warning("Scheduler already initialized")
return
# Store database URL for passing to background jobs
self.db_url = app.config['SQLALCHEMY_DATABASE_URI']
# Configure executor for concurrent jobs
max_workers = app.config.get('SCHEDULER_MAX_WORKERS', 3)
executors = {
'default': ThreadPoolExecutor(max_workers=max_workers)
}
# Configure job defaults
job_defaults = {
'coalesce': True, # Combine multiple pending instances into one
'max_instances': app.config.get('SCHEDULER_MAX_INSTANCES', 3),
'misfire_grace_time': 60 # Allow 60 seconds for delayed starts
}
# Create scheduler with local system timezone
# This allows users to schedule jobs using their local time
# APScheduler will automatically use the system's local timezone
self.scheduler = BackgroundScheduler(
executors=executors,
job_defaults=job_defaults
# timezone defaults to local system timezone
)
# Start scheduler
self.scheduler.start()
logger.info(f"APScheduler started with {max_workers} max workers")
# Register shutdown handler
import atexit
atexit.register(lambda: self.shutdown())
def shutdown(self):
"""
Shutdown scheduler gracefully.
Waits for running jobs to complete before shutting down.
"""
if self.scheduler:
logger.info("Shutting down APScheduler...")
self.scheduler.shutdown(wait=True)
logger.info("APScheduler shutdown complete")
self.scheduler = None
def load_schedules_on_startup(self):
"""
Load all enabled schedules from database and register with APScheduler.
Should be called after init_scheduler() to restore scheduled jobs
that were active when the application last shutdown.
Raises:
RuntimeError: If scheduler not initialized
"""
if not self.scheduler:
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
# Import here to avoid circular imports
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from web.models import Schedule
try:
# Create database session
engine = create_engine(self.db_url)
Session = sessionmaker(bind=engine)
session = Session()
try:
# Query all enabled schedules
enabled_schedules = (
session.query(Schedule)
.filter(Schedule.enabled == True)
.all()
)
logger.info(f"Loading {len(enabled_schedules)} enabled schedules on startup")
# Register each schedule with APScheduler
for schedule in enabled_schedules:
try:
self.add_scheduled_scan(
schedule_id=schedule.id,
config_id=schedule.config_id,
cron_expression=schedule.cron_expression
)
logger.info(f"Loaded schedule {schedule.id}: '{schedule.name}'")
except Exception as e:
logger.error(
f"Failed to load schedule {schedule.id} ('{schedule.name}'): {str(e)}",
exc_info=True
)
logger.info("Schedule loading complete")
finally:
session.close()
except Exception as e:
logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True)
@staticmethod
def validate_cron_expression(cron_expression: str) -> tuple[bool, str]:
"""
Validate a cron expression and provide helpful feedback.
Args:
cron_expression: Cron expression to validate
Returns:
Tuple of (is_valid: bool, message: str)
- If valid: (True, "Valid cron expression")
- If invalid: (False, "Error message with details")
Note:
Standard crontab format: minute hour day month day_of_week
Day of week: 0=Sunday, 1=Monday, ..., 6=Saturday (or 7=Sunday)
"""
from apscheduler.triggers.cron import CronTrigger
try:
# Try to parse the expression
trigger = CronTrigger.from_crontab(cron_expression)
# Validate basic format (5 fields)
fields = cron_expression.split()
if len(fields) != 5:
return False, f"Cron expression must have 5 fields (minute hour day month day_of_week), got {len(fields)}"
return True, "Valid cron expression"
except (ValueError, KeyError) as e:
error_msg = str(e)
# Provide helpful hints for common errors
if "day_of_week" in error_msg.lower() or (len(cron_expression.split()) >= 5):
# Check if day_of_week field might be using APScheduler format by mistake
fields = cron_expression.split()
if len(fields) == 5:
dow_field = fields[4]
if dow_field.isdigit() and int(dow_field) >= 0:
hint = "\nNote: Use standard crontab format where 0=Sunday, 1=Monday, ..., 6=Saturday"
return False, f"Invalid cron expression: {error_msg}{hint}"
return False, f"Invalid cron expression: {error_msg}"
def queue_scan(self, scan_id: int, config_id: int) -> str:
"""
Queue a scan for immediate background execution.
Args:
scan_id: Database ID of the scan
config_id: Database config ID
Returns:
Job ID from APScheduler
Raises:
RuntimeError: If scheduler not initialized
"""
if not self.scheduler:
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
# Add job to run immediately
job = self.scheduler.add_job(
func=execute_scan,
kwargs={'scan_id': scan_id, 'config_id': config_id, 'db_url': self.db_url},
id=f'scan_{scan_id}',
name=f'Scan {scan_id}',
replace_existing=True,
misfire_grace_time=300 # 5 minutes
)
logger.info(f"Queued scan {scan_id} for background execution (job_id={job.id})")
return job.id
def add_scheduled_scan(self, schedule_id: int, config_id: int,
cron_expression: str) -> str:
"""
Add a recurring scheduled scan.
Args:
schedule_id: Database ID of the schedule
config_id: Database config ID
cron_expression: Cron expression (e.g., "0 2 * * *" for 2am daily)
IMPORTANT: Use standard crontab format where:
- Day of week: 0 = Sunday, 1 = Monday, ..., 6 = Saturday
- APScheduler automatically converts to its internal format
- from_crontab() handles the conversion properly
Returns:
Job ID from APScheduler
Raises:
RuntimeError: If scheduler not initialized
ValueError: If cron expression is invalid
Note:
APScheduler internally uses Monday=0, but from_crontab() accepts
standard crontab format (Sunday=0) and converts it automatically.
"""
if not self.scheduler:
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
from apscheduler.triggers.cron import CronTrigger
# Validate cron expression first to provide helpful error messages
is_valid, message = self.validate_cron_expression(cron_expression)
if not is_valid:
raise ValueError(message)
# Create cron trigger from expression using local timezone
# from_crontab() parses standard crontab format (Sunday=0)
# and converts to APScheduler's internal format (Monday=0) automatically
try:
trigger = CronTrigger.from_crontab(cron_expression)
# timezone defaults to local system timezone
except (ValueError, KeyError) as e:
# This should not happen due to validation above, but catch anyway
raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}")
# Add cron job
job = self.scheduler.add_job(
func=self._trigger_scheduled_scan,
args=[schedule_id],
trigger=trigger,
id=f'schedule_{schedule_id}',
name=f'Schedule {schedule_id}',
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})")
return job.id
def remove_scheduled_scan(self, schedule_id: int):
"""
Remove a scheduled scan job.
Args:
schedule_id: Database ID of the schedule
Raises:
RuntimeError: If scheduler not initialized
"""
if not self.scheduler:
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
job_id = f'schedule_{schedule_id}'
try:
self.scheduler.remove_job(job_id)
logger.info(f"Removed scheduled scan job: {job_id}")
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):
"""
Internal method to trigger a scan from a schedule.
Creates a new scan record and queues it for execution.
Args:
schedule_id: Database ID of the schedule
"""
logger.info(f"Scheduled scan triggered: schedule_id={schedule_id}")
# 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_id=schedule['config_id'],
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_id'])
# Update schedule's last_run and next_run
from croniter import croniter
now_utc = datetime.now(timezone.utc)
next_run = croniter(schedule['cron_expression'], now_utc).get_next(datetime)
# croniter returns naive datetime, add UTC timezone
if next_run.tzinfo is None:
next_run = next_run.replace(tzinfo=timezone.utc)
schedule_service.update_run_times(
schedule_id=schedule_id,
last_run=now_utc,
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]:
"""
Get status of a scheduled job.
Args:
job_id: APScheduler job ID
Returns:
Dictionary with job information, or None if not found
"""
if not self.scheduler:
return None
job = self.scheduler.get_job(job_id)
if not job:
return None
return {
'id': job.id,
'name': job.name,
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
'trigger': str(job.trigger)
}
def list_jobs(self) -> list:
"""
List all scheduled jobs.
Returns:
List of job information dictionaries
"""
if not self.scheduler:
return []
jobs = self.scheduler.get_jobs()
return [
{
'id': job.id,
'name': job.name,
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
'trigger': str(job.trigger)
}
for job in jobs
]

View File

@@ -0,0 +1,683 @@
"""
Site service for managing reusable site definitions.
This service handles the business logic for creating, updating, and managing
sites with their associated CIDR ranges and IP-level overrides.
"""
import ipaddress
import json
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from web.models import (
Site, SiteIP, ScanSiteAssociation
)
from web.utils.pagination import paginate, PaginatedResult
logger = logging.getLogger(__name__)
class SiteService:
"""
Service for managing reusable site definitions.
Handles site lifecycle: creation, updates, deletion (with safety checks),
CIDR management, and IP-level overrides.
"""
def __init__(self, db_session: Session):
"""
Initialize site service.
Args:
db_session: SQLAlchemy database session
"""
self.db = db_session
def create_site(self, name: str, description: Optional[str] = None) -> Dict[str, Any]:
"""
Create a new site.
Args:
name: Unique site name
description: Optional site description
Returns:
Dictionary with created site data
Raises:
ValueError: If site name already exists
"""
# Validate site name is unique
existing = self.db.query(Site).filter(Site.name == name).first()
if existing:
raise ValueError(f"Site with name '{name}' already exists")
# Create site (can be empty, IPs added separately)
site = Site(
name=name,
description=description,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
self.db.add(site)
self.db.commit()
self.db.refresh(site)
logger.info(f"Created site '{name}' (id={site.id})")
return self._site_to_dict(site)
def update_site(self, site_id: int, name: Optional[str] = None,
description: Optional[str] = None) -> Dict[str, Any]:
"""
Update site metadata (name and/or description).
Args:
site_id: Site ID to update
name: New site name (must be unique)
description: New description
Returns:
Dictionary with updated site data
Raises:
ValueError: If site not found or name already exists
"""
site = self.db.query(Site).filter(Site.id == site_id).first()
if not site:
raise ValueError(f"Site with id {site_id} not found")
# Update name if provided
if name is not None and name != site.name:
# Check uniqueness
existing = self.db.query(Site).filter(
Site.name == name,
Site.id != site_id
).first()
if existing:
raise ValueError(f"Site with name '{name}' already exists")
site.name = name
# Update description if provided
if description is not None:
site.description = description
site.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(site)
logger.info(f"Updated site {site_id} ('{site.name}')")
return self._site_to_dict(site)
def delete_site(self, site_id: int) -> None:
"""
Delete a site.
Prevents deletion if the site is used in any scan (per user requirement).
Args:
site_id: Site ID to delete
Raises:
ValueError: If site not found or is used in scans
"""
site = self.db.query(Site).filter(Site.id == site_id).first()
if not site:
raise ValueError(f"Site with id {site_id} not found")
# Check if site is used in any scans
usage_count = (
self.db.query(func.count(ScanSiteAssociation.id))
.filter(ScanSiteAssociation.site_id == site_id)
.scalar()
)
if usage_count > 0:
raise ValueError(
f"Cannot delete site '{site.name}': it is used in {usage_count} scan(s). "
f"Sites that have been used in scans cannot be deleted."
)
# Safe to delete
self.db.delete(site)
self.db.commit()
logger.info(f"Deleted site {site_id} ('{site.name}')")
def get_site(self, site_id: int) -> Optional[Dict[str, Any]]:
"""
Get site details.
Args:
site_id: Site ID to retrieve
Returns:
Dictionary with site data, or None if not found
"""
site = (
self.db.query(Site)
.filter(Site.id == site_id)
.first()
)
if not site:
return None
return self._site_to_dict(site)
def get_site_by_name(self, name: str) -> Optional[Dict[str, Any]]:
"""
Get site details by name.
Args:
name: Site name to retrieve
Returns:
Dictionary with site data, or None if not found
"""
site = (
self.db.query(Site)
.filter(Site.name == name)
.first()
)
if not site:
return None
return self._site_to_dict(site)
def list_sites(self, page: int = 1, per_page: int = 20) -> PaginatedResult:
"""
List all sites with pagination.
Args:
page: Page number (1-indexed)
per_page: Number of items per page
Returns:
PaginatedResult with site data
"""
query = (
self.db.query(Site)
.order_by(Site.name)
)
return paginate(query, page, per_page, self._site_to_dict)
def list_all_sites(self) -> List[Dict[str, Any]]:
"""
List all sites without pagination (for dropdowns, etc.).
Returns:
List of site dictionaries
"""
sites = (
self.db.query(Site)
.order_by(Site.name)
.all()
)
return [self._site_to_dict(site) for site in sites]
def get_global_ip_stats(self) -> Dict[str, int]:
"""
Get global IP statistics across all sites.
Returns:
Dictionary with:
- total_ips: Total count of IP entries (including duplicates)
- unique_ips: Count of distinct IP addresses
- duplicate_ips: Number of duplicate entries (total - unique)
"""
# Total IP entries
total_ips = (
self.db.query(func.count(SiteIP.id))
.scalar() or 0
)
# Unique IP addresses
unique_ips = (
self.db.query(func.count(func.distinct(SiteIP.ip_address)))
.scalar() or 0
)
return {
'total_ips': total_ips,
'unique_ips': unique_ips,
'duplicate_ips': total_ips - unique_ips
}
def bulk_add_ips_from_cidr(self, site_id: int, cidr: str,
expected_ping: Optional[bool] = None,
expected_tcp_ports: Optional[List[int]] = None,
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
"""
Expand a CIDR range and add all IPs to a site.
CIDRs are NOT stored - they are just used to generate IP records.
Args:
site_id: Site ID
cidr: CIDR notation (e.g., "10.0.0.0/24")
expected_ping: Expected ping response for all IPs
expected_tcp_ports: List of expected TCP ports for all IPs
expected_udp_ports: List of expected UDP ports for all IPs
Returns:
Dictionary with:
- cidr: The CIDR that was expanded
- ip_count: Number of IPs created
- ips_added: List of IP addresses created
- ips_skipped: List of IPs that already existed
Raises:
ValueError: If site not found or CIDR is invalid/too large
"""
site = self.db.query(Site).filter(Site.id == site_id).first()
if not site:
raise ValueError(f"Site with id {site_id} not found")
# Validate CIDR format and size
try:
network = ipaddress.ip_network(cidr, strict=False)
except ValueError as e:
raise ValueError(f"Invalid CIDR notation '{cidr}': {str(e)}")
# Enforce CIDR size limits (max /24 for IPv4, /64 for IPv6)
if isinstance(network, ipaddress.IPv4Network) and network.prefixlen < 24:
raise ValueError(
f"CIDR '{cidr}' is too large ({network.num_addresses} IPs). "
f"Maximum allowed is /24 (256 IPs) for IPv4."
)
elif isinstance(network, ipaddress.IPv6Network) and network.prefixlen < 64:
raise ValueError(
f"CIDR '{cidr}' is too large. "
f"Maximum allowed is /64 for IPv6."
)
# Expand CIDR to individual IPs (no cidr_id since we're not storing CIDR)
ip_count, ips_added, ips_skipped = self._expand_cidr_to_ips(
site_id=site_id,
network=network,
expected_ping=expected_ping,
expected_tcp_ports=expected_tcp_ports or [],
expected_udp_ports=expected_udp_ports or []
)
site.updated_at = datetime.utcnow()
self.db.commit()
logger.info(
f"Expanded CIDR '{cidr}' for site {site_id} ('{site.name}'): "
f"added {ip_count} IPs, skipped {len(ips_skipped)} duplicates"
)
return {
'cidr': cidr,
'ip_count': ip_count,
'ips_added': ips_added,
'ips_skipped': ips_skipped
}
def bulk_add_ips_from_list(self, site_id: int, ip_list: List[str],
expected_ping: Optional[bool] = None,
expected_tcp_ports: Optional[List[int]] = None,
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
"""
Add multiple IPs from a list (e.g., from CSV/text import).
Args:
site_id: Site ID
ip_list: List of IP addresses as strings
expected_ping: Expected ping response for all IPs
expected_tcp_ports: List of expected TCP ports for all IPs
expected_udp_ports: List of expected UDP ports for all IPs
Returns:
Dictionary with:
- ip_count: Number of IPs successfully created
- ips_added: List of IP addresses created
- ips_skipped: List of IPs that already existed
- errors: List of validation errors {ip: error_message}
Raises:
ValueError: If site not found
"""
site = self.db.query(Site).filter(Site.id == site_id).first()
if not site:
raise ValueError(f"Site with id {site_id} not found")
ips_added = []
ips_skipped = []
errors = []
for ip_str in ip_list:
ip_str = ip_str.strip()
if not ip_str:
continue # Skip empty lines
# Validate IP format
try:
ipaddress.ip_address(ip_str)
except ValueError as e:
errors.append({'ip': ip_str, 'error': f"Invalid IP address: {str(e)}"})
continue
# Check for duplicate (across all IPs in the site)
existing = (
self.db.query(SiteIP)
.filter(SiteIP.site_id == site_id, SiteIP.ip_address == ip_str)
.first()
)
if existing:
ips_skipped.append(ip_str)
continue
# Create IP record
try:
ip_obj = SiteIP(
site_id=site_id,
ip_address=ip_str,
expected_ping=expected_ping,
expected_tcp_ports=json.dumps(expected_tcp_ports or []),
expected_udp_ports=json.dumps(expected_udp_ports or []),
created_at=datetime.utcnow()
)
self.db.add(ip_obj)
ips_added.append(ip_str)
except Exception as e:
errors.append({'ip': ip_str, 'error': f"Database error: {str(e)}"})
site.updated_at = datetime.utcnow()
self.db.commit()
logger.info(
f"Bulk added {len(ips_added)} IPs to site {site_id} ('{site.name}'), "
f"skipped {len(ips_skipped)} duplicates, {len(errors)} errors"
)
return {
'ip_count': len(ips_added),
'ips_added': ips_added,
'ips_skipped': ips_skipped,
'errors': errors
}
def add_standalone_ip(self, site_id: int, ip_address: str,
expected_ping: Optional[bool] = None,
expected_tcp_ports: Optional[List[int]] = None,
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
"""
Add a standalone IP (without a CIDR parent) to a site.
Args:
site_id: Site ID
ip_address: IP address to add
expected_ping: Expected ping response
expected_tcp_ports: List of expected TCP ports
expected_udp_ports: List of expected UDP ports
Returns:
Dictionary with IP data
Raises:
ValueError: If site not found, IP is invalid, or already exists
"""
site = self.db.query(Site).filter(Site.id == site_id).first()
if not site:
raise ValueError(f"Site with id {site_id} not found")
# Validate IP format
try:
ipaddress.ip_address(ip_address)
except ValueError as e:
raise ValueError(f"Invalid IP address '{ip_address}': {str(e)}")
# Check for duplicate (across all IPs in the site)
existing = (
self.db.query(SiteIP)
.filter(SiteIP.site_id == site_id, SiteIP.ip_address == ip_address)
.first()
)
if existing:
raise ValueError(f"IP '{ip_address}' already exists in this site")
# Create IP
ip_obj = SiteIP(
site_id=site_id,
ip_address=ip_address,
expected_ping=expected_ping,
expected_tcp_ports=json.dumps(expected_tcp_ports or []),
expected_udp_ports=json.dumps(expected_udp_ports or []),
created_at=datetime.utcnow()
)
self.db.add(ip_obj)
site.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(ip_obj)
logger.info(f"Added IP '{ip_address}' to site {site_id} ('{site.name}')")
return self._ip_to_dict(ip_obj)
def update_ip_settings(self, site_id: int, ip_id: int,
expected_ping: Optional[bool] = None,
expected_tcp_ports: Optional[List[int]] = None,
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
"""
Update settings for an individual IP.
Args:
site_id: Site ID
ip_id: IP ID to update
expected_ping: New ping expectation (if provided)
expected_tcp_ports: New TCP ports expectation (if provided)
expected_udp_ports: New UDP ports expectation (if provided)
Returns:
Dictionary with updated IP data
Raises:
ValueError: If IP not found
"""
ip_obj = (
self.db.query(SiteIP)
.filter(SiteIP.id == ip_id, SiteIP.site_id == site_id)
.first()
)
if not ip_obj:
raise ValueError(f"IP with id {ip_id} not found for site {site_id}")
# Update settings if provided
if expected_ping is not None:
ip_obj.expected_ping = expected_ping
if expected_tcp_ports is not None:
ip_obj.expected_tcp_ports = json.dumps(expected_tcp_ports)
if expected_udp_ports is not None:
ip_obj.expected_udp_ports = json.dumps(expected_udp_ports)
self.db.commit()
self.db.refresh(ip_obj)
logger.info(f"Updated settings for IP '{ip_obj.ip_address}' in site {site_id}")
return self._ip_to_dict(ip_obj)
def remove_ip(self, site_id: int, ip_id: int) -> None:
"""
Remove an IP from a site.
Args:
site_id: Site ID
ip_id: IP ID to remove
Raises:
ValueError: If IP not found
"""
ip_obj = (
self.db.query(SiteIP)
.filter(SiteIP.id == ip_id, SiteIP.site_id == site_id)
.first()
)
if not ip_obj:
raise ValueError(f"IP with id {ip_id} not found for site {site_id}")
ip_address = ip_obj.ip_address
self.db.delete(ip_obj)
self.db.commit()
logger.info(f"Removed IP '{ip_address}' from site {site_id}")
def list_ips(self, site_id: int, page: int = 1, per_page: int = 50) -> PaginatedResult:
"""
List IPs in a site with pagination.
Args:
site_id: Site ID
page: Page number (1-indexed)
per_page: Number of items per page
Returns:
PaginatedResult with IP data
"""
query = (
self.db.query(SiteIP)
.filter(SiteIP.site_id == site_id)
.order_by(SiteIP.ip_address)
)
return paginate(query, page, per_page, self._ip_to_dict)
def get_scan_usage(self, site_id: int) -> List[Dict[str, Any]]:
"""
Get list of scans that use this site.
Args:
site_id: Site ID
Returns:
List of scan dictionaries
"""
from web.models import Scan # Import here to avoid circular dependency
associations = (
self.db.query(ScanSiteAssociation)
.options(joinedload(ScanSiteAssociation.scan))
.filter(ScanSiteAssociation.site_id == site_id)
.all()
)
return [
{
'id': assoc.scan.id,
'title': assoc.scan.title,
'timestamp': assoc.scan.timestamp.isoformat() if assoc.scan.timestamp else None,
'status': assoc.scan.status
}
for assoc in associations
]
# Private helper methods
def _expand_cidr_to_ips(self, site_id: int,
network: ipaddress.IPv4Network | ipaddress.IPv6Network,
expected_ping: Optional[bool],
expected_tcp_ports: List[int],
expected_udp_ports: List[int]) -> tuple[int, List[str], List[str]]:
"""
Expand a CIDR to individual IP addresses.
Args:
site_id: Site ID
network: ipaddress network object
expected_ping: Default ping setting for all IPs
expected_tcp_ports: Default TCP ports for all IPs
expected_udp_ports: Default UDP ports for all IPs
Returns:
Tuple of (count of IPs created, list of IPs added, list of IPs skipped)
"""
ip_count = 0
ips_added = []
ips_skipped = []
# For /32 or /128 (single host), use the network address
# For larger ranges, use hosts() to exclude network/broadcast addresses
if network.num_addresses == 1:
ip_list = [network.network_address]
elif network.num_addresses == 2:
# For /31 networks (point-to-point), both addresses are usable
ip_list = [network.network_address, network.broadcast_address]
else:
# Use hosts() to get usable IPs (excludes network and broadcast)
ip_list = list(network.hosts())
for ip in ip_list:
ip_str = str(ip)
# Check for duplicate
existing = (
self.db.query(SiteIP)
.filter(SiteIP.site_id == site_id, SiteIP.ip_address == ip_str)
.first()
)
if existing:
ips_skipped.append(ip_str)
continue
# Create SiteIP entry
ip_obj = SiteIP(
site_id=site_id,
ip_address=ip_str,
expected_ping=expected_ping,
expected_tcp_ports=json.dumps(expected_tcp_ports),
expected_udp_ports=json.dumps(expected_udp_ports),
created_at=datetime.utcnow()
)
self.db.add(ip_obj)
ips_added.append(ip_str)
ip_count += 1
return ip_count, ips_added, ips_skipped
def _site_to_dict(self, site: Site) -> Dict[str, Any]:
"""Convert Site model to dictionary."""
# Count IPs for this site
ip_count = (
self.db.query(func.count(SiteIP.id))
.filter(SiteIP.site_id == site.id)
.scalar() or 0
)
return {
'id': site.id,
'name': site.name,
'description': site.description,
'created_at': site.created_at.isoformat() if site.created_at else None,
'updated_at': site.updated_at.isoformat() if site.updated_at else None,
'ip_count': ip_count
}
def _ip_to_dict(self, ip: SiteIP) -> Dict[str, Any]:
"""Convert SiteIP model to dictionary."""
return {
'id': ip.id,
'site_id': ip.site_id,
'ip_address': ip.ip_address,
'expected_ping': ip.expected_ping,
'expected_tcp_ports': json.loads(ip.expected_tcp_ports) if ip.expected_tcp_ports else [],
'expected_udp_ports': json.loads(ip.expected_udp_ports) if ip.expected_udp_ports else [],
'created_at': ip.created_at.isoformat() if ip.created_at else None
}

View File

@@ -0,0 +1,294 @@
"""
Webhook Template Service
Provides Jinja2 template rendering for webhook payloads with a sandboxed
environment and comprehensive context building from scan/alert/rule data.
"""
from jinja2 import Environment, BaseLoader, TemplateError, meta
from jinja2.sandbox import SandboxedEnvironment
import json
from typing import Dict, Any, Optional, Tuple
from datetime import datetime
class TemplateService:
"""
Service for rendering webhook templates safely using Jinja2.
Features:
- Sandboxed Jinja2 environment to prevent code execution
- Rich context with alert, scan, rule, service, cert data
- Support for both JSON and text output formats
- Template validation and error handling
"""
def __init__(self):
"""Initialize the sandboxed Jinja2 environment."""
self.env = SandboxedEnvironment(
loader=BaseLoader(),
autoescape=False, # We control the output format
trim_blocks=True,
lstrip_blocks=True
)
# Add custom filters
self.env.filters['isoformat'] = self._isoformat_filter
def _isoformat_filter(self, value):
"""Custom filter to convert datetime to ISO format."""
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def build_context(
self,
alert,
scan,
rule,
app_name: str = "SneakyScanner",
app_version: str = "1.0.0",
app_url: str = "https://github.com/sneakygeek/SneakyScan"
) -> Dict[str, Any]:
"""
Build the template context from alert, scan, and rule objects.
Args:
alert: Alert model instance
scan: Scan model instance
rule: AlertRule model instance
app_name: Application name
app_version: Application version
app_url: Application repository URL
Returns:
Dictionary with all available template variables
"""
context = {
"alert": {
"id": alert.id,
"type": alert.alert_type,
"severity": alert.severity,
"message": alert.message,
"ip_address": alert.ip_address,
"port": alert.port,
"acknowledged": alert.acknowledged,
"acknowledged_at": alert.acknowledged_at,
"acknowledged_by": alert.acknowledged_by,
"created_at": alert.created_at,
"email_sent": alert.email_sent,
"email_sent_at": alert.email_sent_at,
"webhook_sent": alert.webhook_sent,
"webhook_sent_at": alert.webhook_sent_at,
},
"scan": {
"id": scan.id,
"title": scan.title,
"timestamp": scan.timestamp,
"duration": scan.duration,
"status": scan.status,
"config_id": scan.config_id,
"triggered_by": scan.triggered_by,
"started_at": scan.started_at,
"completed_at": scan.completed_at,
"error_message": scan.error_message,
},
"rule": {
"id": rule.id,
"name": rule.name,
"type": rule.rule_type,
"threshold": rule.threshold,
"severity": rule.severity,
"enabled": rule.enabled,
},
"app": {
"name": app_name,
"version": app_version,
"url": app_url,
},
"timestamp": datetime.utcnow(),
}
# Add service information if available (for service-related alerts)
# This would require additional context from the caller
# For now, we'll add placeholder support
context["service"] = None
context["cert"] = None
context["tls"] = None
return context
def render(
self,
template_string: str,
context: Dict[str, Any],
template_format: str = 'json'
) -> Tuple[str, Optional[str]]:
"""
Render a template with the given context.
Args:
template_string: The Jinja2 template string
context: Template context dictionary
template_format: Output format ('json' or 'text')
Returns:
Tuple of (rendered_output, error_message)
- If successful: (rendered_string, None)
- If failed: (None, error_message)
"""
try:
template = self.env.from_string(template_string)
rendered = template.render(context)
# For JSON format, validate that the output is valid JSON
if template_format == 'json':
try:
# Parse to validate JSON structure
json.loads(rendered)
except json.JSONDecodeError as e:
return None, f"Template rendered invalid JSON: {str(e)}"
return rendered, None
except TemplateError as e:
return None, f"Template rendering error: {str(e)}"
except Exception as e:
return None, f"Unexpected error rendering template: {str(e)}"
def validate_template(
self,
template_string: str,
template_format: str = 'json'
) -> Tuple[bool, Optional[str]]:
"""
Validate a template without rendering it.
Args:
template_string: The Jinja2 template string to validate
template_format: Expected output format ('json' or 'text')
Returns:
Tuple of (is_valid, error_message)
- If valid: (True, None)
- If invalid: (False, error_message)
"""
try:
# Parse the template to check syntax
self.env.parse(template_string)
# For JSON templates, check if it looks like valid JSON structure
# (this is a basic check - full validation happens during render)
if template_format == 'json':
# Just check for basic JSON structure markers
stripped = template_string.strip()
if not (stripped.startswith('{') or stripped.startswith('[')):
return False, "JSON template must start with { or ["
return True, None
except TemplateError as e:
return False, f"Template syntax error: {str(e)}"
except Exception as e:
return False, f"Template validation error: {str(e)}"
def get_template_variables(self, template_string: str) -> set:
"""
Extract all variables used in a template.
Args:
template_string: The Jinja2 template string
Returns:
Set of variable names used in the template
"""
try:
ast = self.env.parse(template_string)
return meta.find_undeclared_variables(ast)
except Exception:
return set()
def render_test_payload(
self,
template_string: str,
template_format: str = 'json'
) -> Tuple[str, Optional[str]]:
"""
Render a template with sample/test data for preview purposes.
Args:
template_string: The Jinja2 template string
template_format: Output format ('json' or 'text')
Returns:
Tuple of (rendered_output, error_message)
"""
# Create sample context data
sample_context = {
"alert": {
"id": 123,
"type": "unexpected_port",
"severity": "warning",
"message": "Unexpected port 8080 found open on 192.168.1.100",
"ip_address": "192.168.1.100",
"port": 8080,
"acknowledged": False,
"acknowledged_at": None,
"acknowledged_by": None,
"created_at": datetime.utcnow(),
"email_sent": False,
"email_sent_at": None,
"webhook_sent": False,
"webhook_sent_at": None,
},
"scan": {
"id": 456,
"title": "Production Infrastructure Scan",
"timestamp": datetime.utcnow(),
"duration": 125.5,
"status": "completed",
"config_id": 1,
"triggered_by": "schedule",
"started_at": datetime.utcnow(),
"completed_at": datetime.utcnow(),
"error_message": None,
},
"rule": {
"id": 789,
"name": "Unexpected Port Detection",
"type": "unexpected_port",
"threshold": None,
"severity": "warning",
"enabled": True,
},
"service": {
"name": "http",
"product": "nginx",
"version": "1.20.0",
},
"cert": {
"subject": "CN=example.com",
"issuer": "CN=Let's Encrypt Authority X3",
"days_until_expiry": 15,
},
"app": {
"name": "SneakyScanner",
"version": "1.0.0-phase5",
"url": "https://github.com/sneakygeek/SneakyScan",
},
"timestamp": datetime.utcnow(),
}
return self.render(template_string, sample_context, template_format)
# Singleton instance
_template_service = None
def get_template_service() -> TemplateService:
"""Get the singleton TemplateService instance."""
global _template_service
if _template_service is None:
_template_service = TemplateService()
return _template_service

View File

@@ -0,0 +1,566 @@
"""
Webhook Service Module
Handles webhook delivery for alert notifications with retry logic,
authentication support, and comprehensive logging.
"""
import json
import logging
import time
from datetime import datetime, timezone
from typing import List, Dict, Optional, Any, Tuple
from sqlalchemy.orm import Session
import requests
from requests.auth import HTTPBasicAuth
from cryptography.fernet import Fernet
import os
from ..models import Webhook, WebhookDeliveryLog, Alert, AlertRule, Scan
from .template_service import get_template_service
from ..config import APP_NAME, APP_VERSION, REPO_URL
logger = logging.getLogger(__name__)
class WebhookService:
"""
Service for webhook delivery and management.
Handles queuing webhook deliveries, executing HTTP requests with
authentication, retry logic, and logging delivery attempts.
"""
def __init__(self, db_session: Session, encryption_key: Optional[bytes] = None):
"""
Initialize webhook service.
Args:
db_session: SQLAlchemy database session
encryption_key: Fernet encryption key for auth_token encryption
"""
self.db = db_session
self._encryption_key = encryption_key or self._get_encryption_key()
self._cipher = Fernet(self._encryption_key) if self._encryption_key else None
def _get_encryption_key(self) -> Optional[bytes]:
"""
Get encryption key from environment or database.
Returns:
Fernet encryption key or None if not available
"""
# Try environment variable first
key_str = os.environ.get('SNEAKYSCANNER_ENCRYPTION_KEY')
if key_str:
return key_str.encode()
# Try to get from settings (would need to query Setting table)
# For now, generate a temporary key if none exists
try:
return Fernet.generate_key()
except Exception as e:
logger.warning(f"Could not generate encryption key: {e}")
return None
def _encrypt_value(self, value: str) -> str:
"""Encrypt a string value."""
if not self._cipher:
return value # Return plain text if encryption not available
return self._cipher.encrypt(value.encode()).decode()
def _decrypt_value(self, encrypted_value: str) -> str:
"""Decrypt an encrypted string value."""
if not self._cipher:
return encrypted_value # Return as-is if encryption not available
try:
return self._cipher.decrypt(encrypted_value.encode()).decode()
except Exception as e:
logger.error(f"Failed to decrypt value: {e}")
return encrypted_value
def get_matching_webhooks(self, alert: Alert) -> List[Webhook]:
"""
Get all enabled webhooks that match an alert's type and severity.
Args:
alert: Alert object to match against
Returns:
List of matching Webhook objects
"""
# Get all enabled webhooks
webhooks = self.db.query(Webhook).filter(Webhook.enabled == True).all()
matching_webhooks = []
for webhook in webhooks:
# Check if webhook matches alert type filter
if webhook.alert_types:
try:
alert_types = json.loads(webhook.alert_types)
if alert.alert_type not in alert_types:
continue # Skip if alert type doesn't match
except json.JSONDecodeError:
logger.warning(f"Invalid alert_types JSON for webhook {webhook.id}")
continue
# Check if webhook matches severity filter
if webhook.severity_filter:
try:
severity_filter = json.loads(webhook.severity_filter)
if alert.severity not in severity_filter:
continue # Skip if severity doesn't match
except json.JSONDecodeError:
logger.warning(f"Invalid severity_filter JSON for webhook {webhook.id}")
continue
matching_webhooks.append(webhook)
logger.info(f"Found {len(matching_webhooks)} matching webhooks for alert {alert.id}")
return matching_webhooks
def queue_webhook_delivery(self, webhook_id: int, alert_id: int, scheduler_service=None) -> bool:
"""
Queue a webhook delivery for async execution via APScheduler.
Args:
webhook_id: ID of webhook to deliver
alert_id: ID of alert to send
scheduler_service: SchedulerService instance (if None, deliver synchronously)
Returns:
True if queued successfully, False otherwise
"""
if scheduler_service and scheduler_service.scheduler:
try:
# Import here to avoid circular dependency
from web.jobs.webhook_job import execute_webhook_delivery
# Schedule immediate execution
scheduler_service.scheduler.add_job(
execute_webhook_delivery,
args=[webhook_id, alert_id, scheduler_service.db_url],
id=f"webhook_{webhook_id}_{alert_id}_{int(time.time())}",
replace_existing=False
)
logger.info(f"Queued webhook {webhook_id} for alert {alert_id}")
return True
except Exception as e:
logger.error(f"Failed to queue webhook delivery: {e}")
# Fall back to synchronous delivery
return self.deliver_webhook(webhook_id, alert_id)
else:
# No scheduler available, deliver synchronously
logger.info(f"No scheduler available, delivering webhook {webhook_id} synchronously")
return self.deliver_webhook(webhook_id, alert_id)
def deliver_webhook(self, webhook_id: int, alert_id: int, attempt_number: int = 1) -> bool:
"""
Deliver a webhook with retry logic.
Args:
webhook_id: ID of webhook to deliver
alert_id: ID of alert to send
attempt_number: Current attempt number (for retries)
Returns:
True if delivered successfully, False otherwise
"""
# Get webhook and alert
webhook = self.db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
logger.error(f"Webhook {webhook_id} not found")
return False
alert = self.db.query(Alert).filter(Alert.id == alert_id).first()
if not alert:
logger.error(f"Alert {alert_id} not found")
return False
logger.info(f"Delivering webhook {webhook_id} for alert {alert_id} (attempt {attempt_number}/{webhook.retry_count})")
# Build payload with template support
payload, content_type = self._build_payload(webhook, alert)
# Prepare headers
headers = {'Content-Type': content_type}
# Add custom headers if provided
if webhook.custom_headers:
try:
custom_headers = json.loads(webhook.custom_headers)
headers.update(custom_headers)
except json.JSONDecodeError:
logger.warning(f"Invalid custom_headers JSON for webhook {webhook_id}")
# Prepare authentication
auth = None
if webhook.auth_type == 'bearer' and webhook.auth_token:
decrypted_token = self._decrypt_value(webhook.auth_token)
headers['Authorization'] = f'Bearer {decrypted_token}'
elif webhook.auth_type == 'basic' and webhook.auth_token:
# Expecting format: "username:password"
decrypted_token = self._decrypt_value(webhook.auth_token)
if ':' in decrypted_token:
username, password = decrypted_token.split(':', 1)
auth = HTTPBasicAuth(username, password)
# Execute HTTP request
try:
timeout = webhook.timeout or 10
# Use appropriate parameter based on payload type
if isinstance(payload, dict):
# JSON payload
response = requests.post(
webhook.url,
json=payload,
headers=headers,
auth=auth,
timeout=timeout
)
else:
# Text payload
response = requests.post(
webhook.url,
data=payload,
headers=headers,
auth=auth,
timeout=timeout
)
# Log delivery attempt
log_entry = WebhookDeliveryLog(
webhook_id=webhook_id,
alert_id=alert_id,
status='success' if response.status_code < 400 else 'failed',
response_code=response.status_code,
response_body=response.text[:1000], # Limit to 1000 chars
error_message=None if response.status_code < 400 else f"HTTP {response.status_code}",
attempt_number=attempt_number,
delivered_at=datetime.now(timezone.utc)
)
self.db.add(log_entry)
# Update alert webhook status if successful
if response.status_code < 400:
alert.webhook_sent = True
alert.webhook_sent_at = datetime.now(timezone.utc)
logger.info(f"Webhook {webhook_id} delivered successfully (HTTP {response.status_code})")
self.db.commit()
return True
else:
# Failed but got a response
logger.warning(f"Webhook {webhook_id} failed with HTTP {response.status_code}")
self.db.commit()
# Retry if attempts remaining
if attempt_number < webhook.retry_count:
delay = self._calculate_retry_delay(attempt_number)
logger.info(f"Retrying webhook {webhook_id} in {delay} seconds")
time.sleep(delay)
return self.deliver_webhook(webhook_id, alert_id, attempt_number + 1)
return False
except requests.exceptions.Timeout:
error_msg = f"Request timeout after {timeout} seconds"
logger.error(f"Webhook {webhook_id} timeout: {error_msg}")
self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number)
except requests.exceptions.ConnectionError as e:
error_msg = f"Connection error: {str(e)}"
logger.error(f"Webhook {webhook_id} connection error: {error_msg}")
self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number)
except requests.exceptions.RequestException as e:
error_msg = f"Request error: {str(e)}"
logger.error(f"Webhook {webhook_id} request error: {error_msg}")
self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number)
except Exception as e:
error_msg = f"Unexpected error: {str(e)}"
logger.error(f"Webhook {webhook_id} unexpected error: {error_msg}")
self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number)
# Retry if attempts remaining
if attempt_number < webhook.retry_count:
delay = self._calculate_retry_delay(attempt_number)
logger.info(f"Retrying webhook {webhook_id} in {delay} seconds")
time.sleep(delay)
return self.deliver_webhook(webhook_id, alert_id, attempt_number + 1)
return False
def _log_delivery_failure(self, webhook_id: int, alert_id: int, error_message: str, attempt_number: int):
"""Log a failed delivery attempt."""
log_entry = WebhookDeliveryLog(
webhook_id=webhook_id,
alert_id=alert_id,
status='failed',
response_code=None,
response_body=None,
error_message=error_message[:500], # Limit error message length
attempt_number=attempt_number,
delivered_at=datetime.now(timezone.utc)
)
self.db.add(log_entry)
self.db.commit()
def _calculate_retry_delay(self, attempt_number: int) -> int:
"""
Calculate exponential backoff delay for retries.
Args:
attempt_number: Current attempt number
Returns:
Delay in seconds
"""
# Exponential backoff: 2^attempt seconds (2, 4, 8, 16...)
return min(2 ** attempt_number, 60) # Cap at 60 seconds
def _build_payload(self, webhook: Webhook, alert: Alert) -> Tuple[Any, str]:
"""
Build payload for webhook delivery using template if configured.
Args:
webhook: Webhook object with optional template configuration
alert: Alert object
Returns:
Tuple of (payload, content_type):
- payload: Rendered payload (dict for JSON, string for text)
- content_type: Content-Type header value
"""
# Get related scan
scan = self.db.query(Scan).filter(Scan.id == alert.scan_id).first()
# Get related rule
rule = self.db.query(AlertRule).filter(AlertRule.id == alert.rule_id).first()
# If webhook has a custom template, use it
if webhook.template:
template_service = get_template_service()
context = template_service.build_context(
alert=alert,
scan=scan,
rule=rule,
app_name=APP_NAME,
app_version=APP_VERSION,
app_url=REPO_URL
)
rendered, error = template_service.render(
webhook.template,
context,
webhook.template_format or 'json'
)
if error:
logger.error(f"Template rendering error for webhook {webhook.id}: {error}")
# Fall back to default payload
return self._build_default_payload(alert, scan, rule), 'application/json'
# Determine content type
if webhook.content_type_override:
content_type = webhook.content_type_override
elif webhook.template_format == 'text':
content_type = 'text/plain'
else:
content_type = 'application/json'
# For JSON format, parse the rendered string back to a dict
# For text format, return as string
if webhook.template_format == 'json':
try:
payload = json.loads(rendered)
except json.JSONDecodeError:
logger.error(f"Failed to parse rendered JSON template for webhook {webhook.id}")
return self._build_default_payload(alert, scan, rule), 'application/json'
else:
payload = rendered
return payload, content_type
# No template - use default payload
return self._build_default_payload(alert, scan, rule), 'application/json'
def _build_default_payload(self, alert: Alert, scan: Optional[Scan], rule: Optional[AlertRule]) -> Dict[str, Any]:
"""
Build default JSON payload for webhook delivery.
Args:
alert: Alert object
scan: Scan object (optional)
rule: AlertRule object (optional)
Returns:
Dict containing alert details in generic JSON format
"""
payload = {
"event": "alert.created",
"alert": {
"id": alert.id,
"type": alert.alert_type,
"severity": alert.severity,
"message": alert.message,
"ip_address": alert.ip_address,
"port": alert.port,
"acknowledged": alert.acknowledged,
"created_at": alert.created_at.isoformat() if alert.created_at else None
},
"scan": {
"id": scan.id if scan else None,
"title": scan.title if scan else None,
"timestamp": scan.timestamp.isoformat() if scan and scan.timestamp else None,
"status": scan.status if scan else None
},
"rule": {
"id": rule.id if rule else None,
"name": rule.name if rule else None,
"type": rule.rule_type if rule else None,
"threshold": rule.threshold if rule else None
}
}
return payload
def test_webhook(self, webhook_id: int) -> Dict[str, Any]:
"""
Send a test payload to a webhook.
Args:
webhook_id: ID of webhook to test
Returns:
Dict with test result details
"""
webhook = self.db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return {
'success': False,
'message': 'Webhook not found',
'status_code': None
}
# Build test payload - use template if configured
if webhook.template:
template_service = get_template_service()
rendered, error = template_service.render_test_payload(
webhook.template,
webhook.template_format or 'json'
)
if error:
return {
'success': False,
'message': f'Template error: {error}',
'status_code': None
}
# Determine content type
if webhook.content_type_override:
content_type = webhook.content_type_override
elif webhook.template_format == 'text':
content_type = 'text/plain'
else:
content_type = 'application/json'
# Parse JSON template
if webhook.template_format == 'json':
try:
payload = json.loads(rendered)
except json.JSONDecodeError:
return {
'success': False,
'message': 'Template rendered invalid JSON',
'status_code': None
}
else:
payload = rendered
else:
# Default test payload
payload = {
"event": "webhook.test",
"message": "This is a test webhook from SneakyScanner",
"timestamp": datetime.now(timezone.utc).isoformat(),
"webhook": {
"id": webhook.id,
"name": webhook.name
}
}
content_type = 'application/json'
# Prepare headers
headers = {'Content-Type': content_type}
if webhook.custom_headers:
try:
custom_headers = json.loads(webhook.custom_headers)
headers.update(custom_headers)
except json.JSONDecodeError:
pass
# Prepare authentication
auth = None
if webhook.auth_type == 'bearer' and webhook.auth_token:
decrypted_token = self._decrypt_value(webhook.auth_token)
headers['Authorization'] = f'Bearer {decrypted_token}'
elif webhook.auth_type == 'basic' and webhook.auth_token:
decrypted_token = self._decrypt_value(webhook.auth_token)
if ':' in decrypted_token:
username, password = decrypted_token.split(':', 1)
auth = HTTPBasicAuth(username, password)
# Execute test request
try:
timeout = webhook.timeout or 10
# Use appropriate parameter based on payload type
if isinstance(payload, dict):
# JSON payload
response = requests.post(
webhook.url,
json=payload,
headers=headers,
auth=auth,
timeout=timeout
)
else:
# Text payload
response = requests.post(
webhook.url,
data=payload,
headers=headers,
auth=auth,
timeout=timeout
)
return {
'success': response.status_code < 400,
'message': f'HTTP {response.status_code}',
'status_code': response.status_code,
'response_body': response.text[:500]
}
except requests.exceptions.Timeout:
return {
'success': False,
'message': f'Request timeout after {timeout} seconds',
'status_code': None
}
except requests.exceptions.ConnectionError as e:
return {
'success': False,
'message': f'Connection error: {str(e)}',
'status_code': None
}
except Exception as e:
return {
'success': False,
'message': f'Error: {str(e)}',
'status_code': None
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,505 @@
{% extends "base.html" %}
{% block title %}Alert Rules - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1>Alert Rules</h1>
<div>
<a href="{{ url_for('main.alerts') }}" class="btn btn-outline-primary me-2">
<i class="bi bi-bell"></i> View Alerts
</a>
<button class="btn btn-primary" onclick="showCreateRuleModal()">
<i class="bi bi-plus-circle"></i> Create Rule
</button>
</div>
</div>
</div>
<!-- Rule Statistics -->
<div class="row mb-4">
<div class="col-md-6 mb-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Total Rules</h6>
<h3 class="mb-0">{{ rules | length }}</h3>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Active Rules</h6>
<h3 class="mb-0 text-success">{{ rules | selectattr('enabled') | list | length }}</h3>
</div>
</div>
</div>
</div>
<!-- Rules Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Alert Rules Configuration</h5>
</div>
<div class="card-body">
{% if rules %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Severity</th>
<th>Threshold</th>
<th>Config</th>
<th>Notifications</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for rule in rules %}
<tr>
<td>
<strong>{{ rule.name or 'Unnamed Rule' }}</strong>
<br>
<small class="text-muted">ID: {{ rule.id }}</small>
</td>
<td>
<span class="badge bg-secondary">
{{ rule.rule_type.replace('_', ' ').title() }}
</span>
</td>
<td>
{% if rule.severity == 'critical' %}
<span class="badge bg-danger">Critical</span>
{% elif rule.severity == 'warning' %}
<span class="badge bg-warning">Warning</span>
{% else %}
<span class="badge bg-info">{{ rule.severity or 'Info' }}</span>
{% endif %}
</td>
<td>
{% if rule.threshold %}
{% if rule.rule_type == 'cert_expiry' %}
{{ rule.threshold }} days
{% elif rule.rule_type == 'drift_detection' %}
{{ rule.threshold }}%
{% else %}
{{ rule.threshold }}
{% endif %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if rule.config %}
<small class="text-muted">{{ rule.config.title }}</small>
{% else %}
<span class="badge bg-primary">All Configs</span>
{% endif %}
</td>
<td>
{% if rule.email_enabled %}
<i class="bi bi-envelope-fill text-primary" title="Email enabled"></i>
{% endif %}
{% if rule.webhook_enabled %}
<i class="bi bi-send-fill text-primary" title="Webhook enabled"></i>
{% endif %}
{% if not rule.email_enabled and not rule.webhook_enabled %}
<span class="text-muted">None</span>
{% endif %}
</td>
<td>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox"
id="rule-enabled-{{ rule.id }}"
{% if rule.enabled %}checked{% endif %}
onchange="toggleRule({{ rule.id }}, this.checked)">
<label class="form-check-label" for="rule-enabled-{{ rule.id }}">
{% if rule.enabled %}
<span class="text-success ms-2">Active</span>
{% else %}
<span class="text-muted ms-2">Inactive</span>
{% endif %}
</label>
</div>
</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="editRule({{ rule.id }})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteRule({{ rule.id }}, '{{ rule.name }}')">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-bell-slash" style="font-size: 3rem;"></i>
<h5 class="mt-3">No alert rules configured</h5>
<p>Create alert rules to be notified of important scan findings.</p>
<button class="btn btn-primary mt-3" onclick="showCreateRuleModal()">
<i class="bi bi-plus-circle"></i> Create Your First Rule
</button>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Create/Edit Rule Modal -->
<div class="modal fade" id="ruleModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ruleModalTitle">Create Alert Rule</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="ruleForm">
<input type="hidden" id="rule-id">
<div class="row">
<div class="col-md-6 mb-3">
<label for="rule-name" class="form-label">Rule Name</label>
<input type="text" class="form-control" id="rule-name" required>
</div>
<div class="col-md-6 mb-3">
<label for="rule-type" class="form-label">Rule Type</label>
<select class="form-select" id="rule-type" required onchange="updateThresholdLabel()">
<option value="">Select a type...</option>
<option value="unexpected_port">Unexpected Port Detection</option>
<option value="drift_detection">Drift Detection</option>
<option value="cert_expiry">Certificate Expiry</option>
<option value="weak_tls">Weak TLS Version</option>
<option value="ping_failed">Ping Failed</option>
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="rule-severity" class="form-label">Severity</label>
<select class="form-select" id="rule-severity" required>
<option value="info">Info</option>
<option value="warning" selected>Warning</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label for="rule-threshold" class="form-label" id="threshold-label">Threshold</label>
<input type="number" class="form-control" id="rule-threshold">
<small class="form-text text-muted" id="threshold-help">
Numeric value that triggers the alert (varies by rule type)
</small>
</div>
</div>
<div class="row">
<div class="col-md-12 mb-3">
<label for="rule-config" class="form-label">Apply to Config (optional)</label>
<select class="form-select" id="rule-config">
<option value="">All Configs (Apply to all scans)</option>
</select>
<small class="form-text text-muted" id="config-help-text">
Select a specific config to limit this rule, or leave as "All Configs" to apply to all scans
</small>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rule-email">
<label class="form-check-label" for="rule-email">
Send Email Notifications
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rule-webhook">
<label class="form-check-label" for="rule-webhook">
Send Webhook Notifications
</label>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rule-enabled" checked>
<label class="form-check-label" for="rule-enabled">
Enable this rule immediately
</label>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveRule()">
<span id="save-rule-text">Create Rule</span>
<span id="save-rule-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</div>
</div>
</div>
</div>
<script>
let editingRuleId = null;
// Load available configs for the dropdown
async function loadConfigsForRule() {
const selectEl = document.getElementById('rule-config');
try {
const response = await fetch('/api/configs');
if (!response.ok) {
throw new Error('Failed to load configurations');
}
const data = await response.json();
const configs = data.configs || [];
// Preserve the "All Configs" option and current selection
const currentValue = selectEl.value;
selectEl.innerHTML = '<option value="">All Configs (Apply to all scans)</option>';
configs.forEach(config => {
const option = document.createElement('option');
// Store the config ID as the value
option.value = config.id;
const siteText = config.site_count === 1 ? 'site' : 'sites';
option.textContent = `${config.title} (${config.site_count} ${siteText})`;
selectEl.appendChild(option);
});
// Restore selection if it was set
if (currentValue) {
selectEl.value = currentValue;
}
} catch (error) {
console.error('Error loading configs:', error);
}
}
function showCreateRuleModal() {
editingRuleId = null;
document.getElementById('ruleModalTitle').textContent = 'Create Alert Rule';
document.getElementById('save-rule-text').textContent = 'Create Rule';
document.getElementById('ruleForm').reset();
document.getElementById('rule-enabled').checked = true;
// Load configs when modal is shown
loadConfigsForRule();
new bootstrap.Modal(document.getElementById('ruleModal')).show();
}
function editRule(ruleId) {
editingRuleId = ruleId;
document.getElementById('ruleModalTitle').textContent = 'Edit Alert Rule';
document.getElementById('save-rule-text').textContent = 'Update Rule';
// Load configs first, then fetch rule details
loadConfigsForRule().then(() => {
// Fetch rule details
fetch(`/api/alerts/rules`, {
headers: {
'X-API-Key': localStorage.getItem('api_key') || ''
}
})
.then(response => response.json())
.then(data => {
const rule = data.rules.find(r => r.id === ruleId);
if (rule) {
document.getElementById('rule-id').value = rule.id;
document.getElementById('rule-name').value = rule.name || '';
document.getElementById('rule-type').value = rule.rule_type;
document.getElementById('rule-severity').value = rule.severity || 'warning';
document.getElementById('rule-threshold').value = rule.threshold || '';
document.getElementById('rule-config').value = rule.config_id || '';
document.getElementById('rule-email').checked = rule.email_enabled;
document.getElementById('rule-webhook').checked = rule.webhook_enabled;
document.getElementById('rule-enabled').checked = rule.enabled;
updateThresholdLabel();
new bootstrap.Modal(document.getElementById('ruleModal')).show();
}
})
.catch(error => {
console.error('Error fetching rule:', error);
alert('Failed to load rule details');
});
});
}
function updateThresholdLabel() {
const ruleType = document.getElementById('rule-type').value;
const label = document.getElementById('threshold-label');
const help = document.getElementById('threshold-help');
switch(ruleType) {
case 'cert_expiry':
label.textContent = 'Days Before Expiry';
help.textContent = 'Alert when certificate expires within this many days (default: 30)';
break;
case 'drift_detection':
label.textContent = 'Drift Percentage';
help.textContent = 'Alert when drift exceeds this percentage (0-100, default: 5)';
break;
case 'unexpected_port':
label.textContent = 'Threshold (optional)';
help.textContent = 'Leave blank - this rule alerts on any port not in your config file';
break;
case 'weak_tls':
label.textContent = 'Threshold (optional)';
help.textContent = 'Leave blank - this rule alerts on TLS versions below 1.2';
break;
case 'ping_failed':
label.textContent = 'Threshold (optional)';
help.textContent = 'Leave blank - this rule alerts when a host fails to respond to ping';
break;
default:
label.textContent = 'Threshold';
help.textContent = 'Numeric value that triggers the alert (select a rule type for specific guidance)';
}
}
function saveRule() {
const name = document.getElementById('rule-name').value;
const ruleType = document.getElementById('rule-type').value;
const severity = document.getElementById('rule-severity').value;
const threshold = document.getElementById('rule-threshold').value;
const configId = document.getElementById('rule-config').value;
const emailEnabled = document.getElementById('rule-email').checked;
const webhookEnabled = document.getElementById('rule-webhook').checked;
const enabled = document.getElementById('rule-enabled').checked;
if (!name || !ruleType) {
alert('Please fill in required fields');
return;
}
const data = {
name: name,
rule_type: ruleType,
severity: severity,
threshold: threshold ? parseInt(threshold) : null,
config_id: configId ? parseInt(configId) : null,
email_enabled: emailEnabled,
webhook_enabled: webhookEnabled,
enabled: enabled
};
// Show spinner
document.getElementById('save-rule-text').style.display = 'none';
document.getElementById('save-rule-spinner').style.display = 'inline-block';
const url = editingRuleId
? `/api/alerts/rules/${editingRuleId}`
: '/api/alerts/rules';
const method = editingRuleId ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('api_key') || ''
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
location.reload();
} else {
alert('Failed to save rule: ' + (data.message || 'Unknown error'));
// Hide spinner
document.getElementById('save-rule-text').style.display = 'inline';
document.getElementById('save-rule-spinner').style.display = 'none';
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to save rule');
// Hide spinner
document.getElementById('save-rule-text').style.display = 'inline';
document.getElementById('save-rule-spinner').style.display = 'none';
});
}
function toggleRule(ruleId, enabled) {
fetch(`/api/alerts/rules/${ruleId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('api_key') || ''
},
body: JSON.stringify({ enabled: enabled })
})
.then(response => response.json())
.then(data => {
if (data.status !== 'success') {
alert('Failed to update rule status');
// Revert checkbox
document.getElementById(`rule-enabled-${ruleId}`).checked = !enabled;
} else {
// Update label
const label = document.querySelector(`label[for="rule-enabled-${ruleId}"] span`);
if (enabled) {
label.className = 'text-success';
label.textContent = 'Active';
} else {
label.className = 'text-muted';
label.textContent = 'Inactive';
}
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to update rule status');
// Revert checkbox
document.getElementById(`rule-enabled-${ruleId}`).checked = !enabled;
});
}
function deleteRule(ruleId, ruleName) {
if (!confirm(`Delete alert rule "${ruleName}"? This cannot be undone.`)) {
return;
}
fetch(`/api/alerts/rules/${ruleId}`, {
method: 'DELETE',
headers: {
'X-API-Key': localStorage.getItem('api_key') || ''
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
location.reload();
} else {
alert('Failed to delete rule: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to delete rule');
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,303 @@
{% extends "base.html" %}
{% block title %}Alerts - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1>Alert History</h1>
<div>
<button class="btn btn-success me-2" onclick="acknowledgeAllAlerts()">
<i class="bi bi-check-all"></i> Ack All
</button>
<a href="{{ url_for('main.alert_rules') }}" class="btn btn-primary">
<i class="bi bi-gear"></i> Manage Alert Rules
</a>
</div>
</div>
</div>
<!-- Alert Statistics -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Total Alerts</h6>
<h3 class="mb-0">{{ pagination.total }}</h3>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Critical</h6>
<h3 class="mb-0 text-danger">
{{ alerts | selectattr('severity', 'equalto', 'critical') | list | length }}
</h3>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Warnings</h6>
<h3 class="mb-0 text-warning">
{{ alerts | selectattr('severity', 'equalto', 'warning') | list | length }}
</h3>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Unacknowledged</h6>
<h3 class="mb-0 text-warning">
{{ alerts | rejectattr('acknowledged') | list | length }}
</h3>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<form method="get" action="{{ url_for('main.alerts') }}" class="row g-3">
<div class="col-md-3">
<label for="severity-filter" class="form-label">Severity</label>
<select class="form-select" id="severity-filter" name="severity">
<option value="">All Severities</option>
<option value="critical" {% if current_severity == 'critical' %}selected{% endif %}>Critical</option>
<option value="warning" {% if current_severity == 'warning' %}selected{% endif %}>Warning</option>
<option value="info" {% if current_severity == 'info' %}selected{% endif %}>Info</option>
</select>
</div>
<div class="col-md-3">
<label for="type-filter" class="form-label">Alert Type</label>
<select class="form-select" id="type-filter" name="alert_type">
<option value="">All Types</option>
{% for at in alert_types %}
<option value="{{ at }}" {% if current_alert_type == at %}selected{% endif %}>
{{ at.replace('_', ' ').title() }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="ack-filter" class="form-label">Acknowledgment</label>
<select class="form-select" id="ack-filter" name="acknowledged">
<option value="">All</option>
<option value="false" {% if current_acknowledged == 'false' %}selected{% endif %}>Unacknowledged</option>
<option value="true" {% if current_acknowledged == 'true' %}selected{% endif %}>Acknowledged</option>
</select>
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-funnel"></i> Apply Filters
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Alerts Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Alerts</h5>
</div>
<div class="card-body">
{% if alerts %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 100px;">Severity</th>
<th>Type</th>
<th>Message</th>
<th style="width: 120px;">Target</th>
<th style="width: 150px;">Scan</th>
<th style="width: 150px;">Created</th>
<th style="width: 100px;">Status</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
{% for alert in alerts %}
<tr>
<td>
{% if alert.severity == 'critical' %}
<span class="badge bg-danger">Critical</span>
{% elif alert.severity == 'warning' %}
<span class="badge bg-warning">Warning</span>
{% else %}
<span class="badge bg-info">Info</span>
{% endif %}
</td>
<td>
<span class="text-muted">{{ alert.alert_type.replace('_', ' ').title() }}</span>
</td>
<td>
{{ alert.message }}
</td>
<td>
{% if alert.ip_address %}
<small class="text-muted">
{{ alert.ip_address }}{% if alert.port %}:{{ alert.port }}{% endif %}
</small>
{% else %}
<small class="text-muted">-</small>
{% endif %}
</td>
<td>
<a href="{{ url_for('main.scan_detail', scan_id=alert.scan_id) }}" class="text-decoration-none">
Scan #{{ alert.scan_id }}
</a>
</td>
<td>
<small class="text-muted">{{ alert.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
</td>
<td>
{% if alert.acknowledged %}
<span class="badge bg-success">
<i class="bi bi-check-circle"></i> Ack'd
</span>
{% else %}
<span class="badge bg-secondary">New</span>
{% endif %}
{% if alert.email_sent %}
<i class="bi bi-envelope-fill text-muted" title="Email sent"></i>
{% endif %}
{% if alert.webhook_sent %}
<i class="bi bi-send-fill text-muted" title="Webhook sent"></i>
{% endif %}
</td>
<td>
{% if not alert.acknowledged %}
<button class="btn btn-sm btn-outline-success" onclick="acknowledgeAlert({{ alert.id }})">
<i class="bi bi-check"></i> Ack
</button>
{% else %}
<small class="text-muted" title="Acknowledged by {{ alert.acknowledged_by }} at {{ alert.acknowledged_at.strftime('%Y-%m-%d %H:%M') }}">
By: {{ alert.acknowledged_by }}
</small>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<nav aria-label="Alert pagination" class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('main.alerts', page=pagination.prev_num, severity=current_severity, alert_type=current_alert_type, acknowledged=current_acknowledged) }}">
Previous
</a>
</li>
{% for page_num in pagination.iter_pages(left_edge=1, left_current=1, right_current=2, right_edge=1) %}
{% if page_num %}
<li class="page-item {% if page_num == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('main.alerts', page=page_num, severity=current_severity, alert_type=current_alert_type, acknowledged=current_acknowledged) }}">
{{ page_num }}
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('main.alerts', page=pagination.next_num, severity=current_severity, alert_type=current_alert_type, acknowledged=current_acknowledged) }}">
Next
</a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-bell-slash" style="font-size: 3rem;"></i>
<h5 class="mt-3">No alerts found</h5>
<p>Alerts will appear here when scan results trigger alert rules.</p>
<a href="{{ url_for('main.alert_rules') }}" class="btn btn-primary mt-3">
<i class="bi bi-plus-circle"></i> Configure Alert Rules
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<script>
function acknowledgeAlert(alertId) {
if (!confirm('Acknowledge this alert?')) {
return;
}
fetch(`/api/alerts/${alertId}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('api_key') || ''
},
body: JSON.stringify({
acknowledged_by: 'web_user'
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
location.reload();
} else {
alert('Failed to acknowledge alert: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to acknowledge alert');
});
}
function acknowledgeAllAlerts() {
if (!confirm('Acknowledge all unacknowledged alerts?')) {
return;
}
fetch('/api/alerts/acknowledge-all', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('api_key') || ''
},
body: JSON.stringify({
acknowledged_by: 'web_user'
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
location.reload();
} else {
alert('Failed to acknowledge alerts: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to acknowledge alerts');
});
}
</script>
{% endblock %}

129
app/web/templates/base.html Normal file
View File

@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<!-- Custom CSS (extracted from inline) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<!-- Chart.js for visualizations -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- Chart.js Dark Theme Configuration -->
<script>
// Configure Chart.js defaults for dark theme
if (typeof Chart !== 'undefined') {
Chart.defaults.color = '#e2e8f0';
Chart.defaults.borderColor = '#334155';
Chart.defaults.backgroundColor = '#1e293b';
}
</script>
{% block extra_styles %}{% endblock %}
</head>
<body>
{% if not hide_nav %}
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
{{ app_name }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.dashboard' %}active{% endif %}"
href="{{ url_for('main.dashboard') }}">Dashboard</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if request.endpoint and ('config' in request.endpoint or request.endpoint == 'main.sites') %}active{% endif %}"
href="#" id="configsDropdown" role="button" data-bs-toggle="dropdown">
Configs
</a>
<ul class="dropdown-menu" aria-labelledby="configsDropdown">
<li><a class="dropdown-item" href="{{ url_for('main.configs') }}">Scan Configs</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.sites') }}">Sites</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.scans' %}active{% endif %}"
href="{{ url_for('main.scans') }}">Scans</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}"
href="{{ url_for('main.schedules') }}">Schedules</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if request.endpoint and ('alert' in request.endpoint or 'webhook' in request.endpoint) %}active{% endif %}"
href="#" id="alertsDropdown" role="button" data-bs-toggle="dropdown">
Alerts
</a>
<ul class="dropdown-menu" aria-labelledby="alertsDropdown">
<li><a class="dropdown-item" href="{{ url_for('main.alerts') }}">Alert History</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.alert_rules') }}">Alert Rules</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('webhooks.list_webhooks') }}">Webhooks</a></li>
</ul>
</li>
</ul>
<form class="d-flex me-3" action="{{ url_for('main.search_ip') }}" method="GET">
<input class="form-control form-control-sm me-2" type="search" name="ip"
placeholder="Search IP..." aria-label="Search IP" style="width: 150px;">
<button class="btn btn-outline-primary btn-sm" type="submit">
<i class="bi bi-search"></i>
</button>
</form>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.help' %}active{% endif %}"
href="{{ url_for('main.help') }}">
<i class="bi bi-question-circle"></i> Help
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<div class="container-fluid">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show mt-3" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<div class="footer">
<div class="container-fluid">
<a href="{{ repo_url }}" target="_blank">{{ app_name }}</a> - v{{ app_version }}
</div>
</div>
<!-- Global notification container - always above modals -->
<div id="notification-container" style="position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 300px;"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,580 @@
{% extends "base.html" %}
{% block title %}Scan Configurations - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Scan Configurations</h1>
<div>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createConfigModal">
<i class="bi bi-plus-circle"></i> Create New Config
</button>
</div>
</div>
</div>
</div>
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="total-configs">-</div>
<div class="stat-label">Total Configs</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="total-sites-used">-</div>
<div class="stat-label">Total Sites Referenced</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="recent-updates">-</div>
<div class="stat-label">Updated This Week</div>
</div>
</div>
</div>
<!-- Configs Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">All Configurations</h5>
<input type="text" id="search-input" class="form-control" style="max-width: 300px;"
placeholder="Search configs...">
</div>
</div>
<div class="card-body">
<div id="configs-loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading configurations...</p>
</div>
<div id="configs-error" style="display: none;" class="alert alert-danger">
<strong>Error:</strong> <span id="error-message"></span>
</div>
<div id="configs-content" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Title</th>
<th>Description</th>
<th>Sites</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="configs-tbody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
<div id="empty-state" style="display: none;" class="text-center py-5">
<i class="bi bi-gear" style="font-size: 3rem; color: #64748b;"></i>
<h5 class="mt-3 text-muted">No configurations defined</h5>
<p class="text-muted">Create your first scan configuration</p>
<button class="btn btn-primary mt-2" data-bs-toggle="modal" data-bs-target="#createConfigModal">
<i class="bi bi-plus-circle"></i> Create Config
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create Config Modal -->
<div class="modal fade" id="createConfigModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-plus-circle me-2"></i>Create New Configuration
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="create-config-form">
<div class="mb-3">
<label for="config-title" class="form-label">Title <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="config-title" required
placeholder="e.g., Production Weekly Scan">
</div>
<div class="mb-3">
<label for="config-description" class="form-label">Description</label>
<textarea class="form-control" id="config-description" rows="3"
placeholder="Optional description of this configuration"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Sites <span class="text-danger">*</span></label>
<div id="sites-loading-modal" class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<span class="ms-2 text-muted">Loading available sites...</span>
</div>
<div id="sites-list" style="display: none;">
<!-- Populated by JavaScript -->
</div>
<small class="form-text text-muted">Select at least one site to include in this configuration</small>
</div>
<div class="alert alert-danger" id="create-config-error" style="display: none;">
<span id="create-config-error-message"></span>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="create-config-btn">
<i class="bi bi-check-circle me-1"></i>Create Configuration
</button>
</div>
</div>
</div>
</div>
<!-- Edit Config Modal -->
<div class="modal fade" id="editConfigModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-pencil me-2"></i>Edit Configuration
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="edit-config-form">
<input type="hidden" id="edit-config-id">
<div class="mb-3">
<label for="edit-config-title" class="form-label">Title <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="edit-config-title" required>
</div>
<div class="mb-3">
<label for="edit-config-description" class="form-label">Description</label>
<textarea class="form-control" id="edit-config-description" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Sites <span class="text-danger">*</span></label>
<div id="edit-sites-list">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="alert alert-danger" id="edit-config-error" style="display: none;">
<span id="edit-config-error-message"></span>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="edit-config-btn">
<i class="bi bi-check-circle me-1"></i>Save Changes
</button>
</div>
</div>
</div>
</div>
<!-- View Config Modal -->
<div class="modal fade" id="viewConfigModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-eye me-2"></i>Configuration Details
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="view-config-content">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteConfigModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title text-danger">
<i class="bi bi-trash me-2"></i>Delete Configuration
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete configuration <strong id="delete-config-name"></strong>?</p>
<p class="text-warning"><i class="bi bi-exclamation-triangle"></i> This action cannot be undone.</p>
<input type="hidden" id="delete-config-id">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-delete-btn">
<i class="bi bi-trash me-1"></i>Delete
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Global state
let allConfigs = [];
let allSites = [];
// Load data on page load
document.addEventListener('DOMContentLoaded', function() {
loadSites();
loadConfigs();
});
// Load all sites
async function loadSites() {
try {
const response = await fetch('/api/sites?all=true');
if (!response.ok) throw new Error('Failed to load sites');
const data = await response.json();
allSites = data.sites || [];
renderSitesCheckboxes();
} catch (error) {
console.error('Error loading sites:', error);
document.getElementById('sites-loading-modal').innerHTML =
'<div class="alert alert-danger">Failed to load sites</div>';
}
}
// Render sites checkboxes
function renderSitesCheckboxes(selectedIds = [], isEditMode = false) {
const container = isEditMode ? document.getElementById('edit-sites-list') : document.getElementById('sites-list');
if (!container) return;
if (allSites.length === 0) {
const message = '<div class="alert alert-info">No sites available. <a href="/sites">Create a site first</a>.</div>';
container.innerHTML = message;
if (!isEditMode) {
document.getElementById('sites-loading-modal').style.display = 'none';
container.style.display = 'block';
}
return;
}
const prefix = isEditMode ? 'edit-site' : 'site';
const checkboxClass = isEditMode ? 'edit-site-checkbox' : 'site-checkbox';
let html = '<div style="max-height: 300px; overflow-y: auto;">';
allSites.forEach(site => {
const isChecked = selectedIds.includes(site.id);
html += `
<div class="form-check">
<input class="form-check-input ${checkboxClass}" type="checkbox" value="${site.id}"
id="${prefix}-${site.id}" ${isChecked ? 'checked' : ''}>
<label class="form-check-label" for="${prefix}-${site.id}">
${escapeHtml(site.name)}
<small class="text-muted">(${site.ip_count || 0} IP${site.ip_count !== 1 ? 's' : ''})</small>
</label>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
if (!isEditMode) {
document.getElementById('sites-loading-modal').style.display = 'none';
container.style.display = 'block';
}
}
// Load all configs
async function loadConfigs() {
try {
const response = await fetch('/api/configs');
if (!response.ok) throw new Error('Failed to load configs');
const data = await response.json();
allConfigs = data.configs || [];
renderConfigs();
updateStats();
document.getElementById('configs-loading').style.display = 'none';
document.getElementById('configs-content').style.display = 'block';
} catch (error) {
console.error('Error loading configs:', error);
document.getElementById('configs-loading').style.display = 'none';
document.getElementById('configs-error').style.display = 'block';
document.getElementById('error-message').textContent = error.message;
}
}
// Render configs table
function renderConfigs(filter = '') {
const tbody = document.getElementById('configs-tbody');
const emptyState = document.getElementById('empty-state');
const filteredConfigs = filter
? allConfigs.filter(c =>
c.title.toLowerCase().includes(filter.toLowerCase()) ||
(c.description && c.description.toLowerCase().includes(filter.toLowerCase()))
)
: allConfigs;
if (filteredConfigs.length === 0) {
tbody.innerHTML = '';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
tbody.innerHTML = filteredConfigs.map(config => `
<tr>
<td><strong>${escapeHtml(config.title)}</strong></td>
<td>${config.description ? escapeHtml(config.description) : '<span class="text-muted">-</span>'}</td>
<td>
<span class="badge bg-primary">${config.site_count} site${config.site_count !== 1 ? 's' : ''}</span>
</td>
<td>${formatDate(config.updated_at)}</td>
<td>
<button class="btn btn-sm btn-info" onclick="viewConfig(${config.id})" title="View">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-sm btn-warning" onclick="editConfig(${config.id})" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteConfig(${config.id}, '${escapeHtml(config.title).replace(/'/g, "\\'")}');" title="Delete">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
// Update stats
function updateStats() {
document.getElementById('total-configs').textContent = allConfigs.length;
const uniqueSites = new Set();
allConfigs.forEach(c => c.sites.forEach(s => uniqueSites.add(s.id)));
document.getElementById('total-sites-used').textContent = uniqueSites.size;
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
const recentUpdates = allConfigs.filter(c => new Date(c.updated_at) > oneWeekAgo).length;
document.getElementById('recent-updates').textContent = recentUpdates;
}
// Search functionality
document.getElementById('search-input').addEventListener('input', function(e) {
renderConfigs(e.target.value);
});
// Create config
document.getElementById('create-config-btn').addEventListener('click', async function() {
const title = document.getElementById('config-title').value.trim();
const description = document.getElementById('config-description').value.trim();
const siteCheckboxes = document.querySelectorAll('.site-checkbox:checked');
const siteIds = Array.from(siteCheckboxes).map(cb => parseInt(cb.value));
if (!title) {
showError('create-config-error', 'Title is required');
return;
}
if (siteIds.length === 0) {
showError('create-config-error', 'At least one site must be selected');
return;
}
try {
const response = await fetch('/api/configs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: description || null, site_ids: siteIds })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to create config');
}
// Close modal and reload
bootstrap.Modal.getInstance(document.getElementById('createConfigModal')).hide();
document.getElementById('create-config-form').reset();
renderSitesCheckboxes(); // Reset checkboxes
await loadConfigs();
} catch (error) {
showError('create-config-error', error.message);
}
});
// View config
async function viewConfig(id) {
try {
const response = await fetch(`/api/configs/${id}`);
if (!response.ok) throw new Error('Failed to load config');
const config = await response.json();
let html = `
<div class="mb-3">
<strong>Title:</strong> ${escapeHtml(config.title)}
</div>
<div class="mb-3">
<strong>Description:</strong> ${config.description ? escapeHtml(config.description) : '<span class="text-muted">None</span>'}
</div>
<div class="mb-3">
<strong>Sites (${config.site_count}):</strong>
<ul class="mt-2">
${config.sites.map(site => `
<li>${escapeHtml(site.name)} <small class="text-muted">(${site.ip_count} IP${site.ip_count !== 1 ? 's' : ''})</small></li>
`).join('')}
</ul>
</div>
<div class="mb-3">
<strong>Created:</strong> ${formatDate(config.created_at)}
</div>
<div class="mb-3">
<strong>Last Updated:</strong> ${formatDate(config.updated_at)}
</div>
`;
document.getElementById('view-config-content').innerHTML = html;
new bootstrap.Modal(document.getElementById('viewConfigModal')).show();
} catch (error) {
alert('Error loading config: ' + error.message);
}
}
// Edit config
async function editConfig(id) {
try {
const response = await fetch(`/api/configs/${id}`);
if (!response.ok) throw new Error('Failed to load config');
const config = await response.json();
document.getElementById('edit-config-id').value = config.id;
document.getElementById('edit-config-title').value = config.title;
document.getElementById('edit-config-description').value = config.description || '';
const selectedIds = config.sites.map(s => s.id);
renderSitesCheckboxes(selectedIds, true); // true = isEditMode
new bootstrap.Modal(document.getElementById('editConfigModal')).show();
} catch (error) {
alert('Error loading config: ' + error.message);
}
}
// Save edited config
document.getElementById('edit-config-btn').addEventListener('click', async function() {
const id = document.getElementById('edit-config-id').value;
const title = document.getElementById('edit-config-title').value.trim();
const description = document.getElementById('edit-config-description').value.trim();
const siteCheckboxes = document.querySelectorAll('.edit-site-checkbox:checked');
const siteIds = Array.from(siteCheckboxes).map(cb => parseInt(cb.value));
if (!title) {
showError('edit-config-error', 'Title is required');
return;
}
if (siteIds.length === 0) {
showError('edit-config-error', 'At least one site must be selected');
return;
}
try {
const response = await fetch(`/api/configs/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: description || null, site_ids: siteIds })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to update config');
}
// Close modal and reload
bootstrap.Modal.getInstance(document.getElementById('editConfigModal')).hide();
await loadConfigs();
} catch (error) {
showError('edit-config-error', error.message);
}
});
// Delete config
function deleteConfig(id, name) {
document.getElementById('delete-config-id').value = id;
document.getElementById('delete-config-name').textContent = name;
new bootstrap.Modal(document.getElementById('deleteConfigModal')).show();
}
// Confirm delete
document.getElementById('confirm-delete-btn').addEventListener('click', async function() {
const id = document.getElementById('delete-config-id').value;
try {
const response = await fetch(`/api/configs/${id}`, { method: 'DELETE' });
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to delete config');
}
// Close modal and reload
bootstrap.Modal.getInstance(document.getElementById('deleteConfigModal')).hide();
await loadConfigs();
} catch (error) {
alert('Error deleting config: ' + error.message);
}
});
// Utility functions
function showError(elementId, message) {
const errorEl = document.getElementById(elementId);
const messageEl = document.getElementById(elementId + '-message');
messageEl.textContent = message;
errorEl.style.display = 'block';
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,626 @@
{% extends "base.html" %}
{% block title %}Dashboard - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<h1 class="mb-4">Dashboard</h1>
</div>
</div>
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-scans">-</div>
<div class="stat-label">Total Scans</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="running-scans">-</div>
<div class="stat-label">Running</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="completed-scans">-</div>
<div class="stat-label">Completed</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="failed-scans">-</div>
<div class="stat-label">Failed</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Quick Actions</h5>
</div>
<div class="card-body">
<button class="btn btn-primary btn-lg" onclick="showTriggerScanModal()">
<span id="trigger-btn-text">Run Scan Now</span>
<span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
<a href="{{ url_for('main.scans') }}" class="btn btn-secondary btn-lg ms-2">View All Scans</a>
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary btn-lg ms-2">
<i class="bi bi-calendar-plus"></i> Manage Schedules
</a>
</div>
</div>
</div>
</div>
<!-- Scan Activity Chart -->
<div class="row mb-4">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Scan Activity (Last 30 Days)</h5>
</div>
<div class="card-body">
<div id="chart-loading" class="text-center py-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<canvas id="scanTrendChart" height="100" style="display: none;"></canvas>
</div>
</div>
</div>
<!-- Schedules Widget -->
<div class="col-md-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Upcoming Schedules</h5>
<a href="{{ url_for('main.schedules') }}" class="btn btn-sm btn-secondary">Manage</a>
</div>
<div class="card-body">
<div id="schedules-loading" class="text-center py-4">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div id="schedules-content" style="display: none;"></div>
<div id="schedules-empty" class="text-muted text-center py-4" style="display: none;">
No schedules configured yet.
<br><br>
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-sm btn-primary">Create Schedule</a>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Scans -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Recent Scans</h5>
<button class="btn btn-sm btn-secondary" onclick="refreshScans()">
<span id="refresh-text">Refresh</span>
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
</button>
</div>
<div class="card-body">
<div id="scans-loading" class="text-center py-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div id="scans-error" class="alert alert-danger" style="display: none;"></div>
<div id="scans-empty" class="text-center py-4 text-muted" style="display: none;">
No scans found. Click "Run Scan Now" to trigger your first scan.
</div>
<div id="scans-table-container" style="display: none;">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Timestamp</th>
<th>Duration</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="scans-tbody">
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Trigger Scan Modal -->
<div class="modal fade" id="triggerScanModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Trigger New Scan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="trigger-scan-form">
<div class="mb-3">
<label for="config-select" class="form-label">Scan Configuration</label>
<select class="form-select" id="config-select" name="config_id" required>
<option value="">Loading configurations...</option>
</select>
<div class="form-text text-muted" id="config-help-text">
Select a scan configuration
</div>
<div id="no-configs-warning" class="alert alert-warning mt-2 mb-0" role="alert" style="display: none;">
<i class="bi bi-exclamation-triangle"></i>
<strong>No configurations available</strong>
<p class="mb-2 mt-2">You need to create a configuration before you can trigger a scan.</p>
<a href="{{ url_for('main.configs') }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle"></i> Create Configuration
</a>
</div>
</div>
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()">
<span id="modal-trigger-text">Trigger Scan</span>
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let refreshInterval = null;
// Load initial data when page loads
document.addEventListener('DOMContentLoaded', function() {
refreshScans();
loadStats();
loadScanTrend();
loadSchedules();
// Auto-refresh every 10 seconds if there are running scans
refreshInterval = setInterval(function() {
const runningCount = parseInt(document.getElementById('running-scans').textContent);
if (runningCount > 0) {
refreshScans();
loadStats();
}
}, 10000);
// Refresh schedules every 30 seconds
setInterval(loadSchedules, 30000);
});
// Load dashboard stats
async function loadStats() {
try {
const response = await fetch('/api/scans?per_page=1000');
if (!response.ok) {
throw new Error('Failed to load stats');
}
const data = await response.json();
const scans = data.scans || [];
document.getElementById('total-scans').textContent = scans.length;
document.getElementById('running-scans').textContent = scans.filter(s => s.status === 'running').length;
document.getElementById('completed-scans').textContent = scans.filter(s => s.status === 'completed').length;
document.getElementById('failed-scans').textContent = scans.filter(s => s.status === 'failed').length;
} catch (error) {
console.error('Error loading stats:', error);
}
}
// Refresh scans list
async function refreshScans() {
const loadingEl = document.getElementById('scans-loading');
const errorEl = document.getElementById('scans-error');
const emptyEl = document.getElementById('scans-empty');
const tableEl = document.getElementById('scans-table-container');
const refreshBtn = document.getElementById('refresh-text');
const refreshSpinner = document.getElementById('refresh-spinner');
// Show loading state
loadingEl.style.display = 'block';
errorEl.style.display = 'none';
emptyEl.style.display = 'none';
tableEl.style.display = 'none';
refreshBtn.style.display = 'none';
refreshSpinner.style.display = 'inline-block';
try {
const response = await fetch('/api/scans?per_page=10&page=1');
if (!response.ok) {
throw new Error('Failed to load scans');
}
const data = await response.json();
const scans = data.scans || [];
loadingEl.style.display = 'none';
refreshBtn.style.display = 'inline';
refreshSpinner.style.display = 'none';
if (scans.length === 0) {
emptyEl.style.display = 'block';
} else {
tableEl.style.display = 'block';
renderScansTable(scans);
}
} catch (error) {
console.error('Error loading scans:', error);
loadingEl.style.display = 'none';
refreshBtn.style.display = 'inline';
refreshSpinner.style.display = 'none';
errorEl.textContent = 'Failed to load scans. Please try again.';
errorEl.style.display = 'block';
}
}
// Render scans table
function renderScansTable(scans) {
const tbody = document.getElementById('scans-tbody');
tbody.innerHTML = '';
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();
// Format duration
const duration = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
// Status badge
let statusBadge = '';
if (scan.status === 'completed') {
statusBadge = '<span class="badge badge-success">Completed</span>';
} else if (scan.status === 'running') {
statusBadge = '<span class="badge badge-info">Running</span>';
} else if (scan.status === 'failed') {
statusBadge = '<span class="badge badge-danger">Failed</span>';
} else {
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
}
row.innerHTML = `
<td class="mono">${scan.id}</td>
<td>${scan.title || 'Untitled Scan'}</td>
<td class="text-muted">${timestamp}</td>
<td class="mono">${duration}</td>
<td>${statusBadge}</td>
<td>
<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>
${scan.status !== 'running' ? `<button class="btn btn-sm btn-danger ms-1" onclick="deleteScan(${scan.id})">Delete</button>` : ''}
</td>
`;
tbody.appendChild(row);
});
}
// Load available configs
async function loadConfigs() {
const selectEl = document.getElementById('config-select');
const helpTextEl = document.getElementById('config-help-text');
const noConfigsWarning = document.getElementById('no-configs-warning');
const triggerBtn = document.getElementById('trigger-scan-btn');
try {
const response = await fetch('/api/configs');
if (!response.ok) {
throw new Error('Failed to load configurations');
}
const data = await response.json();
const configs = data.configs || [];
// Clear existing options
selectEl.innerHTML = '';
if (configs.length === 0) {
selectEl.innerHTML = '<option value="">No configurations available</option>';
selectEl.disabled = true;
triggerBtn.disabled = true;
helpTextEl.style.display = 'none';
noConfigsWarning.style.display = 'block';
} else {
selectEl.innerHTML = '<option value="">Select a configuration...</option>';
configs.forEach(config => {
const option = document.createElement('option');
option.value = config.id;
const siteText = config.site_count === 1 ? 'site' : 'sites';
option.textContent = `${config.title} (${config.site_count} ${siteText})`;
selectEl.appendChild(option);
});
selectEl.disabled = false;
triggerBtn.disabled = false;
helpTextEl.style.display = 'block';
noConfigsWarning.style.display = 'none';
}
} catch (error) {
console.error('Error loading configs:', error);
selectEl.innerHTML = '<option value="">Error loading configurations</option>';
selectEl.disabled = true;
triggerBtn.disabled = true;
helpTextEl.style.display = 'none';
}
}
// Show trigger scan modal
function showTriggerScanModal() {
const modal = new bootstrap.Modal(document.getElementById('triggerScanModal'));
document.getElementById('trigger-error').style.display = 'none';
document.getElementById('trigger-scan-form').reset();
// Load configs when modal is shown
loadConfigs();
modal.show();
}
// Trigger scan
async function triggerScan() {
const configId = document.getElementById('config-select').value;
const errorEl = document.getElementById('trigger-error');
const btnText = document.getElementById('modal-trigger-text');
const btnSpinner = document.getElementById('modal-trigger-spinner');
if (!configId) {
errorEl.textContent = 'Please select a configuration.';
errorEl.style.display = 'block';
return;
}
// Show loading state
btnText.style.display = 'none';
btnSpinner.style.display = 'inline-block';
errorEl.style.display = 'none';
try {
const response = await fetch('/api/scans', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config_id: parseInt(configId)
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || data.error || 'Failed to trigger scan');
}
const data = await response.json();
// Hide error before closing modal to prevent flash
errorEl.style.display = 'none';
// Close modal
bootstrap.Modal.getInstance(document.getElementById('triggerScanModal')).hide();
// Show success message
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3';
alertDiv.innerHTML = `
Scan triggered successfully! (ID: ${data.scan_id})
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
// Insert at the beginning of container-fluid
const container = document.querySelector('.container-fluid');
container.insertBefore(alertDiv, container.firstChild);
// Refresh scans and stats
refreshScans();
loadStats();
} catch (error) {
console.error('Error triggering scan:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
} finally {
btnText.style.display = 'inline';
btnSpinner.style.display = 'none';
}
}
// Load scan trend chart
async function loadScanTrend() {
const chartLoading = document.getElementById('chart-loading');
const canvas = document.getElementById('scanTrendChart');
try {
const response = await fetch('/api/stats/scan-trend?days=30');
if (!response.ok) {
throw new Error('Failed to load trend data');
}
const data = await response.json();
// Hide loading, show chart
chartLoading.style.display = 'none';
canvas.style.display = 'block';
// Create chart
const ctx = canvas.getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Scans per Day',
data: data.values,
borderColor: '#60a5fa',
backgroundColor: 'rgba(96, 165, 250, 0.1)',
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: function(context) {
return new Date(context[0].label).toLocaleDateString();
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
color: '#94a3b8'
},
grid: {
color: '#334155'
}
},
x: {
ticks: {
color: '#94a3b8',
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 10
},
grid: {
color: '#334155'
}
}
}
}
});
} catch (error) {
console.error('Error loading chart:', error);
chartLoading.innerHTML = '<p class="text-muted">Failed to load chart data</p>';
}
}
// Load upcoming schedules
async function loadSchedules() {
const loadingEl = document.getElementById('schedules-loading');
const contentEl = document.getElementById('schedules-content');
const emptyEl = document.getElementById('schedules-empty');
try {
const response = await fetch('/api/schedules?per_page=5');
if (!response.ok) {
throw new Error('Failed to load schedules');
}
const data = await response.json();
const schedules = data.schedules || [];
loadingEl.style.display = 'none';
if (schedules.length === 0) {
emptyEl.style.display = 'block';
} else {
contentEl.style.display = 'block';
// Filter enabled schedules and sort by next_run
const enabledSchedules = schedules
.filter(s => s.enabled && s.next_run)
.sort((a, b) => new Date(a.next_run) - new Date(b.next_run))
.slice(0, 3);
if (enabledSchedules.length === 0) {
contentEl.innerHTML = '<p class="text-muted">No enabled schedules</p>';
} else {
contentEl.innerHTML = enabledSchedules.map(schedule => {
const nextRun = new Date(schedule.next_run);
const now = new Date();
const diffMs = nextRun - now;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
let timeStr;
if (diffMins < 1) {
timeStr = 'In less than 1 minute';
} else if (diffMins < 60) {
timeStr = `In ${diffMins} minute${diffMins === 1 ? '' : 's'}`;
} else if (diffHours < 24) {
timeStr = `In ${diffHours} hour${diffHours === 1 ? '' : 's'}`;
} else if (diffDays < 7) {
timeStr = `In ${diffDays} day${diffDays === 1 ? '' : 's'}`;
} else {
timeStr = nextRun.toLocaleDateString();
}
return `
<div class="mb-3 pb-3 border-bottom">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${schedule.name}</strong>
<br>
<small class="text-muted">${timeStr}</small>
<br>
<small class="text-muted mono">${schedule.cron_expression}</small>
</div>
</div>
</div>
`;
}).join('');
}
}
} catch (error) {
console.error('Error loading schedules:', error);
loadingEl.style.display = 'none';
contentEl.style.display = 'block';
contentEl.innerHTML = '<p class="text-muted">Failed to load schedules</p>';
}
}
// Delete scan
async function deleteScan(scanId) {
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
return;
}
try {
const response = await fetch(`/api/scans/${scanId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete scan');
}
// Refresh scans and stats
refreshScans();
loadStats();
} catch (error) {
console.error('Error deleting scan:', error);
alert('Failed to delete scan. Please try again.');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>400 - Bad Request | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #f59e0b;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">⚠️</div>
<div class="error-code">400</div>
<h1 class="error-title">Bad Request</h1>
<p class="error-message">
The request could not be understood or was missing required parameters.
<br>
Please check your input and try again.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>401 - Unauthorized | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #f59e0b;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">🔒</div>
<div class="error-code">401</div>
<h1 class="error-title">Unauthorized</h1>
<p class="error-message">
You need to be authenticated to access this page.
<br>
Please log in to continue.
</p>
<a href="/auth/login" class="btn btn-primary">Go to Login</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>403 - Forbidden | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #ef4444;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">🚫</div>
<div class="error-code">403</div>
<h1 class="error-title">Forbidden</h1>
<p class="error-message">
You don't have permission to access this resource.
<br>
If you think this is an error, please contact the administrator.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Page Not Found | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #60a5fa;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(96, 165, 250, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">🔍</div>
<div class="error-code">404</div>
<h1 class="error-title">Page Not Found</h1>
<p class="error-message">
The page you're looking for doesn't exist or has been moved.
<br>
Let's get you back on track.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>405 - Method Not Allowed | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #f59e0b;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">🚧</div>
<div class="error-code">405</div>
<h1 class="error-title">Method Not Allowed</h1>
<p class="error-message">
The HTTP method used is not allowed for this endpoint.
<br>
Please check the API documentation for valid methods.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 - Internal Server Error | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #ef4444;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.error-details {
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
padding: 1rem;
margin: 1.5rem 0;
text-align: left;
}
.error-details-title {
font-size: 0.9rem;
font-weight: 600;
color: #94a3b8;
margin-bottom: 0.5rem;
}
.error-details-text {
font-size: 0.85rem;
color: #64748b;
font-family: 'Courier New', monospace;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">⚠️</div>
<div class="error-code">500</div>
<h1 class="error-title">Internal Server Error</h1>
<p class="error-message">
Something went wrong on our end. We've logged the error and will look into it.
<br>
Please try again in a few moments.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
<div class="error-details">
<div class="error-details-title">Error Information:</div>
<div class="error-details-text">
An unexpected error occurred while processing your request. Our team has been notified and is working to resolve the issue.
</div>
</div>
</div>
</body>
</html>

375
app/web/templates/help.html Normal file
View File

@@ -0,0 +1,375 @@
{% extends "base.html" %}
{% block title %}Help - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-question-circle"></i> Help & Documentation</h1>
<p class="text-muted">Learn how to use SneakyScanner to manage your network scanning operations.</p>
</div>
</div>
<!-- Quick Navigation -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-compass"></i> Quick Navigation</h5>
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-md-3 col-6">
<a href="#getting-started" class="btn btn-outline-primary w-100">Getting Started</a>
</div>
<div class="col-md-3 col-6">
<a href="#sites" class="btn btn-outline-primary w-100">Sites</a>
</div>
<div class="col-md-3 col-6">
<a href="#scan-configs" class="btn btn-outline-primary w-100">Scan Configs</a>
</div>
<div class="col-md-3 col-6">
<a href="#running-scans" class="btn btn-outline-primary w-100">Running Scans</a>
</div>
<div class="col-md-3 col-6">
<a href="#scheduling" class="btn btn-outline-primary w-100">Scheduling</a>
</div>
<div class="col-md-3 col-6">
<a href="#comparisons" class="btn btn-outline-primary w-100">Comparisons</a>
</div>
<div class="col-md-3 col-6">
<a href="#alerts" class="btn btn-outline-primary w-100">Alerts</a>
</div>
<div class="col-md-3 col-6">
<a href="#webhooks" class="btn btn-outline-primary w-100">Webhooks</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Getting Started -->
<div class="row mb-4" id="getting-started">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-rocket-takeoff"></i> Getting Started</h5>
</div>
<div class="card-body">
<p>SneakyScanner helps you perform network vulnerability scans and track changes over time. Here's the typical workflow:</p>
<div class="alert alert-info">
<strong>Basic Workflow:</strong>
<ol class="mb-0 mt-2">
<li><strong>Create a Site</strong> - Define a logical grouping for your targets</li>
<li><strong>Add IPs</strong> - Add IP addresses or ranges to your site</li>
<li><strong>Create a Scan Config</strong> - Configure how scans should run using your site</li>
<li><strong>Run a Scan</strong> - Execute scans manually or on a schedule</li>
<li><strong>Review Results</strong> - Analyze findings and compare scans over time</li>
</ol>
</div>
</div>
</div>
</div>
</div>
<!-- Sites -->
<div class="row mb-4" id="sites">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-globe"></i> Creating Sites & Adding IPs</h5>
</div>
<div class="card-body">
<h6>What is a Site?</h6>
<p>A Site is a logical grouping of IP addresses that you want to scan together. For example, you might create separate sites for "Production Servers", "Development Environment", or "Office Network".</p>
<h6>Creating a Site</h6>
<ol>
<li>Navigate to <strong>Configs → Sites</strong> in the navigation menu</li>
<li>Click the <strong>Create Site</strong> button</li>
<li>Enter a descriptive name for your site</li>
<li>Optionally add a description to help identify the site's purpose</li>
<li>Click <strong>Create</strong> to save the site</li>
</ol>
<h6>Adding IP Addresses</h6>
<p>After creating a site, you need to add the IP addresses you want to scan:</p>
<ol>
<li>Find your site in the Sites list</li>
<li>Click the <strong>Manage IPs</strong> button (or the site name)</li>
<li>Click <strong>Add IP</strong></li>
<li>Enter the IP address or CIDR range (e.g., <code>192.168.1.1</code> or <code>192.168.1.0/24</code>)</li>
<li>Click <strong>Add</strong> to save</li>
</ol>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i> <strong>Note:</strong> You can add individual IPs or CIDR notation ranges. Large ranges will result in longer scan times.
</div>
</div>
</div>
</div>
</div>
<!-- Scan Configs -->
<div class="row mb-4" id="scan-configs">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-gear"></i> Creating Scan Configurations</h5>
</div>
<div class="card-body">
<h6>What is a Scan Config?</h6>
<p>A Scan Configuration defines how a scan should be performed. It links to a Site and specifies scanning parameters like ports to scan, timing options, and other settings.</p>
<h6>Creating a Scan Config</h6>
<ol>
<li>Navigate to <strong>Configs → Scan Configs</strong> in the navigation menu</li>
<li>Click the <strong>Create Config</strong> button</li>
<li>Enter a name for the configuration</li>
<li>Select the <strong>Site</strong> to associate with this config</li>
<li>Configure scan parameters:
<ul>
<li><strong>Ports</strong> - Specify ports to scan (e.g., <code>22,80,443</code> or <code>1-1000</code>)</li>
<li><strong>Timing</strong> - Set scan speed/aggressiveness</li>
<li><strong>Additional Options</strong> - Configure other nmap parameters as needed</li>
</ul>
</li>
<li>Click <strong>Create</strong> to save the configuration</li>
</ol>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> <strong>Tip:</strong> Create different configs for different purposes - a quick config for daily checks and a thorough config for weekly deep scans.
</div>
</div>
</div>
</div>
</div>
<!-- Running Scans -->
<div class="row mb-4" id="running-scans">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-play-circle"></i> Running Scans</h5>
</div>
<div class="card-body">
<h6>Starting a Manual Scan</h6>
<ol>
<li>Navigate to <strong>Scans</strong> in the navigation menu</li>
<li>Click the <strong>New Scan</strong> button</li>
<li>Select the <strong>Scan Config</strong> you want to use</li>
<li>Click <strong>Start Scan</strong></li>
</ol>
<h6>Monitoring Scan Progress</h6>
<p>While a scan is running:</p>
<ul>
<li>The scan will appear in the Scans list with a <span class="badge badge-warning">Running</span> status</li>
<li>You can view live progress by clicking on the scan</li>
<li>The Dashboard also shows active scans</li>
</ul>
<h6>Viewing Scan Results</h6>
<ol>
<li>Once complete, click on a scan in the Scans list</li>
<li>View discovered hosts, open ports, and services</li>
<li>Export results or compare with previous scans</li>
</ol>
</div>
</div>
</div>
</div>
<!-- Scheduling -->
<div class="row mb-4" id="scheduling">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-calendar-check"></i> Scheduling Scans</h5>
</div>
<div class="card-body">
<h6>Why Schedule Scans?</h6>
<p>Scheduled scans allow you to automatically run scans at regular intervals, ensuring continuous monitoring of your network without manual intervention.</p>
<h6>Creating a Schedule</h6>
<ol>
<li>Navigate to <strong>Schedules</strong> in the navigation menu</li>
<li>Click the <strong>Create Schedule</strong> button</li>
<li>Enter a name for the schedule</li>
<li>Select the <strong>Scan Config</strong> to use</li>
<li>Configure the schedule:
<ul>
<li><strong>Frequency</strong> - How often to run (daily, weekly, monthly, custom cron)</li>
<li><strong>Time</strong> - When to start the scan</li>
<li><strong>Days</strong> - Which days to run (for weekly schedules)</li>
</ul>
</li>
<li>Enable/disable the schedule as needed</li>
<li>Click <strong>Create</strong> to save</li>
</ol>
<h6>Managing Schedules</h6>
<ul>
<li><strong>Enable/Disable</strong> - Toggle schedules on or off without deleting them</li>
<li><strong>Edit</strong> - Modify the schedule timing or associated config</li>
<li><strong>Delete</strong> - Remove schedules you no longer need</li>
<li><strong>View History</strong> - See past runs triggered by the schedule</li>
</ul>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> <strong>Tip:</strong> Schedule comprehensive scans during off-peak hours to minimize network impact.
</div>
</div>
</div>
</div>
</div>
<!-- Scan Comparisons -->
<div class="row mb-4" id="comparisons">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-arrow-left-right"></i> Scan Comparisons</h5>
</div>
<div class="card-body">
<h6>Why Compare Scans?</h6>
<p>Comparing scans helps you identify changes in your network over time - new hosts, closed ports, new services, or potential security issues.</p>
<h6>Comparing Two Scans</h6>
<ol>
<li>Navigate to <strong>Scans</strong> in the navigation menu</li>
<li>Find the scan you want to use as the baseline</li>
<li>Click on the scan to view its details</li>
<li>Click the <strong>Compare</strong> button</li>
<li>Select another scan to compare against</li>
<li>Review the comparison results</li>
</ol>
<h6>Understanding Comparison Results</h6>
<p>The comparison view shows:</p>
<ul>
<li><span class="badge badge-success">New</span> - Hosts or ports that appear in the newer scan but not the older one</li>
<li><span class="badge badge-danger">Removed</span> - Hosts or ports that were in the older scan but not the newer one</li>
<li><span class="badge badge-warning">Changed</span> - Services or states that differ between scans</li>
<li><span class="badge badge-info">Unchanged</span> - Items that remain the same</li>
</ul>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i> <strong>Security Note:</strong> Pay close attention to unexpected new open ports or services - these could indicate unauthorized changes or potential compromises.
</div>
</div>
</div>
</div>
</div>
<!-- Alerts -->
<div class="row mb-4" id="alerts">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-bell"></i> Alerts & Alert Rules</h5>
</div>
<div class="card-body">
<h6>Understanding Alerts</h6>
<p>Alerts notify you when scan results match certain conditions you define. This helps you stay informed about important changes without manually reviewing every scan.</p>
<h6>Viewing Alert History</h6>
<ol>
<li>Navigate to <strong>Alerts → Alert History</strong></li>
<li>View all triggered alerts with timestamps and details</li>
<li>Filter alerts by severity, date, or type</li>
<li>Click on an alert to see full details and the associated scan</li>
</ol>
<h6>Creating Alert Rules</h6>
<ol>
<li>Navigate to <strong>Alerts → Alert Rules</strong></li>
<li>Click <strong>Create Rule</strong></li>
<li>Configure the rule:
<ul>
<li><strong>Name</strong> - A descriptive name for the rule</li>
<li><strong>Condition</strong> - What triggers the alert (e.g., new open port, new host, specific service detected)</li>
<li><strong>Severity</strong> - How critical is this alert (Info, Warning, Critical)</li>
<li><strong>Scope</strong> - Which sites or configs this rule applies to</li>
</ul>
</li>
<li>Enable the rule</li>
<li>Click <strong>Create</strong> to save</li>
</ol>
<h6>Common Alert Rule Examples</h6>
<ul>
<li><strong>New Host Detected</strong> - Alert when a previously unknown host appears</li>
<li><strong>New Open Port</strong> - Alert when a new port opens on any host</li>
<li><strong>Critical Port Open</strong> - Alert for specific high-risk ports (e.g., 23/Telnet, 3389/RDP)</li>
<li><strong>Service Change</strong> - Alert when a service version changes</li>
<li><strong>Host Offline</strong> - Alert when an expected host stops responding</li>
</ul>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> <strong>Tip:</strong> Start with a few important rules and refine them over time to avoid alert fatigue.
</div>
</div>
</div>
</div>
</div>
<!-- Webhooks -->
<div class="row mb-4" id="webhooks">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-broadcast"></i> Webhooks</h5>
</div>
<div class="card-body">
<h6>What are Webhooks?</h6>
<p>Webhooks allow SneakyScanner to send notifications to external services when events occur, such as scan completion or alert triggers. This enables integration with tools like Slack, Discord, Microsoft Teams, or custom systems.</p>
<h6>Creating a Webhook</h6>
<ol>
<li>Navigate to <strong>Alerts → Webhooks</strong></li>
<li>Click <strong>Create Webhook</strong></li>
<li>Configure the webhook:
<ul>
<li><strong>Name</strong> - A descriptive name</li>
<li><strong>URL</strong> - The endpoint to send notifications to</li>
<li><strong>Events</strong> - Which events trigger this webhook</li>
<li><strong>Secret</strong> - Optional secret for request signing</li>
</ul>
</li>
<li>Test the webhook to verify it works</li>
<li>Click <strong>Create</strong> to save</li>
</ol>
<h6>Webhook Events</h6>
<ul>
<li><strong>Scan Started</strong> - When a scan begins</li>
<li><strong>Scan Completed</strong> - When a scan finishes</li>
<li><strong>Scan Failed</strong> - When a scan encounters an error</li>
<li><strong>Alert Triggered</strong> - When an alert rule matches</li>
</ul>
<h6>Integration Examples</h6>
<ul>
<li><strong>Slack</strong> - Use a Slack Incoming Webhook URL</li>
<li><strong>Discord</strong> - Use a Discord Webhook URL</li>
<li><strong>Microsoft Teams</strong> - Use a Teams Incoming Webhook</li>
<li><strong>Custom API</strong> - Send to your own endpoint for custom processing</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Back to Top -->
<div class="row mb-4">
<div class="col-12 text-center">
<a href="#" class="btn btn-outline-secondary">
<i class="bi bi-arrow-up"></i> Back to Top
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,175 @@
{% extends "base.html" %}
{% block title %}Search Results for {{ ip_address }} - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1>
<i class="bi bi-search"></i>
Search Results
{% if ip_address %}
<small class="text-muted">for {{ ip_address }}</small>
{% endif %}
</h1>
<a href="{{ url_for('main.scans') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Scans
</a>
</div>
</div>
{% if not ip_address %}
<!-- No IP provided -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-exclamation-circle text-warning" style="font-size: 3rem;"></i>
<h4 class="mt-3">No IP Address Provided</h4>
<p class="text-muted">Please enter an IP address in the search box to find related scans.</p>
</div>
</div>
</div>
</div>
{% else %}
<!-- Results Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Last 10 Scans Containing {{ ip_address }}</h5>
</div>
<div class="card-body">
<div id="results-loading" class="text-center py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Searching for scans...</p>
</div>
<div id="results-error" class="alert alert-danger" style="display: none;"></div>
<div id="results-empty" class="text-center py-5 text-muted" style="display: none;">
<i class="bi bi-search" style="font-size: 3rem;"></i>
<h5 class="mt-3">No Scans Found</h5>
<p>No completed scans contain the IP address <strong>{{ ip_address }}</strong>.</p>
</div>
<div id="results-table-container" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 80px;">ID</th>
<th>Title</th>
<th style="width: 200px;">Timestamp</th>
<th style="width: 100px;">Duration</th>
<th style="width: 120px;">Status</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody id="results-tbody">
</tbody>
</table>
</div>
<div class="text-muted mt-3">
Found <span id="result-count">0</span> scan(s) containing this IP address.
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
const ipAddress = "{{ ip_address | e }}";
// Load results when page loads
document.addEventListener('DOMContentLoaded', function() {
if (ipAddress) {
loadResults();
}
});
// Load search results from API
async function loadResults() {
const loadingEl = document.getElementById('results-loading');
const errorEl = document.getElementById('results-error');
const emptyEl = document.getElementById('results-empty');
const tableEl = document.getElementById('results-table-container');
// Show loading state
loadingEl.style.display = 'block';
errorEl.style.display = 'none';
emptyEl.style.display = 'none';
tableEl.style.display = 'none';
try {
const response = await fetch(`/api/scans/by-ip/${encodeURIComponent(ipAddress)}`);
if (!response.ok) {
throw new Error('Failed to search for scans');
}
const data = await response.json();
const scans = data.scans || [];
loadingEl.style.display = 'none';
if (scans.length === 0) {
emptyEl.style.display = 'block';
} else {
tableEl.style.display = 'block';
renderResultsTable(scans);
document.getElementById('result-count').textContent = data.count;
}
} catch (error) {
console.error('Error searching for scans:', error);
loadingEl.style.display = 'none';
errorEl.textContent = 'Failed to search for scans. Please try again.';
errorEl.style.display = 'block';
}
}
// Render results table
function renderResultsTable(scans) {
const tbody = document.getElementById('results-tbody');
tbody.innerHTML = '';
scans.forEach(scan => {
const row = document.createElement('tr');
row.classList.add('scan-row');
// Format timestamp
const timestamp = new Date(scan.timestamp).toLocaleString();
// Format duration
const duration = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
// Status badge
let statusBadge = '';
if (scan.status === 'completed') {
statusBadge = '<span class="badge badge-success">Completed</span>';
} else if (scan.status === 'running') {
statusBadge = '<span class="badge badge-info">Running</span>';
} else if (scan.status === 'failed') {
statusBadge = '<span class="badge badge-danger">Failed</span>';
} else {
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
}
row.innerHTML = `
<td class="mono">${scan.id}</td>
<td>${scan.title || 'Untitled Scan'}</td>
<td class="text-muted">${timestamp}</td>
<td class="mono">${duration}</td>
<td>${statusBadge}</td>
<td>
<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>
</td>
`;
tbody.appendChild(row);
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Login - SneakyScanner{% endblock %}
{% set hide_nav = true %}
{% block content %}
<div class="login-card">
<div class="text-center mb-4">
<h1 class="brand-title">SneakyScanner</h1>
<p class="brand-subtitle">Network Security Scanner</p>
</div>
{% if password_not_set %}
<div class="alert alert-warning">
<strong>Setup Required:</strong> Please set an application password first.
<a href="{{ url_for('auth.setup') }}" class="alert-link">Go to Setup</a>
</div>
{% else %}
<form method="post" action="{{ url_for('auth.login') }}">
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password"
class="form-control form-control-lg"
id="password"
name="password"
required
autofocus
placeholder="Enter your password">
</div>
<div class="mb-3 form-check">
<input type="checkbox"
class="form-check-input"
id="remember"
name="remember">
<label class="form-check-label" for="remember">
Remember me
</label>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
Login
</button>
</form>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,545 @@
{% extends "base.html" %}
{% block title %}Compare Scans - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="{{ url_for('main.scans') }}" class="text-muted text-decoration-none mb-2 d-inline-block">
← Back to All Scans
</a>
<h1 style="color: #60a5fa;">Scan Comparison</h1>
<p class="text-muted">Comparing Scan #{{ scan_id1 }} vs Scan #{{ scan_id2 }}</p>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div id="comparison-loading" class="text-center py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading comparison...</p>
</div>
<!-- Error State -->
<div id="comparison-error" class="alert alert-danger" style="display: none;"></div>
<!-- Config Warning -->
<div id="config-warning" class="alert alert-warning" style="display: none;">
<i class="bi bi-exclamation-triangle"></i>
<strong>Different Configurations Detected</strong>
<p class="mb-0" id="config-warning-message"></p>
</div>
<!-- Comparison Content -->
<div id="comparison-content" style="display: none;">
<!-- Drift Score Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Infrastructure Drift Analysis</h5>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-3">
<div class="text-center">
<div class="display-4 mb-2" id="drift-score" style="color: #60a5fa;">-</div>
<div class="text-muted">Drift Score</div>
<small class="text-muted d-block mt-1">(0.0 = identical, 1.0 = completely different)</small>
</div>
</div>
<div class="col-md-9">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label text-muted">Older Scan (#<span id="scan1-id"></span>)</label>
<div id="scan1-title" class="fw-bold">-</div>
<small class="text-muted d-block" id="scan1-timestamp">-</small>
<small class="text-muted d-block"><i class="bi bi-file-earmark-text"></i> <span id="scan1-config">-</span></small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label text-muted">Newer Scan (#<span id="scan2-id"></span>)</label>
<div id="scan2-title" class="fw-bold">-</div>
<small class="text-muted d-block" id="scan2-timestamp">-</small>
<small class="text-muted d-block"><i class="bi bi-file-earmark-text"></i> <span id="scan2-config">-</span></small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label text-muted">Quick Actions</label>
<div>
<a href="/scans/{{ scan_id1 }}" class="btn btn-sm btn-secondary">View Scan #{{ scan_id1 }}</a>
<a href="/scans/{{ scan_id2 }}" class="btn btn-sm btn-secondary">View Scan #{{ scan_id2 }}</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Ports Comparison -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-hdd-network"></i> Port Changes
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
<div class="stat-value" id="ports-added-count">0</div>
<div class="stat-label">Ports Added</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
<div class="stat-value" id="ports-removed-count">0</div>
<div class="stat-label">Ports Removed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="ports-unchanged-count">0</div>
<div class="stat-label">Ports Unchanged</div>
</div>
</div>
</div>
<!-- Added Ports -->
<div id="ports-added-section" style="display: none;">
<h6 class="text-success mb-2"><i class="bi bi-plus-circle"></i> Added Ports</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Protocol</th>
<th>State</th>
</tr>
</thead>
<tbody id="ports-added-tbody"></tbody>
</table>
</div>
</div>
<!-- Removed Ports -->
<div id="ports-removed-section" style="display: none;">
<h6 class="text-danger mb-2"><i class="bi bi-dash-circle"></i> Removed Ports</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Protocol</th>
<th>State</th>
</tr>
</thead>
<tbody id="ports-removed-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Services Comparison -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-gear"></i> Service Changes
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
<div class="stat-value" id="services-added-count">0</div>
<div class="stat-label">Services Added</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
<div class="stat-value" id="services-removed-count">0</div>
<div class="stat-label">Services Removed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #78350f; border-color: #fcd34d;">
<div class="stat-value" id="services-changed-count">0</div>
<div class="stat-label">Services Changed</div>
</div>
</div>
</div>
<!-- Changed Services -->
<div id="services-changed-section" style="display: none;">
<h6 class="text-warning mb-2"><i class="bi bi-arrow-left-right"></i> Changed Services</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP:Port</th>
<th>Old Service</th>
<th>New Service</th>
<th>Old Version</th>
<th>New Version</th>
</tr>
</thead>
<tbody id="services-changed-tbody"></tbody>
</table>
</div>
</div>
<!-- Added Services -->
<div id="services-added-section" style="display: none;">
<h6 class="text-success mb-2"><i class="bi bi-plus-circle"></i> Added Services</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Service</th>
<th>Product</th>
<th>Version</th>
</tr>
</thead>
<tbody id="services-added-tbody"></tbody>
</table>
</div>
</div>
<!-- Removed Services -->
<div id="services-removed-section" style="display: none;">
<h6 class="text-danger mb-2"><i class="bi bi-dash-circle"></i> Removed Services</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Service</th>
<th>Product</th>
<th>Version</th>
</tr>
</thead>
<tbody id="services-removed-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Certificates Comparison -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-shield-lock"></i> Certificate Changes
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
<div class="stat-value" id="certs-added-count">0</div>
<div class="stat-label">Certificates Added</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
<div class="stat-value" id="certs-removed-count">0</div>
<div class="stat-label">Certificates Removed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #78350f; border-color: #fcd34d;">
<div class="stat-value" id="certs-changed-count">0</div>
<div class="stat-label">Certificates Changed</div>
</div>
</div>
</div>
<!-- Changed Certificates -->
<div id="certs-changed-section" style="display: none;">
<h6 class="text-warning mb-2"><i class="bi bi-arrow-left-right"></i> Changed Certificates</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP:Port</th>
<th>Old Subject</th>
<th>New Subject</th>
<th>Old Expiry</th>
<th>New Expiry</th>
</tr>
</thead>
<tbody id="certs-changed-tbody"></tbody>
</table>
</div>
</div>
<!-- Added/Removed Certificates (shown if any) -->
<div id="certs-added-removed-info" style="display: none;">
<p class="text-muted mb-0">
<i class="bi bi-info-circle"></i>
Additional certificate additions and removals correspond to the port changes shown above.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const scanId1 = {{ scan_id1 }};
const scanId2 = {{ scan_id2 }};
// Load comparison data
async function loadComparison() {
const loadingDiv = document.getElementById('comparison-loading');
const errorDiv = document.getElementById('comparison-error');
const contentDiv = document.getElementById('comparison-content');
try {
const response = await fetch(`/api/scans/${scanId1}/compare/${scanId2}`);
if (!response.ok) {
throw new Error('Failed to load comparison');
}
const data = await response.json();
// Hide loading, show content
loadingDiv.style.display = 'none';
contentDiv.style.display = 'block';
// Populate comparison UI
populateComparison(data);
} catch (error) {
console.error('Error loading comparison:', error);
loadingDiv.style.display = 'none';
errorDiv.textContent = `Error: ${error.message}`;
errorDiv.style.display = 'block';
}
}
function populateComparison(data) {
// Show config warning if configs differ
if (data.config_warning) {
const warningDiv = document.getElementById('config-warning');
const warningMessage = document.getElementById('config-warning-message');
warningMessage.textContent = data.config_warning;
warningDiv.style.display = 'block';
}
// Drift score
const driftScore = data.drift_score || 0;
document.getElementById('drift-score').textContent = driftScore.toFixed(3);
// Color code drift score
const driftElement = document.getElementById('drift-score');
if (driftScore < 0.1) {
driftElement.style.color = '#6ee7b7'; // Green - minimal drift
} else if (driftScore < 0.3) {
driftElement.style.color = '#fcd34d'; // Yellow - moderate drift
} else {
driftElement.style.color = '#fca5a5'; // Red - significant drift
}
// Scan metadata
document.getElementById('scan1-id').textContent = data.scan1.id;
document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan';
document.getElementById('scan1-timestamp').textContent = new Date(data.scan1.timestamp).toLocaleString();
document.getElementById('scan1-config').textContent = data.scan1.config_id || 'Unknown';
document.getElementById('scan2-id').textContent = data.scan2.id;
document.getElementById('scan2-title').textContent = data.scan2.title || 'Untitled Scan';
document.getElementById('scan2-timestamp').textContent = new Date(data.scan2.timestamp).toLocaleString();
document.getElementById('scan2-config').textContent = data.scan2.config_id || 'Unknown';
// Ports comparison
populatePortsComparison(data.ports);
// Services comparison
populateServicesComparison(data.services);
// Certificates comparison
populateCertificatesComparison(data.certificates);
}
function populatePortsComparison(ports) {
const addedCount = ports.added.length;
const removedCount = ports.removed.length;
const unchangedCount = ports.unchanged.length;
document.getElementById('ports-added-count').textContent = addedCount;
document.getElementById('ports-removed-count').textContent = removedCount;
document.getElementById('ports-unchanged-count').textContent = unchangedCount;
// Show added ports
if (addedCount > 0) {
document.getElementById('ports-added-section').style.display = 'block';
const tbody = document.getElementById('ports-added-tbody');
tbody.innerHTML = '';
ports.added.forEach(port => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${port.ip}</td>
<td class="mono">${port.port}</td>
<td>${port.protocol.toUpperCase()}</td>
<td>${port.state}</td>
`;
tbody.appendChild(row);
});
}
// Show removed ports
if (removedCount > 0) {
document.getElementById('ports-removed-section').style.display = 'block';
const tbody = document.getElementById('ports-removed-tbody');
tbody.innerHTML = '';
ports.removed.forEach(port => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${port.ip}</td>
<td class="mono">${port.port}</td>
<td>${port.protocol.toUpperCase()}</td>
<td>${port.state}</td>
`;
tbody.appendChild(row);
});
}
}
function populateServicesComparison(services) {
const addedCount = services.added.length;
const removedCount = services.removed.length;
const changedCount = services.changed.length;
document.getElementById('services-added-count').textContent = addedCount;
document.getElementById('services-removed-count').textContent = removedCount;
document.getElementById('services-changed-count').textContent = changedCount;
// Show changed services
if (changedCount > 0) {
document.getElementById('services-changed-section').style.display = 'block';
const tbody = document.getElementById('services-changed-tbody');
tbody.innerHTML = '';
services.changed.forEach(svc => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td class="mono">${svc.ip}:${svc.port}</td>
<td>${svc.old.service_name || '-'}</td>
<td class="text-warning">${svc.new.service_name || '-'}</td>
<td>${svc.old.version || '-'}</td>
<td class="text-warning">${svc.new.version || '-'}</td>
`;
tbody.appendChild(row);
});
}
// Show added services
if (addedCount > 0) {
document.getElementById('services-added-section').style.display = 'block';
const tbody = document.getElementById('services-added-tbody');
tbody.innerHTML = '';
services.added.forEach(svc => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${svc.ip}</td>
<td class="mono">${svc.port}</td>
<td>${svc.service_name || '-'}</td>
<td>${svc.product || '-'}</td>
<td>${svc.version || '-'}</td>
`;
tbody.appendChild(row);
});
}
// Show removed services
if (removedCount > 0) {
document.getElementById('services-removed-section').style.display = 'block';
const tbody = document.getElementById('services-removed-tbody');
tbody.innerHTML = '';
services.removed.forEach(svc => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${svc.ip}</td>
<td class="mono">${svc.port}</td>
<td>${svc.service_name || '-'}</td>
<td>${svc.product || '-'}</td>
<td>${svc.version || '-'}</td>
`;
tbody.appendChild(row);
});
}
}
function populateCertificatesComparison(certs) {
const addedCount = certs.added.length;
const removedCount = certs.removed.length;
const changedCount = certs.changed.length;
document.getElementById('certs-added-count').textContent = addedCount;
document.getElementById('certs-removed-count').textContent = removedCount;
document.getElementById('certs-changed-count').textContent = changedCount;
// Show changed certificates
if (changedCount > 0) {
document.getElementById('certs-changed-section').style.display = 'block';
const tbody = document.getElementById('certs-changed-tbody');
tbody.innerHTML = '';
certs.changed.forEach(cert => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td class="mono">${cert.ip}:${cert.port}</td>
<td>${cert.old.subject || '-'}</td>
<td class="text-warning">${cert.new.subject || '-'}</td>
<td>${cert.old.not_valid_after ? new Date(cert.old.not_valid_after).toLocaleDateString() : '-'}</td>
<td class="text-warning">${cert.new.not_valid_after ? new Date(cert.new.not_valid_after).toLocaleDateString() : '-'}</td>
`;
tbody.appendChild(row);
});
}
// Show info if there are added/removed certs
if (addedCount > 0 || removedCount > 0) {
document.getElementById('certs-added-removed-info').style.display = 'block';
}
}
// Load comparison on page load
loadComparison();
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,555 @@
{% extends "base.html" %}
{% block title %}All Scans - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1>All Scans</h1>
<button class="btn btn-primary" onclick="showTriggerScanModal()">
<span id="trigger-btn-text">Trigger New Scan</span>
<span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label for="status-filter" class="form-label">Filter by Status</label>
<select class="form-select" id="status-filter" onchange="filterScans()">
<option value="">All Statuses</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="col-md-4">
<label for="per-page" class="form-label">Results per Page</label>
<select class="form-select" id="per-page" onchange="changePerPage()">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<button class="btn btn-secondary w-100" onclick="refreshScans()">
<span id="refresh-text">Refresh</span>
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Scans Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Scan History</h5>
</div>
<div class="card-body">
<div id="scans-loading" class="text-center py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading scans...</p>
</div>
<div id="scans-error" class="alert alert-danger" style="display: none;"></div>
<div id="scans-empty" class="text-center py-5 text-muted" style="display: none;">
<h5>No scans found</h5>
<p>Click "Trigger New Scan" to create your first scan.</p>
</div>
<div id="scans-table-container" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 80px;">ID</th>
<th>Title</th>
<th style="width: 200px;">Timestamp</th>
<th style="width: 100px;">Duration</th>
<th style="width: 120px;">Status</th>
<th style="width: 200px;">Actions</th>
</tr>
</thead>
<tbody id="scans-tbody">
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Showing <span id="showing-start">0</span> to <span id="showing-end">0</span> of <span id="total-count">0</span> scans
</div>
<nav>
<ul class="pagination mb-0" id="pagination">
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Trigger Scan Modal -->
<div class="modal fade" id="triggerScanModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Trigger New Scan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="trigger-scan-form">
<div class="mb-3">
<label for="config-select" class="form-label">Scan Configuration</label>
<select class="form-select" id="config-select" name="config_id" required>
<option value="">Loading configurations...</option>
</select>
<div class="form-text text-muted" id="config-help-text">
Select a scan configuration
</div>
<div id="no-configs-warning" class="alert alert-warning mt-2 mb-0" role="alert" style="display: none;">
<i class="bi bi-exclamation-triangle"></i>
<strong>No configurations available</strong>
<p class="mb-2 mt-2">You need to create a configuration before you can trigger a scan.</p>
<a href="{{ url_for('main.configs') }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle"></i> Create Configuration
</a>
</div>
</div>
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()">
<span id="modal-trigger-text">Trigger Scan</span>
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentPage = 1;
let perPage = 20;
let statusFilter = '';
let totalCount = 0;
// Show alert notification
function showAlert(type, message) {
const container = document.getElementById('notification-container');
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
container.appendChild(notification);
// Auto-dismiss after 5 seconds
setTimeout(() => {
notification.remove();
}, 5000);
}
// Load initial data when page loads
document.addEventListener('DOMContentLoaded', function() {
loadScans();
// Auto-refresh every 15 seconds
setInterval(function() {
loadScans();
}, 15000);
});
// Load scans from API
async function loadScans() {
const loadingEl = document.getElementById('scans-loading');
const errorEl = document.getElementById('scans-error');
const emptyEl = document.getElementById('scans-empty');
const tableEl = document.getElementById('scans-table-container');
// Show loading state
loadingEl.style.display = 'block';
errorEl.style.display = 'none';
emptyEl.style.display = 'none';
tableEl.style.display = 'none';
try {
let url = `/api/scans?page=${currentPage}&per_page=${perPage}`;
if (statusFilter) {
url += `&status=${statusFilter}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to load scans');
}
const data = await response.json();
const scans = data.scans || [];
totalCount = data.total || 0;
loadingEl.style.display = 'none';
if (scans.length === 0) {
emptyEl.style.display = 'block';
} else {
tableEl.style.display = 'block';
renderScansTable(scans);
renderPagination(data.page, data.per_page, data.total, data.pages);
}
} catch (error) {
console.error('Error loading scans:', error);
loadingEl.style.display = 'none';
errorEl.textContent = 'Failed to load scans. Please try again.';
errorEl.style.display = 'block';
}
}
// Render scans table
function renderScansTable(scans) {
const tbody = document.getElementById('scans-tbody');
tbody.innerHTML = '';
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();
// Format duration
const duration = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
// Status badge
let statusBadge = '';
if (scan.status === 'completed') {
statusBadge = '<span class="badge badge-success">Completed</span>';
} else if (scan.status === 'running') {
statusBadge = '<span class="badge badge-info">Running</span>';
} else if (scan.status === 'failed') {
statusBadge = '<span class="badge badge-danger">Failed</span>';
} else if (scan.status === 'cancelled') {
statusBadge = '<span class="badge badge-warning">Cancelled</span>';
} else {
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
}
// Action buttons
let actionButtons = `<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>`;
if (scan.status === 'running') {
actionButtons += `<button class="btn btn-sm btn-warning ms-1" onclick="stopScan(${scan.id})">Stop</button>`;
} else {
actionButtons += `<button class="btn btn-sm btn-danger ms-1" onclick="deleteScan(${scan.id})">Delete</button>`;
}
row.innerHTML = `
<td class="mono">${scan.id}</td>
<td>${scan.title || 'Untitled Scan'}</td>
<td class="text-muted">${timestamp}</td>
<td class="mono">${duration}</td>
<td>${statusBadge}</td>
<td>${actionButtons}</td>
`;
tbody.appendChild(row);
});
}
// Render pagination
function renderPagination(page, per_page, total, pages) {
const paginationEl = document.getElementById('pagination');
paginationEl.innerHTML = '';
// Update showing text
const start = (page - 1) * per_page + 1;
const end = Math.min(page * per_page, total);
document.getElementById('showing-start').textContent = start;
document.getElementById('showing-end').textContent = end;
document.getElementById('total-count').textContent = total;
if (pages <= 1) {
return;
}
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${page === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(${page - 1}); return false;">Previous</a>`;
paginationEl.appendChild(prevLi);
// Page numbers
const maxPagesToShow = 5;
let startPage = Math.max(1, page - Math.floor(maxPagesToShow / 2));
let endPage = Math.min(pages, startPage + maxPagesToShow - 1);
if (endPage - startPage < maxPagesToShow - 1) {
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
if (startPage > 1) {
const firstLi = document.createElement('li');
firstLi.className = 'page-item';
firstLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(1); return false;">1</a>`;
paginationEl.appendChild(firstLi);
if (startPage > 2) {
const ellipsisLi = document.createElement('li');
ellipsisLi.className = 'page-item disabled';
ellipsisLi.innerHTML = '<a class="page-link" href="#">...</a>';
paginationEl.appendChild(ellipsisLi);
}
}
for (let i = startPage; i <= endPage; i++) {
const pageLi = document.createElement('li');
pageLi.className = `page-item ${i === page ? 'active' : ''}`;
pageLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(${i}); return false;">${i}</a>`;
paginationEl.appendChild(pageLi);
}
if (endPage < pages) {
if (endPage < pages - 1) {
const ellipsisLi = document.createElement('li');
ellipsisLi.className = 'page-item disabled';
ellipsisLi.innerHTML = '<a class="page-link" href="#">...</a>';
paginationEl.appendChild(ellipsisLi);
}
const lastLi = document.createElement('li');
lastLi.className = 'page-item';
lastLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(${pages}); return false;">${pages}</a>`;
paginationEl.appendChild(lastLi);
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${page === pages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(${page + 1}); return false;">Next</a>`;
paginationEl.appendChild(nextLi);
}
// Navigation functions
function goToPage(page) {
currentPage = page;
loadScans();
}
function filterScans() {
statusFilter = document.getElementById('status-filter').value;
currentPage = 1;
loadScans();
}
function changePerPage() {
perPage = parseInt(document.getElementById('per-page').value);
currentPage = 1;
loadScans();
}
function refreshScans() {
const refreshBtn = document.getElementById('refresh-text');
const refreshSpinner = document.getElementById('refresh-spinner');
refreshBtn.style.display = 'none';
refreshSpinner.style.display = 'inline-block';
loadScans().finally(() => {
refreshBtn.style.display = 'inline';
refreshSpinner.style.display = 'none';
});
}
// Load available configs
async function loadConfigs() {
const selectEl = document.getElementById('config-select');
const helpTextEl = document.getElementById('config-help-text');
const noConfigsWarning = document.getElementById('no-configs-warning');
const triggerBtn = document.getElementById('trigger-scan-btn');
try {
const response = await fetch('/api/configs');
if (!response.ok) {
throw new Error('Failed to load configurations');
}
const data = await response.json();
const configs = data.configs || [];
// Clear existing options
selectEl.innerHTML = '';
if (configs.length === 0) {
selectEl.innerHTML = '<option value="">No configurations available</option>';
selectEl.disabled = true;
triggerBtn.disabled = true;
helpTextEl.style.display = 'none';
noConfigsWarning.style.display = 'block';
} else {
selectEl.innerHTML = '<option value="">Select a configuration...</option>';
configs.forEach(config => {
const option = document.createElement('option');
option.value = config.id;
const siteText = config.site_count === 1 ? 'site' : 'sites';
option.textContent = `${config.title} (${config.site_count} ${siteText})`;
selectEl.appendChild(option);
});
selectEl.disabled = false;
triggerBtn.disabled = false;
helpTextEl.style.display = 'block';
noConfigsWarning.style.display = 'none';
}
} catch (error) {
console.error('Error loading configs:', error);
selectEl.innerHTML = '<option value="">Error loading configurations</option>';
selectEl.disabled = true;
triggerBtn.disabled = true;
helpTextEl.style.display = 'none';
}
}
// Show trigger scan modal
function showTriggerScanModal() {
const modal = new bootstrap.Modal(document.getElementById('triggerScanModal'));
document.getElementById('trigger-error').style.display = 'none';
document.getElementById('trigger-scan-form').reset();
// Load configs when modal is shown
loadConfigs();
modal.show();
}
// Trigger scan
async function triggerScan() {
const configId = document.getElementById('config-select').value;
const errorEl = document.getElementById('trigger-error');
const btnText = document.getElementById('modal-trigger-text');
const btnSpinner = document.getElementById('modal-trigger-spinner');
if (!configId) {
errorEl.textContent = 'Please select a configuration.';
errorEl.style.display = 'block';
return;
}
// Show loading state
btnText.style.display = 'none';
btnSpinner.style.display = 'inline-block';
errorEl.style.display = 'none';
try {
const response = await fetch('/api/scans', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config_id: parseInt(configId)
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || data.error || 'Failed to trigger scan');
}
const data = await response.json();
// Hide error before closing modal to prevent flash
errorEl.style.display = 'none';
// Close modal
bootstrap.Modal.getInstance(document.getElementById('triggerScanModal')).hide();
// Show success message
showAlert('success', `Scan triggered successfully! (ID: ${data.scan_id})`);
// Refresh scans
loadScans();
} catch (error) {
console.error('Error triggering scan:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
} finally {
btnText.style.display = 'inline';
btnSpinner.style.display = 'none';
}
}
// Stop scan
async function stopScan(scanId) {
if (!confirm(`Are you sure you want to stop scan ${scanId}?`)) {
return;
}
try {
const response = await fetch(`/api/scans/${scanId}/stop`, {
method: 'POST'
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to stop scan');
}
// Show success message
showAlert('success', `Stop signal sent to scan ${scanId}.`);
// Refresh scans after a short delay
setTimeout(() => loadScans(), 1000);
} catch (error) {
console.error('Error stopping scan:', error);
showAlert('danger', `Failed to stop scan: ${error.message}`);
}
}
// Delete scan
async function deleteScan(scanId) {
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
return;
}
try {
const response = await fetch(`/api/scans/${scanId}`, {
method: 'DELETE'
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to delete scan');
}
// Show success message
showAlert('success', `Scan ${scanId} deleted successfully.`);
// Refresh scans
loadScans();
} catch (error) {
console.error('Error deleting scan:', error);
showAlert('danger', `Failed to delete scan: ${error.message}`);
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,438 @@
{% extends "base.html" %}
{% block title %}Create Schedule - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Create Schedule</h1>
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Schedules
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<form id="create-schedule-form">
<!-- Basic Information Card -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Basic Information</h5>
</div>
<div class="card-body">
<!-- Schedule Name -->
<div class="mb-3">
<label for="schedule-name" class="form-label">Schedule Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="schedule-name" name="name"
placeholder="e.g., Daily Infrastructure Scan"
required>
<small class="form-text text-muted">A descriptive name for this schedule</small>
</div>
<!-- Config -->
<div class="mb-3">
<label for="config-id" class="form-label">Configuration <span class="text-danger">*</span></label>
<select class="form-select" id="config-id" name="config_id" required>
<option value="">Select a configuration...</option>
{% for config in configs %}
<option value="{{ config.id }}">{{ config.title }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">The scan configuration to use for this schedule</small>
</div>
<!-- Enable/Disable -->
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="schedule-enabled"
name="enabled" checked>
<label class="form-check-label" for="schedule-enabled">
Enable schedule immediately
</label>
</div>
<small class="form-text text-muted">If disabled, the schedule will be created but not executed</small>
</div>
</div>
</div>
<!-- Cron Expression Card -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Schedule Configuration</h5>
</div>
<div class="card-body">
<!-- Quick Templates -->
<div class="mb-3">
<label class="form-label">Quick Templates:</label>
<div class="btn-group-vertical btn-group-sm w-100" role="group">
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
<strong>Daily at Midnight (local)</strong> <code class="float-end">0 0 * * *</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
<strong>Daily at 2 AM (local)</strong> <code class="float-end">0 2 * * *</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * 0')">
<strong>Weekly (Sunday at Midnight)</strong> <code class="float-end">0 0 * * 0</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 1 * *')">
<strong>Monthly (1st at Midnight)</strong> <code class="float-end">0 0 1 * *</code>
</button>
</div>
</div>
<!-- Manual Cron Entry -->
<div class="mb-3">
<label for="cron-expression" class="form-label">
Cron Expression <span class="text-danger">*</span>
<span class="badge bg-info">LOCAL TIME</span>
</label>
<input type="text" class="form-control font-monospace" id="cron-expression"
name="cron_expression" placeholder="0 2 * * *"
oninput="validateCron()" required>
<small class="form-text text-muted">
Format: <code>minute hour day month weekday</code><br>
<strong class="text-info"> All times use your local timezone (CST/UTC-6)</strong>
</small>
</div>
<!-- Cron Validation Feedback -->
<div id="cron-feedback" class="alert" style="display: none;"></div>
<!-- Human-Readable Description -->
<div id="cron-description-container" style="display: none;">
<div class="alert alert-info">
<strong>Description:</strong>
<div id="cron-description" class="mt-1"></div>
</div>
</div>
<!-- Next Run Times Preview -->
<div id="next-runs-container" style="display: none;">
<label class="form-label">Next 5 execution times (local time):</label>
<ul id="next-runs-list" class="list-group">
<!-- Populated by JavaScript -->
</ul>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between">
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary" id="submit-btn">
<i class="bi bi-plus-circle"></i> Create Schedule
</button>
</div>
</div>
</div>
</form>
</div>
<!-- Help Sidebar -->
<div class="col-lg-4">
<div class="card sticky-top" style="top: 20px;">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Cron Expression Help</h5>
</div>
<div class="card-body">
<h6>Field Format:</h6>
<table class="table table-sm">
<thead>
<tr>
<th>Field</th>
<th>Values</th>
</tr>
</thead>
<tbody>
<tr>
<td>Minute</td>
<td>0-59</td>
</tr>
<tr>
<td>Hour</td>
<td>0-23</td>
</tr>
<tr>
<td>Day</td>
<td>1-31</td>
</tr>
<tr>
<td>Month</td>
<td>1-12</td>
</tr>
<tr>
<td>Weekday</td>
<td>0-6 (0=Sunday)</td>
</tr>
</tbody>
</table>
<h6 class="mt-3">Special Characters:</h6>
<ul class="list-unstyled">
<li><code>*</code> - Any value</li>
<li><code>*/n</code> - Every n units</li>
<li><code>1,2,3</code> - Specific values</li>
<li><code>1-5</code> - Range of values</li>
</ul>
<h6 class="mt-3">Examples:</h6>
<ul class="list-unstyled">
<li><code>0 0 * * *</code> - Daily at midnight</li>
<li><code>*/15 * * * *</code> - Every 15 minutes</li>
<li><code>0 9-17 * * 1-5</code> - Hourly, 9am-5pm, Mon-Fri</li>
</ul>
<div class="alert alert-info mt-3">
<strong><i class="bi bi-info-circle"></i> Timezone Information:</strong><br>
All cron expressions use your <strong>local system time</strong>.<br><br>
<strong>Current local time:</strong> <span id="user-local-time"></span><br>
<strong>Your timezone:</strong> <span id="timezone-offset"></span><br><br>
<small>Schedules will run at the specified time in your local timezone.</small>
</div>
</div>
</div>
</div>
</div>
<script>
// Update local time and timezone info every second
function updateServerTime() {
const now = new Date();
const localTime = now.toLocaleTimeString();
const offset = -now.getTimezoneOffset() / 60;
const offsetStr = `CST (UTC${offset >= 0 ? '+' : ''}${offset})`;
if (document.getElementById('user-local-time')) {
document.getElementById('user-local-time').textContent = localTime;
}
if (document.getElementById('timezone-offset')) {
document.getElementById('timezone-offset').textContent = offsetStr;
}
}
updateServerTime();
setInterval(updateServerTime, 1000);
// Set cron expression from template button
function setCron(expression) {
document.getElementById('cron-expression').value = expression;
validateCron();
}
// Validate cron expression (client-side basic validation)
function validateCron() {
const input = document.getElementById('cron-expression');
const expression = input.value.trim();
const feedback = document.getElementById('cron-feedback');
const descContainer = document.getElementById('cron-description-container');
const description = document.getElementById('cron-description');
const nextRunsContainer = document.getElementById('next-runs-container');
if (!expression) {
feedback.style.display = 'none';
descContainer.style.display = 'none';
nextRunsContainer.style.display = 'none';
return;
}
// Basic validation: should have 5 fields
const parts = expression.split(/\s+/);
if (parts.length !== 5) {
feedback.className = 'alert alert-danger';
feedback.textContent = 'Invalid format: Cron expression must have exactly 5 fields (minute hour day month weekday)';
feedback.style.display = 'block';
descContainer.style.display = 'none';
nextRunsContainer.style.display = 'none';
return;
}
// Basic field validation
const [minute, hour, day, month, weekday] = parts;
const errors = [];
if (!isValidCronField(minute, 0, 59)) errors.push('minute (0-59)');
if (!isValidCronField(hour, 0, 23)) errors.push('hour (0-23)');
if (!isValidCronField(day, 1, 31)) errors.push('day (1-31)');
if (!isValidCronField(month, 1, 12)) errors.push('month (1-12)');
if (!isValidCronField(weekday, 0, 6)) errors.push('weekday (0-6)');
if (errors.length > 0) {
feedback.className = 'alert alert-danger';
feedback.textContent = 'Invalid fields: ' + errors.join(', ');
feedback.style.display = 'block';
descContainer.style.display = 'none';
nextRunsContainer.style.display = 'none';
return;
}
// Valid expression
feedback.className = 'alert alert-success';
feedback.textContent = 'Valid cron expression';
feedback.style.display = 'block';
// Show human-readable description
description.textContent = describeCron(parts);
descContainer.style.display = 'block';
// Calculate and show next run times
calculateNextRuns(expression);
nextRunsContainer.style.display = 'block';
}
// Validate individual cron field
function isValidCronField(field, min, max) {
if (field === '*') return true;
// Handle ranges: 1-5
if (field.includes('-')) {
const [start, end] = field.split('-').map(Number);
return start >= min && end <= max && start <= end;
}
// Handle steps: */5 or 1-10/2
if (field.includes('/')) {
const [range, step] = field.split('/');
if (range === '*') return Number(step) > 0;
return isValidCronField(range, min, max) && Number(step) > 0;
}
// Handle lists: 1,2,3
if (field.includes(',')) {
return field.split(',').every(v => {
const num = Number(v);
return !isNaN(num) && num >= min && num <= max;
});
}
// Single number
const num = Number(field);
return !isNaN(num) && num >= min && num <= max;
}
// Generate human-readable description
function describeCron(parts) {
const [minute, hour, day, month, weekday] = parts;
// Common patterns
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday === '*') {
return 'Runs daily at midnight (local time)';
}
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
return `Runs daily at ${hour.padStart(2, '0')}:00 (local time)`;
}
if (minute !== '*' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
return `Runs daily at ${hour.padStart(2, '0')}:${minute.padStart(2, '0')} (local time)`;
}
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday !== '*') {
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
return `Runs weekly on ${days[Number(weekday)]} at midnight`;
}
if (minute === '0' && hour === '0' && day !== '*' && month === '*' && weekday === '*') {
return `Runs monthly on day ${day} at midnight`;
}
if (minute.startsWith('*/')) {
const interval = minute.split('/')[1];
return `Runs every ${interval} minutes`;
}
if (hour.startsWith('*/') && minute === '0') {
const interval = hour.split('/')[1];
return `Runs every ${interval} hours`;
}
return `Runs at ${minute} ${hour} ${day} ${month} ${weekday} (cron format)`;
}
// Calculate next 5 run times (simplified - server will do actual calculation)
function calculateNextRuns(expression) {
const list = document.getElementById('next-runs-list');
list.innerHTML = '<li class="list-group-item"><em>Will be calculated by server...</em></li>';
// In production, this would call an API endpoint to get accurate next runs
// For now, just show placeholder
}
// Handle form submission
document.getElementById('create-schedule-form').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submit-btn');
const originalText = submitBtn.innerHTML;
// Get form data
const formData = {
name: document.getElementById('schedule-name').value.trim(),
config_id: parseInt(document.getElementById('config-id').value),
cron_expression: document.getElementById('cron-expression').value.trim(),
enabled: document.getElementById('schedule-enabled').checked
};
// Validate
if (!formData.name || !formData.config_id || !formData.cron_expression) {
showNotification('Please fill in all required fields', 'warning');
return;
}
// Disable submit button
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
try {
const response = await fetch('/api/schedules', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
showNotification('Schedule created successfully! Redirecting...', 'success');
// Redirect to schedules list
setTimeout(() => {
window.location.href = '/schedules';
}, 1500);
} catch (error) {
console.error('Error creating schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
// Re-enable submit button
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
// Show notification
function showNotification(message, type = 'info') {
const container = document.getElementById('notification-container');
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
container.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 5000);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,596 @@
{% extends "base.html" %}
{% block title %}Edit Schedule - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Edit Schedule</h1>
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Schedules
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<!-- Loading State -->
<div id="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading schedule...</p>
</div>
<!-- Error State -->
<div id="error-state" style="display: none;" class="alert alert-danger">
<strong>Error:</strong> <span id="error-message"></span>
</div>
<!-- Edit Form -->
<form id="edit-schedule-form" style="display: none;">
<input type="hidden" id="schedule-id">
<!-- Basic Information Card -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Basic Information</h5>
</div>
<div class="card-body">
<!-- Schedule Name -->
<div class="mb-3">
<label for="schedule-name" class="form-label">Schedule Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="schedule-name" name="name"
placeholder="e.g., Daily Infrastructure Scan"
required>
</div>
<!-- Config File (read-only) -->
<div class="mb-3">
<label for="config-file" class="form-label">Configuration File</label>
<input type="text" class="form-control" id="config-file" readonly>
<small class="form-text text-muted">Configuration file cannot be changed after creation</small>
</div>
<!-- Enable/Disable -->
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="schedule-enabled"
name="enabled">
<label class="form-check-label" for="schedule-enabled">
Schedule enabled
</label>
</div>
</div>
<!-- Metadata -->
<div class="row">
<div class="col-md-6">
<small class="text-muted">
<strong>Created:</strong> <span id="created-at">-</span>
</small>
</div>
<div class="col-md-6">
<small class="text-muted">
<strong>Last Modified:</strong> <span id="updated-at">-</span>
</small>
</div>
</div>
</div>
</div>
<!-- Cron Expression Card -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Schedule Configuration</h5>
</div>
<div class="card-body">
<!-- Quick Templates -->
<div class="mb-3">
<label class="form-label">Quick Templates:</label>
<div class="btn-group-vertical btn-group-sm w-100" role="group">
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
<strong>Daily at Midnight (local)</strong> <code class="float-end">0 0 * * *</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
<strong>Daily at 2 AM (local)</strong> <code class="float-end">0 2 * * *</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * 0')">
<strong>Weekly (Sunday at Midnight)</strong> <code class="float-end">0 0 * * 0</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 1 * *')">
<strong>Monthly (1st at Midnight)</strong> <code class="float-end">0 0 1 * *</code>
</button>
</div>
</div>
<!-- Manual Cron Entry -->
<div class="mb-3">
<label for="cron-expression" class="form-label">
Cron Expression <span class="text-danger">*</span>
</label>
<input type="text" class="form-control font-monospace" id="cron-expression"
name="cron_expression" placeholder="0 2 * * *"
oninput="validateCron()" required>
<small class="form-text text-muted">
Format: <code>minute hour day month weekday</code> (local timezone)
</small>
</div>
<!-- Cron Validation Feedback -->
<div id="cron-feedback" class="alert" style="display: none;"></div>
<!-- Run Times Info -->
<div class="row">
<div class="col-md-6">
<div class="alert alert-info">
<strong>Last Run:</strong><br>
<span id="last-run" style="white-space: pre-line;">Never</span>
</div>
</div>
<div class="col-md-6">
<div class="alert alert-info">
<strong>Next Run:</strong><br>
<span id="next-run" style="white-space: pre-line;">Not scheduled</span>
</div>
</div>
</div>
</div>
</div>
<!-- Execution History Card -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Execution History</h5>
</div>
<div class="card-body">
<div id="history-loading" class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary"></div>
<span class="ms-2 text-muted">Loading history...</span>
</div>
<div id="history-content" style="display: none;">
<p class="text-muted">Last 10 scans triggered by this schedule:</p>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Scan ID</th>
<th>Started</th>
<th>Status</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="history-tbody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
<div id="history-empty" style="display: none;" class="text-center py-3 text-muted">
No executions yet
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<button type="button" class="btn btn-danger" onclick="deleteSchedule()">
<i class="bi bi-trash"></i> Delete Schedule
</button>
<button type="button" class="btn btn-secondary" onclick="testRun()">
<i class="bi bi-play-fill"></i> Test Run Now
</button>
</div>
<div>
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary me-2">Cancel</a>
<button type="submit" class="btn btn-primary" id="submit-btn">
<i class="bi bi-check-circle"></i> Save Changes
</button>
</div>
</div>
</div>
</div>
</form>
</div>
<!-- Help Sidebar -->
<div class="col-lg-4">
<div class="card sticky-top" style="top: 20px;">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Cron Expression Help</h5>
</div>
<div class="card-body">
<h6>Field Format:</h6>
<table class="table table-sm">
<thead>
<tr>
<th>Field</th>
<th>Values</th>
</tr>
</thead>
<tbody>
<tr>
<td>Minute</td>
<td>0-59</td>
</tr>
<tr>
<td>Hour</td>
<td>0-23</td>
</tr>
<tr>
<td>Day</td>
<td>1-31</td>
</tr>
<tr>
<td>Month</td>
<td>1-12</td>
</tr>
<tr>
<td>Weekday</td>
<td>0-6 (0=Sunday)</td>
</tr>
</tbody>
</table>
<h6 class="mt-3">Special Characters:</h6>
<ul class="list-unstyled">
<li><code>*</code> - Any value</li>
<li><code>*/n</code> - Every n units</li>
<li><code>1,2,3</code> - Specific values</li>
<li><code>1-5</code> - Range of values</li>
</ul>
<div class="alert alert-info mt-3">
<strong><i class="bi bi-info-circle"></i> Timezone Information:</strong><br>
All cron expressions use your <strong>local system time</strong>.<br><br>
<strong>Current local time:</strong> <span id="current-local"></span><br>
<strong>Your timezone:</strong> <span id="tz-offset"></span>
</div>
</div>
</div>
</div>
</div>
<script>
let scheduleData = null;
// Get schedule ID from URL
const scheduleId = parseInt(window.location.pathname.split('/')[2]);
// Load schedule data
async function loadSchedule() {
try {
const response = await fetch(`/api/schedules/${scheduleId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
scheduleData = await response.json();
// Populate form
populateForm(scheduleData);
// Load execution history
loadHistory();
// Hide loading, show form
document.getElementById('loading').style.display = 'none';
document.getElementById('edit-schedule-form').style.display = 'block';
} catch (error) {
console.error('Error loading schedule:', error);
document.getElementById('loading').style.display = 'none';
document.getElementById('error-state').style.display = 'block';
document.getElementById('error-message').textContent = error.message;
}
}
// Populate form with schedule data
function populateForm(schedule) {
document.getElementById('schedule-id').value = schedule.id;
document.getElementById('schedule-name').value = schedule.name;
// Display config name and ID in the readonly config-file field
const configDisplay = schedule.config_name
? `${schedule.config_name} (ID: ${schedule.config_id})`
: `Config ID: ${schedule.config_id}`;
document.getElementById('config-file').value = configDisplay;
document.getElementById('cron-expression').value = schedule.cron_expression;
document.getElementById('schedule-enabled').checked = schedule.enabled;
// Metadata
document.getElementById('created-at').textContent = new Date(schedule.created_at).toLocaleString();
document.getElementById('updated-at').textContent = new Date(schedule.updated_at).toLocaleString();
// Run times - show in local time
document.getElementById('last-run').textContent = schedule.last_run
? formatRelativeTime(schedule.last_run) + '\n' +
new Date(schedule.last_run).toLocaleString()
: 'Never';
document.getElementById('next-run').textContent = schedule.next_run && schedule.enabled
? formatRelativeTime(schedule.next_run) + '\n' +
new Date(schedule.next_run).toLocaleString()
: (schedule.enabled ? 'Calculating...' : 'Disabled');
// Validate cron
validateCron();
}
// Load execution history
async function loadHistory() {
try {
// Note: This would ideally be a separate API endpoint
// For now, we'll fetch scans filtered by schedule_id
const response = await fetch(`/api/scans?schedule_id=${scheduleId}&limit=10`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const scans = data.scans || [];
renderHistory(scans);
document.getElementById('history-loading').style.display = 'none';
document.getElementById('history-content').style.display = 'block';
} catch (error) {
console.error('Error loading history:', error);
document.getElementById('history-loading').innerHTML = '<p class="text-danger">Failed to load history</p>';
}
}
// Render history table
function renderHistory(scans) {
const tbody = document.getElementById('history-tbody');
tbody.innerHTML = '';
if (scans.length === 0) {
document.querySelector('#history-content .table-responsive').style.display = 'none';
document.getElementById('history-empty').style.display = 'block';
return;
}
document.querySelector('#history-content .table-responsive').style.display = 'block';
document.getElementById('history-empty').style.display = 'none';
scans.forEach(scan => {
const row = document.createElement('tr');
row.classList.add('schedule-row');
row.style.cursor = 'pointer';
row.onclick = () => window.location.href = `/scans/${scan.id}`;
const duration = scan.end_time
? Math.round((new Date(scan.end_time) - new Date(scan.timestamp)) / 1000) + 's'
: '-';
row.innerHTML = `
<td class="mono"><a href="/scans/${scan.id}">#${scan.id}</a></td>
<td>${new Date(scan.timestamp).toLocaleString()}</td>
<td>${getStatusBadge(scan.status)}</td>
<td>${duration}</td>
`;
tbody.appendChild(row);
});
}
// Get status badge
function getStatusBadge(status) {
const badges = {
'running': '<span class="badge bg-primary">Running</span>',
'completed': '<span class="badge bg-success">Completed</span>',
'failed': '<span class="badge bg-danger">Failed</span>',
'pending': '<span class="badge bg-warning">Pending</span>'
};
return badges[status] || '<span class="badge bg-secondary">' + status + '</span>';
}
// Format relative time
function formatRelativeTime(timestamp) {
if (!timestamp) return 'Never';
const now = new Date();
const date = new Date(timestamp);
const diffMs = date - now;
const diffMinutes = Math.abs(Math.floor(diffMs / 60000));
const diffHours = Math.abs(Math.floor(diffMs / 3600000));
const diffDays = Math.abs(Math.floor(diffMs / 86400000));
if (diffMs < 0) {
// Past time
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
if (diffDays === 1) return 'Yesterday';
return `${diffDays} days ago`;
} else {
// Future time
if (diffMinutes < 1) return 'In less than a minute';
if (diffMinutes < 60) return `In ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
if (diffHours < 24) return `In ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
if (diffDays === 1) return 'Tomorrow';
return `In ${diffDays} days`;
}
}
// Set cron from template
function setCron(expression) {
document.getElementById('cron-expression').value = expression;
validateCron();
}
// Validate cron (basic client-side)
function validateCron() {
const expression = document.getElementById('cron-expression').value.trim();
const feedback = document.getElementById('cron-feedback');
if (!expression) {
feedback.style.display = 'none';
return;
}
const parts = expression.split(/\s+/);
if (parts.length !== 5) {
feedback.className = 'alert alert-danger';
feedback.textContent = 'Invalid: Must have exactly 5 fields';
feedback.style.display = 'block';
return;
}
feedback.className = 'alert alert-success';
feedback.textContent = 'Valid cron expression';
feedback.style.display = 'block';
}
// Handle form submission
document.getElementById('edit-schedule-form').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submit-btn');
const originalText = submitBtn.innerHTML;
const formData = {
name: document.getElementById('schedule-name').value.trim(),
cron_expression: document.getElementById('cron-expression').value.trim(),
enabled: document.getElementById('schedule-enabled').checked
};
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
try {
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
showNotification('Schedule updated successfully! Redirecting...', 'success');
setTimeout(() => {
window.location.href = '/schedules';
}, 1500);
} catch (error) {
console.error('Error updating schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
// Test run
async function testRun() {
if (!confirm('Trigger a test run of this schedule now?')) {
return;
}
try {
const response = await fetch(`/api/schedules/${scheduleId}/trigger`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
showNotification(`Scan triggered! Redirecting to scan #${data.scan_id}...`, 'success');
setTimeout(() => {
window.location.href = `/scans/${data.scan_id}`;
}, 1500);
} catch (error) {
console.error('Error triggering schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
}
}
// Delete schedule
async function deleteSchedule() {
const scheduleName = document.getElementById('schedule-name').value;
if (!confirm(`Delete schedule "${scheduleName}"?\n\nThis action cannot be undone. Associated scan history will be preserved.`)) {
return;
}
try {
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
showNotification('Schedule deleted successfully! Redirecting...', 'success');
setTimeout(() => {
window.location.href = '/schedules';
}, 1500);
} catch (error) {
console.error('Error deleting schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
}
}
// Show notification
function showNotification(message, type = 'info') {
const container = document.getElementById('notification-container');
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
container.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 5000);
}
// Update current time display
function updateCurrentTime() {
const now = new Date();
if (document.getElementById('current-local')) {
document.getElementById('current-local').textContent = now.toLocaleTimeString();
}
if (document.getElementById('tz-offset')) {
const offset = -now.getTimezoneOffset() / 60;
document.getElementById('tz-offset').textContent = `CST (UTC${offset >= 0 ? '+' : ''}${offset})`;
}
}
// Load on page load
document.addEventListener('DOMContentLoaded', () => {
loadSchedule();
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,388 @@
{% extends "base.html" %}
{% block title %}Scheduled Scans - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Scheduled Scans</h1>
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Schedule
</a>
</div>
</div>
</div>
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-schedules">-</div>
<div class="stat-label">Total Schedules</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="enabled-schedules">-</div>
<div class="stat-label">Enabled</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="next-run-time">-</div>
<div class="stat-label">Next Run</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="recent-executions">-</div>
<div class="stat-label">Executions (24h)</div>
</div>
</div>
</div>
<!-- Schedules Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">All Schedules</h5>
</div>
<div class="card-body">
<div id="schedules-loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading schedules...</p>
</div>
<div id="schedules-error" style="display: none;" class="alert alert-danger">
<strong>Error:</strong> <span id="error-message"></span>
</div>
<div id="schedules-content" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Schedule (Cron)</th>
<th>Next Run</th>
<th>Last Run</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="schedules-tbody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
<div id="empty-state" style="display: none;" class="text-center py-5">
<i class="bi bi-calendar-x" style="font-size: 3rem; color: #64748b;"></i>
<h5 class="mt-3 text-muted">No schedules configured</h5>
<p class="text-muted">Create your first schedule to automate scans</p>
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary mt-2">
<i class="bi bi-plus-circle"></i> Create Schedule
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Global variables
let schedulesData = [];
// Format relative time (e.g., "in 2 hours", "5 minutes ago")
function formatRelativeTime(timestamp) {
if (!timestamp) return 'Never';
const now = new Date();
const date = new Date(timestamp);
const diffMs = date - now;
const diffMinutes = Math.floor(diffMs / 60000);
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);
const absDiffHours = Math.abs(diffHours);
const absDiffDays = Math.abs(diffDays);
if (absDiffMinutes < 1) return 'Just now';
if (absDiffMinutes === 1) return '1 minute ago';
if (absDiffMinutes < 60) return `${absDiffMinutes} minutes ago`;
if (absDiffHours === 1) return '1 hour ago';
if (absDiffHours < 24) return `${absDiffHours} hours ago`;
if (absDiffDays === 1) return 'Yesterday';
if (absDiffDays < 7) return `${absDiffDays} days ago`;
return `<span title="${localStr}">${absDiffDays} days ago</span>`;
} else {
// Future time
if (diffMinutes < 1) return 'In less than a minute';
if (diffMinutes === 1) return 'In 1 minute';
if (diffMinutes < 60) return `In ${diffMinutes} minutes`;
if (diffHours === 1) return 'In 1 hour';
if (diffHours < 24) return `In ${diffHours} hours`;
if (diffDays === 1) return 'Tomorrow';
if (diffDays < 7) return `In ${diffDays} days`;
return `<span title="${localStr}">In ${diffDays} days</span>`;
}
}
// Get status badge HTML
function getStatusBadge(enabled) {
if (enabled) {
return '<span class="badge bg-success">Enabled</span>';
} else {
return '<span class="badge bg-secondary">Disabled</span>';
}
}
// Load schedules from API
async function loadSchedules() {
try {
const response = await fetch('/api/schedules');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
schedulesData = data.schedules || [];
renderSchedules();
updateStats(data);
// Hide loading, show content
document.getElementById('schedules-loading').style.display = 'none';
document.getElementById('schedules-error').style.display = 'none';
document.getElementById('schedules-content').style.display = 'block';
} catch (error) {
console.error('Error loading schedules:', error);
document.getElementById('schedules-loading').style.display = 'none';
document.getElementById('schedules-content').style.display = 'none';
document.getElementById('schedules-error').style.display = 'block';
document.getElementById('error-message').textContent = error.message;
}
}
// Render schedules table
function renderSchedules() {
const tbody = document.getElementById('schedules-tbody');
tbody.innerHTML = '';
if (schedulesData.length === 0) {
document.querySelector('.table-responsive').style.display = 'none';
document.getElementById('empty-state').style.display = 'block';
return;
}
document.querySelector('.table-responsive').style.display = 'block';
document.getElementById('empty-state').style.display = 'none';
schedulesData.forEach(schedule => {
const row = document.createElement('tr');
row.classList.add('schedule-row');
row.innerHTML = `
<td class="mono">#${schedule.id}</td>
<td>
<strong>${escapeHtml(schedule.name)}</strong>
<br>
<small class="text-muted">Config ID: ${schedule.config_id || 'N/A'}</small>
</td>
<td class="mono"><code>${escapeHtml(schedule.cron_expression)}</code></td>
<td>${formatRelativeTime(schedule.next_run)}</td>
<td>${formatRelativeTime(schedule.last_run)}</td>
<td>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox"
id="enable-${schedule.id}"
${schedule.enabled ? 'checked' : ''}
onchange="toggleSchedule(${schedule.id}, this.checked)">
<label class="form-check-label" for="enable-${schedule.id}">
${schedule.enabled ? 'Enabled' : 'Disabled'}
</label>
</div>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-secondary" onclick="triggerSchedule(${schedule.id})"
title="Run Now">
<i class="bi bi-play-fill"></i>
</button>
<a href="/schedules/${schedule.id}/edit" class="btn btn-secondary"
title="Edit">
<i class="bi bi-pencil"></i>
</a>
<button class="btn btn-danger" onclick="deleteSchedule(${schedule.id})"
title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
}
// Update stats
function updateStats(data) {
const totalSchedules = data.total || schedulesData.length;
const enabledSchedules = schedulesData.filter(s => s.enabled).length;
// Find next run time
let nextRun = null;
schedulesData.filter(s => s.enabled && s.next_run).forEach(s => {
const scheduleNext = new Date(s.next_run);
if (!nextRun || scheduleNext < nextRun) {
nextRun = scheduleNext;
}
});
// Calculate executions in last 24h (would need API support)
const recentExecutions = data.recent_executions || 0;
document.getElementById('total-schedules').textContent = totalSchedules;
document.getElementById('enabled-schedules').textContent = enabledSchedules;
document.getElementById('next-run-time').innerHTML = nextRun
? `<small>${formatRelativeTime(nextRun)}</small>`
: '<small>None</small>';
document.getElementById('recent-executions').textContent = recentExecutions;
}
// Toggle schedule enabled/disabled
async function toggleSchedule(scheduleId, enabled) {
try {
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled: enabled })
});
if (!response.ok) {
throw new Error(`Failed to update schedule: ${response.statusText}`);
}
// Reload schedules
await loadSchedules();
// Show success notification
showNotification(`Schedule ${enabled ? 'enabled' : 'disabled'} successfully`, 'success');
} catch (error) {
console.error('Error toggling schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
// Revert checkbox
document.getElementById(`enable-${scheduleId}`).checked = !enabled;
}
}
// Manually trigger schedule
async function triggerSchedule(scheduleId) {
if (!confirm('Run this schedule now?')) {
return;
}
try {
const response = await fetch(`/api/schedules/${scheduleId}/trigger`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`Failed to trigger schedule: ${response.statusText}`);
}
const data = await response.json();
showNotification(`Scan triggered! Redirecting to scan #${data.scan_id}...`, 'success');
// Redirect to scan detail page
setTimeout(() => {
window.location.href = `/scans/${data.scan_id}`;
}, 1500);
} catch (error) {
console.error('Error triggering schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
}
}
// Delete schedule
async function deleteSchedule(scheduleId) {
const schedule = schedulesData.find(s => s.id === scheduleId);
const scheduleName = schedule ? schedule.name : `#${scheduleId}`;
if (!confirm(`Delete schedule "${scheduleName}"?\n\nThis action cannot be undone. Associated scan history will be preserved.`)) {
return;
}
try {
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to delete schedule: ${response.statusText}`);
}
showNotification('Schedule deleted successfully', 'success');
// Reload schedules
await loadSchedules();
} catch (error) {
console.error('Error deleting schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
}
}
// Show notification
function showNotification(message, type = 'info') {
const container = document.getElementById('notification-container');
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
container.appendChild(notification);
// Auto-remove after 5 seconds
setTimeout(() => {
notification.remove();
}, 5000);
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load schedules on page load
document.addEventListener('DOMContentLoaded', () => {
loadSchedules();
// Refresh every 30 seconds
setInterval(loadSchedules, 30000);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}Setup - SneakyScanner{% endblock %}
{% set hide_nav = true %}
{% block content %}
<div class="login-card">
<div class="text-center mb-4">
<h1 class="brand-title">SneakyScanner</h1>
<p class="brand-subtitle">Initial Setup</p>
</div>
<div class="alert alert-info mb-4">
<i class="bi bi-info-circle me-1"></i>
<strong>Welcome!</strong> Please set an application password to secure your scanner.
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('auth.setup') }}">
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password"
class="form-control form-control-lg"
id="password"
name="password"
required
minlength="8"
autofocus
placeholder="Enter password (min 8 characters)">
<div class="form-text">Password must be at least 8 characters long.</div>
</div>
<div class="mb-4">
<label for="confirm_password" class="form-label">Confirm Password</label>
<input type="password"
class="form-control form-control-lg"
id="confirm_password"
name="confirm_password"
required
minlength="8"
placeholder="Confirm your password">
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
Set Password
</button>
</form>
</div>
{% endblock %}

1187
app/web/templates/sites.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
{
"title": "{{ scan.title }} - {{ alert.type|title|replace('_', ' ') }}",
"message": "{{ alert.message }}{% if alert.ip_address %} on {{ alert.ip_address }}{% endif %}{% if alert.port %}:{{ alert.port }}{% endif %}",
"priority": {% if alert.severity == 'critical' %}5{% elif alert.severity == 'warning' %}3{% else %}1{% endif %},
"severity": "{{ alert.severity }}",
"scan_id": {{ scan.id }},
"alert_id": {{ alert.id }},
"timestamp": "{{ timestamp.isoformat() }}"
}

View File

@@ -0,0 +1,25 @@
{
"event": "alert.created",
"alert": {
"id": {{ alert.id }},
"type": "{{ alert.type }}",
"severity": "{{ alert.severity }}",
"message": "{{ alert.message }}",
{% if alert.ip_address %}"ip_address": "{{ alert.ip_address }}",{% endif %}
{% if alert.port %}"port": {{ alert.port }},{% endif %}
"acknowledged": {{ alert.acknowledged|lower }},
"created_at": "{{ alert.created_at.isoformat() }}"
},
"scan": {
"id": {{ scan.id }},
"title": "{{ scan.title }}",
"timestamp": "{{ scan.timestamp.isoformat() }}",
"status": "{{ scan.status }}"
},
"rule": {
"id": {{ rule.id }},
"name": "{{ rule.name }}",
"type": "{{ rule.type }}",
"threshold": {{ rule.threshold if rule.threshold else 'null' }}
}
}

View File

@@ -0,0 +1,41 @@
{
"username": "SneakyScanner",
"embeds": [
{
"title": "{{ alert.type|title|replace('_', ' ') }} Alert",
"description": "{{ alert.message }}",
"color": {% if alert.severity == 'critical' %}15158332{% elif alert.severity == 'warning' %}16776960{% else %}3447003{% endif %},
"fields": [
{
"name": "Severity",
"value": "{{ alert.severity|upper }}",
"inline": true
},
{
"name": "Scan",
"value": "{{ scan.title }}",
"inline": true
},
{
"name": "Rule",
"value": "{{ rule.name }}",
"inline": false
}{% if alert.ip_address %},
{
"name": "IP Address",
"value": "{{ alert.ip_address }}",
"inline": true
}{% endif %}{% if alert.port %},
{
"name": "Port",
"value": "{{ alert.port }}",
"inline": true
}{% endif %}
],
"footer": {
"text": "Alert ID: {{ alert.id }} | Scan ID: {{ scan.id }}"
},
"timestamp": "{{ timestamp.isoformat() }}"
}
]
}

View File

@@ -0,0 +1,13 @@
{
"title": "{{ scan.title }}",
"message": "**{{ alert.severity|upper }}**: {{ alert.message }}\n\n**Scan:** {{ scan.title }}\n**Status:** {{ scan.status }}\n**Rule:** {{ rule.name }}{% if alert.ip_address %}\n**IP:** {{ alert.ip_address }}{% endif %}{% if alert.port %}\n**Port:** {{ alert.port }}{% endif %}",
"priority": {% if alert.severity == 'critical' %}8{% elif alert.severity == 'warning' %}5{% else %}2{% endif %},
"extras": {
"client::display": {
"contentType": "text/markdown"
},
"alert_id": {{ alert.id }},
"scan_id": {{ scan.id }},
"alert_type": "{{ alert.type }}"
}
}

View File

@@ -0,0 +1,10 @@
{{ alert.message }}
Scan: {{ scan.title }}
Rule: {{ rule.name }}
Severity: {{ alert.severity|upper }}{% if alert.ip_address %}
IP: {{ alert.ip_address }}{% endif %}{% if alert.port %}
Port: {{ alert.port }}{% endif %}
Scan Status: {{ scan.status }}
Alert ID: {{ alert.id }}

View File

@@ -0,0 +1,27 @@
SNEAKYSCANNER ALERT - {{ alert.severity|upper }}
Alert: {{ alert.message }}
Type: {{ alert.type|title|replace('_', ' ') }}
Severity: {{ alert.severity|upper }}
Scan Information:
Title: {{ scan.title }}
Status: {{ scan.status }}
Duration: {{ scan.duration }}s
Triggered By: {{ scan.triggered_by }}
Rule Information:
Name: {{ rule.name }}
Type: {{ rule.type }}
{% if rule.threshold %} Threshold: {{ rule.threshold }}
{% endif %}
{% if alert.ip_address %}IP Address: {{ alert.ip_address }}
{% endif %}{% if alert.port %}Port: {{ alert.port }}
{% endif %}
Alert ID: {{ alert.id }}
Scan ID: {{ scan.id }}
Timestamp: {{ timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}
---
Generated by {{ app.name }} v{{ app.version }}
{{ app.url }}

View File

@@ -0,0 +1,65 @@
[
{
"id": "default_json",
"name": "Default JSON (Current Format)",
"description": "Standard webhook payload format matching the current implementation",
"format": "json",
"content_type": "application/json",
"file": "default_json.j2",
"category": "general"
},
{
"id": "custom_json",
"name": "Custom JSON",
"description": "Flexible custom JSON format with configurable title, message, and priority fields",
"format": "json",
"content_type": "application/json",
"file": "custom_json.j2",
"category": "general"
},
{
"id": "gotify",
"name": "Gotify",
"description": "Optimized for Gotify push notification server with markdown support",
"format": "json",
"content_type": "application/json",
"file": "gotify.j2",
"category": "service"
},
{
"id": "ntfy",
"name": "Ntfy",
"description": "Simple text format for Ntfy pub-sub notification service",
"format": "text",
"content_type": "text/plain",
"file": "ntfy.j2",
"category": "service"
},
{
"id": "slack",
"name": "Slack",
"description": "Rich Block Kit format for Slack webhooks with visual formatting",
"format": "json",
"content_type": "application/json",
"file": "slack.j2",
"category": "service"
},
{
"id": "discord",
"name": "Discord",
"description": "Embedded message format for Discord webhooks with color-coded severity",
"format": "json",
"content_type": "application/json",
"file": "discord.j2",
"category": "service"
},
{
"id": "plain_text",
"name": "Plain Text",
"description": "Simple plain text format for logging or basic notification services",
"format": "text",
"content_type": "text/plain",
"file": "plain_text.j2",
"category": "general"
}
]

View File

@@ -0,0 +1,60 @@
{
"text": "{{ alert.severity|upper }}: {{ alert.message }}",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "🚨 {{ alert.severity|upper }} Alert: {{ alert.type|title|replace('_', ' ') }}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Alert:*\n{{ alert.message }}"
},
{
"type": "mrkdwn",
"text": "*Severity:*\n{{ alert.severity|upper }}"
}
]
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Scan:*\n{{ scan.title }}"
},
{
"type": "mrkdwn",
"text": "*Rule:*\n{{ rule.name }}"
}
]
}{% if alert.ip_address or alert.port %},
{
"type": "section",
"fields": [{% if alert.ip_address %}
{
"type": "mrkdwn",
"text": "*IP Address:*\n{{ alert.ip_address }}"
}{% if alert.port %},{% endif %}{% endif %}{% if alert.port %}
{
"type": "mrkdwn",
"text": "*Port:*\n{{ alert.port }}"
}{% endif %}
]
}{% endif %},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "Scan ID: {{ scan.id }} | Alert ID: {{ alert.id }} | {{ timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}"
}
]
}
]
}

View File

@@ -0,0 +1,633 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Webhook - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 mb-4">
<h1 style="color: #60a5fa;">{{ 'Edit' if mode == 'edit' else 'Create' }} Webhook</h1>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('webhooks.list_webhooks') }}">Webhooks</a></li>
<li class="breadcrumb-item active">{{ 'Edit' if mode == 'edit' else 'New' }}</li>
</ol>
</nav>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form id="webhook-form">
<!-- Basic Information -->
<h5 class="card-title mb-3">Basic Information</h5>
<div class="mb-3">
<label for="name" class="form-label">Webhook Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" required
placeholder="e.g., Slack Notifications">
<div class="form-text">A descriptive name for this webhook</div>
</div>
<div class="mb-3">
<label for="url" class="form-label">Webhook URL <span class="text-danger">*</span></label>
<input type="url" class="form-control" id="url" name="url" required
placeholder="https://hooks.example.com/webhook">
<div class="form-text">The endpoint where alerts will be sent</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enabled" name="enabled" checked>
<label class="form-check-label" for="enabled">Enabled</label>
</div>
<div class="form-text">Disabled webhooks will not receive notifications</div>
</div>
<hr class="my-4">
<!-- Authentication -->
<h5 class="card-title mb-3">Authentication</h5>
<div class="mb-3">
<label for="auth_type" class="form-label">Authentication Type</label>
<select class="form-select" id="auth_type" name="auth_type">
<option value="none">None</option>
<option value="bearer">Bearer Token</option>
<option value="basic">Basic Auth (username:password)</option>
<option value="custom">Custom Headers</option>
</select>
</div>
<div class="mb-3" id="auth_token_field" style="display: none;">
<label for="auth_token" class="form-label">Authentication Token</label>
<input type="password" class="form-control" id="auth_token" name="auth_token"
placeholder="Enter token or username:password">
<div class="form-text" id="auth_token_help">Will be encrypted when stored</div>
</div>
<div class="mb-3" id="custom_headers_field" style="display: none;">
<label for="custom_headers" class="form-label">Custom Headers (JSON)</label>
<textarea class="form-control font-monospace" id="custom_headers" name="custom_headers" rows="4"
placeholder='{"X-API-Key": "your-key", "X-Custom-Header": "value"}'></textarea>
<div class="form-text">JSON object with custom HTTP headers</div>
</div>
<hr class="my-4">
<!-- Filters -->
<h5 class="card-title mb-3">Alert Filters</h5>
<div class="mb-3">
<label class="form-label">Alert Types</label>
<div class="form-text mb-2">Select which alert types trigger this webhook (leave all unchecked for all types)</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="unexpected_port" id="type_unexpected">
<label class="form-check-label" for="type_unexpected">Unexpected Port</label>
</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="drift_detection" id="type_drift">
<label class="form-check-label" for="type_drift">Drift Detection</label>
</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="cert_expiry" id="type_cert">
<label class="form-check-label" for="type_cert">Certificate Expiry</label>
</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="weak_tls" id="type_tls">
<label class="form-check-label" for="type_tls">Weak TLS</label>
</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="ping_failed" id="type_ping">
<label class="form-check-label" for="type_ping">Ping Failed</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">Severity Filter</label>
<div class="form-text mb-2">Select which severities trigger this webhook (leave all unchecked for all severities)</div>
<div class="form-check">
<input class="form-check-input severity-check" type="checkbox" value="critical" id="severity_critical">
<label class="form-check-label" for="severity_critical">Critical</label>
</div>
<div class="form-check">
<input class="form-check-input severity-check" type="checkbox" value="warning" id="severity_warning">
<label class="form-check-label" for="severity_warning">Warning</label>
</div>
<div class="form-check">
<input class="form-check-input severity-check" type="checkbox" value="info" id="severity_info">
<label class="form-check-label" for="severity_info">Info</label>
</div>
</div>
<hr class="my-4">
<!-- Webhook Template -->
<h5 class="card-title mb-3">Webhook Template</h5>
<div class="alert alert-info small">
<i class="bi bi-info-circle"></i>
Customize the webhook payload using Jinja2 templates. Leave empty to use the default JSON format.
</div>
<div class="mb-3">
<label for="preset_selector" class="form-label">Load Preset Template</label>
<select class="form-select" id="preset_selector">
<option value="">-- Select a preset --</option>
</select>
<div class="form-text">Choose from pre-built templates for popular services</div>
</div>
<div class="mb-3">
<label for="template_format" class="form-label">Template Format</label>
<select class="form-select" id="template_format" name="template_format">
<option value="json">JSON</option>
<option value="text">Plain Text</option>
</select>
<div class="form-text">Output format of the rendered template</div>
</div>
<div class="mb-3">
<label for="template" class="form-label">Template</label>
<textarea class="form-control font-monospace" id="template" name="template" rows="12"
placeholder="Leave empty for default format, or enter custom Jinja2 template..."></textarea>
<div class="form-text">
Available variables: <code>{{ "{{" }} alert.* {{ "}}" }}</code>, <code>{{ "{{" }} scan.* {{ "}}" }}</code>, <code>{{ "{{" }} rule.* {{ "}}" }}</code>
<a href="#" data-bs-toggle="modal" data-bs-target="#variablesModal">View all variables</a>
</div>
</div>
<div class="mb-3">
<label for="content_type_override" class="form-label">Custom Content-Type (optional)</label>
<input type="text" class="form-control font-monospace" id="content_type_override" name="content_type_override"
placeholder="e.g., application/json, text/plain, text/markdown">
<div class="form-text">Override the default Content-Type header (auto-detected from template format if not set)</div>
</div>
<div class="mb-3">
<button type="button" class="btn btn-outline-secondary btn-sm" id="preview-template-btn">
<i class="bi bi-eye"></i> Preview Template
</button>
<button type="button" class="btn btn-outline-secondary btn-sm ms-2" id="clear-template-btn">
<i class="bi bi-x-circle"></i> Clear Template
</button>
</div>
<hr class="my-4">
<!-- Advanced Settings -->
<h5 class="card-title mb-3">Advanced Settings</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label for="timeout" class="form-label">Timeout (seconds)</label>
<input type="number" class="form-control" id="timeout" name="timeout" min="1" max="60" value="10">
<div class="form-text">Maximum time to wait for response</div>
</div>
<div class="col-md-6 mb-3">
<label for="retry_count" class="form-label">Retry Count</label>
<input type="number" class="form-control" id="retry_count" name="retry_count" min="0" max="5" value="3">
<div class="form-text">Number of retry attempts on failure</div>
</div>
</div>
<hr class="my-4">
<!-- Submit Buttons -->
<div class="d-flex justify-content-between">
<a href="{{ url_for('webhooks.list_webhooks') }}" class="btn btn-secondary">Cancel</a>
<div>
<button type="button" class="btn btn-outline-primary me-2" id="test-btn">
<i class="bi bi-send"></i> Test Webhook
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Save Webhook
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Help Sidebar -->
<div class="col-lg-4">
<div class="card">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-info-circle"></i> Help</h5>
<h6 class="mt-3">Payload Format</h6>
<p class="small text-muted">Default JSON payload format (can be customized with templates):</p>
<pre class="small bg-dark text-light p-2 rounded"><code>{
"event": "alert.created",
"alert": {
"id": 123,
"type": "cert_expiry",
"severity": "warning",
"message": "...",
"ip_address": "192.168.1.10",
"port": 443
},
"scan": {...},
"rule": {...}
}</code></pre>
<h6 class="mt-3">Custom Templates</h6>
<p class="small text-muted">Use Jinja2 templates to customize payloads for services like Slack, Discord, Gotify, or create your own format. Select a preset or write a custom template.</p>
<h6 class="mt-3">Authentication Types</h6>
<ul class="small">
<li><strong>None:</strong> No authentication</li>
<li><strong>Bearer:</strong> Add Authorization header with token</li>
<li><strong>Basic:</strong> Use username:password format</li>
<li><strong>Custom:</strong> Define custom HTTP headers</li>
</ul>
<h6 class="mt-3">Retry Logic</h6>
<p class="small text-muted">Failed webhooks are retried with exponential backoff (2^attempt seconds, max 60s).</p>
</div>
</div>
</div>
</div>
<!-- Template Variables Modal -->
<div class="modal fade" id="variablesModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Available Template Variables</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<h6>Alert Variables</h6>
<ul class="small font-monospace">
<li>{{ "{{" }} alert.id {{ "}}" }} - Alert ID</li>
<li>{{ "{{" }} alert.type {{ "}}" }} - Alert type (unexpected_port, cert_expiry, etc.)</li>
<li>{{ "{{" }} alert.severity {{ "}}" }} - Severity level (critical, warning, info)</li>
<li>{{ "{{" }} alert.message {{ "}}" }} - Human-readable alert message</li>
<li>{{ "{{" }} alert.ip_address {{ "}}" }} - IP address (if applicable)</li>
<li>{{ "{{" }} alert.port {{ "}}" }} - Port number (if applicable)</li>
<li>{{ "{{" }} alert.acknowledged {{ "}}" }} - Boolean: is acknowledged</li>
<li>{{ "{{" }} alert.created_at {{ "}}" }} - Alert creation timestamp</li>
</ul>
<h6 class="mt-3">Scan Variables</h6>
<ul class="small font-monospace">
<li>{{ "{{" }} scan.id {{ "}}" }} - Scan ID</li>
<li>{{ "{{" }} scan.title {{ "}}" }} - Scan title from config</li>
<li>{{ "{{" }} scan.timestamp {{ "}}" }} - Scan start time</li>
<li>{{ "{{" }} scan.duration {{ "}}" }} - Scan duration in seconds</li>
<li>{{ "{{" }} scan.status {{ "}}" }} - Scan status (running, completed, failed)</li>
<li>{{ "{{" }} scan.triggered_by {{ "}}" }} - How scan was triggered (manual, scheduled, api)</li>
</ul>
<h6 class="mt-3">Rule Variables</h6>
<ul class="small font-monospace">
<li>{{ "{{" }} rule.id {{ "}}" }} - Rule ID</li>
<li>{{ "{{" }} rule.name {{ "}}" }} - Rule name</li>
<li>{{ "{{" }} rule.type {{ "}}" }} - Rule type</li>
<li>{{ "{{" }} rule.threshold {{ "}}" }} - Rule threshold value</li>
<li>{{ "{{" }} rule.severity {{ "}}" }} - Rule severity</li>
</ul>
<h6 class="mt-3">App Variables</h6>
<ul class="small font-monospace">
<li>{{ "{{" }} app.name {{ "}}" }} - Application name</li>
<li>{{ "{{" }} app.version {{ "}}" }} - Application version</li>
<li>{{ "{{" }} app.url {{ "}}" }} - Repository URL</li>
</ul>
<h6 class="mt-3">Other Variables</h6>
<ul class="small font-monospace">
<li>{{ "{{" }} timestamp {{ "}}" }} - Current UTC timestamp</li>
</ul>
<h6 class="mt-3">Jinja2 Features</h6>
<p class="small">Templates support Jinja2 syntax including:</p>
<ul class="small">
<li>Conditionals: <code>{{ "{%" }} if alert.severity == 'critical' {{ "%}" }}...{{ "{%" }} endif {{ "%}" }}</code></li>
<li>Filters: <code>{{ "{{" }} alert.type|upper {{ "}}" }}</code>, <code>{{ "{{" }} alert.created_at.isoformat() {{ "}}" }}</code></li>
<li>Default values: <code>{{ "{{" }} alert.port|default('N/A') {{ "}}" }}</code></li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Template Preview Modal -->
<div class="modal fade" id="previewModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Template Preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="small text-muted">Preview using sample data:</p>
<pre class="bg-dark text-light p-3 rounded" id="preview-output" style="max-height: 500px; overflow-y: auto;"><code></code></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const webhookId = {{ webhook.id if webhook else 'null' }};
const mode = '{{ mode }}';
// Load template presets on page load
async function loadPresets() {
try {
const response = await fetch('/api/webhooks/template-presets');
const data = await response.json();
if (data.status === 'success') {
const selector = document.getElementById('preset_selector');
data.presets.forEach(preset => {
const option = document.createElement('option');
option.value = JSON.stringify({
template: preset.template,
format: preset.format,
content_type: preset.content_type
});
option.textContent = `${preset.name} - ${preset.description}`;
selector.appendChild(option);
});
}
} catch (error) {
console.error('Failed to load presets:', error);
}
}
// Handle preset selection
document.getElementById('preset_selector').addEventListener('change', function() {
if (!this.value) return;
try {
const preset = JSON.parse(this.value);
document.getElementById('template').value = preset.template;
document.getElementById('template_format').value = preset.format;
document.getElementById('content_type_override').value = preset.content_type;
} catch (error) {
console.error('Failed to load preset:', error);
}
});
// Handle preview template button
document.getElementById('preview-template-btn').addEventListener('click', async function() {
const template = document.getElementById('template').value.trim();
if (!template) {
alert('Please enter a template first');
return;
}
const templateFormat = document.getElementById('template_format').value;
try {
const response = await fetch('/api/webhooks/preview-template', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template: template,
template_format: templateFormat
})
});
const result = await response.json();
if (result.status === 'success') {
// Display preview in modal
const output = document.querySelector('#preview-output code');
if (templateFormat === 'json') {
// Pretty print JSON
try {
const parsed = JSON.parse(result.rendered);
output.textContent = JSON.stringify(parsed, null, 2);
} catch (e) {
output.textContent = result.rendered;
}
} else {
output.textContent = result.rendered;
}
// Show modal
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
modal.show();
} else {
alert(`Preview failed: ${result.message}`);
}
} catch (error) {
console.error('Error previewing template:', error);
alert('Failed to preview template');
}
});
// Handle clear template button
document.getElementById('clear-template-btn').addEventListener('click', function() {
if (confirm('Clear template and reset to default format?')) {
document.getElementById('template').value = '';
document.getElementById('template_format').value = 'json';
document.getElementById('content_type_override').value = '';
document.getElementById('preset_selector').value = '';
}
});
// Load presets on page load
loadPresets();
// Show/hide auth fields based on type
document.getElementById('auth_type').addEventListener('change', function() {
const authType = this.value;
const tokenField = document.getElementById('auth_token_field');
const headersField = document.getElementById('custom_headers_field');
const tokenHelp = document.getElementById('auth_token_help');
tokenField.style.display = 'none';
headersField.style.display = 'none';
if (authType === 'bearer') {
tokenField.style.display = 'block';
document.getElementById('auth_token').placeholder = 'Enter bearer token';
tokenHelp.textContent = 'Bearer token for Authorization header (encrypted when stored)';
} else if (authType === 'basic') {
tokenField.style.display = 'block';
document.getElementById('auth_token').placeholder = 'username:password';
tokenHelp.textContent = 'Format: username:password (encrypted when stored)';
} else if (authType === 'custom') {
headersField.style.display = 'block';
}
});
// Load existing webhook data if editing
if (mode === 'edit' && webhookId) {
loadWebhookData(webhookId);
}
async function loadWebhookData(id) {
try {
const response = await fetch(`/api/webhooks/${id}`);
const data = await response.json();
const webhook = data.webhook;
// Populate form fields
document.getElementById('name').value = webhook.name || '';
document.getElementById('url').value = webhook.url || '';
document.getElementById('enabled').checked = webhook.enabled;
document.getElementById('auth_type').value = webhook.auth_type || 'none';
document.getElementById('timeout').value = webhook.timeout || 10;
document.getElementById('retry_count').value = webhook.retry_count || 3;
// Trigger auth type change to show relevant fields
document.getElementById('auth_type').dispatchEvent(new Event('change'));
// Don't populate auth_token (it's encrypted)
if (webhook.custom_headers) {
document.getElementById('custom_headers').value = JSON.stringify(webhook.custom_headers, null, 2);
}
// Check alert types
if (webhook.alert_types && webhook.alert_types.length > 0) {
webhook.alert_types.forEach(type => {
const checkbox = document.querySelector(`.alert-type-check[value="${type}"]`);
if (checkbox) checkbox.checked = true;
});
}
// Check severities
if (webhook.severity_filter && webhook.severity_filter.length > 0) {
webhook.severity_filter.forEach(sev => {
const checkbox = document.querySelector(`.severity-check[value="${sev}"]`);
if (checkbox) checkbox.checked = true;
});
}
// Load template fields
if (webhook.template) {
document.getElementById('template').value = webhook.template;
}
if (webhook.template_format) {
document.getElementById('template_format').value = webhook.template_format;
}
if (webhook.content_type_override) {
document.getElementById('content_type_override').value = webhook.content_type_override;
}
} catch (error) {
console.error('Error loading webhook:', error);
alert('Failed to load webhook data');
}
}
// Form submission
document.getElementById('webhook-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
name: document.getElementById('name').value,
url: document.getElementById('url').value,
enabled: document.getElementById('enabled').checked,
auth_type: document.getElementById('auth_type').value,
timeout: parseInt(document.getElementById('timeout').value),
retry_count: parseInt(document.getElementById('retry_count').value)
};
// Add auth token if provided
const authToken = document.getElementById('auth_token').value;
if (authToken) {
formData.auth_token = authToken;
}
// Add custom headers if provided
const customHeaders = document.getElementById('custom_headers').value;
if (customHeaders.trim()) {
try {
formData.custom_headers = JSON.parse(customHeaders);
} catch (e) {
alert('Invalid JSON in custom headers');
return;
}
}
// Collect selected alert types
const alertTypes = Array.from(document.querySelectorAll('.alert-type-check:checked'))
.map(cb => cb.value);
if (alertTypes.length > 0) {
formData.alert_types = alertTypes;
}
// Collect selected severities
const severities = Array.from(document.querySelectorAll('.severity-check:checked'))
.map(cb => cb.value);
if (severities.length > 0) {
formData.severity_filter = severities;
}
// Add template fields
const template = document.getElementById('template').value.trim();
if (template) {
formData.template = template;
formData.template_format = document.getElementById('template_format').value;
const contentTypeOverride = document.getElementById('content_type_override').value.trim();
if (contentTypeOverride) {
formData.content_type_override = contentTypeOverride;
}
}
try {
const url = mode === 'edit' ? `/api/webhooks/${webhookId}` : '/api/webhooks';
const method = mode === 'edit' ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.status === 'success') {
alert('Webhook saved successfully!');
window.location.href = '{{ url_for("webhooks.list_webhooks") }}';
} else {
alert(`Failed to save webhook: ${result.message}`);
}
} catch (error) {
console.error('Error saving webhook:', error);
alert('Failed to save webhook');
}
});
// Test webhook button
document.getElementById('test-btn').addEventListener('click', async function() {
if (mode !== 'edit' || !webhookId) {
alert('Please save the webhook first before testing');
return;
}
if (!confirm('Send a test payload to this webhook?')) return;
try {
const response = await fetch(`/api/webhooks/${webhookId}/test`, { method: 'POST' });
const result = await response.json();
if (result.status === 'success') {
alert(`Test successful!\nHTTP ${result.status_code}\n${result.message}`);
} else {
alert(`Test failed:\n${result.message}`);
}
} catch (error) {
console.error('Error testing webhook:', error);
alert('Failed to test webhook');
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,250 @@
{% extends "base.html" %}
{% block title %}Webhooks - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1>Webhook Management</h1>
<a href="{{ url_for('webhooks.new_webhook') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Webhook
</a>
</div>
</div>
<!-- Loading indicator -->
<div id="loading" class="text-center my-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Webhooks table -->
<div id="webhooks-container" style="display: none;">
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th>Alert Types</th>
<th>Severity Filter</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="webhooks-tbody">
<!-- Populated via JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<nav aria-label="Webhooks pagination" id="pagination-container">
<ul class="pagination justify-content-center" id="pagination">
<!-- Populated via JavaScript -->
</ul>
</nav>
</div>
<!-- Empty state -->
<div id="empty-state" class="text-center my-5" style="display: none;">
<i class="bi bi-webhook" style="font-size: 4rem; color: #94a3b8;"></i>
<p class="text-muted mt-3">No webhooks configured yet.</p>
<a href="{{ url_for('webhooks.new_webhook') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create Your First Webhook
</a>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentPage = 1;
const perPage = 20;
async function loadWebhooks(page = 1) {
try {
const response = await fetch(`/api/webhooks?page=${page}&per_page=${perPage}`);
const data = await response.json();
if (data.webhooks && data.webhooks.length > 0) {
renderWebhooks(data.webhooks);
renderPagination(data.page, data.pages, data.total);
document.getElementById('webhooks-container').style.display = 'block';
document.getElementById('empty-state').style.display = 'none';
} else {
document.getElementById('webhooks-container').style.display = 'none';
document.getElementById('empty-state').style.display = 'block';
}
} catch (error) {
console.error('Error loading webhooks:', error);
alert('Failed to load webhooks');
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function renderWebhooks(webhooks) {
const tbody = document.getElementById('webhooks-tbody');
tbody.innerHTML = '';
webhooks.forEach(webhook => {
const row = document.createElement('tr');
// Truncate URL for display
const truncatedUrl = webhook.url.length > 50 ?
webhook.url.substring(0, 47) + '...' : webhook.url;
// Format alert types
const alertTypes = webhook.alert_types && webhook.alert_types.length > 0 ?
webhook.alert_types.map(t => `<span class="badge bg-secondary me-1">${t}</span>`).join('') :
'<span class="text-muted">All</span>';
// Format severity filter
const severityFilter = webhook.severity_filter && webhook.severity_filter.length > 0 ?
webhook.severity_filter.map(s => `<span class="badge bg-${getSeverityColor(s)} me-1">${s}</span>`).join('') :
'<span class="text-muted">All</span>';
// Status badge
const statusBadge = webhook.enabled ?
'<span class="badge bg-success">Enabled</span>' :
'<span class="badge bg-secondary">Disabled</span>';
row.innerHTML = `
<td><strong>${escapeHtml(webhook.name)}</strong></td>
<td><code class="small">${escapeHtml(truncatedUrl)}</code></td>
<td>${alertTypes}</td>
<td>${severityFilter}</td>
<td>${statusBadge}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" onclick="testWebhook(${webhook.id})" title="Test">
<i class="bi bi-send"></i>
</button>
<a href="/webhooks/${webhook.id}/edit" class="btn btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a href="/webhooks/${webhook.id}/logs" class="btn btn-outline-info" title="Logs">
<i class="bi bi-list-ul"></i>
</a>
<button class="btn btn-outline-danger" onclick="deleteWebhook(${webhook.id}, '${escapeHtml(webhook.name)}')" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
}
function renderPagination(currentPage, totalPages, totalItems) {
const pagination = document.getElementById('pagination');
pagination.innerHTML = '';
if (totalPages <= 1) {
document.getElementById('pagination-container').style.display = 'none';
return;
}
document.getElementById('pagination-container').style.display = 'block';
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage - 1}); return false;">Previous</a>`;
pagination.appendChild(prevLi);
// Page numbers
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
const li = document.createElement('li');
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
li.innerHTML = `<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i}</a>`;
pagination.appendChild(li);
} else if (i === currentPage - 3 || i === currentPage + 3) {
const li = document.createElement('li');
li.className = 'page-item disabled';
li.innerHTML = '<a class="page-link" href="#">...</a>';
pagination.appendChild(li);
}
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage + 1}); return false;">Next</a>`;
pagination.appendChild(nextLi);
}
function changePage(page) {
currentPage = page;
loadWebhooks(page);
}
async function testWebhook(id) {
if (!confirm('Send a test payload to this webhook?')) return;
try {
const response = await fetch(`/api/webhooks/${id}/test`, { method: 'POST' });
const result = await response.json();
if (result.status === 'success') {
alert(`Test successful!\nHTTP ${result.status_code}\n${result.message}`);
} else {
alert(`Test failed:\n${result.message}`);
}
} catch (error) {
console.error('Error testing webhook:', error);
alert('Failed to test webhook');
}
}
async function deleteWebhook(id, name) {
if (!confirm(`Are you sure you want to delete webhook "${name}"?`)) return;
try {
const response = await fetch(`/api/webhooks/${id}`, { method: 'DELETE' });
const result = await response.json();
if (result.status === 'success') {
alert('Webhook deleted successfully');
loadWebhooks(currentPage);
} else {
alert(`Failed to delete webhook: ${result.message}`);
}
} catch (error) {
console.error('Error deleting webhook:', error);
alert('Failed to delete webhook');
}
}
function getSeverityColor(severity) {
const colors = {
'critical': 'danger',
'warning': 'warning',
'info': 'info'
};
return colors[severity] || 'secondary';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load webhooks on page load
document.addEventListener('DOMContentLoaded', () => {
loadWebhooks(1);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,328 @@
{% extends "base.html" %}
{% block title %}Webhook Logs - {{ webhook.name }} - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 mb-4">
<h1 style="color: #60a5fa;">Webhook Delivery Logs</h1>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('webhooks.list_webhooks') }}">Webhooks</a></li>
<li class="breadcrumb-item active">{{ webhook.name }}</li>
</ol>
</nav>
</div>
</div>
<!-- Webhook Info -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h5 class="card-title">{{ webhook.name }}</h5>
<p class="text-muted mb-1"><strong>URL:</strong> <code>{{ webhook.url }}</code></p>
<p class="text-muted mb-0">
<strong>Status:</strong>
{% if webhook.enabled %}
<span class="badge bg-success">Enabled</span>
{% else %}
<span class="badge bg-secondary">Disabled</span>
{% endif %}
</p>
</div>
<div class="col-md-6 text-md-end">
<a href="{{ url_for('webhooks.edit_webhook', webhook_id=webhook.id) }}" class="btn btn-primary">
<i class="bi bi-pencil"></i> Edit Webhook
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label for="status-filter" class="form-label">Status</label>
<select class="form-select" id="status-filter">
<option value="">All</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="button" class="btn btn-primary w-100" onclick="applyFilter()">
<i class="bi bi-funnel"></i> Apply Filter
</button>
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="button" class="btn btn-outline-secondary w-100" onclick="refreshLogs()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Loading indicator -->
<div id="loading" class="text-center my-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Logs table -->
<div id="logs-container" style="display: none;">
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Timestamp</th>
<th>Alert</th>
<th>Status</th>
<th>HTTP Code</th>
<th>Attempt</th>
<th>Details</th>
</tr>
</thead>
<tbody id="logs-tbody">
<!-- Populated via JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<nav aria-label="Logs pagination" id="pagination-container">
<ul class="pagination justify-content-center" id="pagination">
<!-- Populated via JavaScript -->
</ul>
</nav>
</div>
<!-- Empty state -->
<div id="empty-state" class="text-center my-5" style="display: none;">
<i class="bi bi-list-ul" style="font-size: 4rem; color: #94a3b8;"></i>
<p class="text-muted mt-3">No delivery logs yet.</p>
<p class="small text-muted">Logs will appear here after alerts trigger this webhook.</p>
</div>
<!-- Modal for log details -->
<div class="modal fade" id="logDetailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delivery Log Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="modal-content">
<!-- Populated via JavaScript -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const webhookId = {{ webhook.id }};
let currentPage = 1;
let currentStatus = '';
const perPage = 20;
async function loadLogs(page = 1, status = '') {
try {
let url = `/api/webhooks/${webhookId}/logs?page=${page}&per_page=${perPage}`;
if (status) {
url += `&status=${status}`;
}
const response = await fetch(url);
const data = await response.json();
if (data.logs && data.logs.length > 0) {
renderLogs(data.logs);
renderPagination(data.page, data.pages, data.total);
document.getElementById('logs-container').style.display = 'block';
document.getElementById('empty-state').style.display = 'none';
} else {
document.getElementById('logs-container').style.display = 'none';
document.getElementById('empty-state').style.display = 'block';
}
} catch (error) {
console.error('Error loading logs:', error);
alert('Failed to load delivery logs');
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function renderLogs(logs) {
const tbody = document.getElementById('logs-tbody');
tbody.innerHTML = '';
logs.forEach(log => {
const row = document.createElement('tr');
// Format timestamp
const timestamp = new Date(log.delivered_at).toLocaleString();
// Status badge
const statusBadge = log.status === 'success' ?
'<span class="badge bg-success">Success</span>' :
'<span class="badge bg-danger">Failed</span>';
// HTTP code badge
const httpBadge = log.response_code ?
`<span class="badge ${log.response_code < 400 ? 'bg-success' : 'bg-danger'}">${log.response_code}</span>` :
'<span class="text-muted">N/A</span>';
// Alert info
const alertInfo = log.alert_type ?
`<span class="badge bg-secondary">${log.alert_type}</span><br><small class="text-muted">${escapeHtml(log.alert_message || '')}</small>` :
`<small class="text-muted">Alert #${log.alert_id}</small>`;
row.innerHTML = `
<td><small>${timestamp}</small></td>
<td>${alertInfo}</td>
<td>${statusBadge}</td>
<td>${httpBadge}</td>
<td>${log.attempt_number || 1}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="showLogDetails(${JSON.stringify(log).replace(/"/g, '&quot;')})">
<i class="bi bi-eye"></i> View
</button>
</td>
`;
tbody.appendChild(row);
});
}
function renderPagination(currentPage, totalPages, totalItems) {
const pagination = document.getElementById('pagination');
pagination.innerHTML = '';
if (totalPages <= 1) {
document.getElementById('pagination-container').style.display = 'none';
return;
}
document.getElementById('pagination-container').style.display = 'block';
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage - 1}); return false;">Previous</a>`;
pagination.appendChild(prevLi);
// Page numbers
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
const li = document.createElement('li');
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
li.innerHTML = `<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i}</a>`;
pagination.appendChild(li);
} else if (i === currentPage - 3 || i === currentPage + 3) {
const li = document.createElement('li');
li.className = 'page-item disabled';
li.innerHTML = '<a class="page-link" href="#">...</a>';
pagination.appendChild(li);
}
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage + 1}); return false;">Next</a>`;
pagination.appendChild(nextLi);
}
function changePage(page) {
currentPage = page;
loadLogs(page, currentStatus);
}
function applyFilter() {
currentStatus = document.getElementById('status-filter').value;
currentPage = 1;
loadLogs(1, currentStatus);
}
function refreshLogs() {
loadLogs(currentPage, currentStatus);
}
function showLogDetails(log) {
const modalContent = document.getElementById('modal-content');
let detailsHTML = `
<div class="mb-3">
<strong>Log ID:</strong> ${log.id}<br>
<strong>Alert ID:</strong> ${log.alert_id}<br>
<strong>Status:</strong> <span class="badge ${log.status === 'success' ? 'bg-success' : 'bg-danger'}">${log.status}</span><br>
<strong>HTTP Code:</strong> ${log.response_code || 'N/A'}<br>
<strong>Attempt:</strong> ${log.attempt_number || 1}<br>
<strong>Delivered At:</strong> ${new Date(log.delivered_at).toLocaleString()}
</div>
`;
if (log.response_body) {
detailsHTML += `
<div class="mb-3">
<strong>Response Body:</strong>
<pre class="bg-dark text-light p-2 rounded mt-2"><code>${escapeHtml(log.response_body)}</code></pre>
</div>
`;
}
if (log.error_message) {
detailsHTML += `
<div class="mb-3">
<strong>Error Message:</strong>
<div class="alert alert-danger mt-2">${escapeHtml(log.error_message)}</div>
</div>
`;
}
modalContent.innerHTML = detailsHTML;
const modal = new bootstrap.Modal(document.getElementById('logDetailModal'));
modal.show();
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load logs on page load
document.addEventListener('DOMContentLoaded', () => {
loadLogs(1);
});
</script>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More