1use std::{fmt, str::FromStr, time::Duration};
9
10use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
11use thiserror::Error;
12
13use super::web_assessment::{WebAssessmentLimits, WebAssessmentLimitsError};
14use crate::DEFAULT_MAX_ACTIVE_VERIFICATIONS;
15
16pub const SCAN_PROFILE_V1_SCHEMA: &str = "venom.scan-profile/v1";
18pub const BASELINE_SCAN_PROFILE_ID: &str = "baseline";
20pub const WEB_REVIEW_SCAN_PROFILE_ID: &str = "web-review";
22pub const BASELINE_SCAN_PROFILE_MAX_TOTAL_REQUESTS: u32 = 16;
24pub const BASELINE_SCAN_PROFILE_MAX_WALL_TIME_MS: u64 = 60_000;
26pub const BASELINE_SCAN_PROFILE_MAX_TOTAL_RESPONSE_BYTES: u64 = 1024 * 1024;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30enum ScanProfileSchemaV1 {
31 #[serde(rename = "venom.scan-profile/v1")]
32 V1,
33}
34
35impl<'de> Deserialize<'de> for ScanProfileSchemaV1 {
36 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
37 where
38 D: Deserializer<'de>,
39 {
40 let value = String::deserialize(deserializer)?;
41 match value.as_str() {
42 SCAN_PROFILE_V1_SCHEMA => Ok(Self::V1),
43 _ => Err(D::Error::custom("unknown scan-profile schema version")),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[non_exhaustive]
51pub enum BuiltInScanProfile {
52 #[serde(rename = "baseline")]
54 Baseline,
55 #[serde(rename = "web-review")]
57 WebReview,
58}
59
60impl<'de> Deserialize<'de> for BuiltInScanProfile {
61 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
62 where
63 D: Deserializer<'de>,
64 {
65 let value = String::deserialize(deserializer)?;
66 Self::parse_id(&value).map_err(D::Error::custom)
67 }
68}
69
70impl BuiltInScanProfile {
71 pub const fn id(self) -> &'static str {
73 match self {
74 Self::Baseline => BASELINE_SCAN_PROFILE_ID,
75 Self::WebReview => WEB_REVIEW_SCAN_PROFILE_ID,
76 }
77 }
78
79 const fn required_scope(self) -> ScanProfileScope {
80 match self {
81 Self::Baseline => ScanProfileScope::SingleResource,
82 Self::WebReview => ScanProfileScope::ExactOrigin,
83 }
84 }
85
86 const fn required_capabilities(self) -> ScanProfileCapabilitiesV1 {
87 match self {
88 Self::Baseline => ScanProfileCapabilitiesV1::BASELINE,
89 Self::WebReview => ScanProfileCapabilitiesV1::WEB_REVIEW,
90 }
91 }
92}
93
94impl fmt::Display for BuiltInScanProfile {
95 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96 formatter.write_str(self.id())
97 }
98}
99
100impl FromStr for BuiltInScanProfile {
101 type Err = BuiltInScanProfileParseError;
102
103 fn from_str(value: &str) -> Result<Self, Self::Err> {
104 Self::parse_id(value)
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
112#[error("unknown built-in scan profile identifier")]
113pub struct BuiltInScanProfileParseError;
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
117#[non_exhaustive]
118pub enum ScanProfileScope {
119 #[serde(rename = "single-resource")]
121 SingleResource,
122 #[serde(rename = "exact-origin")]
124 ExactOrigin,
125}
126
127impl<'de> Deserialize<'de> for ScanProfileScope {
128 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129 where
130 D: Deserializer<'de>,
131 {
132 let value = String::deserialize(deserializer)?;
133 match value.as_str() {
134 "single-resource" => Ok(Self::SingleResource),
135 "exact-origin" => Ok(Self::ExactOrigin),
136 _ => Err(D::Error::custom("unknown scan-profile scope")),
137 }
138 }
139}
140
141impl ScanProfileScope {
142 pub const fn id(self) -> &'static str {
144 match self {
145 Self::SingleResource => "single-resource",
146 Self::ExactOrigin => "exact-origin",
147 }
148 }
149}
150
151impl fmt::Display for ScanProfileScope {
152 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153 formatter.write_str(self.id())
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
162#[serde(deny_unknown_fields)]
163pub struct ScanProfileCapabilitiesV1 {
164 standard_web_decision: bool,
165 origin_discovery: bool,
166 semantic_extraction: bool,
167 defense_observation: bool,
168 defense_shadow_planning: bool,
169 passive_security_review: bool,
170 low_risk_differential_review: bool,
171}
172
173#[derive(Deserialize)]
174#[serde(deny_unknown_fields)]
175struct WireScanProfileCapabilitiesV1 {
176 standard_web_decision: bool,
177 origin_discovery: bool,
178 semantic_extraction: bool,
179 defense_observation: bool,
180 defense_shadow_planning: bool,
181 passive_security_review: bool,
182 low_risk_differential_review: bool,
183}
184
185impl WireScanProfileCapabilitiesV1 {
186 const fn into_capabilities(self) -> ScanProfileCapabilitiesV1 {
187 ScanProfileCapabilitiesV1 {
188 standard_web_decision: self.standard_web_decision,
189 origin_discovery: self.origin_discovery,
190 semantic_extraction: self.semantic_extraction,
191 defense_observation: self.defense_observation,
192 defense_shadow_planning: self.defense_shadow_planning,
193 passive_security_review: self.passive_security_review,
194 low_risk_differential_review: self.low_risk_differential_review,
195 }
196 }
197}
198
199impl ScanProfileCapabilitiesV1 {
200 const BASELINE: Self = Self {
201 standard_web_decision: true,
202 origin_discovery: false,
203 semantic_extraction: false,
204 defense_observation: false,
205 defense_shadow_planning: false,
206 passive_security_review: false,
207 low_risk_differential_review: false,
208 };
209
210 const WEB_REVIEW: Self = Self {
214 standard_web_decision: true,
215 origin_discovery: true,
216 semantic_extraction: true,
217 defense_observation: true,
218 defense_shadow_planning: true,
219 passive_security_review: true,
220 low_risk_differential_review: true,
221 };
222
223 pub const fn standard_web_decision(self) -> bool {
225 self.standard_web_decision
226 }
227
228 pub const fn origin_discovery(self) -> bool {
230 self.origin_discovery
231 }
232
233 pub const fn semantic_extraction(self) -> bool {
235 self.semantic_extraction
236 }
237
238 pub const fn defense_observation(self) -> bool {
240 self.defense_observation
241 }
242
243 pub const fn defense_shadow_planning(self) -> bool {
245 self.defense_shadow_planning
246 }
247
248 pub const fn passive_security_review(self) -> bool {
251 self.passive_security_review
252 }
253
254 pub const fn low_risk_differential_review(self) -> bool {
256 self.low_risk_differential_review
257 }
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
266#[serde(deny_unknown_fields)]
267pub struct ScanProfileLimitsV1 {
268 max_subjects: usize,
269 max_discovery_depth: u16,
270 max_references_per_document: usize,
271 max_canonical_url_bytes: usize,
272 max_retained_url_bytes: usize,
273 max_forms: usize,
274 max_controls_per_form: usize,
275 max_query_parameter_names: usize,
276 max_total_requests: u32,
277 max_response_body_bytes: usize,
278 max_total_response_bytes: u64,
279 max_wall_time_ms: u64,
280 max_active_verifications: u16,
281 #[serde(skip)]
282 checked: WebAssessmentLimits,
283}
284
285impl ScanProfileLimitsV1 {
286 fn from_checked(checked: WebAssessmentLimits) -> Result<Self, ScanProfileV1Error> {
287 if Duration::from_millis(duration_millis(checked.max_wall_time()))
288 != checked.max_wall_time()
289 {
290 return Err(ScanProfileV1Error::SubMillisecondWallTime);
291 }
292 Ok(Self {
293 max_subjects: checked.max_subjects(),
294 max_discovery_depth: checked.max_discovery_depth(),
295 max_references_per_document: checked.max_references_per_document(),
296 max_canonical_url_bytes: checked.max_canonical_url_bytes(),
297 max_retained_url_bytes: checked.max_retained_url_bytes(),
298 max_forms: checked.max_forms(),
299 max_controls_per_form: checked.max_controls_per_form(),
300 max_query_parameter_names: checked.max_query_parameter_names(),
301 max_total_requests: checked.max_total_requests(),
302 max_response_body_bytes: checked.max_response_body_bytes(),
303 max_total_response_bytes: checked.max_total_response_bytes(),
304 max_wall_time_ms: duration_millis(checked.max_wall_time()),
305 max_active_verifications: checked.max_active_verifications(),
306 checked,
307 })
308 }
309
310 pub fn from_web_assessment_limits(
315 limits: WebAssessmentLimits,
316 ) -> Result<Self, ScanProfileV1Error> {
317 Self::from_checked(limits)
318 }
319
320 pub const fn web_assessment_limits(&self) -> WebAssessmentLimits {
322 self.checked
323 }
324
325 pub const fn max_subjects(&self) -> usize {
327 self.max_subjects
328 }
329
330 pub const fn max_discovery_depth(&self) -> u16 {
332 self.max_discovery_depth
333 }
334
335 pub const fn max_references_per_document(&self) -> usize {
337 self.max_references_per_document
338 }
339
340 pub const fn max_canonical_url_bytes(&self) -> usize {
342 self.max_canonical_url_bytes
343 }
344
345 pub const fn max_retained_url_bytes(&self) -> usize {
347 self.max_retained_url_bytes
348 }
349
350 pub const fn max_forms(&self) -> usize {
352 self.max_forms
353 }
354
355 pub const fn max_controls_per_form(&self) -> usize {
357 self.max_controls_per_form
358 }
359
360 pub const fn max_query_parameter_names(&self) -> usize {
362 self.max_query_parameter_names
363 }
364
365 pub const fn max_total_requests(&self) -> u32 {
367 self.max_total_requests
368 }
369
370 pub const fn max_response_body_bytes(&self) -> usize {
372 self.max_response_body_bytes
373 }
374
375 pub const fn max_total_response_bytes(&self) -> u64 {
377 self.max_total_response_bytes
378 }
379
380 pub const fn max_wall_time_ms(&self) -> u64 {
382 self.max_wall_time_ms
383 }
384
385 pub const fn max_active_verifications(&self) -> u16 {
387 self.max_active_verifications
388 }
389
390 pub const fn concurrency(&self) -> usize {
392 self.checked.concurrency()
393 }
394}
395
396#[derive(Deserialize)]
397#[serde(deny_unknown_fields)]
398struct WireScanProfileLimitsV1 {
399 max_subjects: usize,
400 max_discovery_depth: u16,
401 max_references_per_document: usize,
402 max_canonical_url_bytes: usize,
403 max_retained_url_bytes: usize,
404 max_forms: usize,
405 max_controls_per_form: usize,
406 max_query_parameter_names: usize,
407 max_total_requests: u32,
408 max_response_body_bytes: usize,
409 max_total_response_bytes: u64,
410 max_wall_time_ms: u64,
411 max_active_verifications: u16,
412}
413
414impl WireScanProfileLimitsV1 {
415 fn into_checked(self) -> Result<WebAssessmentLimits, WebAssessmentLimitsError> {
416 WebAssessmentLimits::default()
417 .with_max_subjects(self.max_subjects)?
418 .with_max_discovery_depth(self.max_discovery_depth)?
419 .with_max_references_per_document(self.max_references_per_document)?
420 .with_max_canonical_url_bytes(self.max_canonical_url_bytes)?
421 .with_max_retained_url_bytes(self.max_retained_url_bytes)?
422 .with_max_forms(self.max_forms)?
423 .with_max_controls_per_form(self.max_controls_per_form)?
424 .with_max_query_parameter_names(self.max_query_parameter_names)?
425 .with_max_total_requests(self.max_total_requests)?
426 .with_max_response_body_bytes(self.max_response_body_bytes)?
427 .with_max_total_response_bytes(self.max_total_response_bytes)?
428 .with_max_wall_time(Duration::from_millis(self.max_wall_time_ms))?
429 .with_max_active_verifications(self.max_active_verifications)
430 }
431}
432
433impl<'de> Deserialize<'de> for ScanProfileLimitsV1 {
434 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
435 where
436 D: Deserializer<'de>,
437 {
438 let wire = WireScanProfileLimitsV1::deserialize(deserializer)?;
439 let checked = wire.into_checked().map_err(D::Error::custom)?;
440 Self::from_checked(checked).map_err(D::Error::custom)
441 }
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(deny_unknown_fields)]
446struct ScanProfileDefenseV1 {
447 enforcement_enabled: bool,
448}
449
450fn baseline_web_assessment_limits() -> Result<WebAssessmentLimits, WebAssessmentLimitsError> {
451 let defaults = WebAssessmentLimits::default();
452 defaults
453 .with_max_subjects(1)?
454 .with_max_discovery_depth(0)?
455 .with_max_references_per_document(0)?
456 .with_max_canonical_url_bytes(defaults.max_canonical_url_bytes())?
457 .with_max_retained_url_bytes(defaults.max_canonical_url_bytes())?
458 .with_max_forms(0)?
459 .with_max_controls_per_form(0)?
460 .with_max_query_parameter_names(0)?
461 .with_max_total_requests(BASELINE_SCAN_PROFILE_MAX_TOTAL_REQUESTS)?
462 .with_max_response_body_bytes(defaults.max_response_body_bytes())?
463 .with_max_total_response_bytes(BASELINE_SCAN_PROFILE_MAX_TOTAL_RESPONSE_BYTES)?
464 .with_max_wall_time(Duration::from_millis(
465 BASELINE_SCAN_PROFILE_MAX_WALL_TIME_MS,
466 ))?
467 .with_max_active_verifications(DEFAULT_MAX_ACTIVE_VERIFICATIONS)
468}
469
470#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
477#[serde(deny_unknown_fields)]
478pub struct ScanProfileV1 {
479 schema: ScanProfileSchemaV1,
480 profile: BuiltInScanProfile,
481 scope: ScanProfileScope,
482 capabilities: ScanProfileCapabilitiesV1,
483 limits: ScanProfileLimitsV1,
484 defense: ScanProfileDefenseV1,
485}
486
487impl ScanProfileV1 {
488 pub fn baseline() -> Result<Self, ScanProfileV1Error> {
490 let limits = baseline_web_assessment_limits()?;
491 Self::from_parts(
492 BuiltInScanProfile::Baseline,
493 ScanProfileScope::SingleResource,
494 ScanProfileCapabilitiesV1::BASELINE,
495 ScanProfileLimitsV1::from_checked(limits)?,
496 false,
497 )
498 }
499
500 pub fn web_review() -> Result<Self, ScanProfileV1Error> {
502 Self::from_parts(
503 BuiltInScanProfile::WebReview,
504 ScanProfileScope::ExactOrigin,
505 ScanProfileCapabilitiesV1::WEB_REVIEW,
506 ScanProfileLimitsV1::from_checked(WebAssessmentLimits::default())?,
507 false,
508 )
509 }
510
511 pub fn for_builtin(profile: BuiltInScanProfile) -> Result<Self, ScanProfileV1Error> {
513 match profile {
514 BuiltInScanProfile::Baseline => Self::baseline(),
515 BuiltInScanProfile::WebReview => Self::web_review(),
516 }
517 }
518
519 pub fn for_builtin_id(value: &str) -> Result<Self, ScanProfileSelectionError> {
521 let profile = BuiltInScanProfile::parse_id(value)?;
522 Self::for_builtin(profile).map_err(ScanProfileSelectionError::InvalidProfile)
523 }
524
525 fn from_parts(
526 profile: BuiltInScanProfile,
527 scope: ScanProfileScope,
528 capabilities: ScanProfileCapabilitiesV1,
529 limits: ScanProfileLimitsV1,
530 defense_enforcement_enabled: bool,
531 ) -> Result<Self, ScanProfileV1Error> {
532 let candidate = Self {
533 schema: ScanProfileSchemaV1::V1,
534 profile,
535 scope,
536 capabilities,
537 limits,
538 defense: ScanProfileDefenseV1 {
539 enforcement_enabled: defense_enforcement_enabled,
540 },
541 };
542 candidate.validate()?;
543 Ok(candidate)
544 }
545
546 fn validate(&self) -> Result<(), ScanProfileV1Error> {
547 let expected_scope = self.profile.required_scope();
548 if self.scope != expected_scope {
549 return Err(ScanProfileV1Error::ScopeMismatch {
550 profile: self.profile,
551 expected: expected_scope,
552 actual: self.scope,
553 });
554 }
555 if self.capabilities != self.profile.required_capabilities() {
556 return Err(ScanProfileV1Error::CapabilityManifestMismatch {
557 profile: self.profile,
558 });
559 }
560 if self.defense.enforcement_enabled && self.profile != BuiltInScanProfile::WebReview {
561 return Err(ScanProfileV1Error::DefenseEnforcementNotAllowed {
562 profile: self.profile,
563 });
564 }
565 if self.profile == BuiltInScanProfile::Baseline {
566 self.validate_baseline_limits()?;
567 }
568 Ok(())
569 }
570
571 fn validate_baseline_limits(&self) -> Result<(), ScanProfileV1Error> {
572 let limits = &self.limits;
573 let expected = baseline_web_assessment_limits()?;
574 let invalid_dimension = if limits.max_subjects() != expected.max_subjects() {
575 Some("max_subjects")
576 } else if limits.max_discovery_depth() != expected.max_discovery_depth() {
577 Some("max_discovery_depth")
578 } else if limits.max_references_per_document() != expected.max_references_per_document() {
579 Some("max_references_per_document")
580 } else if limits.max_canonical_url_bytes() != expected.max_canonical_url_bytes() {
581 Some("max_canonical_url_bytes")
582 } else if limits.max_retained_url_bytes() != expected.max_retained_url_bytes() {
583 Some("max_retained_url_bytes")
584 } else if limits.max_forms() != expected.max_forms() {
585 Some("max_forms")
586 } else if limits.max_controls_per_form() != expected.max_controls_per_form() {
587 Some("max_controls_per_form")
588 } else if limits.max_query_parameter_names() != expected.max_query_parameter_names() {
589 Some("max_query_parameter_names")
590 } else if limits.max_total_requests() != expected.max_total_requests() {
591 Some("max_total_requests")
592 } else if limits.max_response_body_bytes() != expected.max_response_body_bytes() {
593 Some("max_response_body_bytes")
594 } else if limits.max_total_response_bytes() != expected.max_total_response_bytes() {
595 Some("max_total_response_bytes")
596 } else if limits.max_wall_time_ms() != duration_millis(expected.max_wall_time()) {
597 Some("max_wall_time_ms")
598 } else if limits.max_active_verifications() != expected.max_active_verifications() {
599 Some("max_active_verifications")
600 } else {
601 None
602 };
603 match invalid_dimension {
604 Some(dimension) => Err(ScanProfileV1Error::BaselineLimitMismatch { dimension }),
605 None => Ok(()),
606 }
607 }
608
609 pub const fn schema(&self) -> &'static str {
611 match self.schema {
612 ScanProfileSchemaV1::V1 => SCAN_PROFILE_V1_SCHEMA,
613 }
614 }
615
616 pub const fn profile(&self) -> BuiltInScanProfile {
618 self.profile
619 }
620
621 pub const fn scope(&self) -> ScanProfileScope {
623 self.scope
624 }
625
626 pub const fn capabilities(&self) -> ScanProfileCapabilitiesV1 {
628 self.capabilities
629 }
630
631 pub const fn limits(&self) -> &ScanProfileLimitsV1 {
633 &self.limits
634 }
635
636 pub const fn web_assessment_limits(&self) -> WebAssessmentLimits {
638 self.limits.web_assessment_limits()
639 }
640
641 pub const fn defense_enforcement_enabled(&self) -> bool {
643 self.defense.enforcement_enabled
644 }
645
646 pub fn with_limits(mut self, limits: WebAssessmentLimits) -> Result<Self, ScanProfileV1Error> {
651 self.limits = ScanProfileLimitsV1::from_checked(limits)?;
652 self.validate()?;
653 Ok(self)
654 }
655
656 pub fn with_defense_enforcement_enabled(
661 mut self,
662 enabled: bool,
663 ) -> Result<Self, ScanProfileV1Error> {
664 self.defense.enforcement_enabled = enabled;
665 self.validate()?;
666 Ok(self)
667 }
668}
669
670#[derive(Deserialize)]
671#[serde(deny_unknown_fields)]
672struct WireScanProfileV1 {
673 schema: ScanProfileSchemaV1,
674 profile: BuiltInScanProfile,
675 scope: ScanProfileScope,
676 capabilities: WireScanProfileCapabilitiesV1,
677 limits: ScanProfileLimitsV1,
678 defense: ScanProfileDefenseV1,
679}
680
681impl<'de> Deserialize<'de> for ScanProfileV1 {
682 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
683 where
684 D: Deserializer<'de>,
685 {
686 let wire = WireScanProfileV1::deserialize(deserializer)?;
687 let ScanProfileSchemaV1::V1 = wire.schema;
688 Self::from_parts(
689 wire.profile,
690 wire.scope,
691 wire.capabilities.into_capabilities(),
692 wire.limits,
693 wire.defense.enforcement_enabled,
694 )
695 .map_err(D::Error::custom)
696 }
697}
698
699#[derive(Debug, Clone, PartialEq, Eq, Error)]
701#[non_exhaustive]
702pub enum ScanProfileV1Error {
703 #[error(transparent)]
705 Limits(#[from] WebAssessmentLimitsError),
706 #[error(
708 "scan profile {profile} requires scope {expected}, but the document selected {actual}"
709 )]
710 ScopeMismatch {
711 profile: BuiltInScanProfile,
713 expected: ScanProfileScope,
715 actual: ScanProfileScope,
717 },
718 #[error(
720 "scan profile {profile} has a capability manifest that does not match executable behavior"
721 )]
722 CapabilityManifestMismatch {
723 profile: BuiltInScanProfile,
725 },
726 #[error("baseline scan profile limit {dimension} does not match the built-in envelope")]
728 BaselineLimitMismatch {
729 dimension: &'static str,
731 },
732 #[error("scan-profile wall-time limits must use whole-millisecond precision")]
734 SubMillisecondWallTime,
735 #[error("scan profile {profile} does not permit defense enforcement")]
737 DefenseEnforcementNotAllowed {
738 profile: BuiltInScanProfile,
740 },
741}
742
743#[derive(Debug, Clone, PartialEq, Eq, Error)]
745#[non_exhaustive]
746pub enum ScanProfileSelectionError {
747 #[error(transparent)]
749 UnknownProfile(#[from] BuiltInScanProfileParseError),
750 #[error(transparent)]
752 InvalidProfile(#[from] ScanProfileV1Error),
753}
754
755fn duration_millis(duration: Duration) -> u64 {
756 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
757}
758
759#[cfg(test)]
760mod tests {
761 use serde_json::{json, Value};
762
763 use super::*;
764 use crate::web_runtime::{
765 HARD_MAX_WEB_ASSESSMENT_ACTIVE_VERIFICATIONS, HARD_MAX_WEB_ASSESSMENT_CANONICAL_URL_BYTES,
766 HARD_MAX_WEB_ASSESSMENT_CONTROLS_PER_FORM, HARD_MAX_WEB_ASSESSMENT_DEPTH,
767 HARD_MAX_WEB_ASSESSMENT_FORMS, HARD_MAX_WEB_ASSESSMENT_QUERY_NAMES,
768 HARD_MAX_WEB_ASSESSMENT_REFERENCES_PER_DOCUMENT,
769 HARD_MAX_WEB_ASSESSMENT_RESPONSE_BODY_BYTES, HARD_MAX_WEB_ASSESSMENT_RETAINED_URL_BYTES,
770 HARD_MAX_WEB_ASSESSMENT_SUBJECTS, HARD_MAX_WEB_ASSESSMENT_TOTAL_REQUESTS,
771 HARD_MAX_WEB_ASSESSMENT_TOTAL_RESPONSE_BYTES, HARD_MAX_WEB_ASSESSMENT_WALL_TIME,
772 WEB_ASSESSMENT_CONCURRENCY,
773 };
774
775 fn profile_value(profile: ScanProfileV1) -> Value {
776 serde_json::to_value(profile).unwrap()
777 }
778
779 fn web_review_value() -> Value {
780 profile_value(ScanProfileV1::web_review().unwrap())
781 }
782
783 fn parse(value: &Value) -> Result<ScanProfileV1, serde_json::Error> {
784 serde_json::from_value(value.clone())
785 }
786
787 fn set_limit(value: &mut Value, name: &str, replacement: Value) {
788 value["limits"][name] = replacement;
789 }
790
791 #[test]
792 fn exact_profile_id_parsing_rejects_case_whitespace_and_historical_names() {
793 assert_eq!(
794 BuiltInScanProfile::parse_id("baseline").unwrap(),
795 BuiltInScanProfile::Baseline
796 );
797 assert_eq!(
798 BuiltInScanProfile::parse_id("web-review").unwrap(),
799 BuiltInScanProfile::WebReview
800 );
801 for rejected in [
802 "Baseline",
803 "BASELINE",
804 " baseline",
805 "baseline ",
806 "web_review",
807 "WEB-REVIEW",
808 "enterprise",
809 "cloud",
810 "aggressive",
811 "stealth",
812 "",
813 ] {
814 assert!(
815 BuiltInScanProfile::parse_id(rejected).is_err(),
816 "{rejected:?}"
817 );
818 assert!(
819 ScanProfileV1::for_builtin_id(rejected).is_err(),
820 "{rejected:?}"
821 );
822 }
823 }
824
825 #[test]
826 fn built_in_profiles_pin_executable_capability_truth() {
827 let baseline = ScanProfileV1::baseline().unwrap();
828 assert_eq!(baseline.schema(), SCAN_PROFILE_V1_SCHEMA);
829 assert_eq!(baseline.profile(), BuiltInScanProfile::Baseline);
830 assert_eq!(baseline.scope(), ScanProfileScope::SingleResource);
831 assert!(baseline.capabilities().standard_web_decision());
832 assert!(!baseline.capabilities().origin_discovery());
833 assert!(!baseline.capabilities().semantic_extraction());
834 assert!(!baseline.capabilities().defense_observation());
835 assert!(!baseline.capabilities().defense_shadow_planning());
836 assert!(!baseline.capabilities().passive_security_review());
837 assert!(!baseline.capabilities().low_risk_differential_review());
838 assert!(!baseline.defense_enforcement_enabled());
839
840 let web_review = ScanProfileV1::web_review().unwrap();
841 assert_eq!(web_review.schema(), SCAN_PROFILE_V1_SCHEMA);
842 assert_eq!(web_review.profile(), BuiltInScanProfile::WebReview);
843 assert_eq!(web_review.scope(), ScanProfileScope::ExactOrigin);
844 assert!(web_review.capabilities().standard_web_decision());
845 assert!(web_review.capabilities().origin_discovery());
846 assert!(web_review.capabilities().semantic_extraction());
847 assert!(web_review.capabilities().defense_observation());
848 assert!(web_review.capabilities().defense_shadow_planning());
849 assert!(web_review.capabilities().passive_security_review());
850 assert!(web_review.capabilities().low_risk_differential_review());
851 assert!(!web_review.defense_enforcement_enabled());
852 }
853
854 #[test]
855 fn built_in_limits_are_exact_and_share_fixed_concurrency() {
856 assert_eq!(BASELINE_SCAN_PROFILE_MAX_TOTAL_REQUESTS, 16);
857 assert_eq!(BASELINE_SCAN_PROFILE_MAX_WALL_TIME_MS, 60_000);
858 assert_eq!(BASELINE_SCAN_PROFILE_MAX_TOTAL_RESPONSE_BYTES, 1024 * 1024);
859
860 let baseline = ScanProfileV1::baseline().unwrap();
861 let limits = baseline.limits();
862 assert_eq!(limits.max_subjects(), 1);
863 assert_eq!(limits.max_discovery_depth(), 0);
864 assert_eq!(limits.max_references_per_document(), 0);
865 assert_eq!(
866 limits.max_retained_url_bytes(),
867 limits.max_canonical_url_bytes()
868 );
869 assert_eq!(limits.max_forms(), 0);
870 assert_eq!(limits.max_controls_per_form(), 0);
871 assert_eq!(limits.max_query_parameter_names(), 0);
872 assert_eq!(
873 limits.max_total_requests(),
874 BASELINE_SCAN_PROFILE_MAX_TOTAL_REQUESTS
875 );
876 assert_eq!(
877 limits.max_total_response_bytes(),
878 BASELINE_SCAN_PROFILE_MAX_TOTAL_RESPONSE_BYTES
879 );
880 assert_eq!(
881 limits.max_wall_time_ms(),
882 BASELINE_SCAN_PROFILE_MAX_WALL_TIME_MS
883 );
884 assert_eq!(
885 limits.max_active_verifications(),
886 DEFAULT_MAX_ACTIVE_VERIFICATIONS
887 );
888 assert_eq!(limits.concurrency(), WEB_ASSESSMENT_CONCURRENCY);
889
890 let expected = WebAssessmentLimits::default();
891 let web_review = ScanProfileV1::web_review().unwrap();
892 assert_eq!(web_review.web_assessment_limits(), expected);
893 assert_eq!(
894 web_review.limits().max_total_requests(),
895 expected.max_total_requests()
896 );
897 assert_eq!(
898 web_review.limits().max_wall_time_ms(),
899 duration_millis(expected.max_wall_time())
900 );
901 assert_eq!(
902 web_review.limits().concurrency(),
903 WEB_ASSESSMENT_CONCURRENCY
904 );
905 }
906
907 #[test]
908 fn both_built_ins_round_trip_without_defaults_or_hidden_fields() {
909 for profile in [
910 ScanProfileV1::baseline().unwrap(),
911 ScanProfileV1::web_review().unwrap(),
912 ] {
913 let encoded = serde_json::to_string(&profile).unwrap();
914 assert!(!encoded.contains("checked"));
915 assert!(!encoded.contains("concurrency"));
916 let decoded: ScanProfileV1 = serde_json::from_str(&encoded).unwrap();
917 assert_eq!(decoded, profile);
918 }
919 }
920
921 #[test]
922 fn unknown_schema_versions_and_version_field_fail_closed() {
923 let mut value = web_review_value();
924 value["schema"] = json!("venom.scan-profile/v2");
925 assert!(parse(&value).is_err());
926
927 let mut value = web_review_value();
928 value["schema"] = json!("venom.scan-profile/V1");
929 assert!(parse(&value).is_err());
930
931 let mut value = web_review_value();
932 value["version"] = json!(1);
933 assert!(parse(&value).is_err());
934 }
935
936 #[test]
937 fn unknown_profile_scope_and_case_fail_closed_on_wire() {
938 for replacement in ["enterprise", "WEB-REVIEW", " web-review"] {
939 let mut value = web_review_value();
940 value["profile"] = json!(replacement);
941 assert!(parse(&value).is_err(), "{replacement:?}");
942 }
943 for replacement in ["origin", "ExactOrigin", "exact-origin "] {
944 let mut value = web_review_value();
945 value["scope"] = json!(replacement);
946 assert!(parse(&value).is_err(), "{replacement:?}");
947 }
948 }
949
950 #[test]
951 fn invalid_wire_scalars_never_echo_rejected_secret_shaped_text() {
952 const REJECTED: &str = "Bearer profile-secret-that-must-not-be-echoed";
953
954 for field in ["schema", "profile", "scope"] {
955 let mut value = web_review_value();
956 value[field] = json!(REJECTED);
957 let error = parse(&value).unwrap_err();
958 for rendered in [error.to_string(), format!("{error:?}")] {
959 assert!(!rendered.contains(REJECTED), "{field}: {rendered}");
960 }
961 }
962 }
963
964 #[test]
965 fn every_object_level_rejects_unknown_fields() {
966 for (field, replacement) in [
967 ("target", json!("https://outside.invalid/")),
968 ("headers", json!({"authorization": "not-accepted"})),
969 ("transport", json!({"redirects": true})),
970 ("concurrency", json!(2)),
971 ("secret", json!("not-accepted")),
972 ("unexpected", json!(true)),
973 ] {
974 let mut top = web_review_value();
975 top[field] = replacement;
976 assert!(parse(&top).is_err(), "{field}");
977 }
978
979 let mut capabilities = web_review_value();
980 capabilities["capabilities"]["unexpected"] = json!(true);
981 assert!(parse(&capabilities).is_err());
982
983 let mut limits = web_review_value();
984 limits["limits"]["concurrency"] = json!(2);
985 assert!(parse(&limits).is_err());
986
987 let mut defense = web_review_value();
988 defense["defense"]["strategy"] = json!("evasion");
989 assert!(parse(&defense).is_err());
990 }
991
992 #[test]
993 fn every_required_top_level_and_nested_field_must_be_present() {
994 for field in [
995 "schema",
996 "profile",
997 "scope",
998 "capabilities",
999 "limits",
1000 "defense",
1001 ] {
1002 let mut value = web_review_value();
1003 value.as_object_mut().unwrap().remove(field);
1004 assert!(parse(&value).is_err(), "{field}");
1005 }
1006
1007 for field in [
1008 "standard_web_decision",
1009 "origin_discovery",
1010 "semantic_extraction",
1011 "defense_observation",
1012 "defense_shadow_planning",
1013 "passive_security_review",
1014 "low_risk_differential_review",
1015 ] {
1016 let mut value = web_review_value();
1017 value["capabilities"].as_object_mut().unwrap().remove(field);
1018 assert!(parse(&value).is_err(), "{field}");
1019 }
1020
1021 let mut value = web_review_value();
1022 value["defense"]
1023 .as_object_mut()
1024 .unwrap()
1025 .remove("enforcement_enabled");
1026 assert!(parse(&value).is_err());
1027 }
1028
1029 #[test]
1030 fn complete_limit_object_has_no_implicit_defaults() {
1031 for field in [
1032 "max_subjects",
1033 "max_discovery_depth",
1034 "max_references_per_document",
1035 "max_canonical_url_bytes",
1036 "max_retained_url_bytes",
1037 "max_forms",
1038 "max_controls_per_form",
1039 "max_query_parameter_names",
1040 "max_total_requests",
1041 "max_response_body_bytes",
1042 "max_total_response_bytes",
1043 "max_wall_time_ms",
1044 "max_active_verifications",
1045 ] {
1046 let mut value = web_review_value();
1047 value["limits"].as_object_mut().unwrap().remove(field);
1048 assert!(parse(&value).is_err(), "{field}");
1049 }
1050 }
1051
1052 #[test]
1053 fn every_compiled_limit_rejects_hard_maximum_plus_one() {
1054 let cases = [
1055 ("max_subjects", json!(HARD_MAX_WEB_ASSESSMENT_SUBJECTS + 1)),
1056 (
1057 "max_discovery_depth",
1058 json!(u32::from(HARD_MAX_WEB_ASSESSMENT_DEPTH) + 1),
1059 ),
1060 (
1061 "max_references_per_document",
1062 json!(HARD_MAX_WEB_ASSESSMENT_REFERENCES_PER_DOCUMENT + 1),
1063 ),
1064 (
1065 "max_canonical_url_bytes",
1066 json!(HARD_MAX_WEB_ASSESSMENT_CANONICAL_URL_BYTES + 1),
1067 ),
1068 (
1069 "max_retained_url_bytes",
1070 json!(HARD_MAX_WEB_ASSESSMENT_RETAINED_URL_BYTES + 1),
1071 ),
1072 ("max_forms", json!(HARD_MAX_WEB_ASSESSMENT_FORMS + 1)),
1073 (
1074 "max_controls_per_form",
1075 json!(HARD_MAX_WEB_ASSESSMENT_CONTROLS_PER_FORM + 1),
1076 ),
1077 (
1078 "max_query_parameter_names",
1079 json!(HARD_MAX_WEB_ASSESSMENT_QUERY_NAMES + 1),
1080 ),
1081 (
1082 "max_total_requests",
1083 json!(u64::from(HARD_MAX_WEB_ASSESSMENT_TOTAL_REQUESTS) + 1),
1084 ),
1085 (
1086 "max_response_body_bytes",
1087 json!(HARD_MAX_WEB_ASSESSMENT_RESPONSE_BODY_BYTES + 1),
1088 ),
1089 (
1090 "max_total_response_bytes",
1091 json!(HARD_MAX_WEB_ASSESSMENT_TOTAL_RESPONSE_BYTES + 1),
1092 ),
1093 (
1094 "max_wall_time_ms",
1095 json!(duration_millis(HARD_MAX_WEB_ASSESSMENT_WALL_TIME) + 1),
1096 ),
1097 (
1098 "max_active_verifications",
1099 json!(u32::from(HARD_MAX_WEB_ASSESSMENT_ACTIVE_VERIFICATIONS) + 1),
1100 ),
1101 ];
1102
1103 for (field, replacement) in cases {
1104 let mut value = web_review_value();
1105 set_limit(&mut value, field, replacement);
1106 assert!(parse(&value).is_err(), "{field}");
1107 }
1108 }
1109
1110 #[test]
1111 fn runtime_required_nonzero_limits_reject_zero() {
1112 for field in [
1113 "max_subjects",
1114 "max_canonical_url_bytes",
1115 "max_retained_url_bytes",
1116 "max_response_body_bytes",
1117 "max_total_response_bytes",
1118 ] {
1119 let mut value = web_review_value();
1120 set_limit(&mut value, field, json!(0));
1121 assert!(parse(&value).is_err(), "{field}");
1122 }
1123 }
1124
1125 #[test]
1126 fn fail_closed_zero_limits_remain_valid_where_runtime_contract_allows_them() {
1127 let mut value = web_review_value();
1128 for field in [
1129 "max_discovery_depth",
1130 "max_references_per_document",
1131 "max_forms",
1132 "max_controls_per_form",
1133 "max_query_parameter_names",
1134 "max_total_requests",
1135 "max_wall_time_ms",
1136 "max_active_verifications",
1137 ] {
1138 set_limit(&mut value, field, json!(0));
1139 }
1140 let profile = parse(&value).unwrap();
1141 assert_eq!(profile.limits().max_discovery_depth(), 0);
1142 assert_eq!(profile.limits().max_total_requests(), 0);
1143 assert_eq!(profile.limits().max_wall_time_ms(), 0);
1144 assert_eq!(profile.limits().max_active_verifications(), 0);
1145 }
1146
1147 #[test]
1148 fn baseline_rejects_any_limit_that_differs_from_its_exact_cli_envelope() {
1149 for (field, replacement) in [
1150 ("max_subjects", json!(2)),
1151 ("max_discovery_depth", json!(1)),
1152 ("max_references_per_document", json!(1)),
1153 ("max_canonical_url_bytes", json!(1)),
1154 ("max_retained_url_bytes", json!(1)),
1155 ("max_forms", json!(1)),
1156 ("max_controls_per_form", json!(1)),
1157 ("max_query_parameter_names", json!(1)),
1158 ("max_total_requests", json!(0)),
1159 ("max_response_body_bytes", json!(1)),
1160 ("max_total_response_bytes", json!(1)),
1161 ("max_wall_time_ms", json!(0)),
1162 ("max_active_verifications", json!(0)),
1163 ] {
1164 let mut value = profile_value(ScanProfileV1::baseline().unwrap());
1165 set_limit(&mut value, field, replacement);
1166 assert!(parse(&value).is_err(), "{field}");
1167 }
1168 }
1169
1170 #[test]
1171 fn profile_scope_and_capability_combinations_are_not_user_selectable() {
1172 let mut baseline_scope = profile_value(ScanProfileV1::baseline().unwrap());
1173 baseline_scope["scope"] = json!("exact-origin");
1174 assert!(parse(&baseline_scope).is_err());
1175
1176 let mut web_scope = web_review_value();
1177 web_scope["scope"] = json!("single-resource");
1178 assert!(parse(&web_scope).is_err());
1179
1180 for built_in in [
1181 ScanProfileV1::baseline().unwrap(),
1182 ScanProfileV1::web_review().unwrap(),
1183 ] {
1184 for capability in [
1185 "standard_web_decision",
1186 "origin_discovery",
1187 "semantic_extraction",
1188 "defense_observation",
1189 "defense_shadow_planning",
1190 "passive_security_review",
1191 "low_risk_differential_review",
1192 ] {
1193 let mut value = profile_value(built_in.clone());
1194 let current = value["capabilities"][capability].as_bool().unwrap();
1195 value["capabilities"][capability] = json!(!current);
1196 assert!(parse(&value).is_err(), "{capability}");
1197 }
1198 }
1199 }
1200
1201 #[test]
1202 fn defense_enforcement_is_default_off_and_requires_explicit_web_review_opt_in() {
1203 let baseline = ScanProfileV1::baseline().unwrap();
1204 assert!(baseline.with_defense_enforcement_enabled(true).is_err());
1205
1206 let web_review = ScanProfileV1::web_review().unwrap();
1207 assert!(!web_review.defense_enforcement_enabled());
1208 let enabled = web_review.with_defense_enforcement_enabled(true).unwrap();
1209 assert!(enabled.defense_enforcement_enabled());
1210 let round_trip: ScanProfileV1 =
1211 serde_json::from_value(profile_value(enabled.clone())).unwrap();
1212 assert_eq!(round_trip, enabled);
1213
1214 let mut baseline_wire = profile_value(ScanProfileV1::baseline().unwrap());
1215 baseline_wire["defense"]["enforcement_enabled"] = json!(true);
1216 assert!(parse(&baseline_wire).is_err());
1217 }
1218
1219 #[test]
1220 fn checked_limit_replacement_preserves_profile_invariants() {
1221 let web_review = ScanProfileV1::web_review().unwrap();
1222 let narrowed = WebAssessmentLimits::default()
1223 .with_max_subjects(2)
1224 .unwrap()
1225 .with_max_total_requests(3)
1226 .unwrap();
1227 let web_review = web_review.with_limits(narrowed).unwrap();
1228 assert_eq!(web_review.limits().max_subjects(), 2);
1229 assert_eq!(web_review.limits().max_total_requests(), 3);
1230
1231 let baseline = ScanProfileV1::baseline().unwrap();
1232 assert!(baseline.with_limits(narrowed).is_err());
1233 }
1234
1235 #[test]
1236 fn checked_limit_conversion_requires_lossless_millisecond_precision() {
1237 let sub_millisecond = WebAssessmentLimits::default()
1238 .with_max_wall_time(Duration::from_nanos(1))
1239 .unwrap();
1240 assert!(matches!(
1241 ScanProfileLimitsV1::from_web_assessment_limits(sub_millisecond),
1242 Err(ScanProfileV1Error::SubMillisecondWallTime)
1243 ));
1244 assert!(matches!(
1245 ScanProfileV1::web_review()
1246 .unwrap()
1247 .with_limits(sub_millisecond),
1248 Err(ScanProfileV1Error::SubMillisecondWallTime)
1249 ));
1250
1251 let exact_milliseconds = WebAssessmentLimits::default()
1252 .with_max_wall_time(Duration::from_millis(1_234))
1253 .unwrap();
1254 let profile = ScanProfileV1::web_review()
1255 .unwrap()
1256 .with_limits(exact_milliseconds)
1257 .unwrap();
1258 let encoded = serde_json::to_string(&profile).unwrap();
1259 let decoded: ScanProfileV1 = serde_json::from_str(&encoded).unwrap();
1260 assert_eq!(decoded, profile);
1261 assert_eq!(decoded.web_assessment_limits(), exact_milliseconds);
1262 }
1263}