Skip to content

Commit 05ab1a8

Browse files
committed
Enforce AJAX spider timeout locally with configurable grace period
Co-authored-by: AI (copilot/full)
1 parent 1925c68 commit 05ab1a8

5 files changed

Lines changed: 186 additions & 1 deletion

File tree

src/config/settings.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ pub const DEFAULT_SCAN_ALERT_POLL_INTERVAL_SECONDS: u64 = 10;
3939
/// Default stop grace period in seconds.
4040
pub const DEFAULT_SCAN_STOP_GRACE_PERIOD_SECONDS: u64 = 300;
4141

42+
/// Default grace period added to scan-level AJAX spider timeout before forcing a stop request.
43+
pub const DEFAULT_SCAN_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD_SECONDS: u64 = 60;
44+
4245
/// Default maximum number of retry attempts for transient failures.
4346
pub const DEFAULT_SCAN_RETRY_MAX_RETRIES: u32 = 10;
4447

@@ -77,6 +80,8 @@ pub struct Settings {
7780
pub scan_alert_poll_interval_seconds: u64,
7881
/// Grace period in seconds to wait for running scans to stop before forcing failure.
7982
pub scan_stop_grace_period_seconds: u64,
83+
/// Grace period in seconds added to scan-level AJAX spider timeout before issuing a stop.
84+
pub scan_ajax_spider_timeout_grace_period_seconds: u64,
8085
/// Maximum number of retry attempts for transient ZAP or storage failures.
8186
pub scan_retry_max_retries: u32,
8287
/// Maximum backoff delay between retry attempts, in seconds.
@@ -96,6 +101,7 @@ struct RawSettings {
96101
scan_worker_count: usize,
97102
scan_alert_poll_interval_seconds: u64,
98103
scan_stop_grace_period_seconds: u64,
104+
scan_ajax_spider_timeout_grace_period_seconds: u64,
99105
scan_retry_max_retries: u32,
100106
scan_retry_max_delay_seconds: u64,
101107
}
@@ -135,6 +141,10 @@ impl Settings {
135141
"scan_stop_grace_period_seconds",
136142
DEFAULT_SCAN_STOP_GRACE_PERIOD_SECONDS,
137143
)?
144+
.set_default(
145+
"scan_ajax_spider_timeout_grace_period_seconds",
146+
DEFAULT_SCAN_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD_SECONDS,
147+
)?
138148
.set_default(
139149
"scan_retry_max_retries",
140150
DEFAULT_SCAN_RETRY_MAX_RETRIES as i64,
@@ -215,6 +225,8 @@ impl Settings {
215225
scan_worker_count: raw.scan_worker_count,
216226
scan_alert_poll_interval_seconds: raw.scan_alert_poll_interval_seconds,
217227
scan_stop_grace_period_seconds: raw.scan_stop_grace_period_seconds,
228+
scan_ajax_spider_timeout_grace_period_seconds: raw
229+
.scan_ajax_spider_timeout_grace_period_seconds,
218230
scan_retry_max_retries: raw.scan_retry_max_retries,
219231
scan_retry_max_delay_seconds: raw.scan_retry_max_delay_seconds,
220232
})

src/config/settings_tests.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ fn clear_env() {
2121
env::remove_var("GREENBONE_WAS_SCAN_WORKER_COUNT");
2222
env::remove_var("GREENBONE_WAS_SCAN_ALERT_POLL_INTERVAL_SECONDS");
2323
env::remove_var("GREENBONE_WAS_SCAN_STOP_GRACE_PERIOD_SECONDS");
24+
env::remove_var("GREENBONE_WAS_SCAN_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD_SECONDS");
2425
env::remove_var("GREENBONE_WAS_SCAN_RETRY_MAX_RETRIES");
2526
env::remove_var("GREENBONE_WAS_SCAN_RETRY_MAX_DELAY_SECONDS");
2627
}
@@ -56,6 +57,10 @@ fn test_uses_defaults_when_env_is_unset() {
5657
settings.scan_stop_grace_period_seconds,
5758
settings::DEFAULT_SCAN_STOP_GRACE_PERIOD_SECONDS
5859
);
60+
assert_eq!(
61+
settings.scan_ajax_spider_timeout_grace_period_seconds,
62+
settings::DEFAULT_SCAN_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD_SECONDS
63+
);
5964
assert_eq!(
6065
settings.scan_retry_max_retries,
6166
settings::DEFAULT_SCAN_RETRY_MAX_RETRIES
@@ -82,6 +87,10 @@ fn test_uses_env_overrides_when_set() {
8287
env::set_var("GREENBONE_WAS_SCAN_WORKER_COUNT", "3");
8388
env::set_var("GREENBONE_WAS_SCAN_ALERT_POLL_INTERVAL_SECONDS", "15");
8489
env::set_var("GREENBONE_WAS_SCAN_STOP_GRACE_PERIOD_SECONDS", "120");
90+
env::set_var(
91+
"GREENBONE_WAS_SCAN_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD_SECONDS",
92+
"45",
93+
);
8594
env::set_var("GREENBONE_WAS_SCAN_RETRY_MAX_RETRIES", "7");
8695
env::set_var("GREENBONE_WAS_SCAN_RETRY_MAX_DELAY_SECONDS", "45");
8796
};
@@ -99,6 +108,7 @@ fn test_uses_env_overrides_when_set() {
99108
assert_eq!(settings.scan_worker_count, 3);
100109
assert_eq!(settings.scan_alert_poll_interval_seconds, 15);
101110
assert_eq!(settings.scan_stop_grace_period_seconds, 120);
111+
assert_eq!(settings.scan_ajax_spider_timeout_grace_period_seconds, 45);
102112
assert_eq!(settings.scan_retry_max_retries, 7);
103113
assert_eq!(settings.scan_retry_max_delay_seconds, 45);
104114
}

src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ pub async fn run() -> Result<(), AppError> {
6666
stop_grace_period: std::time::Duration::from_secs(
6767
settings.scan_stop_grace_period_seconds,
6868
),
69+
ajax_spider_timeout_grace_period: std::time::Duration::from_secs(
70+
settings.scan_ajax_spider_timeout_grace_period_seconds,
71+
),
6972
retry_max_retries: settings.scan_retry_max_retries,
7073
retry_max_delay: std::time::Duration::from_secs(settings.scan_retry_max_delay_seconds),
7174
..ScanRuntimeConfig::default()

src/scan/worker.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const DEFAULT_SCAN_POLL_INTERVAL: Duration = Duration::from_millis(50);
3434
const DEFAULT_ALERT_PAGE_SIZE: u32 = 100;
3535
const DEFAULT_PASSIVE_SCAN_PLACEHOLDER_DURATION: Duration = Duration::from_secs(5);
3636
const DEFAULT_AJAX_SPIDER_TIMEOUT_SECONDS: u64 = 60 * 60;
37+
const DEFAULT_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD: Duration = Duration::from_secs(60);
3738

3839
#[derive(Debug, Clone)]
3940
pub struct ScanRuntimeConfig {
@@ -42,6 +43,7 @@ pub struct ScanRuntimeConfig {
4243
pub scan_poll_interval: Duration,
4344
pub alert_page_size: u32,
4445
pub passive_scan_placeholder_duration: Duration,
46+
pub ajax_spider_timeout_grace_period: Duration,
4547
pub stop_grace_period: Duration,
4648
/// Maximum number of retry attempts for transient failures before a scan transitions to `failed`.
4749
pub retry_max_retries: u32,
@@ -57,6 +59,7 @@ impl Default for ScanRuntimeConfig {
5759
scan_poll_interval: DEFAULT_SCAN_POLL_INTERVAL,
5860
alert_page_size: DEFAULT_ALERT_PAGE_SIZE,
5961
passive_scan_placeholder_duration: DEFAULT_PASSIVE_SCAN_PLACEHOLDER_DURATION,
62+
ajax_spider_timeout_grace_period: DEFAULT_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD,
6063
stop_grace_period: Duration::from_secs(300),
6164
retry_max_retries: 10,
6265
retry_max_delay: Duration::from_secs(60),
@@ -369,6 +372,17 @@ impl ScanWorker {
369372
progress.mark_spider_running(index);
370373
self.persist_progress(scan_id, progress).await?;
371374

375+
let spider_stop_deadline = if ajax_spider_timeout_seconds == 0 {
376+
None
377+
} else {
378+
Some(
379+
Instant::now()
380+
+ Duration::from_secs(ajax_spider_timeout_seconds)
381+
+ self.config.ajax_spider_timeout_grace_period,
382+
)
383+
};
384+
let mut timeout_stop_sent = false;
385+
372386
self.zap_client
373387
.set_ajax_spider_max_duration(ajax_spider_timeout_seconds)
374388
.await?;
@@ -386,7 +400,22 @@ impl ScanWorker {
386400

387401
let status = self.zap_client.get_ajax_spider_status().await?;
388402
match status {
389-
AjaxSpiderStatus::Running => sleep(self.config.scan_poll_interval).await,
403+
AjaxSpiderStatus::Running => {
404+
if !timeout_stop_sent
405+
&& spider_stop_deadline.is_some_and(|deadline| Instant::now() >= deadline)
406+
{
407+
warn!(
408+
scan_id,
409+
target,
410+
ajax_spider_timeout_seconds,
411+
grace_period_seconds = self.config.ajax_spider_timeout_grace_period.as_secs(),
412+
"ajax spider exceeded timeout plus grace period; sending stop request"
413+
);
414+
self.zap_client.stop_ajax_spider_scan().await?;
415+
timeout_stop_sent = true;
416+
}
417+
sleep(self.config.scan_poll_interval).await
418+
}
390419
AjaxSpiderStatus::Stopped => break,
391420
}
392421
}

src/scan/worker_tests.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,97 @@ async fn mock_zap_server_for_default_ajax_spider_timeout() -> MockServer {
508508
server
509509
}
510510

511+
async fn mock_zap_server_for_spider_timeout_grace_stop_request() -> MockServer {
512+
let server = MockServer::start().await;
513+
514+
Mock::given(method("POST"))
515+
.and(path("/JSON/context/action/newContext"))
516+
.respond_with(
517+
ResponseTemplate::new(200).set_body_raw(r#"{"contextId":"ctx-1"}"#, "application/json"),
518+
)
519+
.mount(&server)
520+
.await;
521+
522+
Mock::given(method("POST"))
523+
.and(path("/JSON/context/action/includeInContext"))
524+
.respond_with(
525+
ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"),
526+
)
527+
.mount(&server)
528+
.await;
529+
530+
Mock::given(method("POST"))
531+
.and(path("/JSON/ajaxSpider/action/setOptionMaxDuration"))
532+
.and(body_string_contains("Integer=1"))
533+
.respond_with(
534+
ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"),
535+
)
536+
.expect(1)
537+
.mount(&server)
538+
.await;
539+
540+
Mock::given(method("POST"))
541+
.and(path("/JSON/ajaxSpider/action/scan"))
542+
.respond_with(
543+
ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"),
544+
)
545+
.expect(1)
546+
.mount(&server)
547+
.await;
548+
549+
Mock::given(method("POST"))
550+
.and(path("/JSON/ajaxSpider/view/status"))
551+
.respond_with(
552+
ResponseTemplate::new(200).set_body_raw(r#"{"status":"running"}"#, "application/json"),
553+
)
554+
.mount(&server)
555+
.await;
556+
557+
Mock::given(method("POST"))
558+
.and(path("/JSON/ajaxSpider/action/stop"))
559+
.respond_with(
560+
ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"),
561+
)
562+
.mount(&server)
563+
.await;
564+
565+
Mock::given(method("POST"))
566+
.and(path("/JSON/ascan/action/scan"))
567+
.respond_with(
568+
ResponseTemplate::new(200).set_body_raw(r#"{"scan":"active-1"}"#, "application/json"),
569+
)
570+
.expect(0)
571+
.mount(&server)
572+
.await;
573+
574+
Mock::given(method("POST"))
575+
.and(path("/JSON/ascan/view/status"))
576+
.respond_with(
577+
ResponseTemplate::new(200).set_body_raw(r#"{"status":"100"}"#, "application/json"),
578+
)
579+
.expect(0)
580+
.mount(&server)
581+
.await;
582+
583+
Mock::given(method("POST"))
584+
.and(path("/JSON/alert/view/alerts"))
585+
.respond_with(
586+
ResponseTemplate::new(200).set_body_raw(r#"{"alerts":[]}"#, "application/json"),
587+
)
588+
.mount(&server)
589+
.await;
590+
591+
Mock::given(method("POST"))
592+
.and(path("/JSON/context/action/removeContext"))
593+
.respond_with(
594+
ResponseTemplate::new(200).set_body_raw(r#"{"Result":"OK"}"#, "application/json"),
595+
)
596+
.mount(&server)
597+
.await;
598+
599+
server
600+
}
601+
511602
async fn mock_zap_server_with_active_status_error() -> MockServer {
512603
let server = MockServer::start().await;
513604

@@ -1208,6 +1299,46 @@ async fn runtime_applies_default_ajax_spider_timeout_when_preference_is_omitted(
12081299
assert_eq!(scan.status, ScanStatus::Succeeded);
12091300
}
12101301

1302+
#[tokio::test]
1303+
async fn runtime_stops_ajax_spider_when_timeout_plus_grace_period_is_exceeded() {
1304+
let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap();
1305+
let server = mock_zap_server_for_spider_timeout_grace_stop_request().await;
1306+
let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap();
1307+
let runtime = start_scan_runtime(
1308+
storage.clone(),
1309+
zap_client,
1310+
ScanRuntimeConfig {
1311+
worker_count: 1,
1312+
alert_poll_interval: Duration::from_millis(1),
1313+
scan_poll_interval: Duration::from_millis(20),
1314+
alert_page_size: 100,
1315+
passive_scan_placeholder_duration: Duration::from_millis(1),
1316+
ajax_spider_timeout_grace_period: Duration::from_millis(50),
1317+
stop_grace_period: Duration::from_secs(5),
1318+
..ScanRuntimeConfig::default()
1319+
},
1320+
);
1321+
let service = DefaultScanService::new(storage.clone(), runtime);
1322+
1323+
let scan_id = service
1324+
.create_scan(make_safe_mode_request_with_ajax_timeout(
1325+
"https://example.test",
1326+
1,
1327+
))
1328+
.await
1329+
.unwrap();
1330+
1331+
service.start_scan(&scan_id).await.unwrap();
1332+
wait_for_running(storage.as_ref(), &scan_id).await;
1333+
wait_for_request_path(&server, "/JSON/ajaxSpider/action/stop").await;
1334+
1335+
service.stop_scan(&scan_id).await.unwrap();
1336+
wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Stopped).await;
1337+
1338+
let scan = storage.get_scan(&scan_id).await.unwrap();
1339+
assert_eq!(scan.status, ScanStatus::Stopped);
1340+
}
1341+
12111342
#[tokio::test]
12121343
async fn runtime_transitions_running_scan_to_failed_on_worker_error() {
12131344
let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap();

0 commit comments

Comments
 (0)