1use std::collections::BTreeMap;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13use crate::payload::{ContextualPayload, PayloadValidationError};
14
15fn default_get() -> String {
16 "GET".to_string()
17}
18
19fn default_anonymous() -> String {
20 "anonymous".to_string()
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum NoPlanReasonCode {
26 BadEndpoint,
27 AuthMissing,
28 AuthUnsupported,
29 SetupMissing,
30 WeakOracle,
31 BrowserDisabled,
32 RuntimeUnavailable,
33 MissingSeedData,
34 StateChangingBlocked,
35 TargetOutOfScope,
36 NoExecutablePlan,
37 DependencyReviewOnly,
38 UnsupportedClass,
39 UnsafeProbe,
40 RouteNotInferred,
41 Other,
42}
43
44impl NoPlanReasonCode {
45 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::BadEndpoint => "bad_endpoint",
48 Self::AuthMissing => "auth_missing",
49 Self::AuthUnsupported => "auth_unsupported",
50 Self::SetupMissing => "setup_missing",
51 Self::WeakOracle => "weak_oracle",
52 Self::BrowserDisabled => "browser_disabled",
53 Self::RuntimeUnavailable => "runtime_unavailable",
54 Self::MissingSeedData => "missing_seed_data",
55 Self::StateChangingBlocked => "state_changing_blocked",
56 Self::TargetOutOfScope => "target_out_of_scope",
57 Self::NoExecutablePlan => "no_executable_plan",
58 Self::DependencyReviewOnly => "dependency_review_only",
59 Self::UnsupportedClass => "unsupported_class",
60 Self::UnsafeProbe => "unsafe_probe",
61 Self::RouteNotInferred => "route_not_inferred",
62 Self::Other => "other",
63 }
64 }
65}
66
67#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum EnvCapabilityStatus {
70 Available,
71 Missing,
72 Disabled,
73 Blocked,
74 Unknown,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub struct AuthRoleCapability {
79 pub role: String,
80 pub mode: String,
81 pub status: EnvCapabilityStatus,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 pub missing_env_vars: Vec<String>,
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub missing_artifacts: Vec<String>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub notes: Vec<String>,
88}
89
90impl AuthRoleCapability {
91 pub fn ready(&self) -> bool {
92 matches!(self.status, EnvCapabilityStatus::Available)
93 }
94}
95
96#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub struct OwnedObjectCapability {
98 pub role: String,
99 pub name: String,
100 pub id: String,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub route: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub marker: Option<String>,
105}
106
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
108pub struct AuthRolePairCapability {
109 pub owner_role: String,
110 pub accessor_role: String,
111 pub status: EnvCapabilityStatus,
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub notes: Vec<String>,
114}
115
116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117pub struct EnvCapabilityReport {
118 pub target_reachable: EnvCapabilityStatus,
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub target_urls: Vec<String>,
121 pub browser: EnvCapabilityStatus,
122 pub seed: EnvCapabilityStatus,
123 pub reset: EnvCapabilityStatus,
124 pub mailbox: EnvCapabilityStatus,
125 pub state_changing: EnvCapabilityStatus,
126 pub exploit_mode_enabled: bool,
127 pub dry_run: bool,
128 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub auth_roles: Vec<AuthRoleCapability>,
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub usable_auth_role_pairs: Vec<AuthRolePairCapability>,
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 pub owned_objects: Vec<OwnedObjectCapability>,
134 #[serde(default, skip_serializing_if = "Vec::is_empty")]
135 pub findings: Vec<String>,
136}
137
138impl EnvCapabilityReport {
139 pub fn auth_role(&self, role: &str) -> Option<&AuthRoleCapability> {
140 self.auth_roles.iter().find(|cap| cap.role == role)
141 }
142
143 pub fn auth_role_ready(&self, role: &str) -> bool {
144 role == "anonymous" || self.auth_role(role).is_some_and(AuthRoleCapability::ready)
145 }
146
147 pub fn missing_auth_roles<'a>(
148 &'a self,
149 roles: impl IntoIterator<Item = &'a str>,
150 ) -> Vec<&'a AuthRoleCapability> {
151 roles
152 .into_iter()
153 .filter(|role| *role != "anonymous")
154 .filter_map(|role| self.auth_role(role))
155 .filter(|cap| !cap.ready())
156 .collect()
157 }
158
159 pub fn has_owned_object_for_role(&self, role: &str) -> bool {
160 self.owned_objects.iter().any(|object| object.role == role)
161 }
162
163 pub fn ready_auth_role_pair(&self) -> Option<&AuthRolePairCapability> {
164 self.usable_auth_role_pairs
165 .iter()
166 .find(|pair| matches!(pair.status, EnvCapabilityStatus::Available))
167 }
168}
169
170#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
171pub struct NoPlanReason {
172 pub code: NoPlanReasonCode,
173 pub message: String,
174 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
175 pub context: BTreeMap<String, String>,
176}
177
178impl NoPlanReason {
179 pub fn new(code: NoPlanReasonCode, message: impl Into<String>) -> Self {
180 Self { code, message: message.into(), context: BTreeMap::new() }
181 }
182
183 pub fn with_context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
184 self.context.insert(key.into(), value.into());
185 self
186 }
187}
188
189#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
190pub struct LiveHttpRequest {
191 #[serde(default = "default_get")]
192 pub method: String,
193 pub url: String,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub path: Option<String>,
196 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
197 pub headers: BTreeMap<String, String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub body: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub json: Option<serde_json::Value>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub captures: Option<serde_json::Value>,
204 #[serde(default = "default_anonymous", rename = "as", alias = "role")]
205 pub role: String,
206 #[serde(default, skip_serializing_if = "is_false")]
207 pub destructive: bool,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub payload: Option<ContextualPayload>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub label: Option<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub purpose: Option<HttpWorkflowStepPurpose>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub oracle: Option<HttpOracle>,
216}
217
218impl LiveHttpRequest {
219 pub fn get(url: impl Into<String>) -> Self {
220 Self {
221 method: "GET".to_string(),
222 url: url.into(),
223 path: None,
224 headers: BTreeMap::new(),
225 body: None,
226 json: None,
227 captures: None,
228 role: default_anonymous(),
229 destructive: false,
230 payload: None,
231 label: None,
232 purpose: None,
233 oracle: None,
234 }
235 }
236}
237
238#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(rename_all = "snake_case")]
240pub enum HttpWorkflowStepPurpose {
241 Seed,
242 PositiveControl,
243 StateTransition,
244 Exploit,
245 Postcondition,
246 Cleanup,
247}
248
249#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
250pub struct HttpOracle {
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub expect_status: Vec<u16>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub status_range: Option<String>,
255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
256 pub body_contains: Vec<String>,
257 #[serde(default, skip_serializing_if = "Vec::is_empty")]
258 pub body_not_contains: Vec<String>,
259 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
260 pub header_contains: BTreeMap<String, String>,
261}
262
263impl HttpOracle {
264 pub fn has_positive_evidence(&self) -> bool {
265 self.body_contains.iter().any(|s| !s.trim().is_empty())
266 || self.header_contains.values().any(|s| !s.trim().is_empty())
267 }
268}
269
270#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
271pub struct SingleHttpPlan {
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub hypothesis: Option<String>,
274 #[serde(flatten)]
275 pub request: LiveHttpRequest,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub baseline: Option<LiveHttpRequest>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub benign: Option<LiveHttpRequest>,
280 #[serde(flatten)]
281 pub oracle: HttpOracle,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub why_this_confirms: Option<String>,
284}
285
286#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
287pub struct HttpWorkflowPlan {
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub hypothesis: Option<String>,
290 #[serde(default)]
291 pub steps: Vec<LiveHttpRequest>,
292 #[serde(default, skip_serializing_if = "Vec::is_empty")]
293 pub benign_steps: Vec<LiveHttpRequest>,
294 #[serde(default, skip_serializing_if = "Vec::is_empty")]
295 pub cleanup_steps: Vec<LiveHttpRequest>,
296 pub oracle: HttpOracle,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub oracle_step: Option<usize>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub why_this_confirms: Option<String>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub recipe: Option<StatefulFixtureRecipe>,
303}
304
305#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
306pub struct StatefulFixtureRecipe {
307 pub id: String,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub fixture: Option<String>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub reset_required: Option<bool>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub cleanup_required: Option<bool>,
314 #[serde(default, skip_serializing_if = "Vec::is_empty")]
315 pub required_roles: Vec<String>,
316}
317
318#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
319pub struct DifferentialOracle {
320 #[serde(default = "default_differential_oracle_type", rename = "type")]
321 pub oracle_type: String,
322 #[serde(default)]
323 pub expected_allowed_step: usize,
324 #[serde(default = "default_forbidden_step")]
325 pub expected_forbidden_step: usize,
326 #[serde(default = "default_forbidden_statuses")]
327 pub forbidden_status: Vec<u16>,
328 #[serde(default, skip_serializing_if = "Vec::is_empty")]
329 pub sensitive_body_markers: Vec<String>,
330}
331
332impl DifferentialOracle {
333 pub fn has_positive_evidence(&self) -> bool {
334 self.sensitive_body_markers.iter().any(|s| !s.trim().is_empty())
335 }
336}
337
338fn default_differential_oracle_type() -> String {
339 "forbidden_equivalence_break".to_string()
340}
341
342fn default_forbidden_step() -> usize {
343 1
344}
345
346fn default_forbidden_statuses() -> Vec<u16> {
347 vec![401, 403, 404]
348}
349
350fn default_role_comparison_oracle_type() -> String {
351 "role_comparison_break".to_string()
352}
353
354fn default_object_ownership_oracle_type() -> String {
355 "object_ownership_break".to_string()
356}
357
358#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
359pub struct AuthzOracle {
360 #[serde(default = "default_role_comparison_oracle_type", rename = "type")]
361 pub oracle_type: String,
362 #[serde(default = "default_forbidden_statuses")]
363 pub forbidden_status: Vec<u16>,
364 #[serde(default, skip_serializing_if = "Vec::is_empty")]
365 pub positive_markers: Vec<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub allowed_status_range: Option<String>,
368}
369
370impl AuthzOracle {
371 pub fn role_comparison(markers: Vec<String>) -> Self {
372 Self {
373 oracle_type: default_role_comparison_oracle_type(),
374 forbidden_status: default_forbidden_statuses(),
375 positive_markers: markers,
376 allowed_status_range: None,
377 }
378 }
379
380 pub fn object_ownership(markers: Vec<String>) -> Self {
381 Self {
382 oracle_type: default_object_ownership_oracle_type(),
383 forbidden_status: default_forbidden_statuses(),
384 positive_markers: markers,
385 allowed_status_range: None,
386 }
387 }
388
389 pub fn has_positive_evidence(&self) -> bool {
390 self.positive_markers.iter().any(|s| !s.trim().is_empty())
391 }
392}
393
394#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
395pub struct AuthzOwnedObject {
396 pub name: String,
397 pub owner_role: String,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub id: Option<String>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub id_var: Option<String>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub route: Option<String>,
404 #[serde(default, skip_serializing_if = "Vec::is_empty")]
405 pub positive_markers: Vec<String>,
406}
407
408#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
409pub struct AuthzRoleComparisonPlan {
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub hypothesis: Option<String>,
412 pub allowed_role: String,
413 pub challenged_role: String,
414 pub request: LiveHttpRequest,
415 #[serde(default, skip_serializing_if = "Vec::is_empty")]
416 pub setup_steps: Vec<LiveHttpRequest>,
417 #[serde(default, skip_serializing_if = "Vec::is_empty")]
418 pub benign_steps: Vec<LiveHttpRequest>,
419 pub oracle: AuthzOracle,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub why_this_confirms: Option<String>,
422}
423
424#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
425pub struct AuthzObjectOwnershipPlan {
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub hypothesis: Option<String>,
428 pub object: AuthzOwnedObject,
429 pub accessor_role: String,
430 #[serde(default, skip_serializing_if = "Vec::is_empty")]
431 pub seed_steps: Vec<LiveHttpRequest>,
432 pub owner_request: LiveHttpRequest,
433 pub accessor_request: LiveHttpRequest,
434 #[serde(default, skip_serializing_if = "Vec::is_empty")]
435 pub benign_steps: Vec<LiveHttpRequest>,
436 pub oracle: AuthzOracle,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub why_this_confirms: Option<String>,
439}
440
441#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
442pub struct AuthzBrowserRoleComparisonPlan {
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub hypothesis: Option<String>,
445 pub allowed_role: String,
446 pub challenged_role: String,
447 pub workflow: BrowserWorkflowPlan,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub why_this_confirms: Option<String>,
450}
451
452#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
453pub struct DifferentialHttpPlan {
454 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub hypothesis: Option<String>,
456 #[serde(default)]
457 pub steps: Vec<LiveHttpRequest>,
458 #[serde(default, skip_serializing_if = "Vec::is_empty")]
459 pub benign_steps: Vec<LiveHttpRequest>,
460 pub oracle: DifferentialOracle,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub why_this_confirms: Option<String>,
463}
464
465#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
466pub struct BrowserStep {
467 pub action: String,
468 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub url: Option<String>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub selector: Option<String>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub text: Option<String>,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub value: Option<String>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub key: Option<String>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub timeout_ms: Option<u64>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub ms: Option<u64>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub full_page: Option<bool>,
484}
485
486#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
487pub struct BrowserOracle {
488 #[serde(default, skip_serializing_if = "Vec::is_empty")]
489 pub text_contains: Vec<String>,
490 #[serde(default, skip_serializing_if = "Vec::is_empty")]
491 pub html_contains: Vec<String>,
492 #[serde(default, skip_serializing_if = "Vec::is_empty")]
493 pub body_not_contains: Vec<String>,
494 #[serde(default, skip_serializing_if = "Vec::is_empty")]
495 pub selector_exists: Vec<String>,
496 #[serde(default, skip_serializing_if = "Vec::is_empty")]
497 pub selector_text_contains: Vec<serde_json::Value>,
498 #[serde(default, skip_serializing_if = "Vec::is_empty")]
499 pub url_contains: Vec<String>,
500 #[serde(default, skip_serializing_if = "Vec::is_empty")]
501 pub title_contains: Vec<String>,
502 #[serde(default, skip_serializing_if = "Vec::is_empty")]
503 pub console_contains: Vec<String>,
504 #[serde(default, skip_serializing_if = "Vec::is_empty")]
505 pub alert_contains: Vec<String>,
506 #[serde(default, skip_serializing_if = "Vec::is_empty")]
507 pub dialog_contains: Vec<String>,
508}
509
510impl BrowserOracle {
511 pub fn has_positive_evidence(&self) -> bool {
512 [
513 &self.text_contains,
514 &self.html_contains,
515 &self.selector_exists,
516 &self.url_contains,
517 &self.title_contains,
518 &self.console_contains,
519 &self.alert_contains,
520 &self.dialog_contains,
521 ]
522 .iter()
523 .any(|items| items.iter().any(|s| !s.trim().is_empty()))
524 || !self.selector_text_contains.is_empty()
525 }
526}
527
528#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
529pub struct BrowserWorkflowPlan {
530 pub url: String,
531 #[serde(default, rename = "as", alias = "role")]
532 pub role: String,
533 #[serde(default)]
534 pub steps: Vec<BrowserStep>,
535 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub baseline: Option<Box<BrowserWorkflowPlan>>,
537 pub oracle: BrowserOracle,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub payload: Option<ContextualPayload>,
540 #[serde(default, skip_serializing_if = "is_false")]
541 pub state_changing: bool,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
543 pub why_this_confirms: Option<String>,
544}
545
546#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
547pub struct NoPlanLiveTestPlan {
548 pub no_plan_reason: NoPlanReason,
549}
550
551#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
552#[allow(clippy::large_enum_variant)]
553#[serde(tag = "kind", rename_all = "snake_case")]
554pub enum LiveTestPlan {
555 #[serde(rename = "single_http", alias = "http")]
556 SingleHttp(SingleHttpPlan),
557 #[serde(rename = "http_workflow", alias = "multi_step_http")]
558 HttpWorkflow(HttpWorkflowPlan),
559 DifferentialHttp(DifferentialHttpPlan),
560 #[serde(rename = "authz_role_comparison", alias = "role_comparison")]
561 AuthzRoleComparison(AuthzRoleComparisonPlan),
562 #[serde(rename = "authz_object_ownership", alias = "object_ownership")]
563 AuthzObjectOwnership(AuthzObjectOwnershipPlan),
564 #[serde(rename = "authz_browser_role_comparison", alias = "browser_role_comparison")]
565 AuthzBrowserRoleComparison(AuthzBrowserRoleComparisonPlan),
566 #[serde(rename = "browser_workflow", alias = "browser")]
567 BrowserWorkflow(BrowserWorkflowPlan),
568 #[serde(rename = "no_plan")]
569 NoPlan(NoPlanLiveTestPlan),
570}
571
572impl LiveTestPlan {
573 pub fn kind_str(&self) -> &'static str {
574 match self {
575 Self::SingleHttp(_) => "single_http",
576 Self::HttpWorkflow(_) => "http_workflow",
577 Self::DifferentialHttp(_) => "differential_http",
578 Self::AuthzRoleComparison(_) => "authz_role_comparison",
579 Self::AuthzObjectOwnership(_) => "authz_object_ownership",
580 Self::AuthzBrowserRoleComparison(_) => "authz_browser_role_comparison",
581 Self::BrowserWorkflow(_) => "browser_workflow",
582 Self::NoPlan(_) => "no_plan",
583 }
584 }
585
586 pub fn no_plan(reason: NoPlanReason) -> Self {
587 Self::NoPlan(NoPlanLiveTestPlan { no_plan_reason: reason })
588 }
589
590 pub fn no_plan_reason(&self) -> Option<&NoPlanReason> {
591 match self {
592 Self::NoPlan(plan) => Some(&plan.no_plan_reason),
593 _ => None,
594 }
595 }
596
597 pub fn validate(&self) -> Result<(), LivePlanValidationError> {
598 match self {
599 Self::SingleHttp(plan) => {
600 validate_http_request(&plan.request)?;
601 validate_optional_http_request(plan.baseline.as_ref())?;
602 validate_optional_http_request(plan.benign.as_ref())?;
603 validate_payload(plan.request.payload.as_ref())?;
604 if !plan.oracle.has_positive_evidence() {
605 return Err(LivePlanValidationError::WeakOracle(
606 "single_http plans require body_contains or header_contains positive evidence"
607 .to_string(),
608 ));
609 }
610 }
611 Self::HttpWorkflow(plan) => {
612 if plan.steps.is_empty() {
613 return Err(LivePlanValidationError::MissingField("steps".to_string()));
614 }
615 for step in &plan.steps {
616 validate_http_request(step)?;
617 validate_payload(step.payload.as_ref())?;
618 }
619 for step in &plan.benign_steps {
620 validate_http_request(step)?;
621 validate_payload(step.payload.as_ref())?;
622 }
623 for step in &plan.cleanup_steps {
624 validate_http_request(step)?;
625 validate_payload(step.payload.as_ref())?;
626 }
627 let step_oracles_have_evidence = plan
628 .steps
629 .iter()
630 .chain(plan.benign_steps.iter())
631 .chain(plan.cleanup_steps.iter())
632 .filter_map(|step| step.oracle.as_ref())
633 .any(HttpOracle::has_positive_evidence);
634 if !plan.oracle.has_positive_evidence() && !step_oracles_have_evidence {
635 return Err(LivePlanValidationError::WeakOracle(
636 "http_workflow plans require workflow or step body/header positive evidence"
637 .to_string(),
638 ));
639 }
640 if let Some(recipe) = &plan.recipe {
641 if recipe.id.trim().is_empty() {
642 return Err(LivePlanValidationError::MissingField("recipe.id".to_string()));
643 }
644 let mut has_mutation = false;
645 let mut has_cleanup = !plan.cleanup_steps.is_empty();
646 let mut has_required_purpose = false;
647 for step in plan
648 .steps
649 .iter()
650 .chain(plan.benign_steps.iter())
651 .chain(plan.cleanup_steps.iter())
652 {
653 match step.purpose {
654 Some(HttpWorkflowStepPurpose::Seed)
655 | Some(HttpWorkflowStepPurpose::StateTransition)
656 | Some(HttpWorkflowStepPurpose::Exploit)
657 | Some(HttpWorkflowStepPurpose::Cleanup) => {
658 has_mutation |= step.destructive
659 || !matches!(step.method.as_str(), "GET" | "HEAD" | "OPTIONS");
660 }
661 _ => {}
662 }
663 has_cleanup |= step.purpose == Some(HttpWorkflowStepPurpose::Cleanup);
664 has_required_purpose |= matches!(
665 step.purpose,
666 Some(HttpWorkflowStepPurpose::Seed)
667 | Some(HttpWorkflowStepPurpose::PositiveControl)
668 | Some(HttpWorkflowStepPurpose::StateTransition)
669 | Some(HttpWorkflowStepPurpose::Exploit)
670 | Some(HttpWorkflowStepPurpose::Postcondition)
671 );
672 }
673 if !has_required_purpose {
674 return Err(LivePlanValidationError::MissingField(
675 "recipe step purpose".to_string(),
676 ));
677 }
678 if has_mutation
679 && recipe.reset_required != Some(true)
680 && recipe.cleanup_required != Some(true)
681 && !has_cleanup
682 {
683 return Err(LivePlanValidationError::InvalidField(
684 "mutating stateful recipes require reset_required or cleanup_steps"
685 .to_string(),
686 ));
687 }
688 }
689 }
690 Self::DifferentialHttp(plan) => {
691 if plan.steps.len() < 2 {
692 return Err(LivePlanValidationError::MissingField(
693 "at least two differential steps".to_string(),
694 ));
695 }
696 for step in &plan.steps {
697 validate_http_request(step)?;
698 validate_payload(step.payload.as_ref())?;
699 }
700 for step in &plan.benign_steps {
701 validate_http_request(step)?;
702 validate_payload(step.payload.as_ref())?;
703 }
704 if !plan.oracle.has_positive_evidence() {
705 return Err(LivePlanValidationError::WeakOracle(
706 "differential_http plans require sensitive_body_markers".to_string(),
707 ));
708 }
709 }
710 Self::AuthzRoleComparison(plan) => {
711 validate_role_name("allowed_role", &plan.allowed_role)?;
712 validate_role_name("challenged_role", &plan.challenged_role)?;
713 if plan.allowed_role == plan.challenged_role {
714 return Err(LivePlanValidationError::InvalidField(
715 "allowed_role and challenged_role must be different".to_string(),
716 ));
717 }
718 validate_http_request(&plan.request)?;
719 for step in &plan.setup_steps {
720 validate_http_request(step)?;
721 validate_payload(step.payload.as_ref())?;
722 }
723 for step in &plan.benign_steps {
724 validate_http_request(step)?;
725 validate_payload(step.payload.as_ref())?;
726 }
727 validate_payload(plan.request.payload.as_ref())?;
728 if !plan.oracle.has_positive_evidence() {
729 return Err(LivePlanValidationError::WeakOracle(
730 "authz_role_comparison plans require positive_markers".to_string(),
731 ));
732 }
733 }
734 Self::AuthzObjectOwnership(plan) => {
735 validate_role_name("object.owner_role", &plan.object.owner_role)?;
736 validate_role_name("accessor_role", &plan.accessor_role)?;
737 if plan.object.owner_role == plan.accessor_role {
738 return Err(LivePlanValidationError::InvalidField(
739 "object.owner_role and accessor_role must be different".to_string(),
740 ));
741 }
742 if plan.object.name.trim().is_empty() {
743 return Err(LivePlanValidationError::MissingField("object.name".to_string()));
744 }
745 validate_http_request(&plan.owner_request)?;
746 validate_payload(plan.owner_request.payload.as_ref())?;
747 validate_http_request(&plan.accessor_request)?;
748 validate_payload(plan.accessor_request.payload.as_ref())?;
749 for step in &plan.seed_steps {
750 validate_http_request(step)?;
751 validate_payload(step.payload.as_ref())?;
752 }
753 for step in &plan.benign_steps {
754 validate_http_request(step)?;
755 validate_payload(step.payload.as_ref())?;
756 }
757 if !plan.oracle.has_positive_evidence()
758 && plan.object.positive_markers.iter().all(|s| s.trim().is_empty())
759 && plan.object.id.as_deref().is_none_or(|s| s.trim().is_empty())
760 {
761 return Err(LivePlanValidationError::WeakOracle(
762 "authz_object_ownership plans require positive object markers".to_string(),
763 ));
764 }
765 }
766 Self::AuthzBrowserRoleComparison(plan) => {
767 validate_role_name("allowed_role", &plan.allowed_role)?;
768 validate_role_name("challenged_role", &plan.challenged_role)?;
769 if plan.allowed_role == plan.challenged_role {
770 return Err(LivePlanValidationError::InvalidField(
771 "allowed_role and challenged_role must be different".to_string(),
772 ));
773 }
774 if plan.workflow.url.trim().is_empty() {
775 return Err(LivePlanValidationError::MissingField("workflow.url".to_string()));
776 }
777 if !plan.workflow.oracle.has_positive_evidence() {
778 return Err(LivePlanValidationError::WeakOracle(
779 "authz_browser_role_comparison plans require a positive browser oracle"
780 .to_string(),
781 ));
782 }
783 validate_payload(plan.workflow.payload.as_ref())?;
784 }
785 Self::BrowserWorkflow(plan) => {
786 if plan.url.trim().is_empty() {
787 return Err(LivePlanValidationError::MissingField("url".to_string()));
788 }
789 if !plan.oracle.has_positive_evidence() {
790 return Err(LivePlanValidationError::WeakOracle(
791 "browser_workflow plans require a DOM/browser positive oracle".to_string(),
792 ));
793 }
794 validate_payload(plan.payload.as_ref())?;
795 }
796 Self::NoPlan(plan) => {
797 if plan.no_plan_reason.message.trim().is_empty() {
798 return Err(LivePlanValidationError::MissingField(
799 "no_plan_reason.message".to_string(),
800 ));
801 }
802 }
803 }
804 Ok(())
805 }
806}
807
808fn validate_optional_http_request(
809 request: Option<&LiveHttpRequest>,
810) -> Result<(), LivePlanValidationError> {
811 if let Some(request) = request {
812 validate_http_request(request)?;
813 }
814 Ok(())
815}
816
817fn validate_http_request(request: &LiveHttpRequest) -> Result<(), LivePlanValidationError> {
818 if request.method.trim().is_empty() {
819 return Err(LivePlanValidationError::MissingField("method".to_string()));
820 }
821 if request.url.trim().is_empty() {
822 return Err(LivePlanValidationError::MissingField("url".to_string()));
823 }
824 Ok(())
825}
826
827fn validate_role_name(field: &str, role: &str) -> Result<(), LivePlanValidationError> {
828 if role.trim().is_empty() {
829 Err(LivePlanValidationError::MissingField(field.to_string()))
830 } else {
831 Ok(())
832 }
833}
834
835fn validate_payload(payload: Option<&ContextualPayload>) -> Result<(), LivePlanValidationError> {
836 if let Some(payload) = payload {
837 payload.validate().map_err(LivePlanValidationError::Payload)?;
838 }
839 Ok(())
840}
841
842#[derive(Clone, Debug, PartialEq, Eq)]
843pub enum LivePlanValidationError {
844 MissingField(String),
845 InvalidField(String),
846 WeakOracle(String),
847 Payload(PayloadValidationError),
848}
849
850impl fmt::Display for LivePlanValidationError {
851 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
852 match self {
853 Self::MissingField(field) => write!(f, "live test plan missing {field}"),
854 Self::InvalidField(reason) => write!(f, "invalid live test plan field: {reason}"),
855 Self::WeakOracle(reason) => write!(f, "weak live test oracle: {reason}"),
856 Self::Payload(err) => write!(f, "invalid payload: {err}"),
857 }
858 }
859}
860
861impl std::error::Error for LivePlanValidationError {}
862
863fn is_false(value: &bool) -> bool {
864 !*value
865}
866
867#[cfg(test)]
868mod tests {
869 use super::*;
870
871 #[test]
872 fn single_http_plan_roundtrips_with_typed_kind() {
873 let plan = LiveTestPlan::SingleHttp(SingleHttpPlan {
874 hypothesis: Some("debug endpoint leaks diagnostic marker".to_string()),
875 request: LiveHttpRequest::get("http://localhost:3000/api/debug"),
876 baseline: Some(LiveHttpRequest::get("http://localhost:3000/")),
877 benign: None,
878 oracle: HttpOracle {
879 body_contains: vec!["debug".to_string()],
880 ..HttpOracle::default()
881 },
882 why_this_confirms: Some(
883 "debug marker is only expected on the sensitive endpoint".to_string(),
884 ),
885 });
886 plan.validate().unwrap();
887 let raw = serde_json::to_string(&plan).unwrap();
888 assert!(raw.contains("\"kind\":\"single_http\""));
889 let back: LiveTestPlan = serde_json::from_str(&raw).unwrap();
890 assert_eq!(back.kind_str(), "single_http");
891 }
892
893 #[test]
894 fn no_plan_carries_structured_reason() {
895 let plan = LiveTestPlan::no_plan(
896 NoPlanReason::new(
897 NoPlanReasonCode::RouteNotInferred,
898 "could not map source file to route",
899 )
900 .with_context("path", "src/handlers/admin.rs"),
901 );
902 plan.validate().unwrap();
903 assert_eq!(plan.no_plan_reason().unwrap().code.as_str(), "route_not_inferred");
904 }
905
906 #[test]
907 fn env_capability_report_tracks_auth_and_fixture_readiness() {
908 let report = EnvCapabilityReport {
909 target_reachable: EnvCapabilityStatus::Available,
910 target_urls: vec!["http://localhost:3000".to_string()],
911 browser: EnvCapabilityStatus::Missing,
912 seed: EnvCapabilityStatus::Available,
913 reset: EnvCapabilityStatus::Missing,
914 mailbox: EnvCapabilityStatus::Missing,
915 state_changing: EnvCapabilityStatus::Disabled,
916 exploit_mode_enabled: false,
917 dry_run: false,
918 auth_roles: vec![AuthRoleCapability {
919 role: "user_a".to_string(),
920 mode: "header_injection".to_string(),
921 status: EnvCapabilityStatus::Missing,
922 missing_env_vars: vec!["NYX_AGENT_USER_A_TOKEN".to_string()],
923 missing_artifacts: Vec::new(),
924 notes: vec!["missing env vars: NYX_AGENT_USER_A_TOKEN".to_string()],
925 }],
926 usable_auth_role_pairs: Vec::new(),
927 owned_objects: vec![OwnedObjectCapability {
928 role: "user_a".to_string(),
929 name: "project".to_string(),
930 id: "proj-1".to_string(),
931 route: Some("/api/projects/{id}".to_string()),
932 marker: None,
933 }],
934 findings: Vec::new(),
935 };
936
937 assert!(!report.auth_role_ready("user_a"));
938 assert!(report.auth_role_ready("anonymous"));
939 assert!(report.has_owned_object_for_role("user_a"));
940 assert_eq!(report.missing_auth_roles(["user_a"].into_iter()).len(), 1);
941 }
942
943 #[test]
944 fn differential_requires_positive_marker() {
945 let plan = LiveTestPlan::DifferentialHttp(DifferentialHttpPlan {
946 hypothesis: None,
947 steps: vec![
948 LiveHttpRequest::get("http://localhost:3000/api/accounts/1"),
949 LiveHttpRequest::get("http://localhost:3000/api/accounts/1"),
950 ],
951 benign_steps: Vec::new(),
952 oracle: DifferentialOracle {
953 sensitive_body_markers: Vec::new(),
954 ..DifferentialOracle {
955 oracle_type: "forbidden_equivalence_break".to_string(),
956 expected_allowed_step: 0,
957 expected_forbidden_step: 1,
958 forbidden_status: vec![401, 403, 404],
959 sensitive_body_markers: Vec::new(),
960 }
961 },
962 why_this_confirms: None,
963 });
964 assert!(matches!(plan.validate(), Err(LivePlanValidationError::WeakOracle(_))));
965 }
966
967 #[test]
968 fn authz_object_ownership_requires_positive_marker() {
969 let plan = LiveTestPlan::AuthzObjectOwnership(AuthzObjectOwnershipPlan {
970 hypothesis: Some("peer reads owner project".to_string()),
971 object: AuthzOwnedObject {
972 name: "project".to_string(),
973 owner_role: "user_a".to_string(),
974 id: None,
975 id_var: Some("object_id".to_string()),
976 route: Some("/api/projects/{id}".to_string()),
977 positive_markers: Vec::new(),
978 },
979 accessor_role: "user_b".to_string(),
980 seed_steps: Vec::new(),
981 owner_request: LiveHttpRequest::get("http://localhost:3000/api/projects/proj-1"),
982 accessor_request: LiveHttpRequest::get("http://localhost:3000/api/projects/proj-1"),
983 benign_steps: Vec::new(),
984 oracle: AuthzOracle::object_ownership(Vec::new()),
985 why_this_confirms: None,
986 });
987 assert!(matches!(plan.validate(), Err(LivePlanValidationError::WeakOracle(_))));
988 }
989
990 #[test]
991 fn authz_role_comparison_roundtrips_with_markers() {
992 let plan = LiveTestPlan::AuthzRoleComparison(AuthzRoleComparisonPlan {
993 hypothesis: Some("user can read admin report".to_string()),
994 allowed_role: "admin".to_string(),
995 challenged_role: "user".to_string(),
996 request: LiveHttpRequest::get("http://localhost:3000/api/admin/report"),
997 setup_steps: Vec::new(),
998 benign_steps: Vec::new(),
999 oracle: AuthzOracle::role_comparison(vec!["admin-report".to_string()]),
1000 why_this_confirms: Some(
1001 "the user role receives the same admin marker as admin".to_string(),
1002 ),
1003 });
1004 plan.validate().unwrap();
1005 let raw = serde_json::to_string(&plan).unwrap();
1006 assert!(raw.contains("\"kind\":\"authz_role_comparison\""));
1007 let back: LiveTestPlan = serde_json::from_str(&raw).unwrap();
1008 assert_eq!(back.kind_str(), "authz_role_comparison");
1009 }
1010
1011 #[test]
1012 fn authz_browser_role_comparison_requires_positive_oracle() {
1013 let plan = LiveTestPlan::AuthzBrowserRoleComparison(AuthzBrowserRoleComparisonPlan {
1014 hypothesis: Some("user can view admin panel".to_string()),
1015 allowed_role: "admin".to_string(),
1016 challenged_role: "user".to_string(),
1017 workflow: BrowserWorkflowPlan {
1018 url: "http://localhost:3000/admin".to_string(),
1019 role: String::new(),
1020 steps: Vec::new(),
1021 baseline: None,
1022 oracle: BrowserOracle::default(),
1023 payload: None,
1024 state_changing: false,
1025 why_this_confirms: None,
1026 },
1027 why_this_confirms: None,
1028 });
1029
1030 assert!(matches!(plan.validate(), Err(LivePlanValidationError::WeakOracle(_))));
1031 }
1032
1033 #[test]
1034 fn authz_browser_role_comparison_roundtrips_with_marker() {
1035 let plan = LiveTestPlan::AuthzBrowserRoleComparison(AuthzBrowserRoleComparisonPlan {
1036 hypothesis: Some("user can view admin panel".to_string()),
1037 allowed_role: "admin".to_string(),
1038 challenged_role: "user".to_string(),
1039 workflow: BrowserWorkflowPlan {
1040 url: "http://localhost:3000/admin".to_string(),
1041 role: String::new(),
1042 steps: Vec::new(),
1043 baseline: None,
1044 oracle: BrowserOracle {
1045 text_contains: vec!["Admin Console".to_string()],
1046 ..BrowserOracle::default()
1047 },
1048 payload: None,
1049 state_changing: false,
1050 why_this_confirms: None,
1051 },
1052 why_this_confirms: None,
1053 });
1054
1055 plan.validate().unwrap();
1056 let raw = serde_json::to_string(&plan).unwrap();
1057 assert!(raw.contains("\"kind\":\"authz_browser_role_comparison\""));
1058 let back: LiveTestPlan = serde_json::from_str(&raw).unwrap();
1059 assert_eq!(back.kind_str(), "authz_browser_role_comparison");
1060 }
1061}