diff --git a/README.md b/README.md index c0e3394..9b02d74 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ working directory. Environment variables override the built-in defaults. | `GREENBONE_WAS_SCAN_WORKER_COUNT` | `1` | Maximum number of concurrently running scan workers. The value must be greater than `0`. | | `GREENBONE_WAS_SCAN_ALERT_POLL_INTERVAL_SECONDS` | `10` | Interval, in seconds, between ZAP alert polling attempts during active scans. The value must be greater than `0`. | | `GREENBONE_WAS_SCAN_STOP_GRACE_PERIOD_SECONDS` | `300` | Grace period, in seconds, to wait for a running scan to stop before forcing it to failed. The value must be greater than `0`. | +| `GREENBONE_WAS_SCAN_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD_SECONDS` | `60` | Grace period, in seconds, added to the scan-level `ajax_spider_timeout` preference before sending a local AJAX spider stop request. | +| `GREENBONE_WAS_SCAN_PHASE_STOP_STATUS_CHANGE_TIMEOUT_SECONDS` | `60` | Time limit, in seconds, for waiting on a scan phase status change after a local stop request; when exceeded, WAS logs a warning and continues to the next phase. The value must be greater than `0`. | | `GREENBONE_WAS_SCAN_RETRY_MAX_RETRIES` | `10` | Maximum number of retry attempts for transient ZAP or storage failures. | | `GREENBONE_WAS_SCAN_RETRY_MAX_DELAY_SECONDS` | `60` | Maximum backoff delay, in seconds, between retry attempts. The value must be greater than `0`. | diff --git a/deny.toml b/deny.toml index 74c6063..51caf09 100644 --- a/deny.toml +++ b/deny.toml @@ -38,13 +38,11 @@ highlight = "all" # upstream transitions passing while causing any newly introduced duplicate # version to fail CI. skip = [ - { name = "foldhash", version = "0.1.5" }, { name = "getrandom", version = "0.2.17" }, { name = "hashbrown", version = "0.14.5" }, - { name = "hashbrown", version = "0.15.5" }, { name = "hashbrown", version = "0.16.1" }, - { name = "hashlink", version = "0.10.0" }, { name = "windows-sys", version = "0.52.0" }, + { name = "tower-http", version = "0.6.11"}, { name = "wit-bindgen", version = "0.51.0" }, ] diff --git a/doc/openapi-reference.yml b/doc/openapi-reference.yml index bbd2a8e..8a688b9 100644 --- a/doc/openapi-reference.yml +++ b/doc/openapi-reference.yml @@ -701,6 +701,7 @@ components: type: "string" type: description: "Type of the preference" + type: "string" name: description: "Display name for the preference" type: "string" @@ -711,8 +712,14 @@ components: description: "Default value for scans" type: "string" values: - description: "Allowed values" + description: "Allowed values as semicolon-separated string" type: "string" + required: + - "id" + - "type" + - "name" + - "description" + - "default" examples: scan_simple: @@ -793,9 +800,8 @@ components: }, "scan_preferences": [ - { "id": "target_port", "value": "443" }, - { "id": "use_https", "value": "1" }, - { "id": "profile", "value": "fast_scan" }, + { "id": "scan_mode", "value": "safe" }, + { "id": "ajax_spider_timeout", "value": "0" }, ], "vts": [ @@ -874,9 +880,8 @@ components: }, "scan_preferences": [ - { "id": "target_port", "value": "443" }, - { "id": "use_https", "value": "1" }, - { "id": "profile", "value": "fast_scan" }, + { "id": "scan_mode", "value": "safe" }, + { "id": "ajax_spider_timeout", "value": "0" }, ], "vts": [ @@ -1098,15 +1103,18 @@ components: value: [ { - "id": "optimize_test", - "name": "Optimize Test", - "default": true, - "description": "By default, optimize_test is enabled which means openvas does trust the remote host banners and is only launching plugins against the services they have been designed to check. For example it will check a web server claiming to be IIS only for IIS related flaws but will skip plugins testing for Apache flaws, and so on. This default behavior is used to optimize the scanning performance and to avoid false positives. If you are not sure that the banners of the remote host have been tampered with, you can disable this option.", + "id": "scan_mode", + "type": "enum", + "name": "Scan Mode", + "default": "safe", + "values": "safe;active", + "description": "Scan mode: 'safe' disables active scans, 'active' enables active scans.", }, { - "id": "plugins_timeout", - "name": "Plugins Timeout", - "default": 5, - "description": "This is the maximum lifetime, in seconds of a plugin. It may happen that some plugins are slow because of the way they are written or the way the remote server behaves. This option allows you to make sure your scan is never caught in an endless loop because of a non-finishing plugin. Doesn't affect ACT_SCANNER plugins, use 'ACT_SCANNER plugins timeout' for them instead.", + "id": "ajax_spider_timeout", + "type": "integer", + "name": "AJAX Spider Timeout", + "default": "3600", + "description": "Scan-level AJAX spider timeout in seconds, applied per target. Value 0 means unlimited.", }, ] diff --git a/doc/specs/scan-module.md b/doc/specs/scan-module.md index 1aa7600..d3c773b 100644 --- a/doc/specs/scan-module.md +++ b/doc/specs/scan-module.md @@ -170,9 +170,13 @@ If the scan is failed there should be an attempt to stop any spider or active sc Once the context is set up, the worker runs the AJAX spider for each target URL and updates the progress. The AJAX spider timeout is taken from the preferences passed to `create_scan`. -After the spider is finished, the worker runs active scans against the target URLs, updating the progress. The active scan timeout is likewise taken from the `create_scan` preferences. +Before each AJAX spider run, the worker sets the ZAP AJAX spider max-duration option (`ajaxSpider/setOptionMaxDuration`) using the effective `ajax_spider_timeout` value. -If either the AJAX spider or active scan times out, a warning is logged and an error result is added to the storage. +If `ajax_spider_timeout` is omitted, the default timeout is 3600 seconds (60 minutes). If it is set to `0`, the timeout is treated as unlimited. + +After the spider is finished, the worker either runs the active scan stage (`scan_mode=active`) or skips it (`scan_mode=safe`). + +After active-scan completion (or immediately after spider in safe mode), the worker enters a temporary passive-scan placeholder stage for progress tracking and marks it done after a short fixed wait. Alert polling and context operations do not have dedicated timeouts; transient failures are handled by the general retry mechanism. @@ -258,12 +262,12 @@ If all worker slots are occupied, additional started scans remain in `requested` ## Progress model Progress is represented internally using the per-target variables: -- A state enum (`pending`, `running`, `done`) for each stage of the scan (`spider`, `active_scan`). -- The last ZAP state for each stage (`running` or `stopped` for spider, a percentage for active scan). +- A state enum (`pending`, `running`, `done`) for each stage of the scan (`spider`, `active_scan`, `passive_scan`). +- The last ZAP state for spider (`running` or `stopped`) and percentages for active and passive scan. - A per-host progress percentage calculated as follows: - if the spider stage is not started yet, `progress = 0` - if the spider stage is started but not finished yet, `progress = 1` - - once the spider stage is finished, `progress = floor(25 + 0.75 * active_scan_percentage)` + - once the spider stage is finished, `progress = floor(25 + 0.7 * active_scan_percentage + 0.05 * passive_scan_percentage)` For the HTTP API representation, progress is exposed as `host_info`: - `all`: total number of hosts in the scan target scope. @@ -308,6 +312,7 @@ In-memory SQLite is reserved for the storage module's own unit tests. Those stor - Error paths that result in `failed` status. - Startup recovery: non-terminal scans are set to `failed` on service restart. - Alert-to-result mapping, including `Informational -> log`, all other alert risk levels -> `alarm`, URL-derived host and port extraction, and invalid alert URL fallback behavior. +- Preference-driven worker behavior, including `scan_mode=safe` active-stage skip, AJAX spider timeout option updates (including `0` for unlimited and default `3600` seconds), and passive-scan progress stage transitions. ## Notes and open questions diff --git a/doc/specs/scan-preferences.plan.md b/doc/specs/scan-preferences.plan.md new file mode 100644 index 0000000..23cc65b --- /dev/null +++ b/doc/specs/scan-preferences.plan.md @@ -0,0 +1,156 @@ +# Scan Preferences Implementation Plan + +This plan adds scanner preference support to the scan service and aligns the public API contract with `doc/openapi-reference.yml`. The initial preference set contains a `scan_mode` enum preference with values `safe` (disables active scans) and `active` (enables active scans), defaulting to `safe`, plus an `ajax_spider_timeout` preference defined once per scan, with its limit enforced per target. + +## Target Files + +- `src/scan/mod.rs` +- `src/scan/preferences.rs` (new) +- `src/scan/errors.rs` +- `src/scan/service.rs` +- `src/scan/service_tests.rs` +- `src/scan/progress.rs` +- `src/scan/progress_tests.rs` +- `src/scan/worker.rs` +- `src/scan/worker_tests.rs` +- `src/zapclient/ajaxspider.rs` +- `src/zapclient/ajaxspider_tests.rs` +- `src/zapclient/ascan.rs` +- `src/zapclient/ascan_tests.rs` +- `src/api/dto/scans.rs` +- `src/api/dto/scans_tests.rs` +- `src/api/scans.rs` +- `src/api/scans_tests.rs` +- `src/api/openapi.rs` +- `src/api/openapi_tests.rs` +- `doc/openapi-reference.yml` +- `doc/specs/scan-module.md` + +## Phase 1: Preference Registry and Defaults + +Status: Implemented + +Add a scan-owned preference registry that defines the supported scanner preferences. + +- Add a new module under `src/scan/` for scanner preferences. +- Store preference metadata and default values in one place. +- Define `scan_mode` as an enum preference with values `safe` and `active`, with default `safe`. +- Define an `ajax_spider_timeout` preference with units in seconds, configured at scan level and applied per target; value `0` means unlimited timeout. +- Keep the preference registry independent from storage and HTTP transport concerns. + +## Phase 2: API Contract Alignment + +Status: Implemented + +Update the API DTOs and OpenAPI wiring early so the public contract is stable before service behavior is finalized. + +- Ensure `POST /scans` continues to accept preference overrides. +- Ensure `GET /scans/preferences` returns the documented preference list. +- Update OpenAPI annotations and `doc/openapi-reference.yml` so the documented preference list includes `scan_mode` with allowed values `safe|active` and default `safe`, plus `ajax_spider_timeout` described as a scan-level setting enforced per target. + +## Phase 3: Scan Service Integration + +Status: Implemented + +Teach the scan service to implement the phase-2 API contract for defaults and scan creation. + +- Return the default preference list from `get_default_preferences`. +- Pass `scan_mode` and `ajax_spider_timeout` from scan creation input into the scan service preference resolution path. +- Validate preference input during scan creation. +- Allow unknown preference ids but emit a warning message for each unknown preference. +- Validate `scan_mode` values against the allowed enum values (`safe`, `active`). +- Validate the AJAX spider time limit as a non-negative integer number of seconds, where `0` means unlimited. +- Persist the scan with the effective preference set. +- Keep the persisted scan record shape unchanged. + +## Phase 4: Scan Mode Behavior + +Status: Implemented + +Implement mode-driven active-scan behavior using `scan_mode` in scan preferences. + +- Resolve the effective `scan_mode` value once per scan from the scan-level preferences passed through the scan service. +- For each target, if `scan_mode=safe`, skip the active-scan stage and proceed to the post-active-scan flow as if that stage completed normally. +- For each target, if `scan_mode=active`, run the active-scan stage as normal. +- Ensure the mode behavior applies consistently for all targets in the scan. +- Emit a debug log indicating that active scan was skipped due to `scan_mode=safe`, including scan id and target. +- Ensure progress/state transitions remain valid in both `safe` and `active` modes. + +## Phase 5: Passive Scan Progress Stage + +Status: Implemented + +Add a passive-scan target progress stage that follows active scan (or follows spider directly when `scan_mode=safe`) and contributes to overall percentage. + +- Extend target progress state to include a passive-scan stage entered after active-scan completion (or immediately after spider when `scan_mode=safe`). +- Introduce `passive_scan_percentage` in target progress tracking and keep existing stage-state semantics (`pending`, `running`, `done`). +- Implement a temporary placeholder for passive scanning: wait 5 seconds per target, then mark passive-scan stage as done. +- Treat correct passive-scan progress integration beyond the placeholder wait as out of scope for this plan. +- Update overall percentage calculation so the existing spider-crawl contribution remains included: + - old post-spider formula: `25 + 0.75 * active_scan_percentage` + - new post-spider formula: `25 + 0.7 * active_scan_percentage + 0.05 * passive_scan_percentage` +- Keep other parts of progress calculation unchanged. + +## Phase 6: AJAX Spider Limit Enforcement + +Status: Implemented + +Implement runtime enforcement of the scan-level AJAX spider limit with per-target timers. + +- Resolve `ajax_spider_timeout` once for a scan before target iteration begins. +- Start a fresh timer for each target when its AJAX spider stage starts. +- Pass the configured limit to ZAP AJAX spider start APIs if supported by the request contract. +- Enforce timeout locally in worker control flow so per-target limits are guaranteed even if ZAP-side timeout behavior changes. +- Treat `ajax_spider_timeout=0` as unlimited and skip local timeout enforcement while still running normal spider stage polling. +- When a target exceeds its limit: + - stop the target's AJAX spider activity, + - emit an info log with scan id, target, configured limit, and elapsed seconds, + - continue the remaining scan steps for the same target as if the spider stage finished normally, + - continue processing remaining targets unless a stop request or terminal worker error occurs. +- Keep timeout accounting independent per target (no shared global budget across all targets). +- Ensure the default behavior (when preference is omitted) is deterministic and documented. + +## Phase 7: Tests and Documentation + +Status: Implemented + +Add focused coverage for the new preference behavior and update any stale scan-module docs. + +- Cover preference serialization and deserialization. +- Cover scan-service default preference behavior. +- Cover warning behavior for unknown preference ids. +- Cover scan-service handling of scan-level `ajax_spider_timeout` values. +- Cover scan-service propagation and resolution of `scan_mode` for runtime behavior. +- Cover worker behavior for `scan_mode=safe`, including active-scan stage skip, debug logging, and valid per-target flow completion. +- Cover worker behavior for `scan_mode=active`, including normal active-scan execution. +- Cover worker enforcement behavior for per-target AJAX spider limits, including timeout, info logging, continuation of same-target follow-up stages, and continuation to next target. +- Cover progress-model behavior for passive-scan stage transitions and placeholder 5-second completion. +- Cover updated percentage formula using `25 + 0.7 * active_scan_percentage + 0.05 * passive_scan_percentage`. +- Cover API handler delegation and OpenAPI schema coverage. +- Update `doc/specs/scan-module.md` if it still describes the placeholder preference behavior. + +## Verification + +- Run `cargo fmt --all -- --check`. +- Run targeted unit tests for `src/api/dto/scans.rs`, `src/api/scans.rs`, `src/api/openapi.rs`, and `src/scan/service.rs`. +- Run targeted worker and ZAP client tests for `scan_mode=safe` skip behavior and `scan_mode=active` active-scan behavior. +- Run targeted worker and ZAP client tests for AJAX timeout enforcement and timeout signaling. +- Run targeted progress tests for passive-scan stage behavior and revised percentage math. +- Run the broader scan and API test suites after the focused tests pass. +- Inspect the generated OpenAPI output to confirm `/scans/preferences` returns the documented array shape and that `scan_mode` (with values `safe|active`) plus `ajax_spider_timeout` appear with correct defaults and descriptions. + +## Decisions + +- Keep the current persisted scan record shape unchanged. +- Treat the preference registry as the source of truth for defaults and documented metadata. +- Allow unknown preference ids and emit warning messages instead of rejecting scan creation. +- Pass `scan_mode` through scan creation to the scan service. +- In `scan_mode=safe`, skip active-scan stage; in `scan_mode=active`, run active scan. +- Treat the AJAX spider time limit as a scan-level seconds value and enforce it independently for each target in the scan. +- Treat `ajax_spider_timeout=0` as unlimited. +- Introduce passive-scan progress as a temporary 5-second per-target placeholder stage after active scan (or after spider when `scan_mode=safe`). Full handling of the passive scan is out of scope for this plan. + +## Noted Deviations + +- Deviation from Phase 6 local enforcement: the worker no longer stops AJAX spider scans locally when timeout is exceeded. Instead, it sets the ZAP AJAX spider option (`ajaxSpider/setOptionMaxDuration`) before each spider run and relies on ZAP-side timeout behavior. +- Deviation from original default timeout: the default `ajax_spider_timeout` is `3600` seconds (60 minutes) instead of `0`. diff --git a/src/api/dto/scans.rs b/src/api/dto/scans.rs index ececbc8..2e38b3a 100644 --- a/src/api/dto/scans.rs +++ b/src/api/dto/scans.rs @@ -131,7 +131,32 @@ pub enum ResultType { /// Response body for GET /scans/preferences – available scanner preferences. #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] -pub struct PreferencesResponse {} +#[serde(transparent)] +pub struct PreferencesResponse( + /// Available scanner preferences and their default values. + pub Vec, +); + +/// Metadata entry returned by GET /scans/preferences. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] +pub struct ScannerPreferenceMetadata { + /// Preference identifier. + pub id: String, + /// Preference value type (for example: enum, integer). + #[serde(rename = "type")] + pub preference_type: String, + /// Display name for the preference. + pub name: String, + /// Human-readable preference description. + pub description: String, + /// Default value for new scans. + #[serde(rename = "default")] + pub default_value: String, + /// Allowed values for constrained preference types, represented as a semicolon-separated string. + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option, +} /// Response body for GET /scans/{id} – full scan details. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/src/api/dto/scans_tests.rs b/src/api/dto/scans_tests.rs index 77a48d0..1301010 100644 --- a/src/api/dto/scans_tests.rs +++ b/src/api/dto/scans_tests.rs @@ -3,9 +3,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use super::{ - Credential, Parameter, ScanAction, ScanActionRequest, ScanDetailResponse, ScanRequest, - ScanResultResponse, ScanStatusResponse, ScannerPreference, Target, UsernamePasswordCredential, - Vt, + Credential, Parameter, PreferencesResponse, ScanAction, ScanActionRequest, ScanDetailResponse, + ScanRequest, ScanResultResponse, ScanStatusResponse, ScannerPreference, + ScannerPreferenceMetadata, Target, UsernamePasswordCredential, Vt, }; use crate::{api::dto::scans::ResultType, scan::ScanStatus}; @@ -235,3 +235,51 @@ fn host_info_round_trips_with_serde_json() { let decoded = serde_json::from_str::(&json).expect("host info should deserialize"); assert_eq!(decoded, info); } + +#[test] +fn preferences_response_round_trips_as_array_payload() { + let payload = PreferencesResponse(vec![ScannerPreferenceMetadata { + id: "scan_mode".to_string(), + preference_type: "enum".to_string(), + name: "Scan Mode".to_string(), + description: "Scan mode for active scanning".to_string(), + default_value: "safe".to_string(), + values: Some("safe;active".to_string()), + }]); + + let json = serde_json::to_value(&payload).expect("preferences response should serialize"); + assert!( + json.is_array(), + "preferences response must serialize as array" + ); + assert_eq!(json[0]["id"], "scan_mode"); + assert_eq!(json[0]["type"], "enum"); + assert_eq!(json[0]["default"], "safe"); + assert_eq!(json[0]["values"], "safe;active"); + + let decoded = serde_json::from_value::(json) + .expect("preferences should deserialize"); + assert_eq!(decoded, payload); +} + +#[test] +fn preferences_response_supports_ajax_spider_timeout_definition() { + let json = serde_json::json!([ + { + "id": "ajax_spider_timeout", + "type": "integer", + "name": "AJAX Spider Timeout", + "description": "Scan-level timeout in seconds; 0 means unlimited", + "default": "3600" + } + ]); + + let decoded = serde_json::from_value::(json) + .expect("ajax_spider_timeout preference should deserialize"); + + assert_eq!(decoded.0.len(), 1); + assert_eq!(decoded.0[0].id, "ajax_spider_timeout"); + assert_eq!(decoded.0[0].preference_type, "integer"); + assert_eq!(decoded.0[0].default_value, "3600"); + assert!(decoded.0[0].values.is_none()); +} diff --git a/src/api/openapi.rs b/src/api/openapi.rs index e856c93..fa73b78 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -50,6 +50,7 @@ use crate::{api, scan::status::ScanStatus}; api::dto::scans::ScanStatusResponse, api::dto::scans::ScanResultResponse, api::dto::scans::PreferencesResponse, + api::dto::scans::ScannerPreferenceMetadata, api::dto::scans::Target, api::dto::scans::Credential, api::dto::scans::UsernamePasswordCredential, diff --git a/src/api/openapi_tests.rs b/src/api/openapi_tests.rs index d128b52..0550fd3 100644 --- a/src/api/openapi_tests.rs +++ b/src/api/openapi_tests.rs @@ -125,6 +125,7 @@ async fn openapi_spec_includes_all_dto_schemas() { assert!(components["ScanStatusResponse"].is_object()); assert!(components["ScanResultResponse"].is_object()); assert!(components["PreferencesResponse"].is_object()); + assert!(components["ScannerPreferenceMetadata"].is_object()); assert!(components["Target"].is_object()); assert!(components["Credential"].is_object()); assert!(components["UsernamePasswordCredential"].is_object()); diff --git a/src/api/scans.rs b/src/api/scans.rs index 2f46595..5bf0077 100644 --- a/src/api/scans.rs +++ b/src/api/scans.rs @@ -55,6 +55,7 @@ fn scan_service_err(e: ScanServiceError) -> Response { ScanServiceError::InvalidTransition { .. } => StatusCode::NOT_ACCEPTABLE.into_response(), ScanServiceError::ScanNotFound(_) => StatusCode::NOT_FOUND.into_response(), ScanServiceError::InvalidUrl { .. } => StatusCode::BAD_REQUEST.into_response(), + ScanServiceError::InvalidPreference { .. } => StatusCode::BAD_REQUEST.into_response(), ScanServiceError::Storage(storage_error) => storage_err(storage_error), ScanServiceError::ZapClient(zap_error) => { tracing::error!("scan service zap client error: {}", zap_error); @@ -389,12 +390,12 @@ fn progress_to_host_info(progress: &ScanProgress) -> HostInfo { let finished = progress .targets .iter() - .filter(|t| t.active_scan_state == StageState::Done) + .filter(|t| t.passive_scan_state == StageState::Done) .count() as i32; let mut scanning = BTreeMap::new(); for target in progress.targets.iter() { if target.spider_state != StageState::Pending - && target.active_scan_state != StageState::Done + && target.passive_scan_state != StageState::Done { scanning.insert(target.target.clone(), target.overall_percentage); } diff --git a/src/api/scans_tests.rs b/src/api/scans_tests.rs index 72723fa..4751f5c 100644 --- a/src/api/scans_tests.rs +++ b/src/api/scans_tests.rs @@ -305,9 +305,9 @@ fn active_scan_running_target_appears_in_scanning_with_formula_progress() { let info = progress_to_host_info(&progress); - // floor(25 + 0.75 * 50) = 62 + // floor(25 + 0.70 * 50 + 0.05 * 0) = 60 assert_eq!(info.scanning.len(), 1); - assert_eq!(info.scanning.get("http://a.example"), Some(&62)); + assert_eq!(info.scanning.get("http://a.example"), Some(&60)); assert_eq!(info.queued, 0); assert_eq!(info.finished, 0); } @@ -315,7 +315,7 @@ fn active_scan_running_target_appears_in_scanning_with_formula_progress() { // ─── active scan done ───────────────────────────────────────────────────────── #[test] -fn active_scan_done_target_counted_as_finished() { +fn active_scan_done_target_is_still_scanning_until_passive_scan_is_done() { let mut progress = make_progress(&["http://a.example"]); progress.mark_spider_running(0); progress.mark_spider_done(0); @@ -323,6 +323,21 @@ fn active_scan_done_target_counted_as_finished() { let info = progress_to_host_info(&progress); + assert_eq!(info.finished, 0); + assert_eq!(info.queued, 0); + assert_eq!(info.scanning.get("http://a.example"), Some(&95)); +} + +#[test] +fn passive_scan_done_target_counted_as_finished() { + let mut progress = make_progress(&["http://a.example"]); + progress.mark_spider_running(0); + progress.mark_spider_done(0); + progress.mark_active_scan_done(0); + progress.mark_passive_scan_done(0); + + let info = progress_to_host_info(&progress); + assert_eq!(info.finished, 1); assert_eq!(info.queued, 0); assert!(info.scanning.is_empty()); @@ -342,7 +357,7 @@ fn mixed_targets_populate_queued_scanning_and_finished_correctly() { // index 0: stays pending (queued) // index 1: spider running (scanning, progress = 1) progress.mark_spider_running(1); - // index 2: spider done, active scan at 40% (scanning, progress = floor(25 + 30) = 55) + // index 2: spider done, active scan at 40% (scanning, progress = floor(25 + 28 + 0) = 53) progress.mark_spider_running(2); progress.mark_spider_done(2); progress.mark_active_scan_running(2); @@ -351,6 +366,7 @@ fn mixed_targets_populate_queued_scanning_and_finished_correctly() { progress.mark_spider_running(3); progress.mark_spider_done(3); progress.mark_active_scan_done(3); + progress.mark_passive_scan_done(3); let info = progress_to_host_info(&progress); @@ -360,7 +376,7 @@ fn mixed_targets_populate_queued_scanning_and_finished_correctly() { assert_eq!(info.scanning.len(), 2); assert_eq!(info.alive, 1); assert_eq!(info.scanning.get("http://spider.example"), Some(&1)); - assert_eq!(info.scanning.get("http://active.example"), Some(&55)); + assert_eq!(info.scanning.get("http://active.example"), Some(&53)); } // ─── empty target list ──────────────────────────────────────────────────────── diff --git a/src/config/settings.rs b/src/config/settings.rs index f4c41e7..444b4b7 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -39,6 +39,12 @@ pub const DEFAULT_SCAN_ALERT_POLL_INTERVAL_SECONDS: u64 = 10; /// Default stop grace period in seconds. pub const DEFAULT_SCAN_STOP_GRACE_PERIOD_SECONDS: u64 = 300; +/// Default grace period added to scan-level AJAX spider timeout before forcing a stop request. +pub const DEFAULT_SCAN_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD_SECONDS: u64 = 60; + +/// Default time limit for waiting on phase status changes after stop requests. +pub const DEFAULT_SCAN_PHASE_STOP_STATUS_CHANGE_TIMEOUT_SECONDS: u64 = 60; + /// Default maximum number of retry attempts for transient failures. pub const DEFAULT_SCAN_RETRY_MAX_RETRIES: u32 = 10; @@ -77,6 +83,10 @@ pub struct Settings { pub scan_alert_poll_interval_seconds: u64, /// Grace period in seconds to wait for running scans to stop before forcing failure. pub scan_stop_grace_period_seconds: u64, + /// Grace period in seconds added to scan-level AJAX spider timeout before issuing a stop. + pub scan_ajax_spider_timeout_grace_period_seconds: u64, + /// Time limit in seconds for waiting on scan phase status changes after stop requests. + pub scan_phase_stop_status_change_timeout_seconds: u64, /// Maximum number of retry attempts for transient ZAP or storage failures. pub scan_retry_max_retries: u32, /// Maximum backoff delay between retry attempts, in seconds. @@ -96,6 +106,8 @@ struct RawSettings { scan_worker_count: usize, scan_alert_poll_interval_seconds: u64, scan_stop_grace_period_seconds: u64, + scan_ajax_spider_timeout_grace_period_seconds: u64, + scan_phase_stop_status_change_timeout_seconds: u64, scan_retry_max_retries: u32, scan_retry_max_delay_seconds: u64, } @@ -135,6 +147,14 @@ impl Settings { "scan_stop_grace_period_seconds", DEFAULT_SCAN_STOP_GRACE_PERIOD_SECONDS, )? + .set_default( + "scan_ajax_spider_timeout_grace_period_seconds", + DEFAULT_SCAN_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD_SECONDS, + )? + .set_default( + "scan_phase_stop_status_change_timeout_seconds", + DEFAULT_SCAN_PHASE_STOP_STATUS_CHANGE_TIMEOUT_SECONDS, + )? .set_default( "scan_retry_max_retries", DEFAULT_SCAN_RETRY_MAX_RETRIES as i64, @@ -171,6 +191,12 @@ impl Settings { )); } + if raw.scan_phase_stop_status_change_timeout_seconds == 0 { + return Err(ConfigError::Message( + "scan_phase_stop_status_change_timeout_seconds must be greater than 0".to_string(), + )); + } + if raw.scan_retry_max_delay_seconds == 0 { return Err(ConfigError::Message( "scan_retry_max_delay_seconds must be greater than 0".to_string(), @@ -215,6 +241,10 @@ impl Settings { scan_worker_count: raw.scan_worker_count, scan_alert_poll_interval_seconds: raw.scan_alert_poll_interval_seconds, scan_stop_grace_period_seconds: raw.scan_stop_grace_period_seconds, + scan_ajax_spider_timeout_grace_period_seconds: raw + .scan_ajax_spider_timeout_grace_period_seconds, + scan_phase_stop_status_change_timeout_seconds: raw + .scan_phase_stop_status_change_timeout_seconds, scan_retry_max_retries: raw.scan_retry_max_retries, scan_retry_max_delay_seconds: raw.scan_retry_max_delay_seconds, }) diff --git a/src/config/settings_tests.rs b/src/config/settings_tests.rs index d78ecdb..76005d0 100644 --- a/src/config/settings_tests.rs +++ b/src/config/settings_tests.rs @@ -21,6 +21,8 @@ fn clear_env() { env::remove_var("GREENBONE_WAS_SCAN_WORKER_COUNT"); env::remove_var("GREENBONE_WAS_SCAN_ALERT_POLL_INTERVAL_SECONDS"); env::remove_var("GREENBONE_WAS_SCAN_STOP_GRACE_PERIOD_SECONDS"); + env::remove_var("GREENBONE_WAS_SCAN_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD_SECONDS"); + env::remove_var("GREENBONE_WAS_SCAN_PHASE_STOP_STATUS_CHANGE_TIMEOUT_SECONDS"); env::remove_var("GREENBONE_WAS_SCAN_RETRY_MAX_RETRIES"); env::remove_var("GREENBONE_WAS_SCAN_RETRY_MAX_DELAY_SECONDS"); } @@ -56,6 +58,14 @@ fn test_uses_defaults_when_env_is_unset() { settings.scan_stop_grace_period_seconds, settings::DEFAULT_SCAN_STOP_GRACE_PERIOD_SECONDS ); + assert_eq!( + settings.scan_ajax_spider_timeout_grace_period_seconds, + settings::DEFAULT_SCAN_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD_SECONDS + ); + assert_eq!( + settings.scan_phase_stop_status_change_timeout_seconds, + settings::DEFAULT_SCAN_PHASE_STOP_STATUS_CHANGE_TIMEOUT_SECONDS + ); assert_eq!( settings.scan_retry_max_retries, settings::DEFAULT_SCAN_RETRY_MAX_RETRIES @@ -82,6 +92,14 @@ fn test_uses_env_overrides_when_set() { env::set_var("GREENBONE_WAS_SCAN_WORKER_COUNT", "3"); env::set_var("GREENBONE_WAS_SCAN_ALERT_POLL_INTERVAL_SECONDS", "15"); env::set_var("GREENBONE_WAS_SCAN_STOP_GRACE_PERIOD_SECONDS", "120"); + env::set_var( + "GREENBONE_WAS_SCAN_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD_SECONDS", + "45", + ); + env::set_var( + "GREENBONE_WAS_SCAN_PHASE_STOP_STATUS_CHANGE_TIMEOUT_SECONDS", + "33", + ); env::set_var("GREENBONE_WAS_SCAN_RETRY_MAX_RETRIES", "7"); env::set_var("GREENBONE_WAS_SCAN_RETRY_MAX_DELAY_SECONDS", "45"); }; @@ -99,6 +117,8 @@ fn test_uses_env_overrides_when_set() { assert_eq!(settings.scan_worker_count, 3); assert_eq!(settings.scan_alert_poll_interval_seconds, 15); assert_eq!(settings.scan_stop_grace_period_seconds, 120); + assert_eq!(settings.scan_ajax_spider_timeout_grace_period_seconds, 45); + assert_eq!(settings.scan_phase_stop_status_change_timeout_seconds, 33); assert_eq!(settings.scan_retry_max_retries, 7); assert_eq!(settings.scan_retry_max_delay_seconds, 45); } @@ -291,3 +311,23 @@ fn test_zero_scan_stop_grace_period_is_error() { let err = result.err().unwrap(); assert!(err.to_string().contains("scan_stop_grace_period_seconds")); } + +#[test] +#[serial] +fn test_zero_scan_phase_stop_status_change_timeout_is_error() { + clear_env(); + unsafe { + env::set_var( + "GREENBONE_WAS_SCAN_PHASE_STOP_STATUS_CHANGE_TIMEOUT_SECONDS", + "0", + ); + } + + let result = Settings::load(); + assert!(result.is_err()); + let err = result.err().unwrap(); + assert!( + err.to_string() + .contains("scan_phase_stop_status_change_timeout_seconds") + ); +} diff --git a/src/lib.rs b/src/lib.rs index 7eb381a..22d7fce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,6 +66,12 @@ pub async fn run() -> Result<(), AppError> { stop_grace_period: std::time::Duration::from_secs( settings.scan_stop_grace_period_seconds, ), + ajax_spider_timeout_grace_period: std::time::Duration::from_secs( + settings.scan_ajax_spider_timeout_grace_period_seconds, + ), + phase_stop_status_change_timeout: std::time::Duration::from_secs( + settings.scan_phase_stop_status_change_timeout_seconds, + ), retry_max_retries: settings.scan_retry_max_retries, retry_max_delay: std::time::Duration::from_secs(settings.scan_retry_max_delay_seconds), ..ScanRuntimeConfig::default() diff --git a/src/scan/errors.rs b/src/scan/errors.rs index 677e610..a40bf09 100644 --- a/src/scan/errors.rs +++ b/src/scan/errors.rs @@ -26,6 +26,14 @@ pub enum ScanServiceError { #[error("invalid target url '{value}': {reason}")] InvalidUrl { value: String, reason: String }, + /// Scanner preference failed validation. + #[error("invalid scanner preference '{id}' with value '{value}': {reason}")] + InvalidPreference { + id: String, + value: String, + reason: String, + }, + /// Storage backend failure. #[error(transparent)] Storage(#[from] StorageError), diff --git a/src/scan/mod.rs b/src/scan/mod.rs index 3ae5ff0..199ec3f 100644 --- a/src/scan/mod.rs +++ b/src/scan/mod.rs @@ -7,6 +7,7 @@ pub mod errors; mod model; mod observability; +pub mod preferences; pub mod progress; pub mod queue; pub mod retry; diff --git a/src/scan/preferences.rs b/src/scan/preferences.rs new file mode 100644 index 0000000..95f56a2 --- /dev/null +++ b/src/scan/preferences.rs @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2026 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Scan-owned scanner preference definitions and defaults. + +use serde::{Deserialize, Serialize}; + +/// Preference ID for selecting scan behavior mode. +pub const SCAN_MODE_PREFERENCE_ID: &str = "scan_mode"; + +/// Preference ID for AJAX spider timeout in seconds. +pub const AJAX_SPIDER_TIMEOUT_PREFERENCE_ID: &str = "ajax_spider_timeout"; + +/// Scan execution mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ScanMode { + /// Disable active scan stage. + Safe, + /// Enable active scan stage. + Active, +} + +impl ScanMode { + /// Default scan mode used when no preference override is provided. + pub const fn default_mode() -> Self { + Self::Safe + } + + /// String representation used in preference transport values. + pub const fn as_str(self) -> &'static str { + match self { + Self::Safe => "safe", + Self::Active => "active", + } + } +} + +/// Logical value type for preference metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreferenceValueType { + /// Finite set of string values. + Enum, + /// Non-negative decimal integer represented as string. + Integer, +} + +impl PreferenceValueType { + /// Lowercase schema-friendly type name. + pub const fn as_str(self) -> &'static str { + match self { + Self::Enum => "enum", + Self::Integer => "integer", + } + } +} + +/// Static definition for one supported scanner preference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScannerPreferenceDefinition { + /// Stable preference ID. + pub id: &'static str, + /// Human-readable preference display name. + pub name: &'static str, + /// Description shown by preference discovery endpoint. + pub description: &'static str, + /// Logical value type. + pub value_type: PreferenceValueType, + /// Default value encoded as string for transport compatibility. + pub default_value: &'static str, + /// Allowed values for enum preferences; empty for numeric values. + pub allowed_values: &'static [&'static str], +} + +/// `scan_mode` preference definition. +pub const SCAN_MODE_PREFERENCE: ScannerPreferenceDefinition = ScannerPreferenceDefinition { + id: SCAN_MODE_PREFERENCE_ID, + name: "Scan Mode", + description: "Scan mode: 'safe' disables active scans, 'active' enables active scans.", + value_type: PreferenceValueType::Enum, + default_value: "safe", + allowed_values: &["safe", "active"], +}; + +/// `ajax_spider_timeout` preference definition. +pub const AJAX_SPIDER_TIMEOUT_PREFERENCE: ScannerPreferenceDefinition = + ScannerPreferenceDefinition { + id: AJAX_SPIDER_TIMEOUT_PREFERENCE_ID, + name: "AJAX Spider Timeout", + description: "Scan-level AJAX spider timeout in seconds; value 0 means unlimited. Default is 3600 seconds (60 minutes).", + value_type: PreferenceValueType::Integer, + default_value: "3600", + allowed_values: &[], + }; + +/// All supported scanner preference definitions. +pub fn preference_definitions() -> &'static [ScannerPreferenceDefinition] { + &[SCAN_MODE_PREFERENCE, AJAX_SPIDER_TIMEOUT_PREFERENCE] +} + +/// Default scanner preference values as `(id, value)` tuples. +pub fn default_preference_values() -> Vec<(&'static str, &'static str)> { + preference_definitions() + .iter() + .map(|pref| (pref.id, pref.default_value)) + .collect() +} + +#[cfg(test)] +#[path = "preferences_tests.rs"] +mod preferences_tests; diff --git a/src/scan/preferences_tests.rs b/src/scan/preferences_tests.rs new file mode 100644 index 0000000..78b4d47 --- /dev/null +++ b/src/scan/preferences_tests.rs @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{ + AJAX_SPIDER_TIMEOUT_PREFERENCE, AJAX_SPIDER_TIMEOUT_PREFERENCE_ID, PreferenceValueType, + SCAN_MODE_PREFERENCE, SCAN_MODE_PREFERENCE_ID, ScanMode, default_preference_values, + preference_definitions, +}; + +#[test] +fn scan_mode_default_is_safe() { + assert_eq!(ScanMode::default_mode(), ScanMode::Safe); + assert_eq!(ScanMode::default_mode().as_str(), "safe"); +} + +#[test] +fn preference_definitions_include_scan_mode_and_ajax_spider_timeout() { + let defs = preference_definitions(); + assert_eq!(defs.len(), 2); + assert!(defs.iter().any(|p| p.id == SCAN_MODE_PREFERENCE_ID)); + assert!( + defs.iter() + .any(|p| p.id == AJAX_SPIDER_TIMEOUT_PREFERENCE_ID) + ); +} + +#[test] +fn scan_mode_preference_definition_matches_contract() { + assert_eq!(SCAN_MODE_PREFERENCE.id, SCAN_MODE_PREFERENCE_ID); + assert_eq!(SCAN_MODE_PREFERENCE.value_type, PreferenceValueType::Enum); + assert_eq!(SCAN_MODE_PREFERENCE.default_value, "safe"); + assert_eq!(SCAN_MODE_PREFERENCE.allowed_values, &["safe", "active"]); +} + +#[test] +fn ajax_spider_timeout_definition_has_3600_second_default() { + assert_eq!( + AJAX_SPIDER_TIMEOUT_PREFERENCE.id, + AJAX_SPIDER_TIMEOUT_PREFERENCE_ID + ); + assert_eq!( + AJAX_SPIDER_TIMEOUT_PREFERENCE.value_type, + PreferenceValueType::Integer + ); + assert_eq!(AJAX_SPIDER_TIMEOUT_PREFERENCE.default_value, "3600"); + assert!( + AJAX_SPIDER_TIMEOUT_PREFERENCE + .description + .contains("Default is 3600 seconds") + ); + assert!( + AJAX_SPIDER_TIMEOUT_PREFERENCE + .description + .contains("0 means unlimited") + ); +} + +#[test] +fn default_preference_values_match_definition_defaults() { + let defaults = default_preference_values(); + assert_eq!(defaults.len(), 2); + + assert!( + defaults + .iter() + .any(|(id, value)| *id == SCAN_MODE_PREFERENCE_ID && *value == "safe") + ); + assert!( + defaults + .iter() + .any(|(id, value)| *id == AJAX_SPIDER_TIMEOUT_PREFERENCE_ID && *value == "3600") + ); +} diff --git a/src/scan/progress.rs b/src/scan/progress.rs index 0d8e265..532d019 100644 --- a/src/scan/progress.rs +++ b/src/scan/progress.rs @@ -6,9 +6,10 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StageState { + #[default] Pending, Running, Done, @@ -21,6 +22,10 @@ pub struct TargetProgress { pub spider_last_status: Option, pub active_scan_state: StageState, pub active_scan_percentage: i32, + #[serde(default)] + pub passive_scan_state: StageState, + #[serde(default)] + pub passive_scan_percentage: i32, pub overall_percentage: i32, } @@ -41,6 +46,8 @@ impl ScanProgress { spider_last_status: None, active_scan_state: StageState::Pending, active_scan_percentage: 0, + passive_scan_state: StageState::Pending, + passive_scan_percentage: 0, overall_percentage: 0, }) .collect(); @@ -87,6 +94,28 @@ impl ScanProgress { self.refresh(); } + pub fn mark_passive_scan_running(&mut self, index: usize) { + let target = &mut self.targets[index]; + target.passive_scan_state = StageState::Running; + self.refresh(); + } + + pub fn update_passive_scan(&mut self, index: usize, percentage: i32) { + let target = &mut self.targets[index]; + target.passive_scan_percentage = percentage.clamp(0, 100); + if target.passive_scan_percentage >= 100 { + target.passive_scan_state = StageState::Done; + } + self.refresh(); + } + + pub fn mark_passive_scan_done(&mut self, index: usize) { + let target = &mut self.targets[index]; + target.passive_scan_state = StageState::Done; + target.passive_scan_percentage = 100; + self.refresh(); + } + pub fn as_value(&self) -> serde_json::Value { serde_json::to_value(self).expect("scan progress should serialize") } @@ -96,8 +125,9 @@ impl ScanProgress { let spider_done = matches!(target.spider_state, StageState::Done); let spider_running = matches!(target.spider_state, StageState::Running); let active_pct = target.active_scan_percentage.clamp(0, 100) as f64; + let passive_pct = target.passive_scan_percentage.clamp(0, 100) as f64; target.overall_percentage = if spider_done { - (25.0 + (0.75 * active_pct)).floor() as i32 + (25.0 + (0.70 * active_pct) + (0.05 * passive_pct)).floor() as i32 } else if spider_running { 1 } else { diff --git a/src/scan/progress_tests.rs b/src/scan/progress_tests.rs index ca24748..788c67e 100644 --- a/src/scan/progress_tests.rs +++ b/src/scan/progress_tests.rs @@ -21,6 +21,8 @@ fn new_creates_pending_targets_with_zero_progress() { assert_eq!(target.spider_last_status, None); assert_eq!(target.active_scan_state, StageState::Pending); assert_eq!(target.active_scan_percentage, 0); + assert_eq!(target.passive_scan_state, StageState::Pending); + assert_eq!(target.passive_scan_percentage, 0); assert_eq!(target.overall_percentage, 0); } } @@ -73,12 +75,12 @@ fn overall_percentage_applies_formula_for_active_scan_at_50_percent() { progress.mark_active_scan_running(0); progress.update_active_scan(0, 50); - // floor(25 + 0.75 * 50) = floor(62.5) = 62 - assert_eq!(progress.targets[0].overall_percentage, 62); + // floor(25 + 0.70 * 50 + 0.05 * 0) = 60 + assert_eq!(progress.targets[0].overall_percentage, 60); } #[test] -fn overall_percentage_is_100_when_active_scan_done() { +fn overall_percentage_is_95_when_active_scan_done_and_passive_scan_is_pending() { let hosts = vec!["http://a.example".to_string()]; let mut progress = ScanProgress::new(&hosts); @@ -86,7 +88,21 @@ fn overall_percentage_is_100_when_active_scan_done() { progress.mark_spider_done(0); progress.mark_active_scan_done(0); - // floor(25 + 0.75 * 100) = 100 + // floor(25 + 0.70 * 100 + 0.05 * 0) = 95 + assert_eq!(progress.targets[0].overall_percentage, 95); +} + +#[test] +fn overall_percentage_is_100_when_active_and_passive_scan_done() { + let hosts = vec!["http://a.example".to_string()]; + let mut progress = ScanProgress::new(&hosts); + + progress.mark_spider_running(0); + progress.mark_spider_done(0); + progress.mark_active_scan_done(0); + progress.mark_passive_scan_done(0); + + // floor(25 + 0.70 * 100 + 0.05 * 100) = 100 assert_eq!(progress.targets[0].overall_percentage, 100); } @@ -190,6 +206,78 @@ fn mark_active_scan_done_sets_percentage_to_100() { assert_eq!(progress.targets[0].active_scan_state, StageState::Done); } +// ─── mark_passive_scan_running ─────────────────────────────────────────────── + +#[test] +fn mark_passive_scan_running_sets_state_to_running() { + let hosts = vec!["http://a.example".to_string()]; + let mut progress = ScanProgress::new(&hosts); + + progress.mark_spider_running(0); + progress.mark_spider_done(0); + progress.mark_active_scan_done(0); + progress.mark_passive_scan_running(0); + + assert_eq!(progress.targets[0].passive_scan_state, StageState::Running); +} + +// ─── update_passive_scan ───────────────────────────────────────────────────── + +#[test] +fn update_passive_scan_clamps_negative_percentage_to_zero() { + let hosts = vec!["http://a.example".to_string()]; + let mut progress = ScanProgress::new(&hosts); + + progress.mark_spider_running(0); + progress.mark_spider_done(0); + progress.mark_active_scan_done(0); + progress.update_passive_scan(0, -10); + + assert_eq!(progress.targets[0].passive_scan_percentage, 0); +} + +#[test] +fn update_passive_scan_clamps_percentage_above_100() { + let hosts = vec!["http://a.example".to_string()]; + let mut progress = ScanProgress::new(&hosts); + + progress.mark_spider_running(0); + progress.mark_spider_done(0); + progress.mark_active_scan_done(0); + progress.update_passive_scan(0, 150); + + assert_eq!(progress.targets[0].passive_scan_percentage, 100); +} + +#[test] +fn update_passive_scan_at_100_transitions_state_to_done() { + let hosts = vec!["http://a.example".to_string()]; + let mut progress = ScanProgress::new(&hosts); + + progress.mark_spider_running(0); + progress.mark_spider_done(0); + progress.mark_active_scan_done(0); + progress.update_passive_scan(0, 100); + + assert_eq!(progress.targets[0].passive_scan_state, StageState::Done); +} + +// ─── mark_passive_scan_done ────────────────────────────────────────────────── + +#[test] +fn mark_passive_scan_done_sets_percentage_to_100() { + let hosts = vec!["http://a.example".to_string()]; + let mut progress = ScanProgress::new(&hosts); + + progress.mark_spider_running(0); + progress.mark_spider_done(0); + progress.mark_active_scan_done(0); + progress.mark_passive_scan_done(0); + + assert_eq!(progress.targets[0].passive_scan_percentage, 100); + assert_eq!(progress.targets[0].passive_scan_state, StageState::Done); +} + // ─── overall_percentage (multi-target) ─────────────────────────────────────── #[test] @@ -207,9 +295,10 @@ fn overall_percentage_is_average_of_target_percentages() { // overall = (1 + 0) / 2 = 0 (integer division) assert_eq!(progress.overall_percentage, 0); - // Target 0: spider done + active 100% → overall = 100 + // Target 0: spider done + active 100% + passive 100% → overall = 100 progress.mark_spider_done(0); progress.mark_active_scan_done(0); + progress.mark_passive_scan_done(0); // overall = (100 + 0) / 2 = 50 assert_eq!(progress.overall_percentage, 50); } @@ -230,6 +319,7 @@ fn as_value_serializes_to_json_and_deserializes_back() { assert_eq!(restored.targets[0].spider_state, StageState::Done); assert_eq!(restored.targets[0].active_scan_percentage, 50); - assert_eq!(restored.targets[0].overall_percentage, 62); - assert_eq!(restored.overall_percentage, 62); + assert_eq!(restored.targets[0].passive_scan_percentage, 0); + assert_eq!(restored.targets[0].overall_percentage, 60); + assert_eq!(restored.overall_percentage, 60); } diff --git a/src/scan/service.rs b/src/scan/service.rs index 22930af..b23e77a 100644 --- a/src/scan/service.rs +++ b/src/scan/service.rs @@ -7,14 +7,20 @@ use std::sync::Arc; use async_trait::async_trait; +use tracing::warn; use uuid::Uuid; use crate::{ - api::dto::scans::{PreferencesResponse, ScannerPreference, Target, Vt}, + api::dto::scans::{ + PreferencesResponse, ScannerPreference, ScannerPreferenceMetadata, Target, Vt, + }, scan::{ Scan, ScanResult, ScanRuntimeHandle, ScanServiceError, ScanStateCoordinator, ScanStatus, ScanStatusView, observability::{emit_scan_created, emit_scan_deleted}, + preferences::{ + AJAX_SPIDER_TIMEOUT_PREFERENCE_ID, SCAN_MODE_PREFERENCE_ID, preference_definitions, + }, validation::validate_target_urls, }, storage::{StorageError, StorageHandle}, @@ -100,6 +106,101 @@ impl DefaultScanService { other => ScanServiceError::Storage(other), } } + + fn default_preferences_response() -> PreferencesResponse { + PreferencesResponse( + preference_definitions() + .iter() + .map(|pref| ScannerPreferenceMetadata { + id: pref.id.to_string(), + preference_type: pref.value_type.as_str().to_string(), + name: pref.name.to_string(), + description: pref.description.to_string(), + default_value: pref.default_value.to_string(), + values: if pref.allowed_values.is_empty() { + None + } else { + Some(pref.allowed_values.join(";")) + }, + }) + .collect(), + ) + } + + fn resolve_scan_preferences( + scan_preferences: Vec, + ) -> Result, ScanServiceError> { + let mut scan_mode = preference_definitions() + .iter() + .find(|p| p.id == SCAN_MODE_PREFERENCE_ID) + .map(|p| p.default_value.to_string()) + .unwrap_or_else(|| "safe".to_string()); + + let mut ajax_spider_timeout = preference_definitions() + .iter() + .find(|p| p.id == AJAX_SPIDER_TIMEOUT_PREFERENCE_ID) + .map(|p| p.default_value.to_string()) + .unwrap_or_else(|| "0".to_string()); + + let mut unknown_preferences: Vec = Vec::new(); + + for pref in scan_preferences { + let value = pref.value.trim().to_string(); + match pref.id.as_str() { + SCAN_MODE_PREFERENCE_ID => { + if value != "safe" && value != "active" { + return Err(ScanServiceError::InvalidPreference { + id: pref.id, + value, + reason: "allowed values are 'safe' and 'active'".to_string(), + }); + } + scan_mode = value; + } + AJAX_SPIDER_TIMEOUT_PREFERENCE_ID => { + let parsed = value.parse::().map_err(|_| { + ScanServiceError::InvalidPreference { + id: pref.id.clone(), + value: value.clone(), + reason: + "value must be a non-negative integer in seconds (0 means unlimited)" + .to_string(), + } + })?; + if parsed < 0 { + return Err(ScanServiceError::InvalidPreference { + id: pref.id, + value, + reason: + "value must be a non-negative integer in seconds (0 means unlimited)" + .to_string(), + }); + } + ajax_spider_timeout = parsed.to_string(); + } + _ => { + warn!( + preference_id = %pref.id, + "unknown scan preference accepted and forwarded" + ); + unknown_preferences.push(ScannerPreference { id: pref.id, value }); + } + } + } + + let mut resolved = vec![ + ScannerPreference { + id: SCAN_MODE_PREFERENCE_ID.to_string(), + value: scan_mode, + }, + ScannerPreference { + id: AJAX_SPIDER_TIMEOUT_PREFERENCE_ID.to_string(), + value: ajax_spider_timeout, + }, + ]; + resolved.extend(unknown_preferences); + Ok(resolved) + } } #[async_trait] @@ -112,11 +213,12 @@ impl ScanService for DefaultScanService { } async fn get_default_preferences(&self) -> Result { - Ok(PreferencesResponse::default()) + Ok(Self::default_preferences_response()) } async fn create_scan(&self, request: CreateScanRequest) -> Result { let validated_hosts = validate_target_urls(&request.target.hosts)?; + let resolved_scan_preferences = Self::resolve_scan_preferences(request.scan_preferences)?; let id = request .scan_id .unwrap_or_else(|| Uuid::new_v4().to_string()); @@ -126,7 +228,7 @@ impl ScanService for DefaultScanService { hosts: validated_hosts, ..request.target }, - scan_preferences: request.scan_preferences, + scan_preferences: resolved_scan_preferences, vts: request.vts, status: ScanStatus::Stored, stop_requested: false, diff --git a/src/scan/service_tests.rs b/src/scan/service_tests.rs index d04e4b7..aad2148 100644 --- a/src/scan/service_tests.rs +++ b/src/scan/service_tests.rs @@ -5,7 +5,7 @@ use tracing_test::traced_test; use crate::{ - api::dto::scans::{ResultType, Target}, + api::dto::scans::{PreferencesResponse, ResultType, ScannerPreference, Target}, scan::{CreateScanRequest, DefaultScanService, ScanService, ScanServiceError, ScanStatus}, storage::{ResultRecord, ScanRecord, StorageError, test_support::temporary_sqlite_storage}, }; @@ -335,3 +335,141 @@ async fn create_scan_with_duplicate_id_returns_already_exists_error() { ScanServiceError::Storage(StorageError::AlreadyExists(id)) if id == "existing-id" )); } + +#[tokio::test] +async fn get_default_preferences_returns_scan_mode_and_ajax_spider_timeout() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let service = DefaultScanService::new_storage_only(storage); + + let response = service.get_default_preferences().await.unwrap(); + + let PreferencesResponse(preferences) = response; + assert!(preferences.iter().any(|p| { + p.id == "scan_mode" + && p.preference_type == "enum" + && p.default_value == "safe" + && p.values.as_deref() == Some("safe;active") + })); + assert!(preferences.iter().any(|p| { + p.id == "ajax_spider_timeout" + && p.preference_type == "integer" + && p.default_value == "3600" + && p.values.is_none() + })); +} + +#[tokio::test] +async fn create_scan_persists_effective_defaults_when_no_preferences_given() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let service = DefaultScanService::new_storage_only(storage.clone()); + + let scan_id = service.create_scan(make_request()).await.unwrap(); + let persisted = storage.get_scan(&scan_id).await.unwrap(); + + assert!( + persisted + .scan_preferences + .iter() + .any(|p| p.id == "scan_mode" && p.value == "safe") + ); + assert!( + persisted + .scan_preferences + .iter() + .any(|p| p.id == "ajax_spider_timeout" && p.value == "3600") + ); +} + +#[tokio::test] +async fn create_scan_persists_known_preference_overrides() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let service = DefaultScanService::new_storage_only(storage.clone()); + let mut request = make_request(); + request.scan_preferences = vec![ + ScannerPreference { + id: "scan_mode".to_string(), + value: "active".to_string(), + }, + ScannerPreference { + id: "ajax_spider_timeout".to_string(), + value: "42".to_string(), + }, + ]; + + let scan_id = service.create_scan(request).await.unwrap(); + let persisted = storage.get_scan(&scan_id).await.unwrap(); + + assert!( + persisted + .scan_preferences + .iter() + .any(|p| p.id == "scan_mode" && p.value == "active") + ); + assert!( + persisted + .scan_preferences + .iter() + .any(|p| p.id == "ajax_spider_timeout" && p.value == "42") + ); +} + +#[traced_test] +#[tokio::test] +async fn create_scan_allows_unknown_preference_and_logs_warning() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let service = DefaultScanService::new_storage_only(storage.clone()); + let mut request = make_request(); + request.scan_preferences = vec![ScannerPreference { + id: "unknown_pref".to_string(), + value: "abc".to_string(), + }]; + + let scan_id = service.create_scan(request).await.unwrap(); + let persisted = storage.get_scan(&scan_id).await.unwrap(); + + assert!( + persisted + .scan_preferences + .iter() + .any(|p| p.id == "unknown_pref" && p.value == "abc") + ); + assert!(logs_contain( + "unknown scan preference accepted and forwarded" + )); +} + +#[tokio::test] +async fn create_scan_rejects_invalid_scan_mode_value() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let service = DefaultScanService::new_storage_only(storage); + let mut request = make_request(); + request.scan_preferences = vec![ScannerPreference { + id: "scan_mode".to_string(), + value: "turbo".to_string(), + }]; + + let err = service.create_scan(request).await.unwrap_err(); + + assert!(matches!( + err, + ScanServiceError::InvalidPreference { ref id, .. } if id == "scan_mode" + )); +} + +#[tokio::test] +async fn create_scan_rejects_negative_ajax_spider_timeout() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let service = DefaultScanService::new_storage_only(storage); + let mut request = make_request(); + request.scan_preferences = vec![ScannerPreference { + id: "ajax_spider_timeout".to_string(), + value: "-1".to_string(), + }]; + + let err = service.create_scan(request).await.unwrap_err(); + + assert!(matches!( + err, + ScanServiceError::InvalidPreference { ref id, .. } if id == "ajax_spider_timeout" + )); +} diff --git a/src/scan/worker.rs b/src/scan/worker.rs index 1bb4f05..f10ec39 100644 --- a/src/scan/worker.rs +++ b/src/scan/worker.rs @@ -18,7 +18,11 @@ use crate::{ api::dto::scans::ResultType, scan::{ RetryingScanStateCoordinator, Scan, ScanProgress, ScanResult, ScanStateCoordinator, - ScanStatus, observability::emit_queue_wait_telemetry, queue::ScanQueue, retry::IsTransient, + ScanStatus, + observability::emit_queue_wait_telemetry, + preferences::{AJAX_SPIDER_TIMEOUT_PREFERENCE_ID, SCAN_MODE_PREFERENCE_ID, ScanMode}, + queue::ScanQueue, + retry::IsTransient, }, storage::{StorageError, StorageHandle}, zapclient::ajaxspider::AjaxSpiderStatus, @@ -28,6 +32,10 @@ use crate::{ const DEFAULT_SCAN_POLL_INTERVAL: Duration = Duration::from_millis(50); const DEFAULT_ALERT_PAGE_SIZE: u32 = 100; +const DEFAULT_PASSIVE_SCAN_PLACEHOLDER_DURATION: Duration = Duration::from_secs(5); +const DEFAULT_AJAX_SPIDER_TIMEOUT_SECONDS: u64 = 60 * 60; +const DEFAULT_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD: Duration = Duration::from_secs(60); +const DEFAULT_PHASE_STOP_STATUS_CHANGE_TIMEOUT: Duration = Duration::from_secs(60); #[derive(Debug, Clone)] pub struct ScanRuntimeConfig { @@ -35,6 +43,9 @@ pub struct ScanRuntimeConfig { pub alert_poll_interval: Duration, pub scan_poll_interval: Duration, pub alert_page_size: u32, + pub passive_scan_placeholder_duration: Duration, + pub ajax_spider_timeout_grace_period: Duration, + pub phase_stop_status_change_timeout: Duration, pub stop_grace_period: Duration, /// Maximum number of retry attempts for transient failures before a scan transitions to `failed`. pub retry_max_retries: u32, @@ -49,6 +60,9 @@ impl Default for ScanRuntimeConfig { alert_poll_interval: Duration::from_secs(10), scan_poll_interval: DEFAULT_SCAN_POLL_INTERVAL, alert_page_size: DEFAULT_ALERT_PAGE_SIZE, + passive_scan_placeholder_duration: DEFAULT_PASSIVE_SCAN_PLACEHOLDER_DURATION, + ajax_spider_timeout_grace_period: DEFAULT_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD, + phase_stop_status_change_timeout: DEFAULT_PHASE_STOP_STATUS_CHANGE_TIMEOUT, stop_grace_period: Duration::from_secs(300), retry_max_retries: 10, retry_max_delay: Duration::from_secs(60), @@ -149,6 +163,13 @@ pub fn start_scan_runtime( enum RunningStage<'a> { Spider, ActiveScan { active_scan_id: &'a str }, + PassiveScan, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ScanExecutionControl { + Continue, + StopExecution, } struct ScanWorker { @@ -161,6 +182,26 @@ struct ScanWorker { } impl ScanWorker { + fn resolve_scan_mode(scan: &Scan) -> ScanMode { + scan.scan_preferences + .iter() + .find(|pref| pref.id == SCAN_MODE_PREFERENCE_ID) + .and_then(|pref| match pref.value.as_str() { + "safe" => Some(ScanMode::Safe), + "active" => Some(ScanMode::Active), + _ => None, + }) + .unwrap_or_else(ScanMode::default_mode) + } + + fn resolve_ajax_spider_timeout_seconds(scan: &Scan) -> u64 { + scan.scan_preferences + .iter() + .find(|pref| pref.id == AJAX_SPIDER_TIMEOUT_PREFERENCE_ID) + .and_then(|pref| pref.value.parse::().ok()) + .unwrap_or(DEFAULT_AJAX_SPIDER_TIMEOUT_SECONDS) + } + async fn run(self) { loop { let scan_id = self.queue.dequeue().await; @@ -208,107 +249,69 @@ impl ScanWorker { async fn execute_scan(&self, scan: &Scan) -> Result<(), WorkerError> { let mut progress = ScanProgress::new(&scan.target.hosts); - - { - let pv = progress.as_value(); - self.scan_state.update_progress(&scan.id, Some(pv)).await?; - } + let scan_mode = Self::resolve_scan_mode(scan); + let ajax_spider_timeout_seconds = Self::resolve_ajax_spider_timeout_seconds(scan); + self.persist_progress(&scan.id, &progress).await?; let (context_name, context_id) = self.ensure_context(scan).await?; - if self.stop_requested(&scan.id).await? { - self.complete_stop_request(&scan.id, Some(&context_name)) - .await?; + if self + .complete_stop_if_requested(&scan.id, &context_name) + .await? + == ScanExecutionControl::StopExecution + { return Ok(()); } for (index, target) in scan.target.hosts.iter().enumerate() { - if self.stop_requested(&scan.id).await? { - self.complete_stop_request(&scan.id, Some(&context_name)) - .await?; - return Ok(()); - } - - progress.mark_spider_running(index); + if self + .complete_stop_if_requested(&scan.id, &context_name) + .await? + == ScanExecutionControl::StopExecution { - let pv = progress.as_value(); - self.scan_state.update_progress(&scan.id, Some(pv)).await?; - } - - self.zap_client - .start_ajax_spider_scan(&context_name, target, true, false) - .await?; - - loop { - if self.stop_requested(&scan.id).await? { - self.handle_stop_request(&scan.id, Some(&context_name), RunningStage::Spider) - .await?; - return Ok(()); - } - - let status = self.zap_client.get_ajax_spider_status().await?; - match status { - AjaxSpiderStatus::Running => sleep(self.config.scan_poll_interval).await, - AjaxSpiderStatus::Stopped => break, - } + return Ok(()); } - progress.mark_spider_done(index); - progress.mark_active_scan_running(index); + if self + .run_spider_phase( + &scan.id, + &context_name, + target, + index, + &mut progress, + ajax_spider_timeout_seconds, + ) + .await? + == ScanExecutionControl::StopExecution { - let pv = progress.as_value(); - self.scan_state.update_progress(&scan.id, Some(pv)).await?; + return Ok(()); } - let active_scan_id = self - .zap_client - .start_active_scan(&context_id, target, true, true) - .await?; - let mut last_alert_poll = Instant::now() - self.config.alert_poll_interval; - - loop { - if self.stop_requested(&scan.id).await? { - self.handle_stop_request( - &scan.id, - Some(&context_name), - RunningStage::ActiveScan { - active_scan_id: &active_scan_id, - }, - ) - .await?; - return Ok(()); - } - - if last_alert_poll.elapsed() >= self.config.alert_poll_interval { - self.poll_and_persist_alerts(&scan.id, &context_name, Some(target.as_str())) - .await?; - last_alert_poll = Instant::now(); - } - - let active_percentage = self - .zap_client - .get_active_scan_status(&active_scan_id) + if scan_mode == ScanMode::Safe { + self.run_safe_mode_phase(&scan.id, &context_name, target, index, &mut progress) .await?; - progress.update_active_scan(index, active_percentage); - { - let pv = progress.as_value(); - self.scan_state.update_progress(&scan.id, Some(pv)).await?; - } - - if active_percentage >= 100 { - break; - } - - sleep(self.config.scan_poll_interval).await; + } else if self + .run_active_scan_phase( + &scan.id, + &context_name, + &context_id, + target, + index, + &mut progress, + ) + .await? + == ScanExecutionControl::StopExecution + { + return Ok(()); } - progress.mark_active_scan_done(index); + if self + .run_passive_scan_phase(&scan.id, &context_name, index, &mut progress) + .await? + == ScanExecutionControl::StopExecution { - let pv = progress.as_value(); - self.scan_state.update_progress(&scan.id, Some(pv)).await?; + return Ok(()); } - self.poll_and_persist_alerts(&scan.id, &context_name, Some(target.as_str())) - .await?; } self.poll_and_persist_alerts( @@ -318,9 +321,11 @@ impl ScanWorker { ) .await?; - if self.stop_requested(&scan.id).await? { - self.complete_stop_request(&scan.id, Some(&context_name)) - .await?; + if self + .complete_stop_if_requested(&scan.id, &context_name) + .await? + == ScanExecutionControl::StopExecution + { return Ok(()); } @@ -335,6 +340,210 @@ impl ScanWorker { Ok(()) } + async fn persist_progress( + &self, + scan_id: &str, + progress: &ScanProgress, + ) -> Result<(), WorkerError> { + let pv = progress.as_value(); + self.scan_state.update_progress(scan_id, Some(pv)).await?; + Ok(()) + } + + async fn complete_stop_if_requested( + &self, + scan_id: &str, + context_name: &str, + ) -> Result { + if self.stop_requested(scan_id).await? { + self.complete_stop_request(scan_id, Some(context_name)) + .await?; + return Ok(ScanExecutionControl::StopExecution); + } + Ok(ScanExecutionControl::Continue) + } + + async fn run_spider_phase( + &self, + scan_id: &str, + context_name: &str, + target: &str, + index: usize, + progress: &mut ScanProgress, + ajax_spider_timeout_seconds: u64, + ) -> Result { + progress.mark_spider_running(index); + self.persist_progress(scan_id, progress).await?; + + let spider_stop_deadline = if ajax_spider_timeout_seconds == 0 { + None + } else { + Some( + Instant::now() + + Duration::from_secs(ajax_spider_timeout_seconds) + + self.config.ajax_spider_timeout_grace_period, + ) + }; + let mut timeout_stop_sent = false; + let mut stop_status_change_deadline: Option = None; + + self.zap_client + .set_ajax_spider_max_duration(ajax_spider_timeout_seconds) + .await?; + + self.zap_client + .start_ajax_spider_scan(context_name, target, true, false) + .await?; + + loop { + if self.stop_requested(scan_id).await? { + self.handle_stop_request(scan_id, Some(context_name), RunningStage::Spider) + .await?; + return Ok(ScanExecutionControl::StopExecution); + } + + let status = self.zap_client.get_ajax_spider_status().await?; + match status { + AjaxSpiderStatus::Running => { + if stop_status_change_deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + warn!( + scan_id, + target, + timeout_seconds = + self.config.phase_stop_status_change_timeout.as_secs(), + "ajax spider did not report status change after stop request within deadline; continuing to next phase" + ); + break; + } + + if !timeout_stop_sent + && spider_stop_deadline.is_some_and(|deadline| Instant::now() >= deadline) + { + warn!( + scan_id, + target, + ajax_spider_timeout_seconds, + grace_period_seconds = + self.config.ajax_spider_timeout_grace_period.as_secs(), + "ajax spider exceeded timeout plus grace period; sending stop request" + ); + self.zap_client.stop_ajax_spider_scan().await?; + timeout_stop_sent = true; + stop_status_change_deadline = + Some(Instant::now() + self.config.phase_stop_status_change_timeout); + } + sleep(self.config.scan_poll_interval).await + } + AjaxSpiderStatus::Stopped => break, + } + } + + progress.mark_spider_done(index); + self.persist_progress(scan_id, progress).await?; + Ok(ScanExecutionControl::Continue) + } + + async fn run_safe_mode_phase( + &self, + scan_id: &str, + context_name: &str, + target: &str, + index: usize, + progress: &mut ScanProgress, + ) -> Result<(), WorkerError> { + debug!(scan_id, target, "active scan skipped due to scan_mode=safe"); + progress.mark_active_scan_done(index); + self.persist_progress(scan_id, progress).await?; + self.poll_and_persist_alerts(scan_id, context_name, Some(target)) + .await?; + Ok(()) + } + + async fn run_passive_scan_phase( + &self, + scan_id: &str, + context_name: &str, + index: usize, + progress: &mut ScanProgress, + ) -> Result { + progress.mark_passive_scan_running(index); + self.persist_progress(scan_id, progress).await?; + + let start = Instant::now(); + while start.elapsed() < self.config.passive_scan_placeholder_duration { + if self.stop_requested(scan_id).await? { + self.handle_stop_request(scan_id, Some(context_name), RunningStage::PassiveScan) + .await?; + return Ok(ScanExecutionControl::StopExecution); + } + sleep(self.config.scan_poll_interval).await; + } + + progress.mark_passive_scan_done(index); + self.persist_progress(scan_id, progress).await?; + Ok(ScanExecutionControl::Continue) + } + + async fn run_active_scan_phase( + &self, + scan_id: &str, + context_name: &str, + context_id: &str, + target: &str, + index: usize, + progress: &mut ScanProgress, + ) -> Result { + progress.mark_active_scan_running(index); + self.persist_progress(scan_id, progress).await?; + + let active_scan_id = self + .zap_client + .start_active_scan(context_id, target, true, true) + .await?; + let mut last_alert_poll = Instant::now() - self.config.alert_poll_interval; + + loop { + if self.stop_requested(scan_id).await? { + self.handle_stop_request( + scan_id, + Some(context_name), + RunningStage::ActiveScan { + active_scan_id: &active_scan_id, + }, + ) + .await?; + return Ok(ScanExecutionControl::StopExecution); + } + + if last_alert_poll.elapsed() >= self.config.alert_poll_interval { + self.poll_and_persist_alerts(scan_id, context_name, Some(target)) + .await?; + last_alert_poll = Instant::now(); + } + + let active_percentage = self + .zap_client + .get_active_scan_status(&active_scan_id) + .await?; + progress.update_active_scan(index, active_percentage); + self.persist_progress(scan_id, progress).await?; + + if active_percentage >= 100 { + break; + } + + sleep(self.config.scan_poll_interval).await; + } + + progress.mark_active_scan_done(index); + self.persist_progress(scan_id, progress).await?; + self.poll_and_persist_alerts(scan_id, context_name, Some(target)) + .await?; + Ok(ScanExecutionControl::Continue) + } + async fn stop_requested(&self, scan_id: &str) -> Result { let scan: Scan = self.storage.get_scan(scan_id).await?.into(); Ok(scan.stop_requested) @@ -371,6 +580,7 @@ impl ScanWorker { RunningStage::ActiveScan { active_scan_id } => { self.zap_client.stop_active_scan(active_scan_id).await?; } + RunningStage::PassiveScan => {} } self.complete_stop_request(scan_id, context_name).await diff --git a/src/scan/worker_tests.rs b/src/scan/worker_tests.rs index fdb3941..d6167e7 100644 --- a/src/scan/worker_tests.rs +++ b/src/scan/worker_tests.rs @@ -11,7 +11,7 @@ use wiremock::{ }; use crate::{ - api::dto::scans::{ResultType, Target}, + api::dto::scans::{ResultType, ScannerPreference, Target}, scan::{ CreateScanRequest, DefaultScanService, ScanRuntimeConfig, ScanService, ScanStatus, start_scan_runtime, @@ -28,11 +28,62 @@ fn make_request(host: &str) -> CreateScanRequest { excluded_hosts: vec![], credentials: vec![], }, - scan_preferences: vec![], + scan_preferences: vec![ScannerPreference { + id: "scan_mode".to_string(), + value: "active".to_string(), + }], vts: vec![], } } +fn make_safe_mode_request(host: &str) -> CreateScanRequest { + CreateScanRequest { + scan_id: None, + target: Target { + hosts: vec![host.to_string()], + excluded_hosts: vec![], + credentials: vec![], + }, + scan_preferences: vec![ScannerPreference { + id: "scan_mode".to_string(), + value: "safe".to_string(), + }], + vts: vec![], + } +} + +fn make_safe_mode_request_with_ajax_timeout(host: &str, timeout_seconds: u64) -> CreateScanRequest { + CreateScanRequest { + scan_id: None, + target: Target { + hosts: vec![host.to_string()], + excluded_hosts: vec![], + credentials: vec![], + }, + scan_preferences: vec![ + ScannerPreference { + id: "scan_mode".to_string(), + value: "safe".to_string(), + }, + ScannerPreference { + id: "ajax_spider_timeout".to_string(), + value: timeout_seconds.to_string(), + }, + ], + vts: vec![], + } +} + +async fn mount_ajax_spider_set_option_max_duration_ok(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/setOptionMaxDuration")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(server) + .await; +} + async fn mock_zap_server() -> MockServer { let server = MockServer::start().await; @@ -52,51 +103,577 @@ async fn mock_zap_server() -> MockServer { .mount(&server) .await; + mount_ajax_spider_set_option_max_duration_ok(&server).await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/scan")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"stopped"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ascan/action/scan")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"scan":"active-1"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ascan/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"100"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/alert/view/alerts")) + .and(body_string_contains("start=0")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + r#"{"alerts":[{"alertRef":"10001","name":"Finding","risk":"Low","description":"detail","url":"https://example.test/app"}]}"#, + "application/json", + )) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/alert/view/alerts")) + .and(body_string_contains("start=1")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"alerts":[]}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/removeContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + server +} + +async fn mock_zap_server_safe_mode_without_active_scan_requests() -> MockServer { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/newContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"contextId":"ctx-1"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/includeInContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + mount_ajax_spider_set_option_max_duration_ok(&server).await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/scan")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"stopped"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ascan/action/scan")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"scan":"active-1"}"#, "application/json"), + ) + .expect(0) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ascan/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"100"}"#, "application/json"), + ) + .expect(0) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/alert/view/alerts")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"alerts":[]}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/removeContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + server +} + +async fn mock_zap_server_for_ajax_spider_timeout_enforcement() -> MockServer { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/newContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"contextId":"ctx-1"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/includeInContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/setOptionMaxDuration")) + .and(body_string_contains("Integer=1")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/scan")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"stopped"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/stop")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .expect(0) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ascan/action/scan")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"scan":"active-1"}"#, "application/json"), + ) + .expect(0) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ascan/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"100"}"#, "application/json"), + ) + .expect(0) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/alert/view/alerts")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"alerts":[]}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/removeContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + server +} + +async fn mock_zap_server_for_unlimited_ajax_spider_timeout() -> MockServer { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/newContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"contextId":"ctx-1"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/includeInContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/setOptionMaxDuration")) + .and(body_string_contains("Integer=0")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/scan")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"stopped"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/stop")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .expect(0) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ascan/action/scan")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"scan":"active-1"}"#, "application/json"), + ) + .expect(0) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ascan/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"100"}"#, "application/json"), + ) + .expect(0) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/alert/view/alerts")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"alerts":[]}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/removeContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + server +} + +async fn mock_zap_server_for_default_ajax_spider_timeout() -> MockServer { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/newContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"contextId":"ctx-1"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/includeInContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/setOptionMaxDuration")) + .and(body_string_contains("Integer=3600")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/scan")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"stopped"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ascan/action/scan")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"scan":"active-1"}"#, "application/json"), + ) + .expect(0) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ascan/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"100"}"#, "application/json"), + ) + .expect(0) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/alert/view/alerts")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"alerts":[]}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/removeContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + server +} + +async fn mock_zap_server_for_spider_timeout_grace_stop_request() -> MockServer { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/newContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"contextId":"ctx-1"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/includeInContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/setOptionMaxDuration")) + .and(body_string_contains("Integer=1")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/scan")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"running"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/stop")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ascan/action/scan")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"scan":"active-1"}"#, "application/json"), + ) + .expect(0) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ascan/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"100"}"#, "application/json"), + ) + .expect(0) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/alert/view/alerts")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"alerts":[]}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/removeContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + server +} + +async fn mock_zap_server_for_spider_stop_status_change_timeout() -> MockServer { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/newContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"contextId":"ctx-1"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/context/action/includeInContext")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/setOptionMaxDuration")) + .and(body_string_contains("Integer=1")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) .and(path("/JSON/ajaxSpider/action/scan")) .respond_with( ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), ) + .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/JSON/ajaxSpider/view/status")) .respond_with( - ResponseTemplate::new(200).set_body_raw(r#"{"status":"stopped"}"#, "application/json"), + ResponseTemplate::new(200).set_body_raw(r#"{"status":"running"}"#, "application/json"), ) .mount(&server) .await; Mock::given(method("POST")) - .and(path("/JSON/ascan/action/scan")) + .and(path("/JSON/ajaxSpider/action/stop")) .respond_with( - ResponseTemplate::new(200).set_body_raw(r#"{"scan":"active-1"}"#, "application/json"), + ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"), ) + .expect(1) .mount(&server) .await; Mock::given(method("POST")) - .and(path("/JSON/ascan/view/status")) + .and(path("/JSON/ascan/action/scan")) .respond_with( - ResponseTemplate::new(200).set_body_raw(r#"{"status":"100"}"#, "application/json"), + ResponseTemplate::new(200).set_body_raw(r#"{"scan":"active-1"}"#, "application/json"), ) + .expect(0) .mount(&server) .await; Mock::given(method("POST")) - .and(path("/JSON/alert/view/alerts")) - .and(body_string_contains("start=0")) - .respond_with(ResponseTemplate::new(200).set_body_raw( - r#"{"alerts":[{"alertRef":"10001","name":"Finding","risk":"Low","description":"detail","url":"https://example.test/app"}]}"#, - "application/json", - )) + .and(path("/JSON/ascan/view/status")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(r#"{"status":"100"}"#, "application/json"), + ) + .expect(0) .mount(&server) .await; Mock::given(method("POST")) .and(path("/JSON/alert/view/alerts")) - .and(body_string_contains("start=1")) .respond_with( ResponseTemplate::new(200).set_body_raw(r#"{"alerts":[]}"#, "application/json"), ) @@ -133,6 +710,8 @@ async fn mock_zap_server_with_active_status_error() -> MockServer { .mount(&server) .await; + mount_ajax_spider_set_option_max_duration_ok(&server).await; + Mock::given(method("POST")) .and(path("/JSON/ajaxSpider/action/scan")) .respond_with( @@ -195,6 +774,8 @@ async fn mock_zap_server_with_remove_context_error() -> MockServer { .mount(&server) .await; + mount_ajax_spider_set_option_max_duration_ok(&server).await; + Mock::given(method("POST")) .and(path("/JSON/ajaxSpider/action/scan")) .respond_with( @@ -276,6 +857,8 @@ async fn mock_zap_server_for_running_stop_in_active_scan() -> MockServer { .mount(&server) .await; + mount_ajax_spider_set_option_max_duration_ok(&server).await; + Mock::given(method("POST")) .and(path("/JSON/ajaxSpider/action/scan")) .respond_with( @@ -355,6 +938,8 @@ async fn mock_zap_server_for_running_stop_in_spider() -> MockServer { .mount(&server) .await; + mount_ajax_spider_set_option_max_duration_ok(&server).await; + Mock::given(method("POST")) .and(path("/JSON/ajaxSpider/action/scan")) .respond_with( @@ -418,6 +1003,8 @@ async fn mock_zap_server_for_running_stop_in_active_stage_with_stop_failure() -> .mount(&server) .await; + mount_ajax_spider_set_option_max_duration_ok(&server).await; + Mock::given(method("POST")) .and(path("/JSON/ajaxSpider/action/scan")) .respond_with( @@ -497,6 +1084,8 @@ async fn mock_zap_server_for_forced_stop_timeout() -> MockServer { .mount(&server) .await; + mount_ajax_spider_set_option_max_duration_ok(&server).await; + Mock::given(method("POST")) .and(path("/JSON/ajaxSpider/action/scan")) .respond_with( @@ -574,6 +1163,23 @@ async fn wait_for_status(storage: &dyn ScanStorage, scan_id: &str, expected: Sca panic!("scan did not reach expected status"); } +async fn wait_for_passive_running(storage: &dyn ScanStorage, scan_id: &str) { + for _ in 0..200 { + let scan = storage.get_scan(scan_id).await.unwrap(); + let passive_state = scan + .progress + .as_ref() + .and_then(|progress| progress.pointer("/targets/0/passive_scan_state")) + .and_then(serde_json::Value::as_str); + if passive_state == Some("running") { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + + panic!("scan did not reach passive running state"); +} + async fn wait_for_request_path(server: &MockServer, expected_path: &str) { for _ in 0..200 { let seen = server @@ -607,6 +1213,7 @@ async fn runtime_processes_requested_scan_to_succeeded_and_persists_alert_result alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(300), ..ScanRuntimeConfig::default() }, @@ -647,6 +1254,220 @@ async fn runtime_processes_requested_scan_to_succeeded_and_persists_alert_result assert!(logs_contain("scan_queue_wait_seconds")); } +#[traced_test] +#[tokio::test] +async fn runtime_skips_active_scan_when_scan_mode_is_safe() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = mock_zap_server_safe_mode_without_active_scan_requests().await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(1), + alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), + stop_grace_period: Duration::from_secs(300), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_safe_mode_request("https://example.test")) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Succeeded).await; + + let scan = storage.get_scan(&scan_id).await.unwrap(); + assert_eq!(scan.status, ScanStatus::Succeeded); + assert!(logs_contain("active scan skipped due to scan_mode=safe")); +} + +#[traced_test] +#[tokio::test] +async fn runtime_sets_ajax_spider_timeout_option_and_continues_scan_flow() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = mock_zap_server_for_ajax_spider_timeout_enforcement().await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(20), + alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), + stop_grace_period: Duration::from_secs(300), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_safe_mode_request_with_ajax_timeout( + "https://example.test", + 1, + )) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Succeeded).await; + + let scan = storage.get_scan(&scan_id).await.unwrap(); + assert_eq!(scan.status, ScanStatus::Succeeded); +} + +#[tokio::test] +async fn runtime_treats_zero_ajax_spider_timeout_as_unlimited() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = mock_zap_server_for_unlimited_ajax_spider_timeout().await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(20), + alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), + stop_grace_period: Duration::from_secs(300), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_safe_mode_request_with_ajax_timeout( + "https://example.test", + 0, + )) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Succeeded).await; + + let scan = storage.get_scan(&scan_id).await.unwrap(); + assert_eq!(scan.status, ScanStatus::Succeeded); +} + +#[tokio::test] +async fn runtime_applies_default_ajax_spider_timeout_when_preference_is_omitted() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = mock_zap_server_for_default_ajax_spider_timeout().await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(20), + alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), + stop_grace_period: Duration::from_secs(300), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_safe_mode_request("https://example.test")) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Succeeded).await; + + let scan = storage.get_scan(&scan_id).await.unwrap(); + assert_eq!(scan.status, ScanStatus::Succeeded); +} + +#[tokio::test] +async fn runtime_stops_ajax_spider_when_timeout_plus_grace_period_is_exceeded() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = mock_zap_server_for_spider_timeout_grace_stop_request().await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(20), + alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), + ajax_spider_timeout_grace_period: Duration::from_millis(50), + stop_grace_period: Duration::from_secs(5), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_safe_mode_request_with_ajax_timeout( + "https://example.test", + 1, + )) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_running(storage.as_ref(), &scan_id).await; + wait_for_request_path(&server, "/JSON/ajaxSpider/action/stop").await; + + service.stop_scan(&scan_id).await.unwrap(); + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Stopped).await; + + let scan = storage.get_scan(&scan_id).await.unwrap(); + assert_eq!(scan.status, ScanStatus::Stopped); +} + +#[tokio::test] +async fn runtime_continues_when_ajax_spider_status_does_not_change_after_local_stop_request() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = mock_zap_server_for_spider_stop_status_change_timeout().await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(20), + alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), + ajax_spider_timeout_grace_period: Duration::from_millis(50), + phase_stop_status_change_timeout: Duration::from_millis(80), + stop_grace_period: Duration::from_secs(5), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_safe_mode_request_with_ajax_timeout( + "https://example.test", + 1, + )) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Succeeded).await; + + let scan = storage.get_scan(&scan_id).await.unwrap(); + assert_eq!(scan.status, ScanStatus::Succeeded); +} + #[tokio::test] async fn runtime_transitions_running_scan_to_failed_on_worker_error() { let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); @@ -660,6 +1481,7 @@ async fn runtime_transitions_running_scan_to_failed_on_worker_error() { alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(300), ..ScanRuntimeConfig::default() }, @@ -691,6 +1513,7 @@ async fn runtime_keeps_succeeded_status_when_context_cleanup_fails() { alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(300), ..ScanRuntimeConfig::default() }, @@ -765,6 +1588,7 @@ async fn runtime_stop_running_scan_in_active_stage_transitions_to_stopped_and_cl alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(5), ..ScanRuntimeConfig::default() }, @@ -802,6 +1626,7 @@ async fn runtime_stop_running_scan_in_spider_stage_transitions_to_stopped_and_cl alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(5), ..ScanRuntimeConfig::default() }, @@ -825,6 +1650,44 @@ async fn runtime_stop_running_scan_in_spider_stage_transitions_to_stopped_and_cl assert!(!scan.stop_requested); } +#[tokio::test] +async fn runtime_stop_running_scan_in_passive_stage_transitions_to_stopped_and_clears_stop_requested() + { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = mock_zap_server().await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(1), + alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_secs(2), + stop_grace_period: Duration::from_secs(5), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_request("https://example.test")) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_running(storage.as_ref(), &scan_id).await; + wait_for_passive_running(storage.as_ref(), &scan_id).await; + + service.stop_scan(&scan_id).await.unwrap(); + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Stopped).await; + + let scan = storage.get_scan(&scan_id).await.unwrap(); + assert_eq!(scan.status, ScanStatus::Stopped); + assert!(!scan.stop_requested); +} + #[tokio::test] async fn runtime_stop_running_scan_fails_when_zap_stop_fails_non_transiently() { let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); @@ -838,6 +1701,7 @@ async fn runtime_stop_running_scan_fails_when_zap_stop_fails_non_transiently() { alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(5), ..ScanRuntimeConfig::default() }, @@ -873,6 +1737,7 @@ async fn runtime_forces_failed_when_stop_grace_period_expires() { alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, + passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_millis(50), ..ScanRuntimeConfig::default() }, diff --git a/src/zapclient/ajaxspider.rs b/src/zapclient/ajaxspider.rs index ccb1df2..8215668 100644 --- a/src/zapclient/ajaxspider.rs +++ b/src/zapclient/ajaxspider.rs @@ -35,6 +35,14 @@ struct AjaxSpiderStopResponse { status: String, } +/// Response payload returned by the ZAP `ajaxSpider/setOptionMaxDuration` endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct AjaxSpiderSetOptionMaxDurationResponse { + /// The option update request status returned by ZAP. + #[serde(rename = "Result")] + status: String, +} + impl ZapClient { /// Start an AJAX Spider scan for the specified context, target URL and options. pub async fn start_ajax_spider_scan( @@ -77,6 +85,37 @@ impl ZapClient { Ok(()) } + /// Set the global AJAX spider max duration option in seconds. + pub async fn set_ajax_spider_max_duration( + &self, + max_duration_seconds: u64, + ) -> Result<(), ZapClientError> { + let endpoint = self.endpoint_url("JSON/ajaxSpider/action/setOptionMaxDuration"); + let form = vec![ + ("apikey".to_string(), self.api_key.clone()), + ("Integer".to_string(), max_duration_seconds.to_string()), + ]; + let response = self.http_client.post(endpoint).form(&form).send().await?; + + let status = response.status(); + let body = response.text().await?; + + if !status.is_success() { + return Err(ZapClientError::UnexpectedStatus { status, body }); + } + + let parsed_response = + serde_json::from_str::(&body)?; + if parsed_response.status != "OK" { + return Err(ZapClientError::UnexpectedContent { + field: "Result".to_string(), + content: parsed_response.status, + }); + } + + Ok(()) + } + /// Get the current status of the AJAX Spider scan. pub async fn get_ajax_spider_status(&self) -> Result { let endpoint = self.endpoint_url("JSON/ajaxSpider/view/status"); diff --git a/src/zapclient/ajaxspider_tests.rs b/src/zapclient/ajaxspider_tests.rs index a600ef5..d22df3b 100644 --- a/src/zapclient/ajaxspider_tests.rs +++ b/src/zapclient/ajaxspider_tests.rs @@ -37,6 +37,28 @@ async fn start_ajax_spider_scan_posts_to_zap_ajax_scan_endpoint() { .expect("start_ajax_spider_scan should succeed when Result is OK"); } +#[tokio::test] +async fn set_ajax_spider_max_duration_posts_to_zap_option_endpoint() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/JSON/ajaxSpider/action/setOptionMaxDuration")) + .and(body_string_contains(format!("apikey={API_KEY}"))) + .and(body_string_contains("Integer=15")) + .respond_with(ResponseTemplate::new(200).set_body_string("{\"Result\":\"OK\"}")) + .expect(1) + .mount(&server) + .await; + + let client = + ZapClient::new(server.uri(), API_KEY.to_string()).expect("client should be constructed"); + + client + .set_ajax_spider_max_duration(15) + .await + .expect("set_ajax_spider_max_duration should succeed when Result is OK"); +} + #[tokio::test] async fn start_ajax_spider_scan_returns_unexpected_status_on_http_error() { let server = MockServer::start().await; diff --git a/src/zapclient/mod.rs b/src/zapclient/mod.rs index 74784ac..1e71601 100644 --- a/src/zapclient/mod.rs +++ b/src/zapclient/mod.rs @@ -144,6 +144,29 @@ impl RetryingZapClient { .await } + /// Set the global AJAX spider max duration option in seconds. + pub async fn set_ajax_spider_max_duration( + &self, + max_duration_seconds: u64, + ) -> Result<(), ZapClientError> { + let inner = self.inner.clone(); + + crate::scan::retry::with_retry( + "zap.set_ajax_spider_max_duration", + move || { + let inner = inner.clone(); + async move { + inner + .set_ajax_spider_max_duration(max_duration_seconds) + .await + } + }, + self.max_retries, + self.max_delay, + ) + .await + } + /// Get the current status of the AJAX spider. pub async fn get_ajax_spider_status( &self,