Skip to main content

termivar_scanner/web_actions/
native_review.rs

1//! Transport-neutral action catalog for native low-risk web review.
2//!
3//! This catalog defines bounded opt-in differential actions, but deliberately does
4//! not install them into the standard planner, bind an executor, or authorize
5//! network I/O. Runtime composition remains responsible for exact-origin
6//! authority, shared accounting, and request construction.
7//!
8//! Every catalog entry has one immutable relationship: a passive control
9//! followed by one active candidate. Both entries are knowledge-only, so even
10//! a successful action cannot transition a hypothesis into a confirmed state.
11
12use serde::{Deserialize, Serialize};
13
14use crate::planner::{RiskScore, VerificationTarget};
15
16/// Number of actions in the native low-risk web-review catalog.
17pub const NATIVE_WEB_REVIEW_ACTION_COUNT: usize = 10
18    + (2 * cfg!(feature = "template-evaluation-review") as usize)
19    + cfg!(feature = "normalization-resilience") as usize
20    + cfg!(feature = "authorization-review") as usize
21    + cfg!(feature = "openapi-review") as usize
22    + cfg!(feature = "rest-review") as usize
23    + cfg!(feature = "ssrf-oast-review") as usize;
24
25/// Hard per-case request count declared by every native web-review action.
26pub const NATIVE_WEB_REVIEW_REQUESTS_PER_CASE: usize = 2;
27
28/// Hard per-case active-request count declared by every native web-review action.
29pub const NATIVE_WEB_REVIEW_ACTIVE_REQUESTS_PER_CASE: usize = 1;
30
31/// Closed evidence namespace emitted only by the sealed native-review observer.
32#[cfg(feature = "scanning")]
33pub(crate) const NATIVE_WEB_REVIEW_EVIDENCE_NAMESPACE: &str = "web.review.observation";
34/// Marker proving that one response reached the bounded review projection.
35#[cfg(feature = "scanning")]
36pub(crate) const NATIVE_WEB_REVIEW_RESPONSE_MARKER: &str = "response-marker";
37
38/// Returns the exact marker selected by native active pair-completion rules.
39#[cfg(feature = "scanning")]
40pub(crate) fn native_web_review_response_marker_predicate() -> termivar_core::KnowledgePredicate {
41    termivar_core::KnowledgePredicate::new(
42        NATIVE_WEB_REVIEW_EVIDENCE_NAMESPACE,
43        NATIVE_WEB_REVIEW_RESPONSE_MARKER,
44    )
45    .expect("the native review marker predicate is a valid static identity")
46}
47
48/// Marker used by the authorization action to stop before replay after a
49/// rate-limit, defensive challenge, or incomplete passive pair.
50#[cfg(all(feature = "scanning", feature = "authorization-review"))]
51pub(crate) fn authorization_review_phase_terminal_predicate() -> termivar_core::KnowledgePredicate {
52    termivar_core::KnowledgePredicate::new("web.authorization-review.transport", "phase-terminal")
53        .expect("the authorization terminal predicate is a valid static identity")
54}
55
56#[cfg(all(feature = "scanning", feature = "openapi-review"))]
57pub(crate) fn openapi_review_phase_terminal_predicate() -> termivar_core::KnowledgePredicate {
58    termivar_core::KnowledgePredicate::new("web.openapi-review.transport", "phase-terminal")
59        .expect("the OpenAPI terminal predicate is a valid static identity")
60}
61
62#[cfg(all(feature = "scanning", feature = "rest-review"))]
63pub(crate) fn rest_review_catalog_ready_predicate() -> termivar_core::KnowledgePredicate {
64    termivar_core::KnowledgePredicate::new("web.openapi-review.transport", "rest-catalog-ready")
65        .expect("the REST catalog-ready predicate is a valid static identity")
66}
67
68#[cfg(all(feature = "scanning", feature = "rest-review"))]
69pub(crate) fn rest_review_phase_terminal_predicate() -> termivar_core::KnowledgePredicate {
70    termivar_core::KnowledgePredicate::new("web.rest-review.transport", "phase-terminal")
71        .expect("the REST terminal predicate is a valid static identity")
72}
73
74#[cfg(all(feature = "scanning", feature = "ssrf-oast-review"))]
75pub(crate) fn ssrf_oast_review_candidate_ready_predicate() -> termivar_core::KnowledgePredicate {
76    termivar_core::KnowledgePredicate::new("web.ssrf-oast-review.transport", "candidate-ready")
77        .expect("the SSRF OAST candidate-ready predicate is a valid static identity")
78}
79
80#[cfg(all(feature = "scanning", feature = "ssrf-oast-review"))]
81pub(crate) fn ssrf_oast_review_phase_terminal_predicate() -> termivar_core::KnowledgePredicate {
82    termivar_core::KnowledgePredicate::new("web.ssrf-oast-review.transport", "phase-terminal")
83        .expect("the SSRF OAST terminal predicate is a valid static identity")
84}
85
86const CONTROL_CANDIDATE_LEGS: [NativeWebReviewRequestLeg; NATIVE_WEB_REVIEW_REQUESTS_PER_CASE] = [
87    NativeWebReviewRequestLeg::PassiveControl,
88    NativeWebReviewRequestLeg::ActiveCandidate,
89];
90
91/// Stable semantic actions available to an explicitly composed web-review host.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94#[non_exhaustive]
95pub enum NativeWebReviewActionKind {
96    /// Compare a request without `Origin` to one carrying a case-specific origin.
97    CorsPolicyPair,
98    /// Compare a query-free request to one carrying a single case-specific query value.
99    RedirectReflectionQueryPair,
100    /// Classify one inert scanner-owned marker in a bounded HTML parse tree.
101    ReflectionContextQueryPair,
102    /// First matched quote-balance structural comparison.
103    SqlStructuralQueryPair,
104    /// Exact replay of the first SQL structural comparison.
105    SqlStructuralQueryReplayPair,
106    /// First matched safe arithmetic template-expression comparison.
107    SstiStructuralQueryPair,
108    /// Independent arithmetic replay of the SSTI structural comparison.
109    SstiStructuralQueryReplayPair,
110    /// First harmless Jinja-compatible uppercase-filter semantic comparison.
111    #[cfg(feature = "template-evaluation-review")]
112    TemplateJinjaUppercaseQueryPair,
113    /// Independent uppercase-filter replay with a distinct scanner-owned literal.
114    #[cfg(feature = "template-evaluation-review")]
115    TemplateJinjaUppercaseQueryReplayPair,
116    /// One context-selected non-executing structural XSS comparison.
117    XssStructuralQueryPair,
118    /// One source-anchored quote-aware inert attribute-boundary comparison.
119    XssAttributeBoundaryQueryPair,
120    /// One source-anchored non-executing JavaScript lexical-boundary comparison.
121    XssScriptLexicalBoundaryQueryPair,
122    /// One transformed inert candidate followed by one distinct transformed replay.
123    #[cfg(feature = "normalization-resilience")]
124    NormalizationResilienceQueryPair,
125    /// Compare one exact JSON resource across two principal contexts and replays.
126    #[cfg(feature = "authorization-review")]
127    ResourceAuthorizationDifferential,
128    /// Fetch one exact-origin OpenAPI JSON candidate and independently replay it.
129    #[cfg(feature = "openapi-review")]
130    OpenApiDocumentReplay,
131    /// Observe one replay-stable, anonymous, bodyless REST GET selected from OpenAPI evidence.
132    #[cfg(feature = "rest-review")]
133    RestReadOnlyReplay,
134    /// Verify one query-only outbound interaction with distinct candidate and replay callbacks.
135    #[cfg(feature = "ssrf-oast-review")]
136    SsrfOastQueryReview,
137}
138
139/// The only request surface an action may vary between its matched legs.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142#[non_exhaustive]
143pub enum NativeWebReviewDifferentialInput {
144    /// The active candidate adds one bounded `Origin` header.
145    OriginHeader,
146    /// The active candidate adds one bounded query parameter.
147    SingleQueryParameter,
148    /// The fixed resource request changes only the validated principal credential.
149    #[cfg(feature = "authorization-review")]
150    AuthorizationHeader,
151    /// Replay the identical bounded GET request without mutating target input.
152    #[cfg(feature = "openapi-review")]
153    ExactRequestReplay,
154}
155
156/// Ordered request roles for one native web-review case.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159#[non_exhaustive]
160pub enum NativeWebReviewRequestLeg {
161    /// Negative/control observation collected without the candidate mutation.
162    PassiveControl,
163    /// Candidate observation containing exactly the action's declared mutation.
164    ActiveCandidate,
165}
166
167impl NativeWebReviewRequestLeg {
168    /// Returns whether this leg consumes active-verification authority.
169    pub const fn is_active(self) -> bool {
170        matches!(self, Self::ActiveCandidate)
171    }
172}
173
174impl NativeWebReviewActionKind {
175    /// Returns every native web-review action in stable declaration order.
176    pub const fn all() -> [Self; NATIVE_WEB_REVIEW_ACTION_COUNT] {
177        [
178            Self::CorsPolicyPair,
179            Self::RedirectReflectionQueryPair,
180            Self::ReflectionContextQueryPair,
181            Self::SqlStructuralQueryPair,
182            Self::SqlStructuralQueryReplayPair,
183            Self::SstiStructuralQueryPair,
184            Self::SstiStructuralQueryReplayPair,
185            #[cfg(feature = "template-evaluation-review")]
186            Self::TemplateJinjaUppercaseQueryPair,
187            #[cfg(feature = "template-evaluation-review")]
188            Self::TemplateJinjaUppercaseQueryReplayPair,
189            Self::XssStructuralQueryPair,
190            Self::XssAttributeBoundaryQueryPair,
191            Self::XssScriptLexicalBoundaryQueryPair,
192            #[cfg(feature = "normalization-resilience")]
193            Self::NormalizationResilienceQueryPair,
194            #[cfg(feature = "authorization-review")]
195            Self::ResourceAuthorizationDifferential,
196            #[cfg(feature = "openapi-review")]
197            Self::OpenApiDocumentReplay,
198            #[cfg(feature = "rest-review")]
199            Self::RestReadOnlyReplay,
200            #[cfg(feature = "ssrf-oast-review")]
201            Self::SsrfOastQueryReview,
202        ]
203    }
204
205    /// Returns the stable planner action identity.
206    pub const fn action_id(self) -> &'static str {
207        match self {
208            Self::CorsPolicyPair => "web.review.cors.policy-pair@1",
209            Self::RedirectReflectionQueryPair => "web.review.redirect-reflection.query-pair@1",
210            Self::ReflectionContextQueryPair => "web.review.reflection.context-query-pair@1",
211            Self::SqlStructuralQueryPair => "web.review.sql.structural-query-pair@1",
212            Self::SqlStructuralQueryReplayPair => "web.review.sql.structural-query-replay-pair@1",
213            Self::SstiStructuralQueryPair => "web.review.ssti.structural-query-pair@1",
214            Self::SstiStructuralQueryReplayPair => "web.review.ssti.structural-query-replay-pair@1",
215            #[cfg(feature = "template-evaluation-review")]
216            Self::TemplateJinjaUppercaseQueryPair => {
217                "web.review.template-evaluation.jinja-uppercase-query-pair@1"
218            },
219            #[cfg(feature = "template-evaluation-review")]
220            Self::TemplateJinjaUppercaseQueryReplayPair => {
221                "web.review.template-evaluation.jinja-uppercase-query-replay-pair@1"
222            },
223            Self::XssStructuralQueryPair => "web.review.xss.structural-query-pair@1",
224            Self::XssAttributeBoundaryQueryPair => "web.review.xss.attribute-boundary-query-pair@1",
225            Self::XssScriptLexicalBoundaryQueryPair => {
226                "web.review.xss.script-lexical-boundary-query-pair@1"
227            },
228            #[cfg(feature = "normalization-resilience")]
229            Self::NormalizationResilienceQueryPair => {
230                "web.review.normalization-resilience.query-pair@1"
231            },
232            #[cfg(feature = "authorization-review")]
233            Self::ResourceAuthorizationDifferential => {
234                "web.review.authorization.resource-differential"
235            },
236            #[cfg(feature = "openapi-review")]
237            Self::OpenApiDocumentReplay => "web.review.openapi.document-replay@1",
238            #[cfg(feature = "rest-review")]
239            Self::RestReadOnlyReplay => "web.review.rest.readonly-replay@1",
240            #[cfg(feature = "ssrf-oast-review")]
241            Self::SsrfOastQueryReview => "web.review.ssrf.oast-query@1",
242        }
243    }
244
245    /// Returns the stable opaque executor-route identity.
246    ///
247    /// The catalog does not register or implement the route.
248    pub const fn executor_id(self) -> &'static str {
249        match self {
250            Self::CorsPolicyPair => "web.review.probe.cors-policy-pair@1",
251            Self::RedirectReflectionQueryPair => {
252                "web.review.probe.redirect-reflection-query-pair@1"
253            },
254            Self::ReflectionContextQueryPair => "web.review.probe.reflection-context-query-pair@1",
255            Self::SqlStructuralQueryPair => "web.review.probe.sql-structural-query-pair@1",
256            Self::SqlStructuralQueryReplayPair => {
257                "web.review.probe.sql-structural-query-replay-pair@1"
258            },
259            Self::SstiStructuralQueryPair => "web.review.probe.ssti-structural-query-pair@1",
260            Self::SstiStructuralQueryReplayPair => {
261                "web.review.probe.ssti-structural-query-replay-pair@1"
262            },
263            #[cfg(feature = "template-evaluation-review")]
264            Self::TemplateJinjaUppercaseQueryPair => {
265                "web.review.probe.template-jinja-uppercase-query-pair@1"
266            },
267            #[cfg(feature = "template-evaluation-review")]
268            Self::TemplateJinjaUppercaseQueryReplayPair => {
269                "web.review.probe.template-jinja-uppercase-query-replay-pair@1"
270            },
271            Self::XssStructuralQueryPair => "web.review.probe.xss-structural-query-pair@1",
272            Self::XssAttributeBoundaryQueryPair => {
273                "web.review.probe.xss-attribute-boundary-query-pair@1"
274            },
275            Self::XssScriptLexicalBoundaryQueryPair => {
276                "web.review.probe.xss-script-lexical-boundary-query-pair@1"
277            },
278            #[cfg(feature = "normalization-resilience")]
279            Self::NormalizationResilienceQueryPair => {
280                "web.review.probe.normalization-resilience-query-pair@1"
281            },
282            #[cfg(feature = "authorization-review")]
283            Self::ResourceAuthorizationDifferential => "http.authorization-resource-review",
284            #[cfg(feature = "openapi-review")]
285            Self::OpenApiDocumentReplay => "http.openapi-review",
286            #[cfg(feature = "rest-review")]
287            Self::RestReadOnlyReplay => "http.rest-review",
288            #[cfg(feature = "ssrf-oast-review")]
289            Self::SsrfOastQueryReview => "http.ssrf-oast-review",
290        }
291    }
292
293    /// Returns the compact stable name used in derived audit identities.
294    pub const fn slug(self) -> &'static str {
295        match self {
296            Self::CorsPolicyPair => "cors-policy-pair",
297            Self::RedirectReflectionQueryPair => "redirect-reflection-query-pair",
298            Self::ReflectionContextQueryPair => "reflection-context-query-pair",
299            Self::SqlStructuralQueryPair => "sql-structural-query-pair",
300            Self::SqlStructuralQueryReplayPair => "sql-structural-query-replay-pair",
301            Self::SstiStructuralQueryPair => "ssti-structural-query-pair",
302            Self::SstiStructuralQueryReplayPair => "ssti-structural-query-replay-pair",
303            #[cfg(feature = "template-evaluation-review")]
304            Self::TemplateJinjaUppercaseQueryPair => "template-jinja-uppercase-query-pair",
305            #[cfg(feature = "template-evaluation-review")]
306            Self::TemplateJinjaUppercaseQueryReplayPair => {
307                "template-jinja-uppercase-query-replay-pair"
308            },
309            Self::XssStructuralQueryPair => "xss-structural-query-pair",
310            Self::XssAttributeBoundaryQueryPair => "xss-attribute-boundary-query-pair",
311            Self::XssScriptLexicalBoundaryQueryPair => "xss-script-lexical-boundary-query-pair",
312            #[cfg(feature = "normalization-resilience")]
313            Self::NormalizationResilienceQueryPair => "normalization-resilience-query-pair",
314            #[cfg(feature = "authorization-review")]
315            Self::ResourceAuthorizationDifferential => "authorization-resource-differential",
316            #[cfg(feature = "openapi-review")]
317            Self::OpenApiDocumentReplay => "openapi-document-replay",
318            #[cfg(feature = "rest-review")]
319            Self::RestReadOnlyReplay => "rest-readonly-replay",
320            #[cfg(feature = "ssrf-oast-review")]
321            Self::SsrfOastQueryReview => "ssrf-oast-query-review",
322        }
323    }
324
325    /// Returns the single request surface this action may vary.
326    pub const fn differential_input(self) -> NativeWebReviewDifferentialInput {
327        match self {
328            Self::CorsPolicyPair => NativeWebReviewDifferentialInput::OriginHeader,
329            Self::RedirectReflectionQueryPair => {
330                NativeWebReviewDifferentialInput::SingleQueryParameter
331            },
332            Self::ReflectionContextQueryPair => {
333                NativeWebReviewDifferentialInput::SingleQueryParameter
334            },
335            Self::SqlStructuralQueryPair
336            | Self::SqlStructuralQueryReplayPair
337            | Self::SstiStructuralQueryPair
338            | Self::SstiStructuralQueryReplayPair
339            | Self::XssStructuralQueryPair
340            | Self::XssAttributeBoundaryQueryPair
341            | Self::XssScriptLexicalBoundaryQueryPair => {
342                NativeWebReviewDifferentialInput::SingleQueryParameter
343            },
344            #[cfg(feature = "template-evaluation-review")]
345            Self::TemplateJinjaUppercaseQueryPair | Self::TemplateJinjaUppercaseQueryReplayPair => {
346                NativeWebReviewDifferentialInput::SingleQueryParameter
347            },
348            #[cfg(feature = "normalization-resilience")]
349            Self::NormalizationResilienceQueryPair => {
350                NativeWebReviewDifferentialInput::SingleQueryParameter
351            },
352            #[cfg(feature = "authorization-review")]
353            Self::ResourceAuthorizationDifferential => {
354                NativeWebReviewDifferentialInput::AuthorizationHeader
355            },
356            #[cfg(feature = "openapi-review")]
357            Self::OpenApiDocumentReplay => NativeWebReviewDifferentialInput::ExactRequestReplay,
358            #[cfg(feature = "rest-review")]
359            Self::RestReadOnlyReplay => NativeWebReviewDifferentialInput::ExactRequestReplay,
360            #[cfg(feature = "ssrf-oast-review")]
361            Self::SsrfOastQueryReview => NativeWebReviewDifferentialInput::SingleQueryParameter,
362        }
363    }
364
365    /// Returns the fixed passive/active decision phases for one case.
366    ///
367    /// A phase may own more than one broker-accounted wire leg when its closed
368    /// action contract declares that exact relationship.
369    pub const fn request_legs(
370        self,
371    ) -> &'static [NativeWebReviewRequestLeg; NATIVE_WEB_REVIEW_REQUESTS_PER_CASE] {
372        let _ = self;
373        &CONTROL_CANDIDATE_LEGS
374    }
375
376    /// Returns the maximum requests this action may issue for one case.
377    pub const fn maximum_requests_per_case(self) -> usize {
378        #[cfg(feature = "authorization-review")]
379        if matches!(self, Self::ResourceAuthorizationDifferential) {
380            return 4;
381        }
382        #[cfg(feature = "ssrf-oast-review")]
383        if matches!(self, Self::SsrfOastQueryReview) {
384            return 3;
385        }
386        self.request_legs().len()
387    }
388
389    /// Returns the maximum active requests this action may issue for one case.
390    pub const fn maximum_active_requests_per_case(self) -> usize {
391        let _ = self;
392        NATIVE_WEB_REVIEW_ACTIVE_REQUESTS_PER_CASE
393    }
394
395    /// Returns the low operational risk used by future planner composition.
396    ///
397    /// This score models request-side operational risk, not finding severity.
398    pub fn risk(self) -> RiskScore {
399        let percent = match self {
400            Self::CorsPolicyPair => 5,
401            Self::RedirectReflectionQueryPair => 8,
402            Self::ReflectionContextQueryPair => 5,
403            Self::SqlStructuralQueryPair
404            | Self::SqlStructuralQueryReplayPair
405            | Self::SstiStructuralQueryPair
406            | Self::SstiStructuralQueryReplayPair
407            | Self::XssStructuralQueryPair
408            | Self::XssAttributeBoundaryQueryPair
409            | Self::XssScriptLexicalBoundaryQueryPair => 7,
410            #[cfg(feature = "template-evaluation-review")]
411            Self::TemplateJinjaUppercaseQueryPair | Self::TemplateJinjaUppercaseQueryReplayPair => {
412                7
413            },
414            #[cfg(feature = "normalization-resilience")]
415            Self::NormalizationResilienceQueryPair => 7,
416            #[cfg(feature = "authorization-review")]
417            Self::ResourceAuthorizationDifferential => 6,
418            #[cfg(feature = "openapi-review")]
419            Self::OpenApiDocumentReplay => 2,
420            #[cfg(feature = "rest-review")]
421            Self::RestReadOnlyReplay => 2,
422            #[cfg(feature = "ssrf-oast-review")]
423            Self::SsrfOastQueryReview => 5,
424        };
425        RiskScore::from_percent(percent).expect("native web-review risk is a valid constant")
426    }
427
428    /// Returns the immutable claim policy for this action.
429    ///
430    /// No catalog entry can target either its motivation hypothesis or a
431    /// distinct hypothesis. A later planner profile must copy this value rather
432    /// than infer claim authority from a successful execution.
433    pub const fn verification_target(self) -> VerificationTarget {
434        let _ = self;
435        VerificationTarget::KnowledgeOnly
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use std::collections::BTreeSet;
442
443    use crate::{
444        planner::VerificationTarget, web_actions::StandardWebActionKind,
445        web_planning::StandardWebAttackProfile,
446    };
447
448    use super::{
449        NativeWebReviewActionKind, NativeWebReviewDifferentialInput, NativeWebReviewRequestLeg,
450        NATIVE_WEB_REVIEW_ACTION_COUNT, NATIVE_WEB_REVIEW_ACTIVE_REQUESTS_PER_CASE,
451        NATIVE_WEB_REVIEW_REQUESTS_PER_CASE,
452    };
453
454    #[test]
455    fn native_action_identities_are_stable_unique_and_deterministic() {
456        let expected = [
457            (
458                NativeWebReviewActionKind::CorsPolicyPair,
459                "web.review.cors.policy-pair@1",
460                "web.review.probe.cors-policy-pair@1",
461                "cors-policy-pair",
462            ),
463            (
464                NativeWebReviewActionKind::RedirectReflectionQueryPair,
465                "web.review.redirect-reflection.query-pair@1",
466                "web.review.probe.redirect-reflection-query-pair@1",
467                "redirect-reflection-query-pair",
468            ),
469            (
470                NativeWebReviewActionKind::ReflectionContextQueryPair,
471                "web.review.reflection.context-query-pair@1",
472                "web.review.probe.reflection-context-query-pair@1",
473                "reflection-context-query-pair",
474            ),
475            (
476                NativeWebReviewActionKind::SqlStructuralQueryPair,
477                "web.review.sql.structural-query-pair@1",
478                "web.review.probe.sql-structural-query-pair@1",
479                "sql-structural-query-pair",
480            ),
481            (
482                NativeWebReviewActionKind::SqlStructuralQueryReplayPair,
483                "web.review.sql.structural-query-replay-pair@1",
484                "web.review.probe.sql-structural-query-replay-pair@1",
485                "sql-structural-query-replay-pair",
486            ),
487            (
488                NativeWebReviewActionKind::SstiStructuralQueryPair,
489                "web.review.ssti.structural-query-pair@1",
490                "web.review.probe.ssti-structural-query-pair@1",
491                "ssti-structural-query-pair",
492            ),
493            (
494                NativeWebReviewActionKind::SstiStructuralQueryReplayPair,
495                "web.review.ssti.structural-query-replay-pair@1",
496                "web.review.probe.ssti-structural-query-replay-pair@1",
497                "ssti-structural-query-replay-pair",
498            ),
499            #[cfg(feature = "template-evaluation-review")]
500            (
501                NativeWebReviewActionKind::TemplateJinjaUppercaseQueryPair,
502                "web.review.template-evaluation.jinja-uppercase-query-pair@1",
503                "web.review.probe.template-jinja-uppercase-query-pair@1",
504                "template-jinja-uppercase-query-pair",
505            ),
506            #[cfg(feature = "template-evaluation-review")]
507            (
508                NativeWebReviewActionKind::TemplateJinjaUppercaseQueryReplayPair,
509                "web.review.template-evaluation.jinja-uppercase-query-replay-pair@1",
510                "web.review.probe.template-jinja-uppercase-query-replay-pair@1",
511                "template-jinja-uppercase-query-replay-pair",
512            ),
513            (
514                NativeWebReviewActionKind::XssStructuralQueryPair,
515                "web.review.xss.structural-query-pair@1",
516                "web.review.probe.xss-structural-query-pair@1",
517                "xss-structural-query-pair",
518            ),
519            (
520                NativeWebReviewActionKind::XssAttributeBoundaryQueryPair,
521                "web.review.xss.attribute-boundary-query-pair@1",
522                "web.review.probe.xss-attribute-boundary-query-pair@1",
523                "xss-attribute-boundary-query-pair",
524            ),
525            (
526                NativeWebReviewActionKind::XssScriptLexicalBoundaryQueryPair,
527                "web.review.xss.script-lexical-boundary-query-pair@1",
528                "web.review.probe.xss-script-lexical-boundary-query-pair@1",
529                "xss-script-lexical-boundary-query-pair",
530            ),
531            #[cfg(feature = "normalization-resilience")]
532            (
533                NativeWebReviewActionKind::NormalizationResilienceQueryPair,
534                "web.review.normalization-resilience.query-pair@1",
535                "web.review.probe.normalization-resilience-query-pair@1",
536                "normalization-resilience-query-pair",
537            ),
538            #[cfg(feature = "authorization-review")]
539            (
540                NativeWebReviewActionKind::ResourceAuthorizationDifferential,
541                "web.review.authorization.resource-differential",
542                "http.authorization-resource-review",
543                "authorization-resource-differential",
544            ),
545            #[cfg(feature = "openapi-review")]
546            (
547                NativeWebReviewActionKind::OpenApiDocumentReplay,
548                "web.review.openapi.document-replay@1",
549                "http.openapi-review",
550                "openapi-document-replay",
551            ),
552            #[cfg(feature = "rest-review")]
553            (
554                NativeWebReviewActionKind::RestReadOnlyReplay,
555                "web.review.rest.readonly-replay@1",
556                "http.rest-review",
557                "rest-readonly-replay",
558            ),
559            #[cfg(feature = "ssrf-oast-review")]
560            (
561                NativeWebReviewActionKind::SsrfOastQueryReview,
562                "web.review.ssrf.oast-query@1",
563                "http.ssrf-oast-review",
564                "ssrf-oast-query-review",
565            ),
566        ];
567
568        assert_eq!(
569            NativeWebReviewActionKind::all().len(),
570            NATIVE_WEB_REVIEW_ACTION_COUNT
571        );
572        assert_eq!(
573            NativeWebReviewActionKind::all(),
574            expected.map(|entry| entry.0)
575        );
576
577        let mut action_ids = BTreeSet::new();
578        let mut executor_ids = BTreeSet::new();
579        let mut slugs = BTreeSet::new();
580        for (kind, action_id, executor_id, slug) in expected {
581            assert_eq!(kind.action_id(), action_id);
582            assert_eq!(kind.executor_id(), executor_id);
583            assert_eq!(kind.slug(), slug);
584            assert!(action_ids.insert(kind.action_id()));
585            assert!(executor_ids.insert(kind.executor_id()));
586            assert!(slugs.insert(kind.slug()));
587        }
588    }
589
590    #[test]
591    fn every_native_action_is_low_risk_and_irrevocably_knowledge_only() {
592        let expected_risk_basis_points = [
593            500,
594            800,
595            500,
596            700,
597            700,
598            #[cfg(feature = "template-evaluation-review")]
599            700,
600            #[cfg(feature = "template-evaluation-review")]
601            700,
602            700,
603            700,
604            700,
605            700,
606            700,
607            #[cfg(feature = "normalization-resilience")]
608            700,
609            #[cfg(feature = "authorization-review")]
610            600,
611            #[cfg(feature = "openapi-review")]
612            200,
613            #[cfg(feature = "rest-review")]
614            200,
615        ];
616
617        for (kind, expected_risk) in NativeWebReviewActionKind::all()
618            .into_iter()
619            .zip(expected_risk_basis_points)
620        {
621            assert_eq!(kind.risk().basis_points(), expected_risk);
622            assert!(kind.risk().basis_points() <= 1_000);
623            assert_eq!(
624                kind.verification_target(),
625                VerificationTarget::KnowledgeOnly
626            );
627        }
628    }
629
630    #[test]
631    fn request_relationship_is_exactly_passive_control_then_active_candidate() {
632        let expected_legs = [
633            NativeWebReviewRequestLeg::PassiveControl,
634            NativeWebReviewRequestLeg::ActiveCandidate,
635        ];
636
637        for kind in NativeWebReviewActionKind::all() {
638            assert_eq!(kind.request_legs(), &expected_legs);
639            let extended_request_plan = false;
640            #[cfg(feature = "authorization-review")]
641            let extended_request_plan = extended_request_plan
642                || kind == NativeWebReviewActionKind::ResourceAuthorizationDifferential;
643            #[cfg(feature = "authorization-review")]
644            if kind == NativeWebReviewActionKind::ResourceAuthorizationDifferential {
645                assert_eq!(kind.maximum_requests_per_case(), 4);
646            }
647            #[cfg(feature = "ssrf-oast-review")]
648            let extended_request_plan =
649                extended_request_plan || kind == NativeWebReviewActionKind::SsrfOastQueryReview;
650            #[cfg(feature = "ssrf-oast-review")]
651            if kind == NativeWebReviewActionKind::SsrfOastQueryReview {
652                assert_eq!(kind.maximum_requests_per_case(), 3);
653            }
654            if !extended_request_plan {
655                assert_eq!(
656                    kind.maximum_requests_per_case(),
657                    NATIVE_WEB_REVIEW_REQUESTS_PER_CASE
658                );
659            }
660            assert_eq!(
661                kind.maximum_active_requests_per_case(),
662                NATIVE_WEB_REVIEW_ACTIVE_REQUESTS_PER_CASE
663            );
664            assert_eq!(
665                kind.request_legs()
666                    .iter()
667                    .filter(|leg| leg.is_active())
668                    .count(),
669                1
670            );
671        }
672
673        assert_eq!(
674            NativeWebReviewActionKind::CorsPolicyPair.differential_input(),
675            NativeWebReviewDifferentialInput::OriginHeader
676        );
677        assert_eq!(
678            NativeWebReviewActionKind::RedirectReflectionQueryPair.differential_input(),
679            NativeWebReviewDifferentialInput::SingleQueryParameter
680        );
681        assert_eq!(
682            NativeWebReviewActionKind::ReflectionContextQueryPair.differential_input(),
683            NativeWebReviewDifferentialInput::SingleQueryParameter
684        );
685    }
686
687    #[test]
688    fn native_review_actions_are_absent_from_the_default_standard_catalog() {
689        let native_ids = NativeWebReviewActionKind::all()
690            .into_iter()
691            .map(NativeWebReviewActionKind::action_id)
692            .collect::<BTreeSet<_>>();
693        let standard_ids = StandardWebActionKind::all()
694            .into_iter()
695            .map(StandardWebActionKind::action_id)
696            .collect::<BTreeSet<_>>();
697
698        assert!(native_ids.is_disjoint(&standard_ids));
699
700        let standard_profile = StandardWebAttackProfile::new().unwrap();
701        assert!(standard_profile
702            .actions()
703            .iter()
704            .all(|action| !native_ids.contains(action.id())));
705    }
706}