1use std::{
4 collections::BTreeMap,
5 fmt,
6 sync::{Arc, Mutex},
7};
8
9use async_trait::async_trait;
10use serde::Serialize;
11use sha2::{Digest, Sha256};
12use termivar_core::predicates::WebDiscoveryEvidencePredicate;
13use termivar_core::{
14 ConfidenceScore, DerivationAlgorithm, EntityId, Evidence, EvidenceDerivation, EvidenceId,
15 EvidenceKind, EvidenceSource, EvidenceValue, HttpEvidencePredicate, KnowledgePredicate,
16};
17use url::Url;
18
19#[cfg(feature = "rest-review")]
20use super::rest_runtime::StableRestSelectionSlot;
21#[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
22use super::ssrf_oast_runtime::StableSsrfOastSelectionSlot;
23use super::{
24 assessment_defense::{project_assessment_defense_signal, AssessmentDefenseProjectionContext},
25 assessment_item::{
26 AssessmentCapabilityDescriptor, AssessmentItemProjectionError, AssessmentItemTarget,
27 AssessmentProjectionContext,
28 },
29};
30#[cfg(feature = "rest-review")]
31use crate::rest_review::{
32 select_rest_operation, RestOperationSelection, RestOperationSelectionOutcome,
33};
34#[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
35use crate::ssrf_oast_review::{
36 select_openapi_document_query_candidate, SsrfOastCandidateSelection, SsrfOastQueryCandidate,
37};
38use crate::{
39 http_evidence::{HttpRequestBroker, HttpRequestBrokerError},
40 openapi_review::{
41 parse_openapi_document, OpenApiHttpMethod, OpenApiParseOutcome, OpenApiVersion,
42 },
43 DecisionActionExecutor, DecisionActionOrigin, DecisionExecutionRequest, DecisionExecutionStage,
44 DecisionExecutorError, DecisionExecutorRegistry, HttpProbe, HttpProbeMethod, KnowledgeBase,
45 RuleEngine, RuntimeLimitExceeded, StandardApiReasoning, TransportDispatchOutcome,
46 HTTP_EVIDENCE_EXECUTOR_ID,
47};
48
49pub const OPENAPI_REVIEW_ACTION_ID: &str = "web.review.openapi.document-replay@1";
50pub const OPENAPI_REVIEW_CAPABILITY_ID: &str = "api.openapi-contract-observed@1";
51pub const MAX_OPENAPI_REVIEW_DOCUMENTS: usize = 1;
52pub const MAX_OPENAPI_REVIEW_REQUESTS: usize = 2;
53pub const MAX_OPENAPI_REVIEW_ACTIVE_VERIFICATIONS: usize = 1;
54pub(super) const OPENAPI_REVIEW_ACTION_CYCLE_ALLOWANCE: u32 = 1;
55
56const OPENAPI_EXECUTOR_ID: &str = "http.openapi-review";
57const OPENAPI_EVIDENCE_NAMESPACE: &str = "web.openapi-review.transport";
58const OPENAPI_ACCEPT: &str = "application/vnd.oai.openapi+json, application/json, application/yaml, application/x-yaml, text/yaml, text/plain";
59const OPENAPI_DOCUMENT_ID_DOMAIN: &[u8] = b"security.openapi-review.document-target.v1\0";
60const MAX_OPENAPI_CANDIDATE_HINTS: usize = 64;
61const MAX_OPENAPI_CANDIDATE_URL_BYTES: usize = 8 * 1024;
62const MAX_OPENAPI_CANDIDATE_PATH_BYTES: usize = 1024;
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66enum OpenApiResponsePrivacy {
67 HistoricalDigests,
68 #[cfg(feature = "secret-exposure-review")]
69 ValueFree,
70}
71
72impl OpenApiResponsePrivacy {
73 fn is_value_free(self) -> bool {
74 match self {
75 Self::HistoricalDigests => false,
76 #[cfg(feature = "secret-exposure-review")]
77 Self::ValueFree => true,
78 }
79 }
80}
81
82const OPENAPI_CLASSIFICATION_ALGORITHM: &str = "web.openapi-review.value-free-classification";
83
84#[cfg(feature = "secret-exposure-review")]
85pub(super) fn openapi_privacy_route_is_valid(
86 case: &crate::VerificationCase,
87 stage: DecisionExecutionStage,
88 executor_id: &str,
89) -> bool {
90 executor_id == OPENAPI_EXECUTOR_ID
91 && case.action_id() == OPENAPI_REVIEW_ACTION_ID
92 && !case.applies_hypothesis_transition()
93 && case.payload_strategy().is_none()
94 && matches!(
95 stage,
96 DecisionExecutionStage::Passive | DecisionExecutionStage::Active
97 )
98}
99
100const OPENAPI_CAPABILITY: AssessmentCapabilityDescriptor = AssessmentCapabilityDescriptor::informational(
101 OPENAPI_REVIEW_CAPABILITY_ID,
102 "OpenAPI contract observed",
103 "API surface",
104 "Two anonymous exact-origin GET requests reproduced the same bounded OpenAPI contract metadata.",
105 900_000,
106 "api.openapi-contract-review@1",
107 "Confirm that publishing this OpenAPI contract matches deployment policy.",
108);
109
110#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
111#[serde(rename_all = "snake_case")]
112pub enum OpenApiRuntimeOutcome {
113 NotEligible,
114 DocumentObserved,
115 Swagger20MetadataOnly,
116 UnsupportedVersion,
117 ReplayMismatch,
118 UnsupportedMedia,
119 Malformed,
120 LimitExceeded,
121 TooLarge,
122 RedirectObserved,
123 RateLimited,
124 DefensiveInterference,
125 HttpError,
126 Truncated,
127 Incomplete,
128 BudgetExhausted,
129 Cancelled,
130}
131
132#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
133#[serde(rename_all = "snake_case")]
134pub enum OpenApiCandidateSource {
135 DiscoveredOpenApiJson,
136 DiscoveredOpenApiYaml,
137 DiscoveredSwaggerJson,
138 DiscoveredSwaggerYaml,
139 ConventionalOpenApiJson,
140}
141impl OpenApiCandidateSource {
142 pub const fn as_str(self) -> &'static str {
143 match self {
144 Self::DiscoveredOpenApiJson => "discovered_openapi_json",
145 Self::DiscoveredOpenApiYaml => "discovered_openapi_yaml",
146 Self::DiscoveredSwaggerJson => "discovered_swagger_json",
147 Self::DiscoveredSwaggerYaml => "discovered_swagger_yaml",
148 Self::ConventionalOpenApiJson => "conventional_openapi_json",
149 }
150 }
151}
152
153#[derive(Clone, PartialEq, Eq, Serialize)]
154pub struct WebAssessmentOpenApiAudit {
155 outcome: OpenApiRuntimeOutcome,
156 candidate_source: OpenApiCandidateSource,
157 request_count: u8,
158 active_verification_count: u8,
159 version: Option<&'static str>,
160 semantic_digest: Option<String>,
161 path_count: u32,
162 operation_count: u32,
163 get_operation_count: u32,
164 write_operation_count: u32,
165 path_parameter_count: u32,
166 query_parameter_count: u32,
167 explicit_auth_operation_count: u32,
168 anonymous_operation_count: u32,
169 url_like_operation_count: u32,
170 multipart_operation_count: u32,
171 deprecated_operation_count: u32,
172 replay_matched: bool,
173 item_projected: bool,
174}
175
176impl WebAssessmentOpenApiAudit {
177 pub const fn outcome(&self) -> OpenApiRuntimeOutcome {
178 self.outcome
179 }
180 pub const fn candidate_source(&self) -> OpenApiCandidateSource {
181 self.candidate_source
182 }
183 pub const fn request_count(&self) -> u8 {
184 self.request_count
185 }
186 pub const fn active_verification_count(&self) -> u8 {
187 self.active_verification_count
188 }
189 pub const fn version(&self) -> Option<&'static str> {
190 self.version
191 }
192 pub fn semantic_digest(&self) -> Option<&str> {
193 self.semantic_digest.as_deref()
194 }
195 pub const fn path_count(&self) -> u32 {
196 self.path_count
197 }
198 pub const fn operation_count(&self) -> u32 {
199 self.operation_count
200 }
201 pub const fn get_operation_count(&self) -> u32 {
202 self.get_operation_count
203 }
204 pub const fn write_operation_count(&self) -> u32 {
205 self.write_operation_count
206 }
207 pub const fn path_parameter_count(&self) -> u32 {
208 self.path_parameter_count
209 }
210 pub const fn query_parameter_count(&self) -> u32 {
211 self.query_parameter_count
212 }
213 pub const fn explicit_auth_operation_count(&self) -> u32 {
214 self.explicit_auth_operation_count
215 }
216 pub const fn anonymous_operation_count(&self) -> u32 {
217 self.anonymous_operation_count
218 }
219 pub const fn url_like_operation_count(&self) -> u32 {
220 self.url_like_operation_count
221 }
222 pub const fn multipart_operation_count(&self) -> u32 {
223 self.multipart_operation_count
224 }
225 pub const fn deprecated_operation_count(&self) -> u32 {
226 self.deprecated_operation_count
227 }
228 pub const fn replay_matched(&self) -> bool {
229 self.replay_matched
230 }
231 pub const fn item_projected(&self) -> bool {
232 self.item_projected
233 }
234}
235
236impl fmt::Debug for WebAssessmentOpenApiAudit {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 f.debug_struct("WebAssessmentOpenApiAudit")
239 .field("outcome", &self.outcome)
240 .field("candidate_source", &self.candidate_source)
241 .field("request_count", &self.request_count)
242 .field("active_verification_count", &self.active_verification_count)
243 .field("version", &self.version)
244 .field("path_count", &self.path_count)
245 .field("operation_count", &self.operation_count)
246 .field("get_operation_count", &self.get_operation_count)
247 .field("write_operation_count", &self.write_operation_count)
248 .field("path_parameter_count", &self.path_parameter_count)
249 .field("query_parameter_count", &self.query_parameter_count)
250 .field(
251 "explicit_auth_operation_count",
252 &self.explicit_auth_operation_count,
253 )
254 .field("anonymous_operation_count", &self.anonymous_operation_count)
255 .field("url_like_operation_count", &self.url_like_operation_count)
256 .field("multipart_operation_count", &self.multipart_operation_count)
257 .field(
258 "deprecated_operation_count",
259 &self.deprecated_operation_count,
260 )
261 .field("replay_matched", &self.replay_matched)
262 .field("item_projected", &self.item_projected)
263 .finish()
264 }
265}
266
267#[derive(Clone, Eq, PartialEq)]
268pub(super) struct OpenApiCandidate {
269 url: Url,
270 source: OpenApiCandidateSource,
271 identity: String,
272}
273
274impl fmt::Debug for OpenApiCandidate {
275 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276 f.write_str("OpenApiCandidate(<exact-origin-redacted>)")
277 }
278}
279
280pub(super) fn select_openapi_candidate(
281 origin: &Url,
282 hints: impl IntoIterator<Item = Url>,
283) -> Option<OpenApiCandidate> {
284 let mut candidates = hints
285 .into_iter()
286 .take(MAX_OPENAPI_CANDIDATE_HINTS)
287 .filter_map(|url| {
288 if !candidate_url_is_allowed(origin, &url) {
289 return None;
290 }
291 let name = url.path_segments()?.next_back()?;
292 let source = match name.to_ascii_lowercase().as_str() {
293 "openapi.json" => OpenApiCandidateSource::DiscoveredOpenApiJson,
294 "openapi.yaml" | "openapi.yml" => OpenApiCandidateSource::DiscoveredOpenApiYaml,
295 "swagger.json" => OpenApiCandidateSource::DiscoveredSwaggerJson,
296 "swagger.yaml" | "swagger.yml" => OpenApiCandidateSource::DiscoveredSwaggerYaml,
297 _ => return None,
298 };
299 Some((source, url))
300 })
301 .collect::<Vec<_>>();
302 candidates.sort_by(|(left_source, left), (right_source, right)| {
303 candidate_rank(*left_source)
304 .cmp(&candidate_rank(*right_source))
305 .then_with(|| left.as_str().cmp(right.as_str()))
306 });
307 let (source, url) = candidates.into_iter().next().unwrap_or_else(|| {
308 (
309 OpenApiCandidateSource::ConventionalOpenApiJson,
310 origin
311 .join("/openapi.json")
312 .expect("absolute fallback path is valid"),
313 )
314 });
315 if !candidate_url_is_allowed(origin, &url) {
316 return None;
317 }
318 Some(OpenApiCandidate {
319 identity: document_identity(&url),
320 url,
321 source,
322 })
323}
324
325fn candidate_url_is_allowed(origin: &Url, url: &Url) -> bool {
326 matches!(url.scheme(), "http" | "https")
327 && url.origin() == origin.origin()
328 && url.username().is_empty()
329 && url.password().is_none()
330 && url.query().is_none()
331 && url.fragment().is_none()
332 && url.as_str().len() <= MAX_OPENAPI_CANDIDATE_URL_BYTES
333 && url.path().len() <= MAX_OPENAPI_CANDIDATE_PATH_BYTES
334}
335
336fn committed_discovery_hints(knowledge: &KnowledgeBase, subject: &EntityId) -> Vec<Url> {
337 knowledge
338 .evidence_for_subject(subject)
339 .into_iter()
340 .filter_map(|evidence| {
341 let expected_method = if evidence.predicate()
342 == &WebDiscoveryEvidencePredicate::GET_ROUTE.into_knowledge()
343 {
344 "get-route"
345 } else if evidence.predicate()
346 == &WebDiscoveryEvidencePredicate::HEAD_ROUTE.into_knowledge()
347 {
348 "head-route"
349 } else if evidence.predicate()
350 == &WebDiscoveryEvidencePredicate::GET_FORM_ACTION.into_knowledge()
351 {
352 "get-form-action"
353 } else if evidence.predicate()
354 == &WebDiscoveryEvidencePredicate::POST_FORM_ACTION.into_knowledge()
355 {
356 "post-form-action"
357 } else if evidence.predicate()
358 == &WebDiscoveryEvidencePredicate::DIALOG_FORM_ACTION.into_knowledge()
359 {
360 "dialog-form-action"
361 } else {
362 return None;
363 };
364 if evidence.kind() != &EvidenceKind::Content
365 || evidence.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
366 || evidence.source().method() != expected_method
367 || evidence.source().correlation_id() != Some(super::BOOTSTRAP_CASE_ID)
368 || evidence.origin().derivation().is_none()
369 {
370 return None;
371 }
372 let EvidenceValue::Text(value) = evidence.value() else {
373 return None;
374 };
375 Url::parse(value).ok()
376 })
377 .take(MAX_OPENAPI_CANDIDATE_HINTS)
378 .collect()
379}
380
381const fn candidate_rank(source: OpenApiCandidateSource) -> u8 {
382 match source {
383 OpenApiCandidateSource::DiscoveredOpenApiJson => 0,
384 OpenApiCandidateSource::DiscoveredOpenApiYaml => 1,
385 OpenApiCandidateSource::DiscoveredSwaggerJson => 2,
386 OpenApiCandidateSource::DiscoveredSwaggerYaml => 3,
387 OpenApiCandidateSource::ConventionalOpenApiJson => 4,
388 }
389}
390
391fn document_identity(url: &Url) -> String {
392 let mut digest = Sha256::new();
393 digest.update(OPENAPI_DOCUMENT_ID_DOMAIN);
394 digest.update(url.path().as_bytes());
395 format!("openapi-document@1:{:x}", digest.finalize())
396}
397
398fn observed_document_identity(candidate_identity: &str, semantic_digest: &str) -> String {
399 let mut digest = Sha256::new();
400 digest.update(b"security.openapi-review.observed-document.v1\0");
401 digest.update(crate::openapi_review::OPENAPI_CATALOG_ALGORITHM.as_bytes());
402 digest.update(candidate_identity.as_bytes());
403 digest.update(semantic_digest.as_bytes());
404 format!("openapi-observed@1:{:x}", digest.finalize())
405}
406
407pub(super) struct OpenApiReviewConfig {
408 candidate: OpenApiCandidate,
409 privacy: OpenApiResponsePrivacy,
410}
411impl OpenApiReviewConfig {
412 pub(super) fn new(candidate: OpenApiCandidate) -> Self {
413 Self {
414 candidate,
415 privacy: OpenApiResponsePrivacy::HistoricalDigests,
416 }
417 }
418
419 #[cfg(feature = "secret-exposure-review")]
420 pub(super) fn with_value_free_response_privacy(mut self) -> Self {
421 self.privacy = OpenApiResponsePrivacy::ValueFree;
422 self
423 }
424}
425
426pub(super) struct OpenApiRuntimeBinding {
427 executor: Arc<OpenApiDecisionExecutor>,
428 subject: EntityId,
429}
430
431impl OpenApiRuntimeBinding {
432 pub(super) fn new(
433 config: OpenApiReviewConfig,
434 requests: HttpRequestBroker,
435 subject: EntityId,
436 knowledge: KnowledgeBase,
437 ) -> Self {
438 Self::new_internal(
439 config,
440 requests,
441 subject,
442 knowledge,
443 #[cfg(feature = "ssrf-oast-review")]
444 None,
445 )
446 }
447
448 #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
449 pub(super) fn new_with_ssrf_selection(
450 config: OpenApiReviewConfig,
451 requests: HttpRequestBroker,
452 subject: EntityId,
453 knowledge: KnowledgeBase,
454 ssrf_selection: StableSsrfOastSelectionSlot,
455 ) -> Self {
456 Self::new_internal(config, requests, subject, knowledge, Some(ssrf_selection))
457 }
458
459 fn new_internal(
460 config: OpenApiReviewConfig,
461 requests: HttpRequestBroker,
462 subject: EntityId,
463 knowledge: KnowledgeBase,
464 #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
465 ssrf_selection: Option<StableSsrfOastSelectionSlot>,
466 ) -> Self {
467 let candidate = config.candidate;
468 let executor = Arc::new(OpenApiDecisionExecutor {
469 requests,
470 privacy: config.privacy,
471 candidate: Mutex::new(candidate),
472 subject: subject.clone(),
473 knowledge,
474 state: Mutex::new(OpenApiExecutionState::default()),
475 #[cfg(feature = "rest-review")]
476 rest_selection: StableRestSelectionSlot::new(),
477 #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
478 ssrf_selection,
479 });
480 Self { executor, subject }
481 }
482 #[cfg(feature = "rest-review")]
483 pub(super) fn rest_selection_slot(&self) -> StableRestSelectionSlot {
484 self.executor.rest_selection.clone()
485 }
486 #[cfg(feature = "rest-review")]
487 pub(super) fn response_privacy_is_value_free(&self) -> bool {
488 self.executor.privacy.is_value_free()
489 }
490 pub(super) fn install_into_parent_registry(
491 &self,
492 registry: &mut DecisionExecutorRegistry,
493 ) -> Result<(), OpenApiRuntimeInvariantError> {
494 let before = registry.len();
495 let executor: Arc<dyn DecisionActionExecutor> = self.executor.clone();
496 registry
497 .register(executor)
498 .map_err(|_| OpenApiRuntimeInvariantError::Catalog)?;
499 for stage in [
500 DecisionExecutionStage::Passive,
501 DecisionExecutionStage::Active,
502 ] {
503 registry
504 .route_action(stage, OPENAPI_REVIEW_ACTION_ID, OPENAPI_EXECUTOR_ID)
505 .map_err(|_| OpenApiRuntimeInvariantError::Catalog)?;
506 }
507 if registry.len() != before + 1 {
508 return Err(OpenApiRuntimeInvariantError::Catalog);
509 }
510 Ok(())
511 }
512 pub(super) fn finalize(
513 self,
514 knowledge: &KnowledgeBase,
515 standard: &super::StandardWebDecisionRunReport,
516 forced_outcome: Option<OpenApiRuntimeOutcome>,
517 forced_runtime_limit: Option<RuntimeLimitExceeded>,
518 ) -> Result<OpenApiRuntimeResult, OpenApiRuntimeInvariantError> {
519 let transport = standard.transport();
520 if transport.omitted_receipt_count() != 0 {
521 return Err(OpenApiRuntimeInvariantError::Catalog);
522 }
523 let selected_candidate = self
524 .executor
525 .candidate
526 .lock()
527 .map_err(|_| OpenApiRuntimeInvariantError::Catalog)?
528 .clone();
529 let receipts = transport
530 .receipts()
531 .iter()
532 .filter(|r| r.action_id() == OPENAPI_REVIEW_ACTION_ID)
533 .collect::<Vec<_>>();
534 if !openapi_transport_prefix_is_valid(&receipts) {
535 return Err(OpenApiRuntimeInvariantError::Catalog);
536 }
537 let state = self.executor.take_state()?;
538 #[cfg(feature = "secret-exposure-review")]
539 let committed_legs = if self.executor.privacy.is_value_free() {
540 validate_value_free_openapi_commits(
541 standard,
542 knowledge,
543 &self.subject,
544 &selected_candidate,
545 &state,
546 )?
547 } else {
548 true
549 };
550 #[cfg(not(feature = "secret-exposure-review"))]
551 let committed_legs = true;
552 if !captured_openapi_prefix_reconciles(&state, &receipts) {
553 return Err(OpenApiRuntimeInvariantError::Catalog);
554 }
555 let request_count = u8::try_from(receipts.len()).unwrap_or(u8::MAX);
556 let state_terminal = state.terminal;
557 let terminal = forced_outcome.or_else(|| {
558 committed_legs
559 .then(|| state_terminal.as_ref().map(|(outcome, _)| *outcome))
560 .flatten()
561 });
562 let runtime_limit = if forced_outcome.is_some() {
563 forced_runtime_limit
564 } else {
565 state_terminal.and_then(|(_, limit)| limit)
566 };
567 if let Some(outcome) = terminal {
568 let audit = audit(
569 selected_candidate.source,
570 outcome,
571 request_count,
572 None,
573 false,
574 );
575 if matches!(
576 outcome,
577 OpenApiRuntimeOutcome::Incomplete
578 | OpenApiRuntimeOutcome::Truncated
579 | OpenApiRuntimeOutcome::LimitExceeded
580 | OpenApiRuntimeOutcome::TooLarge
581 | OpenApiRuntimeOutcome::BudgetExhausted
582 | OpenApiRuntimeOutcome::Cancelled
583 ) {
584 return Ok(OpenApiRuntimeResult::Stopped {
585 audit,
586 runtime_limit,
587 });
588 }
589 return Ok(OpenApiRuntimeResult::Complete(CommittedOpenApiReview {
590 subject: self.subject,
591 privacy: self.executor.privacy,
592 target_identity: selected_candidate.identity,
593 outcome,
594 evidence_ids: Vec::new(),
595 audit,
596 }));
597 }
598 let (Some(candidate), Some(replay)) = (
599 state.legs.get(&DecisionExecutionStage::Passive),
600 state.legs.get(&DecisionExecutionStage::Active),
601 ) else {
602 return Ok(OpenApiRuntimeResult::Stopped {
603 audit: audit(
604 selected_candidate.source,
605 if receipts.is_empty() {
606 OpenApiRuntimeOutcome::NotEligible
607 } else {
608 OpenApiRuntimeOutcome::Incomplete
609 },
610 request_count,
611 None,
612 false,
613 ),
614 runtime_limit: None,
615 });
616 };
617 if !committed_legs
618 || receipts.len() != MAX_OPENAPI_REVIEW_REQUESTS
619 || receipts
620 .iter()
621 .zip([candidate, replay])
622 .any(|(receipt, leg)| {
623 receipt.outcome() != TransportDispatchOutcome::Completed
624 || receipt.response_bytes() != leg.response_bytes
625 })
626 {
627 return Ok(OpenApiRuntimeResult::Stopped {
628 audit: audit(
629 selected_candidate.source,
630 OpenApiRuntimeOutcome::Incomplete,
631 request_count,
632 None,
633 false,
634 ),
635 runtime_limit: None,
636 });
637 }
638 let outcome = if candidate.semantically_matches(replay) {
639 OpenApiRuntimeOutcome::DocumentObserved
640 } else {
641 OpenApiRuntimeOutcome::ReplayMismatch
642 };
643 let projected = outcome == OpenApiRuntimeOutcome::DocumentObserved;
644 let mut audit = audit(
645 selected_candidate.source,
646 outcome,
647 request_count,
648 Some(candidate),
649 projected,
650 );
651 if self.executor.privacy.is_value_free() {
652 audit.semantic_digest = None;
653 }
654 let evidence_ids = vec![
655 candidate
656 .evidence_id
657 .clone()
658 .ok_or(OpenApiRuntimeInvariantError::Catalog)?,
659 replay
660 .evidence_id
661 .clone()
662 .ok_or(OpenApiRuntimeInvariantError::Catalog)?,
663 ];
664 if evidence_ids
665 .iter()
666 .any(|id| knowledge.evidence(id).is_none())
667 {
668 return Err(OpenApiRuntimeInvariantError::Catalog);
669 }
670 if projected {
671 commit_api_reasoning_inputs(knowledge, &self.subject)?;
672 }
673 let target_identity = if self.executor.privacy.is_value_free() {
674 selected_candidate.identity
675 } else {
676 observed_document_identity(&selected_candidate.identity, &candidate.digest)
677 };
678 Ok(OpenApiRuntimeResult::Complete(CommittedOpenApiReview {
679 subject: self.subject,
680 privacy: self.executor.privacy,
681 target_identity,
682 outcome,
683 evidence_ids,
684 audit,
685 }))
686 }
687}
688
689#[cfg(feature = "secret-exposure-review")]
693fn validate_value_free_openapi_commits(
694 standard: &super::StandardWebDecisionRunReport,
695 knowledge: &KnowledgeBase,
696 subject: &EntityId,
697 candidate: &OpenApiCandidate,
698 state: &OpenApiExecutionState,
699) -> Result<bool, OpenApiRuntimeInvariantError> {
700 use crate::{KnowledgeWrite, StandardWebDecisionRuntimeTurn};
701 use std::collections::BTreeSet;
702 let receipts = standard
703 .turns()
704 .iter()
705 .filter_map(|turn| match turn {
706 StandardWebDecisionRuntimeTurn::Outcome { evidence, .. } => Some(evidence.as_ref()),
707 StandardWebDecisionRuntimeTurn::Planning(_) => None,
708 })
709 .chain(standard.unverified_evidence())
710 .filter(|receipt| {
711 receipt.case().action_id() == OPENAPI_REVIEW_ACTION_ID
712 || receipt.executor_id() == OPENAPI_EXECUTOR_ID
713 })
714 .collect::<Vec<_>>();
715 if receipts.len() > MAX_OPENAPI_REVIEW_REQUESTS {
716 return Err(OpenApiRuntimeInvariantError::Catalog);
717 }
718 let mut committed = BTreeSet::new();
719 let mut terminal_committed = false;
720 for (index, receipt) in receipts.into_iter().enumerate() {
721 let expected_stage = if index == 0 {
722 DecisionExecutionStage::Passive
723 } else {
724 DecisionExecutionStage::Active
725 };
726 if !openapi_privacy_route_is_valid(receipt.case(), receipt.stage(), receipt.executor_id())
727 || receipt.case().subject() != subject
728 || receipt.stage() != expected_stage
729 || receipt.evidence().len() != receipt.writes().len()
730 {
731 return Err(OpenApiRuntimeInvariantError::Catalog);
732 }
733 let mut ids = BTreeSet::new();
734 for (item, write) in receipt.write_set() {
735 if !matches!(write, KnowledgeWrite::Inserted | KnowledgeWrite::Unchanged)
736 || item.subject() != subject
737 || item.source().component() != OPENAPI_EXECUTOR_ID
738 || item.source().correlation_id() != Some(receipt.case().id())
739 || item.reliability() != ConfidenceScore::MAX
740 || !ids.insert(item.id())
741 || knowledge.evidence(item.id()).as_ref() != Some(item)
742 || item.predicate() == &HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge()
743 {
744 return Err(OpenApiRuntimeInvariantError::Catalog);
745 }
746 }
747 let response_received = receipt.evidence().iter().any(|item| {
748 item.predicate() == &HttpEvidencePredicate::REQUEST_METHOD.into_knowledge()
749 });
750 if !response_received {
751 let bytes = openapi_receipt_fact(
753 receipt,
754 &HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
755 )?;
756 let terminal = openapi_receipt_fact(
757 receipt,
758 &crate::web_actions::openapi_review_phase_terminal_predicate(),
759 )?;
760 if state.terminal.is_none()
761 || receipt.evidence().len() != 2
762 || state.legs.contains_key(&receipt.stage())
763 || bytes.kind() != &EvidenceKind::Custom("openapi-review".to_owned())
764 || terminal.kind() != &EvidenceKind::Custom("openapi-review".to_owned())
765 || !bytes.origin().is_direct()
766 || !terminal.origin().is_direct()
767 || bytes.source().method() != "response-body-size"
768 || terminal.source().method() != "phase-terminal"
769 || bytes.value() != &EvidenceValue::Unsigned(0)
770 || terminal.value() != &EvidenceValue::Boolean(true)
771 {
772 return Err(OpenApiRuntimeInvariantError::Catalog);
773 }
774 terminal_committed = true;
775 continue;
776 }
777 let parents = super::secret_exposure::openapi_response_privacy_parent_ids(
778 receipt.evidence(),
779 receipt.case(),
780 receipt.stage(),
781 receipt.executor_id(),
782 )
783 .ok_or(OpenApiRuntimeInvariantError::Catalog)?;
784 let lineage_predicate = KnowledgePredicate::new(
785 super::secret_exposure::SECRET_EXPOSURE_NAMESPACE,
786 super::secret_exposure::ACTIVE_REVIEW_BODY_LINEAGE,
787 )?;
788 let lineage = openapi_receipt_fact(receipt, &lineage_predicate)?;
789 if receipt.evidence().iter().any(|item| {
790 let known_openapi_fact = match item.predicate().name() {
791 "document" | "phase-terminal" => true,
792 #[cfg(feature = "rest-review")]
796 "rest-catalog-ready" => {
797 receipt.stage() == DecisionExecutionStage::Active
798 && item.kind() == &EvidenceKind::Custom("openapi-review".to_owned())
799 && item.source().method() == "rest-catalog-ready"
800 && item.origin().is_direct()
801 && item.value() == &EvidenceValue::Boolean(true)
802 },
803 _ => false,
804 };
805 (item.predicate().namespace() == super::secret_exposure::SECRET_EXPOSURE_NAMESPACE
806 && item.predicate() != &lineage_predicate)
807 || (item.predicate().namespace() == OPENAPI_EVIDENCE_NAMESPACE
808 && !known_openapi_fact)
809 }) {
810 return Err(OpenApiRuntimeInvariantError::Catalog);
811 }
812 if !super::secret_exposure::is_active_review_body_privacy_lineage(
813 lineage,
814 subject,
815 receipt.case().id(),
816 OPENAPI_EXECUTOR_ID,
817 &parents,
818 ) {
819 return Err(OpenApiRuntimeInvariantError::Catalog);
820 }
821 for predicate in [
822 HttpEvidencePredicate::REQUEST_URL,
823 HttpEvidencePredicate::RESPONSE_FINAL_URL,
824 ] {
825 if openapi_receipt_fact(receipt, &predicate.into_knowledge())?.value()
826 != &EvidenceValue::Text(candidate.url.to_string())
827 {
828 return Err(OpenApiRuntimeInvariantError::Catalog);
829 }
830 }
831 let document_predicate = KnowledgePredicate::new(OPENAPI_EVIDENCE_NAMESPACE, "document")?;
832 let document_count = receipt
833 .evidence()
834 .iter()
835 .filter(|item| item.predicate() == &document_predicate)
836 .count();
837 let terminal = openapi_receipt_fact(
838 receipt,
839 &crate::web_actions::openapi_review_phase_terminal_predicate(),
840 )?;
841 if terminal.kind() != &EvidenceKind::Custom("openapi-review".to_owned())
842 || terminal.source().method() != "phase-terminal"
843 || !terminal.origin().is_direct()
844 {
845 return Err(OpenApiRuntimeInvariantError::Catalog);
846 }
847 let completion = receipt
848 .evidence()
849 .iter()
850 .filter(|item| {
851 item.predicate()
852 == &crate::web_actions::native_web_review_response_marker_predicate()
853 })
854 .collect::<Vec<_>>();
855 let Some(leg) = state.legs.get(&receipt.stage()) else {
856 let wire = standard
857 .transport()
858 .receipts()
859 .iter()
860 .filter(|item| item.action_id() == OPENAPI_REVIEW_ACTION_ID)
861 .nth(index)
862 .ok_or(OpenApiRuntimeInvariantError::Catalog)?;
863 let bytes = openapi_receipt_fact(
864 receipt,
865 &HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
866 )?;
867 if state.terminal.is_none()
868 || document_count != 0
869 || !completion.is_empty()
870 || terminal.value() != &EvidenceValue::Boolean(true)
871 || !matches!(
872 wire.outcome(),
873 TransportDispatchOutcome::Completed
874 | TransportDispatchOutcome::ResponseBudgetReached
875 )
876 || !matches!(bytes.value(), EvidenceValue::Unsigned(value) if *value <= wire.response_bytes())
877 {
878 return Err(OpenApiRuntimeInvariantError::Catalog);
879 }
880 terminal_committed = true;
881 continue;
882 };
883 let document = openapi_receipt_fact(receipt, &document_predicate)?;
884 let derivation = document
885 .origin()
886 .derivation()
887 .ok_or(OpenApiRuntimeInvariantError::Catalog)?;
888 let expected_summary = EvidenceValue::TextList(vec![
889 format!("version={}", leg.version.unwrap_or("unknown")),
890 format!("paths={}", leg.paths),
891 format!("operations={}", leg.operations),
892 ]);
893 let phase = crate::web_actions::openapi_review_phase_terminal_predicate();
894 let direct_predicates = [
895 HttpEvidencePredicate::REQUEST_METHOD,
896 HttpEvidencePredicate::REQUEST_URL,
897 HttpEvidencePredicate::RESPONSE_STATUS,
898 HttpEvidencePredicate::RESPONSE_FINAL_URL,
899 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
900 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
901 HttpEvidencePredicate::RATE_LIMIT_DETECTED,
902 HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
903 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE,
904 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE,
905 ]
906 .map(|predicate| predicate.into_knowledge());
907 let mut expected_parents = BTreeSet::new();
908 for item in receipt.evidence().iter().filter(|item| {
909 direct_predicates.contains(item.predicate()) || item.predicate() == &phase
910 }) {
911 if !item.origin().is_direct() || item.reliability() != document.reliability() {
912 return Err(OpenApiRuntimeInvariantError::Catalog);
913 }
914 expected_parents.insert(item.id().clone());
915 }
916 expected_parents.insert(lineage.id().clone());
917 if leg.evidence_id.as_ref() != Some(document.id())
918 || completion.len() != 1
919 || completion[0].value() != &EvidenceValue::Boolean(true)
920 || !completion[0].origin().is_direct()
921 || completion[0].source().method() != "complete"
922 || document.kind() != &EvidenceKind::Custom("openapi-review".to_owned())
923 || document.source().method() != "document"
924 || document.value() != &expected_summary
925 || derivation.algorithm().name() != OPENAPI_CLASSIFICATION_ALGORITHM
926 || derivation.algorithm().version() != 1
927 || derivation.parents() != expected_parents.into_iter().collect::<Vec<_>>()
928 || lineage.reliability() != document.reliability()
929 || openapi_receipt_fact(
930 receipt,
931 &HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
932 )?
933 .value()
934 != &EvidenceValue::Unsigned(leg.response_bytes)
935 || openapi_receipt_fact(
936 receipt,
937 &HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED.into_knowledge(),
938 )?
939 .value()
940 != &EvidenceValue::Boolean(false)
941 || openapi_receipt_fact(receipt, &phase)?.value() != &EvidenceValue::Boolean(false)
942 {
943 return Err(OpenApiRuntimeInvariantError::Catalog);
944 }
945 committed.insert(receipt.stage());
946 }
947 Ok(state.legs.keys().all(|stage| committed.contains(stage))
948 && (state.terminal.is_none() || terminal_committed))
949}
950
951#[cfg(feature = "secret-exposure-review")]
952fn openapi_receipt_fact<'a>(
953 receipt: &'a crate::DecisionEvidenceReceipt,
954 predicate: &KnowledgePredicate,
955) -> Result<&'a Evidence, OpenApiRuntimeInvariantError> {
956 let mut rows = receipt
957 .evidence()
958 .iter()
959 .filter(|item| item.predicate() == predicate);
960 let item = rows.next().ok_or(OpenApiRuntimeInvariantError::Catalog)?;
961 if rows.next().is_some() {
962 return Err(OpenApiRuntimeInvariantError::Catalog);
963 }
964 Ok(item)
965}
966
967fn openapi_transport_prefix_is_valid(receipts: &[&crate::TransportDispatchReceipt]) -> bool {
968 receipts.len() <= MAX_OPENAPI_REVIEW_REQUESTS
969 && receipts.iter().enumerate().all(|(index, receipt)| {
970 let expected_stage = if index == 0 {
971 DecisionExecutionStage::Passive
972 } else {
973 DecisionExecutionStage::Active
974 };
975 let expected_origin = (expected_stage == DecisionExecutionStage::Passive)
976 .then_some(DecisionActionOrigin::Planned);
977 receipt.stage() == expected_stage
978 && receipt.origin() == expected_origin
979 && receipt.request_body_bytes() == 0
980 })
981}
982
983fn captured_openapi_prefix_reconciles(
984 state: &OpenApiExecutionState,
985 receipts: &[&crate::TransportDispatchReceipt],
986) -> bool {
987 state.legs.iter().all(|(stage, leg)| {
988 let index = match stage {
989 DecisionExecutionStage::Passive => 0,
990 DecisionExecutionStage::Active => 1,
991 };
992 receipts.get(index).is_some_and(|receipt| {
993 receipt.outcome() == TransportDispatchOutcome::Completed
994 && receipt.response_bytes() == leg.response_bytes
995 })
996 })
997}
998
999fn commit_api_reasoning_inputs(
1000 knowledge: &KnowledgeBase,
1001 subject: &EntityId,
1002) -> Result<(), OpenApiRuntimeInvariantError> {
1003 let mut rules = RuleEngine::new();
1004 StandardApiReasoning::new()
1005 .and_then(|profile| profile.install(knowledge, &mut rules).map(|_| ()))
1006 .map_err(|_| OpenApiRuntimeInvariantError::Catalog)?;
1007 rules
1008 .apply(knowledge, subject)
1009 .map_err(|_| OpenApiRuntimeInvariantError::Catalog)?;
1010 Ok(())
1011}
1012
1013fn audit(
1014 source: OpenApiCandidateSource,
1015 outcome: OpenApiRuntimeOutcome,
1016 requests: u8,
1017 leg: Option<&OpenApiLeg>,
1018 projected: bool,
1019) -> WebAssessmentOpenApiAudit {
1020 WebAssessmentOpenApiAudit {
1021 outcome,
1022 candidate_source: source,
1023 request_count: requests,
1024 active_verification_count: u8::from(requests >= 2),
1025 version: leg.and_then(|l| l.version),
1026 semantic_digest: leg.map(|l| l.digest.clone()),
1027 path_count: leg.map_or(0, |l| l.paths),
1028 operation_count: leg.map_or(0, |l| l.operations),
1029 get_operation_count: leg.map_or(0, |l| l.get_operations),
1030 write_operation_count: leg.map_or(0, |l| l.write_operations),
1031 path_parameter_count: leg.map_or(0, |l| l.path_parameters),
1032 query_parameter_count: leg.map_or(0, |l| l.query_parameters),
1033 explicit_auth_operation_count: leg.map_or(0, |l| l.explicit_auth),
1034 anonymous_operation_count: leg.map_or(0, |l| l.anonymous),
1035 url_like_operation_count: leg.map_or(0, |l| l.url_like),
1036 multipart_operation_count: leg.map_or(0, |l| l.multipart),
1037 deprecated_operation_count: leg.map_or(0, |l| l.deprecated),
1038 replay_matched: projected,
1039 item_projected: projected,
1040 }
1041}
1042
1043#[derive(Debug, thiserror::Error)]
1044pub(super) enum OpenApiRuntimeInvariantError {
1045 #[error("OpenAPI review catalog invariant failed")]
1046 Catalog,
1047 #[error("OpenAPI evidence identity failed")]
1048 Evidence(#[from] termivar_core::ReasoningModelError),
1049}
1050pub(super) enum OpenApiRuntimeResult {
1051 Complete(CommittedOpenApiReview),
1052 Stopped {
1053 audit: WebAssessmentOpenApiAudit,
1054 runtime_limit: Option<RuntimeLimitExceeded>,
1055 },
1056}
1057pub(super) struct CommittedOpenApiReview {
1058 subject: EntityId,
1059 privacy: OpenApiResponsePrivacy,
1060 target_identity: String,
1061 outcome: OpenApiRuntimeOutcome,
1062 evidence_ids: Vec<EvidenceId>,
1063 audit: WebAssessmentOpenApiAudit,
1064}
1065impl CommittedOpenApiReview {
1066 pub(super) const fn audit(&self) -> &WebAssessmentOpenApiAudit {
1067 &self.audit
1068 }
1069}
1070
1071#[derive(Clone)]
1072struct OpenApiLeg {
1073 digest: String,
1074 version: Option<&'static str>,
1075 paths: u32,
1076 operations: u32,
1077 get_operations: u32,
1078 write_operations: u32,
1079 path_parameters: u32,
1080 query_parameters: u32,
1081 explicit_auth: u32,
1082 anonymous: u32,
1083 url_like: u32,
1084 multipart: u32,
1085 deprecated: u32,
1086 response_bytes: u64,
1087 evidence_id: Option<EvidenceId>,
1088 #[cfg(feature = "rest-review")]
1089 rest_selection: Option<RestOperationSelection>,
1090 #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1091 ssrf_selection: Option<SsrfOastQueryCandidate>,
1092}
1093impl OpenApiLeg {
1094 fn semantically_matches(&self, other: &Self) -> bool {
1095 self.digest == other.digest
1096 && self.version == other.version
1097 && self.paths == other.paths
1098 && self.operations == other.operations
1099 && self.get_operations == other.get_operations
1100 && self.write_operations == other.write_operations
1101 && self.path_parameters == other.path_parameters
1102 && self.query_parameters == other.query_parameters
1103 && self.explicit_auth == other.explicit_auth
1104 && self.anonymous == other.anonymous
1105 && self.url_like == other.url_like
1106 && self.multipart == other.multipart
1107 && self.deprecated == other.deprecated
1108 && {
1109 #[cfg(feature = "rest-review")]
1110 {
1111 self.rest_selection == other.rest_selection
1112 }
1113 #[cfg(not(feature = "rest-review"))]
1114 {
1115 true
1116 }
1117 }
1118 && {
1119 #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1120 {
1121 self.ssrf_selection == other.ssrf_selection
1122 }
1123 #[cfg(not(all(feature = "ssrf-oast-review", feature = "openapi-review")))]
1124 {
1125 true
1126 }
1127 }
1128 }
1129}
1130#[derive(Default)]
1131struct OpenApiExecutionState {
1132 legs: BTreeMap<DecisionExecutionStage, OpenApiLeg>,
1133 terminal: Option<(OpenApiRuntimeOutcome, Option<RuntimeLimitExceeded>)>,
1134}
1135struct OpenApiDecisionExecutor {
1136 requests: HttpRequestBroker,
1137 privacy: OpenApiResponsePrivacy,
1138 candidate: Mutex<OpenApiCandidate>,
1139 subject: EntityId,
1140 knowledge: KnowledgeBase,
1141 state: Mutex<OpenApiExecutionState>,
1142 #[cfg(feature = "rest-review")]
1143 rest_selection: StableRestSelectionSlot,
1144 #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1145 ssrf_selection: Option<StableSsrfOastSelectionSlot>,
1146}
1147
1148impl OpenApiDecisionExecutor {
1149 fn take_state(&self) -> Result<OpenApiExecutionState, OpenApiRuntimeInvariantError> {
1150 Ok(std::mem::take(
1151 &mut *self
1152 .state
1153 .lock()
1154 .map_err(|_| OpenApiRuntimeInvariantError::Catalog)?,
1155 ))
1156 }
1157 fn stop(
1158 &self,
1159 outcome: OpenApiRuntimeOutcome,
1160 limit: Option<RuntimeLimitExceeded>,
1161 ) -> Result<(), DecisionExecutorError> {
1162 let mut state = self
1163 .state
1164 .lock()
1165 .map_err(|_| DecisionExecutorError::new("OpenAPI review state is unavailable"))?;
1166 if state.terminal.replace((outcome, limit)).is_some() {
1167 return Err(DecisionExecutorError::new(
1168 "OpenAPI review terminal state is duplicated",
1169 ));
1170 }
1171 Ok(())
1172 }
1173}
1174
1175#[async_trait]
1176impl DecisionActionExecutor for OpenApiDecisionExecutor {
1177 fn id(&self) -> &str {
1178 OPENAPI_EXECUTOR_ID
1179 }
1180 async fn execute(
1181 &self,
1182 request: &DecisionExecutionRequest,
1183 ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1184 if request.case().action_id() != OPENAPI_REVIEW_ACTION_ID
1185 || request.case().subject() != &self.subject
1186 || request.case().payload_strategy().is_some()
1187 || request.case().applies_hypothesis_transition()
1188 || !matches!(
1189 request.stage(),
1190 DecisionExecutionStage::Passive | DecisionExecutionStage::Active
1191 )
1192 {
1193 return Err(DecisionExecutorError::new(
1194 "OpenAPI executor route contract failed",
1195 ));
1196 }
1197 let candidate = {
1198 let mut selected = self
1199 .candidate
1200 .lock()
1201 .map_err(|_| DecisionExecutorError::new("OpenAPI candidate is unavailable"))?;
1202 if request.stage() == DecisionExecutionStage::Passive {
1203 let hints = committed_discovery_hints(&self.knowledge, &self.subject);
1204 *selected = select_openapi_candidate(&selected.url, hints).ok_or_else(|| {
1205 DecisionExecutorError::new("OpenAPI candidate selection failed")
1206 })?;
1207 }
1208 selected.clone()
1209 };
1210 let probe = HttpProbe::new(candidate.url.clone(), HttpProbeMethod::Get)
1211 .and_then(|p| p.with_header("accept", OPENAPI_ACCEPT))
1212 .map_err(|_| DecisionExecutorError::new("OpenAPI request construction failed"))?;
1213 let response = match self
1214 .requests
1215 .collect_for_runtime(
1216 OPENAPI_REVIEW_ACTION_ID,
1217 request.stage(),
1218 request.origin(),
1219 request.limits(),
1220 &probe,
1221 )
1222 .await
1223 {
1224 Ok(r) => r,
1225 Err(HttpRequestBrokerError::RuntimeLimit(limit)) => {
1226 self.stop(OpenApiRuntimeOutcome::BudgetExhausted, Some(limit))?;
1227 return phase_terminal_evidence(request);
1228 },
1229 Err(HttpRequestBrokerError::Http(_)) => {
1230 self.stop(OpenApiRuntimeOutcome::Incomplete, None)?;
1231 return phase_terminal_evidence(request);
1232 },
1233 };
1234 let status = response.status();
1235 if response.final_url() != &candidate.url {
1236 self.stop(OpenApiRuntimeOutcome::RedirectObserved, None)?;
1237 return transport_evidence(request, &response, None, true, self.privacy);
1238 }
1239 let defense_signal = response.openapi_defense_signal();
1240 let defensive = defense_signal.state().is_challenged();
1241 let rate_limited = defense_signal.state().is_rate_limited();
1242 let media = response.normalized_media_type();
1243 let media_allowed =
1244 response.has_json_compatible_media_type() || media.as_deref() == Some("text/plain");
1245 let terminal = if response.body_truncated() {
1246 Some(OpenApiRuntimeOutcome::Truncated)
1247 } else if !response.body_complete() {
1248 Some(OpenApiRuntimeOutcome::Incomplete)
1249 } else if rate_limited {
1250 Some(OpenApiRuntimeOutcome::RateLimited)
1251 } else if defensive {
1252 Some(OpenApiRuntimeOutcome::DefensiveInterference)
1253 } else if (300..400).contains(&status) {
1254 Some(OpenApiRuntimeOutcome::RedirectObserved)
1255 } else if !(200..300).contains(&status) {
1256 Some(OpenApiRuntimeOutcome::HttpError)
1257 } else if !media_allowed {
1258 Some(OpenApiRuntimeOutcome::UnsupportedMedia)
1259 } else {
1260 None
1261 };
1262 if let Some(outcome) = terminal {
1263 self.stop(outcome, None)?;
1264 return transport_evidence(request, &response, None, true, self.privacy);
1265 }
1266 let parsed = parse_openapi_document(response.body(), &candidate.url);
1267 let leg = match parsed {
1268 OpenApiParseOutcome::Complete(doc) => {
1269 #[cfg(feature = "rest-review")]
1270 let rest_selection = match select_rest_operation(&doc, &candidate.url) {
1271 RestOperationSelectionOutcome::Selected(selection) => Some(selection),
1272 RestOperationSelectionOutcome::NoEligibleOperation => None,
1273 };
1274 #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1275 let ssrf_selection = select_openapi_document_query_candidate(&doc, &candidate.url);
1276 let summary = doc.catalog().summary();
1277 let get = doc
1278 .catalog()
1279 .operations()
1280 .iter()
1281 .filter(|op| op.method() == OpenApiHttpMethod::Get)
1282 .count();
1283 let write = doc
1284 .catalog()
1285 .operations()
1286 .iter()
1287 .filter(|op| {
1288 matches!(
1289 op.method(),
1290 OpenApiHttpMethod::Post
1291 | OpenApiHttpMethod::Put
1292 | OpenApiHttpMethod::Patch
1293 | OpenApiHttpMethod::Delete
1294 )
1295 })
1296 .count();
1297 let deprecated = doc
1298 .catalog()
1299 .operations()
1300 .iter()
1301 .filter(|op| op.deprecated())
1302 .count();
1303 OpenApiLeg {
1304 digest: doc.semantic_digest().to_owned(),
1305 version: doc.version().map(version_name),
1306 paths: u32::try_from(doc.path_count()).unwrap_or(u32::MAX),
1307 operations: u32::try_from(doc.operation_count()).unwrap_or(u32::MAX),
1308 get_operations: u32::try_from(get).unwrap_or(u32::MAX),
1309 write_operations: u32::try_from(write).unwrap_or(u32::MAX),
1310 path_parameters: u32::try_from(summary.path_parameter_count)
1311 .unwrap_or(u32::MAX),
1312 query_parameters: u32::try_from(summary.query_parameter_count)
1313 .unwrap_or(u32::MAX),
1314 explicit_auth: u32::try_from(summary.explicit_auth_operation_count)
1315 .unwrap_or(u32::MAX),
1316 anonymous: u32::try_from(summary.anonymous_operation_count).unwrap_or(u32::MAX),
1317 url_like: u32::try_from(doc.catalog().with_url_like_input().len())
1318 .unwrap_or(u32::MAX),
1319 multipart: u32::try_from(summary.multipart_operation_count).unwrap_or(u32::MAX),
1320 deprecated: u32::try_from(deprecated).unwrap_or(u32::MAX),
1321 response_bytes: u64::try_from(response.body().len()).unwrap_or(u64::MAX),
1322 evidence_id: None,
1323 #[cfg(feature = "rest-review")]
1324 rest_selection,
1325 #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1326 ssrf_selection,
1327 }
1328 },
1329 OpenApiParseOutcome::Swagger20MetadataOnly => {
1330 self.stop(OpenApiRuntimeOutcome::Swagger20MetadataOnly, None)?;
1331 return transport_evidence(request, &response, None, true, self.privacy);
1332 },
1333 OpenApiParseOutcome::UnsupportedVersion => {
1334 self.stop(OpenApiRuntimeOutcome::UnsupportedVersion, None)?;
1335 return transport_evidence(request, &response, None, true, self.privacy);
1336 },
1337 OpenApiParseOutcome::Malformed => {
1338 self.stop(OpenApiRuntimeOutcome::Malformed, None)?;
1339 return transport_evidence(request, &response, None, true, self.privacy);
1340 },
1341 OpenApiParseOutcome::LimitExceeded => {
1342 self.stop(OpenApiRuntimeOutcome::LimitExceeded, None)?;
1343 return transport_evidence(request, &response, None, true, self.privacy);
1344 },
1345 OpenApiParseOutcome::TooLarge => {
1346 self.stop(OpenApiRuntimeOutcome::TooLarge, None)?;
1347 return transport_evidence(request, &response, None, true, self.privacy);
1348 },
1349 };
1350 #[cfg(feature = "rest-review")]
1351 let stable_rest_catalog = if request.stage() == DecisionExecutionStage::Active {
1352 let state = self
1353 .state
1354 .lock()
1355 .map_err(|_| DecisionExecutorError::new("OpenAPI review state is unavailable"))?;
1356 state
1357 .legs
1358 .get(&DecisionExecutionStage::Passive)
1359 .is_some_and(|candidate_leg| candidate_leg.semantically_matches(&leg))
1360 } else {
1361 false
1362 };
1363 #[cfg(feature = "rest-review")]
1364 let stable_rest_selection = stable_rest_catalog
1365 .then(|| leg.rest_selection.clone())
1366 .flatten();
1367 #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1368 let stable_ssrf_selection = if request.stage() == DecisionExecutionStage::Active {
1369 let state = self
1370 .state
1371 .lock()
1372 .map_err(|_| DecisionExecutorError::new("OpenAPI review state is unavailable"))?;
1373 state
1374 .legs
1375 .get(&DecisionExecutionStage::Passive)
1376 .filter(|candidate_leg| candidate_leg.semantically_matches(&leg))
1377 .and_then(|_| leg.ssrf_selection.clone())
1378 } else {
1379 None
1380 };
1381 let mut evidence = transport_evidence(request, &response, Some(&leg), false, self.privacy)?;
1382 #[cfg(feature = "rest-review")]
1383 if stable_rest_catalog {
1384 let ready = make_evidence(
1385 request,
1386 crate::web_actions::rest_review_catalog_ready_predicate(),
1387 EvidenceValue::Boolean(true),
1388 "rest-catalog-ready",
1389 )?;
1390 let first_defense = evidence
1391 .iter()
1392 .position(|item| {
1393 item.predicate().namespace()
1394 == super::assessment_defense::ASSESSMENT_DEFENSE_NAMESPACE
1395 })
1396 .unwrap_or(evidence.len());
1397 evidence.insert(first_defense, ready);
1398 }
1399 let classification = evidence
1400 .iter()
1401 .find(|e| {
1402 e.predicate().namespace() == OPENAPI_EVIDENCE_NAMESPACE
1403 && e.predicate().name() == "document"
1404 })
1405 .ok_or_else(|| DecisionExecutorError::new("OpenAPI evidence is missing"))?
1406 .id()
1407 .clone();
1408 let mut stored = leg;
1409 stored.evidence_id = Some(classification);
1410 let replaced = self
1411 .state
1412 .lock()
1413 .map_err(|_| DecisionExecutorError::new("OpenAPI review state is unavailable"))?
1414 .legs
1415 .insert(request.stage(), stored);
1416 if replaced.is_some() {
1417 return Err(DecisionExecutorError::new(
1418 "OpenAPI review leg is duplicated",
1419 ));
1420 }
1421 #[cfg(feature = "rest-review")]
1422 if let Some(selection) = stable_rest_selection {
1423 self.rest_selection
1424 .commit(selection)
1425 .map_err(|_| DecisionExecutorError::new("REST selection handoff failed"))?;
1426 }
1427 #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1428 if let (Some(slot), Some(selection)) = (&self.ssrf_selection, stable_ssrf_selection) {
1429 slot.commit_openapi(SsrfOastCandidateSelection::Selected(Box::new(selection)))
1430 .map_err(|_| DecisionExecutorError::new("SSRF OAST selection handoff failed"))?;
1431 }
1432 Ok(evidence)
1433 }
1434}
1435
1436fn version_name(version: OpenApiVersion) -> &'static str {
1437 match version {
1438 OpenApiVersion::OpenApi30 => "3.0",
1439 OpenApiVersion::OpenApi31 => "3.1",
1440 }
1441}
1442fn marker(request: &DecisionExecutionRequest) -> Result<Evidence, DecisionExecutorError> {
1443 make_evidence(
1444 request,
1445 crate::web_actions::native_web_review_response_marker_predicate(),
1446 EvidenceValue::Boolean(true),
1447 "complete",
1448 )
1449}
1450fn phase_terminal_evidence(
1451 request: &DecisionExecutionRequest,
1452) -> Result<Vec<Evidence>, DecisionExecutorError> {
1453 Ok(vec![
1454 make_evidence(
1455 request,
1456 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1457 EvidenceValue::Unsigned(0),
1458 "response-body-size",
1459 )?,
1460 make_evidence(
1461 request,
1462 crate::web_actions::openapi_review_phase_terminal_predicate(),
1463 EvidenceValue::Boolean(true),
1464 "phase-terminal",
1465 )?,
1466 ])
1467}
1468fn make_evidence(
1469 request: &DecisionExecutionRequest,
1470 predicate: KnowledgePredicate,
1471 value: EvidenceValue,
1472 method: &str,
1473) -> Result<Evidence, DecisionExecutorError> {
1474 Ok(Evidence::new(
1475 request.case().subject().clone(),
1476 EvidenceKind::Custom("openapi-review".into()),
1477 predicate,
1478 value,
1479 EvidenceSource::new(OPENAPI_EXECUTOR_ID, method)
1480 .and_then(|s| s.with_correlation_id(request.case().id()))
1481 .map_err(|_| DecisionExecutorError::new("OpenAPI evidence source failed"))?,
1482 ConfidenceScore::MAX,
1483 ))
1484}
1485
1486fn make_typed_evidence(
1487 request: &DecisionExecutionRequest,
1488 kind: EvidenceKind,
1489 predicate: KnowledgePredicate,
1490 value: EvidenceValue,
1491 method: &str,
1492) -> Result<Evidence, DecisionExecutorError> {
1493 Ok(Evidence::new(
1494 request.case().subject().clone(),
1495 kind,
1496 predicate,
1497 value,
1498 EvidenceSource::new(OPENAPI_EXECUTOR_ID, method)
1499 .and_then(|s| s.with_correlation_id(request.case().id()))
1500 .map_err(|_| DecisionExecutorError::new("OpenAPI evidence source failed"))?,
1501 ConfidenceScore::MAX,
1502 ))
1503}
1504
1505fn transport_evidence(
1506 request: &DecisionExecutionRequest,
1507 response: &crate::http_evidence::CollectedHttpResponse,
1508 leg: Option<&OpenApiLeg>,
1509 terminal: bool,
1510 privacy: OpenApiResponsePrivacy,
1511) -> Result<Vec<Evidence>, DecisionExecutorError> {
1512 let signal = response.openapi_defense_signal();
1513 let mut evidence = vec![
1514 make_typed_evidence(
1515 request,
1516 EvidenceKind::Http,
1517 HttpEvidencePredicate::REQUEST_METHOD.into_knowledge(),
1518 EvidenceValue::Text("GET".into()),
1519 "request-method",
1520 )?,
1521 make_typed_evidence(
1522 request,
1523 EvidenceKind::Http,
1524 HttpEvidencePredicate::REQUEST_URL.into_knowledge(),
1525 EvidenceValue::Text(response.final_url().to_string()),
1526 "request-url",
1527 )?,
1528 make_typed_evidence(
1529 request,
1530 EvidenceKind::Http,
1531 HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge(),
1532 EvidenceValue::Unsigned(u64::from(response.status())),
1533 "response-status",
1534 )?,
1535 make_typed_evidence(
1536 request,
1537 EvidenceKind::Http,
1538 HttpEvidencePredicate::RESPONSE_FINAL_URL.into_knowledge(),
1539 EvidenceValue::Text(response.final_url().to_string()),
1540 "response-final-url",
1541 )?,
1542 make_typed_evidence(
1543 request,
1544 EvidenceKind::Content,
1545 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1546 EvidenceValue::Unsigned(u64::try_from(response.body().len()).unwrap_or(u64::MAX)),
1547 "response-body-size",
1548 )?,
1549 make_typed_evidence(
1550 request,
1551 EvidenceKind::Content,
1552 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED.into_knowledge(),
1553 EvidenceValue::Boolean(response.body_truncated()),
1554 "response-body-truncation",
1555 )?,
1556 make_typed_evidence(
1557 request,
1558 EvidenceKind::RateLimit,
1559 HttpEvidencePredicate::RATE_LIMIT_DETECTED.into_knowledge(),
1560 EvidenceValue::Boolean(response.status() == 429),
1561 "rate-limit-status",
1562 )?,
1563 make_typed_evidence(
1564 request,
1565 EvidenceKind::RateLimit,
1566 HttpEvidencePredicate::RATE_LIMIT_ADVERTISED.into_knowledge(),
1567 EvidenceValue::Boolean(signal.state().has_rate_limit_headers()),
1568 "rate-limit-headers",
1569 )?,
1570 make_evidence(
1571 request,
1572 crate::web_actions::openapi_review_phase_terminal_predicate(),
1573 EvidenceValue::Boolean(terminal),
1574 "phase-terminal",
1575 )?,
1576 ];
1577 if !privacy.is_value_free() {
1578 evidence.insert(
1580 6,
1581 make_typed_evidence(
1582 request,
1583 EvidenceKind::Content,
1584 HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge(),
1585 EvidenceValue::Text(format!("{:x}", Sha256::digest(response.body()))),
1586 "response-body-sha256",
1587 )?,
1588 );
1589 }
1590 if let Some(media) = response.normalized_media_type() {
1591 evidence.push(make_typed_evidence(
1592 request,
1593 EvidenceKind::Http,
1594 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge(),
1595 EvidenceValue::Text(media.clone()),
1596 "response-media-type",
1597 )?);
1598 evidence.push(make_typed_evidence(
1599 request,
1600 EvidenceKind::Http,
1601 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge(),
1602 EvidenceValue::Boolean(response.has_json_compatible_media_type()),
1603 "response-media-type-json-compatibility",
1604 )?);
1605 }
1606 if privacy.is_value_free() {
1607 #[cfg(feature = "secret-exposure-review")]
1608 evidence.push(
1609 super::secret_exposure::project_openapi_body_privacy_lineage(
1610 request,
1611 &evidence,
1612 OPENAPI_EXECUTOR_ID,
1613 ConfidenceScore::MAX,
1614 )
1615 .map_err(|_| DecisionExecutorError::new("OpenAPI privacy lineage failed"))?,
1616 );
1617 #[cfg(not(feature = "secret-exposure-review"))]
1618 return Err(DecisionExecutorError::new(
1619 "OpenAPI privacy selection is unavailable",
1620 ));
1621 }
1622 let base_predicates = [
1625 HttpEvidencePredicate::REQUEST_METHOD,
1626 HttpEvidencePredicate::REQUEST_URL,
1627 HttpEvidencePredicate::RESPONSE_STATUS,
1628 HttpEvidencePredicate::RESPONSE_FINAL_URL,
1629 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
1630 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
1631 HttpEvidencePredicate::RESPONSE_BODY_SHA256,
1632 HttpEvidencePredicate::RATE_LIMIT_DETECTED,
1633 HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
1634 ]
1635 .map(|predicate| predicate.into_knowledge());
1636 let defense_parents = evidence
1637 .iter()
1638 .filter(|item| {
1639 let lineage = false;
1640 #[cfg(feature = "secret-exposure-review")]
1641 let lineage = lineage
1642 || (item.predicate().namespace()
1643 == super::secret_exposure::SECRET_EXPOSURE_NAMESPACE
1644 && item.predicate().name()
1645 == super::secret_exposure::ACTIVE_REVIEW_BODY_LINEAGE);
1646 base_predicates.contains(item.predicate()) || lineage
1647 })
1648 .map(|item| item.id().clone())
1649 .collect();
1650 if let Some(leg) = leg {
1651 let mut summary = vec![
1652 format!("version={}", leg.version.unwrap_or("unknown")),
1653 format!("paths={}", leg.paths),
1654 format!("operations={}", leg.operations),
1655 ];
1656 if !privacy.is_value_free() {
1657 summary.insert(0, format!("digest={}", leg.digest));
1658 }
1659 let document = make_evidence(
1660 request,
1661 KnowledgePredicate::new(OPENAPI_EVIDENCE_NAMESPACE, "document")
1662 .map_err(|_| DecisionExecutorError::new("OpenAPI predicate failed"))?,
1663 EvidenceValue::TextList(summary),
1664 "document",
1665 )?;
1666 let document = if privacy.is_value_free() {
1667 document.derived_from(
1668 EvidenceDerivation::new(
1669 evidence.iter().map(|item| item.id().clone()),
1670 DerivationAlgorithm::new(OPENAPI_CLASSIFICATION_ALGORITHM, 1).map_err(
1671 |_| DecisionExecutorError::new("OpenAPI classification algorithm failed"),
1672 )?,
1673 )
1674 .map_err(|_| DecisionExecutorError::new("OpenAPI classification lineage failed"))?,
1675 )
1676 } else {
1677 document
1678 };
1679 evidence.push(document);
1680 }
1681 if !terminal {
1682 evidence.push(marker(request)?);
1683 }
1684 evidence.extend(
1685 project_assessment_defense_signal(
1686 &signal,
1687 AssessmentDefenseProjectionContext {
1688 subject: request.case().subject(),
1689 case_id: request.case().id(),
1690 executor_id: OPENAPI_EXECUTOR_ID,
1691 reliability: ConfidenceScore::MAX,
1692 parents: defense_parents,
1693 },
1694 )
1695 .map_err(|_| DecisionExecutorError::new("OpenAPI defense projection failed"))?,
1696 );
1697 if terminal { }
1698 Ok(evidence)
1699}
1700
1701pub(super) fn project_openapi_item(
1702 context: &mut AssessmentProjectionContext,
1703 knowledge: &KnowledgeBase,
1704 review: &CommittedOpenApiReview,
1705) -> Result<(), AssessmentItemProjectionError> {
1706 for id in &review.evidence_ids {
1707 context.register_evidence(knowledge, id)?;
1708 }
1709 if review.outcome == OpenApiRuntimeOutcome::DocumentObserved {
1710 let target = if review.privacy.is_value_free() {
1711 AssessmentItemTarget::Subject
1712 } else {
1713 AssessmentItemTarget::openapi_document(review.target_identity.clone())?
1714 };
1715 context.project_observation(
1716 &OPENAPI_CAPABILITY,
1717 knowledge,
1718 &review.subject,
1719 &target,
1720 &review.evidence_ids,
1721 )?;
1722 }
1723 Ok(())
1724}
1725
1726#[cfg(test)]
1727mod tests {
1728 #[cfg(feature = "secret-exposure-review")]
1729 mod privacy_tests {
1730 use super::*;
1735 use crate::{
1736 runtime_budget::RequestAccountingBroker, DecisionEvidenceReceipt, DecisionLoopCommand,
1737 DecisionRunnerAdapter, DecisionStopReason, KnowledgeWrite, RuntimeBudget, RuntimeUsage,
1738 StandardWebDecisionRunReport, VerificationCase,
1739 };
1740
1741 const CASE: &str = "case:synthetic-openapi-privacy";
1742 const RESOURCE: &str = "https://fixture.invalid/openapi.json";
1743
1744 struct LiteralExecutor {
1745 id: &'static str,
1746 batch: Vec<Evidence>,
1747 }
1748
1749 #[async_trait]
1750 impl DecisionActionExecutor for LiteralExecutor {
1751 fn id(&self) -> &str {
1752 self.id
1753 }
1754
1755 async fn execute(
1756 &self,
1757 _request: &DecisionExecutionRequest,
1758 ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1759 assert!(self.batch.len() <= 32);
1760 Ok(self.batch.clone())
1761 }
1762 }
1763
1764 fn subject() -> EntityId {
1765 EntityId::new("endpoint:https://fixture.invalid/").unwrap()
1766 }
1767
1768 fn candidate() -> OpenApiCandidate {
1769 OpenApiCandidate {
1770 url: Url::parse(RESOURCE).unwrap(),
1771 source: OpenApiCandidateSource::ConventionalOpenApiJson,
1772 identity: "synthetic-url-identity".to_owned(),
1773 }
1774 }
1775
1776 fn fact(
1777 kind: EvidenceKind,
1778 predicate: KnowledgePredicate,
1779 value: EvidenceValue,
1780 method: &str,
1781 ) -> Evidence {
1782 Evidence::new(
1783 subject(),
1784 kind,
1785 predicate,
1786 value,
1787 EvidenceSource::new("http.openapi-review", method)
1788 .unwrap()
1789 .with_correlation_id(CASE)
1790 .unwrap(),
1791 ConfidenceScore::MAX,
1792 )
1793 }
1794
1795 fn custom(name: &str, value: EvidenceValue, method: &str) -> Evidence {
1796 fact(
1797 EvidenceKind::Custom("openapi-review".to_owned()),
1798 KnowledgePredicate::new("web.openapi-review.transport", name).unwrap(),
1799 value,
1800 method,
1801 )
1802 }
1803
1804 fn terminal_batch() -> Vec<Evidence> {
1805 vec![
1806 fact(
1807 EvidenceKind::Custom("openapi-review".to_owned()),
1808 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1809 EvidenceValue::Unsigned(0),
1810 "response-body-size",
1811 ),
1812 fact(
1813 EvidenceKind::Custom("openapi-review".to_owned()),
1814 crate::web_actions::openapi_review_phase_terminal_predicate(),
1815 EvidenceValue::Boolean(true),
1816 "phase-terminal",
1817 ),
1818 ]
1819 }
1820
1821 fn received_batch(parsed: bool) -> Vec<Evidence> {
1822 let rows = [
1823 (
1824 HttpEvidencePredicate::REQUEST_METHOD,
1825 EvidenceKind::Http,
1826 EvidenceValue::Text("GET".to_owned()),
1827 "request-method",
1828 ),
1829 (
1830 HttpEvidencePredicate::REQUEST_URL,
1831 EvidenceKind::Http,
1832 EvidenceValue::Text(RESOURCE.to_owned()),
1833 "request-url",
1834 ),
1835 (
1836 HttpEvidencePredicate::RESPONSE_STATUS,
1837 EvidenceKind::Http,
1838 EvidenceValue::Unsigned(if parsed { 200 } else { 404 }),
1839 "response-status",
1840 ),
1841 (
1842 HttpEvidencePredicate::RESPONSE_FINAL_URL,
1843 EvidenceKind::Http,
1844 EvidenceValue::Text(RESOURCE.to_owned()),
1845 "response-final-url",
1846 ),
1847 (
1848 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
1849 EvidenceKind::Content,
1850 EvidenceValue::Unsigned(7),
1851 "response-body-size",
1852 ),
1853 (
1854 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
1855 EvidenceKind::Content,
1856 EvidenceValue::Boolean(false),
1857 "response-body-truncation",
1858 ),
1859 ];
1860 let mut batch = rows
1861 .into_iter()
1862 .map(|(p, k, v, m)| fact(k, p.into_knowledge(), v, m))
1863 .collect::<Vec<_>>();
1864 let privacy_parents = batch
1865 .iter()
1866 .map(|item| item.id().clone())
1867 .collect::<Vec<_>>();
1868 for (predicate, method) in [
1869 (
1870 HttpEvidencePredicate::RATE_LIMIT_DETECTED,
1871 "rate-limit-status",
1872 ),
1873 (
1874 HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
1875 "rate-limit-headers",
1876 ),
1877 ] {
1878 batch.push(fact(
1879 EvidenceKind::RateLimit,
1880 predicate.into_knowledge(),
1881 EvidenceValue::Boolean(false),
1882 method,
1883 ));
1884 }
1885 batch.push(fact(
1886 EvidenceKind::Custom("openapi-review".to_owned()),
1887 crate::web_actions::openapi_review_phase_terminal_predicate(),
1888 EvidenceValue::Boolean(!parsed),
1889 "phase-terminal",
1890 ));
1891 batch.push(
1892 fact(
1893 EvidenceKind::Custom("passive-secret-exposure".to_owned()),
1894 KnowledgePredicate::new("web.secret-exposure", "active_review_body_lineage")
1895 .unwrap(),
1896 EvidenceValue::Boolean(true),
1897 "active-review-body-digest-privacy-guard",
1898 )
1899 .derived_from(
1900 EvidenceDerivation::new(
1901 privacy_parents,
1902 DerivationAlgorithm::new(
1903 "web.secret-exposure.active-review-body-digest-privacy-guard",
1904 1,
1905 )
1906 .unwrap(),
1907 )
1908 .unwrap(),
1909 ),
1910 );
1911 if parsed {
1912 let parents = batch
1913 .iter()
1914 .map(|item| item.id().clone())
1915 .collect::<Vec<_>>();
1916 batch.push(
1917 custom(
1918 "document",
1919 EvidenceValue::TextList(vec![
1920 "version=3.1".to_owned(),
1921 "paths=1".to_owned(),
1922 "operations=1".to_owned(),
1923 ]),
1924 "document",
1925 )
1926 .derived_from(
1927 EvidenceDerivation::new(
1928 parents,
1929 DerivationAlgorithm::new(
1930 "web.openapi-review.value-free-classification",
1931 1,
1932 )
1933 .unwrap(),
1934 )
1935 .unwrap(),
1936 ),
1937 );
1938 batch.push(fact(
1939 EvidenceKind::Custom("openapi-review".to_owned()),
1940 crate::web_actions::native_web_review_response_marker_predicate(),
1941 EvidenceValue::Boolean(true),
1942 "complete",
1943 ));
1944 }
1945 batch
1946 }
1947
1948 async fn committed_route(
1949 batch: Vec<Evidence>,
1950 action: &str,
1951 executor: &'static str,
1952 stage: DecisionExecutionStage,
1953 transitioned: bool,
1954 ) -> (KnowledgeBase, DecisionEvidenceReceipt) {
1955 let batch = if executor == "http.openapi-review" {
1959 batch
1960 } else {
1961 batch
1962 .into_iter()
1963 .map(|item| {
1964 let source = EvidenceSource::new(executor, item.source().method())
1965 .unwrap()
1966 .with_correlation_id(CASE)
1967 .unwrap();
1968 replace(
1969 &item,
1970 item.kind().clone(),
1971 item.value().clone(),
1972 source,
1973 None,
1974 )
1975 })
1976 .collect()
1977 };
1978 let mut registry = DecisionExecutorRegistry::new();
1979 registry
1980 .register(Arc::new(LiteralExecutor {
1981 id: executor,
1982 batch,
1983 }))
1984 .unwrap();
1985 registry.route_action(stage, action, executor).unwrap();
1986 let case =
1987 VerificationCase::new(CASE, subject(), action, "hypothesis:synthetic-openapi")
1988 .unwrap();
1989 let case = if transitioned {
1990 case
1991 } else {
1992 case.without_hypothesis_transition()
1993 };
1994 let command = match stage {
1995 DecisionExecutionStage::Passive => DecisionLoopCommand::ExecuteAction {
1996 case,
1997 executor: None,
1998 origin: DecisionActionOrigin::Planned,
1999 delay_ms: None,
2000 },
2001 DecisionExecutionStage::Active => {
2002 DecisionLoopCommand::CollectActiveEvidence { case }
2003 },
2004 };
2005 let knowledge = KnowledgeBase::new();
2006 let receipt = DecisionRunnerAdapter::new(registry)
2007 .execute_command(&command, &knowledge)
2008 .await
2009 .unwrap();
2010 (knowledge, receipt)
2011 }
2012
2013 async fn committed(batch: Vec<Evidence>) -> (KnowledgeBase, DecisionEvidenceReceipt) {
2014 committed_route(
2015 batch,
2016 "web.review.openapi.document-replay@1",
2017 "http.openapi-review",
2018 DecisionExecutionStage::Passive,
2019 false,
2020 )
2021 .await
2022 }
2023
2024 fn report(
2025 receipt: Option<DecisionEvidenceReceipt>,
2026 received: bool,
2027 ) -> StandardWebDecisionRunReport {
2028 let accounting = RequestAccountingBroker::new(RuntimeBudget::default());
2029 if received {
2030 let mut lease = accounting
2031 .try_begin(
2032 "web.review.openapi.document-replay@1",
2033 DecisionExecutionStage::Passive,
2034 Some(DecisionActionOrigin::Planned),
2035 )
2036 .unwrap();
2037 assert_eq!(lease.observe_response_bytes(7), 7);
2038 lease.finish(TransportDispatchOutcome::Completed);
2039 }
2040 StandardWebDecisionRunReport {
2041 bootstrap: None,
2042 turns: Vec::new(),
2043 unverified_evidence: receipt,
2044 terminal: DecisionLoopCommand::Halt {
2045 reason: DecisionStopReason::NoEligibleAction,
2046 },
2047 usage: RuntimeUsage::default(),
2048 transport: accounting.dispatch_audit(),
2049 limit_exceeded: None,
2050 execution_failure: None,
2051 }
2052 }
2053
2054 fn terminal_state() -> OpenApiExecutionState {
2055 OpenApiExecutionState {
2056 legs: BTreeMap::new(),
2057 terminal: Some((OpenApiRuntimeOutcome::Incomplete, None)),
2058 }
2059 }
2060
2061 fn parsed_state(batch: &[Evidence]) -> OpenApiExecutionState {
2062 let document = batch
2063 .iter()
2064 .find(|item| {
2065 item.predicate().namespace() == "web.openapi-review.transport"
2066 && item.predicate().name() == "document"
2067 })
2068 .unwrap();
2069 let leg = OpenApiLeg {
2070 digest: "private-synthetic-catalogue-not-exported".to_owned(),
2071 version: Some("3.1"),
2072 paths: 1,
2073 operations: 1,
2074 get_operations: 1,
2075 write_operations: 0,
2076 path_parameters: 0,
2077 query_parameters: 0,
2078 explicit_auth: 0,
2079 anonymous: 1,
2080 url_like: 0,
2081 multipart: 0,
2082 deprecated: 0,
2083 response_bytes: 7,
2084 evidence_id: Some(document.id().clone()),
2085 #[cfg(feature = "rest-review")]
2086 rest_selection: None,
2087 #[cfg(feature = "ssrf-oast-review")]
2088 ssrf_selection: None,
2089 };
2090 OpenApiExecutionState {
2091 legs: BTreeMap::from([(DecisionExecutionStage::Passive, leg)]),
2092 terminal: None,
2093 }
2094 }
2095
2096 fn validate(
2097 receipt: Option<DecisionEvidenceReceipt>,
2098 knowledge: &KnowledgeBase,
2099 state: &OpenApiExecutionState,
2100 received: bool,
2101 ) -> Result<bool, OpenApiRuntimeInvariantError> {
2102 validate_value_free_openapi_commits(
2103 &report(receipt, received),
2104 knowledge,
2105 &subject(),
2106 &candidate(),
2107 state,
2108 )
2109 }
2110
2111 fn mutation(
2112 template: &DecisionEvidenceReceipt,
2113 batch: Vec<Evidence>,
2114 ) -> (KnowledgeBase, DecisionEvidenceReceipt) {
2115 let knowledge = KnowledgeBase::new();
2116 let writes = knowledge.insert_evidence_batch(batch.clone()).unwrap();
2117 let snapshot = knowledge.snapshot_for_subject(template.case().subject());
2118 (
2119 knowledge,
2120 template.with_test_committed_batch(batch, writes, snapshot),
2121 )
2122 }
2123
2124 fn replace(
2125 item: &Evidence,
2126 kind: EvidenceKind,
2127 value: EvidenceValue,
2128 source: EvidenceSource,
2129 parents: Option<Vec<EvidenceId>>,
2130 ) -> Evidence {
2131 let mut result = Evidence::with_id_at(
2132 item.id().clone(),
2133 item.subject().clone(),
2134 kind,
2135 item.predicate().clone(),
2136 value,
2137 source,
2138 item.reliability(),
2139 item.observed_at_ms(),
2140 );
2141 if let Some(original) = item.origin().derivation() {
2142 result = result.derived_from(
2143 EvidenceDerivation::new(
2144 parents.unwrap_or_else(|| original.parents().to_vec()),
2145 original.algorithm().clone(),
2146 )
2147 .unwrap(),
2148 );
2149 }
2150 result
2151 }
2152
2153 #[tokio::test]
2154 async fn openapi_privacy_commit_requires_receipts_not_staged_terminal_or_leg_state() {
2155 let knowledge = KnowledgeBase::new();
2156 assert!(!validate(None, &knowledge, &terminal_state(), false).unwrap());
2157 let batch = received_batch(true);
2158 assert!(!validate(None, &knowledge, &parsed_state(&batch), true).unwrap());
2159 let (knowledge, receipt) = committed(terminal_batch()).await;
2160 assert!(validate(Some(receipt), &knowledge, &terminal_state(), false).unwrap());
2161 let (knowledge, receipt) = committed(received_batch(false)).await;
2162 assert!(validate(Some(receipt), &knowledge, &terminal_state(), true).unwrap());
2163 let state = parsed_state(&batch);
2164 let (knowledge, receipt) = committed(batch).await;
2165 assert!(validate(Some(receipt), &knowledge, &state, true).unwrap());
2166 }
2167
2168 #[tokio::test]
2169 async fn openapi_privacy_commit_rejects_lost_knowledge_duplicate_ids_and_write_mismatch() {
2170 let batch = received_batch(true);
2171 let state = parsed_state(&batch);
2172 let (knowledge, receipt) = committed(batch.clone()).await;
2173 assert!(validate(Some(receipt.clone()), &KnowledgeBase::new(), &state, true).is_err());
2174 let mut duplicate = batch;
2175 duplicate.push(duplicate[0].clone());
2176 let duplicate_receipt = receipt.with_test_committed_batch(
2177 duplicate.clone(),
2178 vec![KnowledgeWrite::Unchanged; duplicate.len()],
2179 knowledge.snapshot_for_subject(&subject()),
2180 );
2181 assert!(validate(Some(duplicate_receipt), &knowledge, &state, true).is_err());
2182 let bad = receipt.with_test_committed_batch(
2183 receipt.evidence().to_vec(),
2184 Vec::new(),
2185 knowledge.snapshot_for_subject(&subject()),
2186 );
2187 assert!(validate(Some(bad), &knowledge, &state, true).is_err());
2188 }
2189
2190 #[tokio::test]
2191 async fn openapi_privacy_commit_rejects_wrong_route_executor_stage_and_subject() {
2192 for (action, executor, stage) in [
2193 (
2194 "synthetic.other-action",
2195 "http.openapi-review",
2196 DecisionExecutionStage::Passive,
2197 ),
2198 (
2199 "web.review.openapi.document-replay@1",
2200 "synthetic.other-executor",
2201 DecisionExecutionStage::Passive,
2202 ),
2203 (
2204 "web.review.openapi.document-replay@1",
2205 "http.openapi-review",
2206 DecisionExecutionStage::Active,
2207 ),
2208 ] {
2209 let (knowledge, receipt) =
2210 committed_route(terminal_batch(), action, executor, stage, false).await;
2211 assert!(validate(Some(receipt), &knowledge, &terminal_state(), false).is_err());
2212 }
2213 let (knowledge, receipt) = committed(terminal_batch()).await;
2214 let different = EntityId::new("endpoint:https://sibling.invalid/").unwrap();
2215 assert!(validate_value_free_openapi_commits(
2216 &report(Some(receipt), false),
2217 &knowledge,
2218 &different,
2219 &candidate(),
2220 &terminal_state(),
2221 )
2222 .is_err());
2223 }
2224
2225 #[tokio::test]
2226 async fn openapi_privacy_commit_rejects_additional_public_identity_facts() {
2227 let batch = received_batch(true);
2228 let state = parsed_state(&batch);
2229 let (_, template) = committed(batch.clone()).await;
2230 for extra in [
2231 fact(
2232 EvidenceKind::Content,
2233 HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge(),
2234 EvidenceValue::Text("a".repeat(64)),
2235 "response-body-sha256",
2236 ),
2237 custom(
2238 "semantic_digest",
2239 EvidenceValue::Text("unkeyed-body-derived-identity".to_owned()),
2240 "semantic-digest",
2241 ),
2242 fact(
2243 EvidenceKind::Custom("passive-secret-exposure".to_owned()),
2244 KnowledgePredicate::new("web.secret-exposure", "response_body_lineage")
2245 .unwrap(),
2246 EvidenceValue::Boolean(true),
2247 "synthetic-extra-lineage",
2248 ),
2249 ] {
2250 let mut changed = batch.clone();
2251 changed.push(extra);
2252 let (knowledge, receipt) = mutation(&template, changed);
2253 assert!(validate(Some(receipt), &knowledge, &state, true).is_err());
2254 }
2255 }
2256
2257 #[tokio::test]
2258 async fn openapi_privacy_commit_rejects_classification_lineage_and_terminal_mutations() {
2259 let batch = received_batch(true);
2260 let state = parsed_state(&batch);
2261 let (_, template) = committed(batch.clone()).await;
2262 let document_index = batch
2263 .iter()
2264 .position(|item| item.source().method() == "document")
2265 .unwrap();
2266 let lineage_index = batch
2267 .iter()
2268 .position(|item| item.predicate().name() == "active_review_body_lineage")
2269 .unwrap();
2270 for index in [document_index, lineage_index] {
2271 let mut changed = batch.clone();
2272 let original = &changed[index];
2273 let parents = original.origin().derivation().unwrap().parents()[1..].to_vec();
2274 changed[index] = replace(
2275 original,
2276 original.kind().clone(),
2277 original.value().clone(),
2278 original.source().clone(),
2279 Some(parents),
2280 );
2281 let (knowledge, receipt) = mutation(&template, changed);
2282 assert!(validate(Some(receipt), &knowledge, &state, true).is_err());
2283 }
2284 let mut changed = batch.clone();
2285 changed[document_index] = replace(
2286 &batch[document_index],
2287 batch[document_index].kind().clone(),
2288 EvidenceValue::TextList(vec![
2289 "version=3.1".to_owned(),
2290 "paths=2".to_owned(),
2291 "operations=1".to_owned(),
2292 ]),
2293 batch[document_index].source().clone(),
2294 None,
2295 );
2296 let (knowledge, receipt) = mutation(&template, changed);
2297 assert!(validate(Some(receipt), &knowledge, &state, true).is_err());
2298
2299 let terminal = terminal_batch();
2300 let (_, template) = committed(terminal.clone()).await;
2301 for (index, value, kind, method) in [
2302 (
2303 0,
2304 EvidenceValue::Unsigned(1),
2305 EvidenceKind::Custom("openapi-review".to_owned()),
2306 "response-body-size",
2307 ),
2308 (
2309 1,
2310 EvidenceValue::Boolean(false),
2311 EvidenceKind::Custom("openapi-review".to_owned()),
2312 "phase-terminal",
2313 ),
2314 (
2315 0,
2316 EvidenceValue::Unsigned(0),
2317 EvidenceKind::Content,
2318 "response-body-size",
2319 ),
2320 (
2321 1,
2322 EvidenceValue::Boolean(true),
2323 EvidenceKind::Custom("openapi-review".to_owned()),
2324 "wrong-method",
2325 ),
2326 ] {
2327 let mut changed = terminal.clone();
2328 let source = EvidenceSource::new("http.openapi-review", method)
2329 .unwrap()
2330 .with_correlation_id(CASE)
2331 .unwrap();
2332 changed[index] = replace(&terminal[index], kind, value, source, None);
2333 let (knowledge, receipt) = mutation(&template, changed);
2334 assert!(validate(Some(receipt), &knowledge, &terminal_state(), false).is_err());
2335 }
2336 }
2337 }
2338 use super::*;
2339 use termivar_core::{ApiKnowledgePredicate, ApiResponseFormat};
2340
2341 #[test]
2342 fn selector_prefers_discovered_json_and_never_fans_out() {
2343 let root = Url::parse("https://example.test/base").unwrap();
2344 let selected = select_openapi_candidate(
2345 &root,
2346 [
2347 Url::parse("https://example.test/swagger.json").unwrap(),
2348 Url::parse("https://example.test/openapi.json").unwrap(),
2349 Url::parse("https://evil.invalid/openapi.json").unwrap(),
2350 ],
2351 )
2352 .unwrap();
2353 assert_eq!(
2354 selected.source,
2355 OpenApiCandidateSource::DiscoveredOpenApiJson
2356 );
2357 assert_eq!(selected.url.path(), "/openapi.json");
2358 }
2359 #[test]
2360 fn selector_uses_one_fixed_fallback() {
2361 let root = Url::parse("https://example.test/nested").unwrap();
2362 let selected = select_openapi_candidate(&root, []).unwrap();
2363 assert_eq!(selected.url.as_str(), "https://example.test/openapi.json");
2364 assert_eq!(
2365 selected.source,
2366 OpenApiCandidateSource::ConventionalOpenApiJson
2367 );
2368 }
2369
2370 #[test]
2371 fn selector_rejects_candidates_outside_the_path_and_url_bounds() {
2372 let root = Url::parse("https://example.test/").unwrap();
2373 let overlong_path = format!(
2374 "https://example.test/{}/openapi.json",
2375 "a".repeat(MAX_OPENAPI_CANDIDATE_PATH_BYTES)
2376 );
2377 let selected =
2378 select_openapi_candidate(&root, [Url::parse(&overlong_path).unwrap()]).unwrap();
2379 assert_eq!(
2380 selected.source,
2381 OpenApiCandidateSource::ConventionalOpenApiJson
2382 );
2383 assert_eq!(selected.url.path(), "/openapi.json");
2384 assert!(selected.url.as_str().len() <= MAX_OPENAPI_CANDIDATE_URL_BYTES);
2385 assert!(selected.url.path().len() <= MAX_OPENAPI_CANDIDATE_PATH_BYTES);
2386 }
2387
2388 #[test]
2389 fn selector_rejects_cross_origin_credentials_queries_and_fragments() {
2390 let root = Url::parse("https://example.test/root").unwrap();
2391 for hint in [
2392 "https://elsewhere.invalid/openapi.json",
2393 "https://user:secret@example.test/openapi.json",
2394 "https://example.test/openapi.json?variant=1",
2395 "https://example.test/openapi.json#fragment",
2396 "ftp://example.test/openapi.json",
2397 ] {
2398 let selected = select_openapi_candidate(&root, [Url::parse(hint).unwrap()]).unwrap();
2399 assert_eq!(
2400 selected.source,
2401 OpenApiCandidateSource::ConventionalOpenApiJson
2402 );
2403 assert_eq!(selected.url.as_str(), "https://example.test/openapi.json");
2404 }
2405 }
2406
2407 #[test]
2408 fn committed_json_evidence_feeds_the_existing_transport_neutral_reasoner() {
2409 for (json_compatible, expected) in [(true, true), (false, false)] {
2410 let knowledge = KnowledgeBase::new();
2411 let subject = EntityId::new(format!(
2412 "openapi-reasoning-fixture:{}",
2413 u8::from(json_compatible)
2414 ))
2415 .unwrap();
2416 let source = EvidenceSource::new(OPENAPI_EXECUTOR_ID, "response-json")
2417 .unwrap()
2418 .with_correlation_id("case:openapi-reasoning-fixture")
2419 .unwrap();
2420 knowledge
2421 .insert_evidence(Evidence::new(
2422 subject.clone(),
2423 EvidenceKind::Http,
2424 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge(),
2425 EvidenceValue::Boolean(json_compatible),
2426 source,
2427 ConfidenceScore::MAX,
2428 ))
2429 .unwrap();
2430
2431 commit_api_reasoning_inputs(&knowledge, &subject).unwrap();
2432
2433 let snapshot = knowledge.snapshot_for_subject(&subject);
2434 let predicate = ApiKnowledgePredicate::RESPONSE_FORMAT.into_knowledge();
2435 let json = EvidenceValue::from(ApiResponseFormat::Json);
2436 assert_eq!(
2437 snapshot.hypotheses().iter().any(|hypothesis| {
2438 hypothesis.predicate() == &predicate && hypothesis.value() == &json
2439 }),
2440 expected
2441 );
2442 }
2443 }
2444}