termivar_scanner/web_runtime/
assessment_api_visibility.rs1use std::fmt;
9
10use termivar_core::{ApiVisibilityDimension, EntityId};
11use thiserror::Error;
12use url::Url;
13
14use crate::{
15 ApiAuthorizationContextPairStrategy, ApiComparisonProfile, ApiObservationCommitReceipt,
16 ApiVisibilityContextProbe, ApiVisibilityDifferentialDisposition,
17 ApiVisibilityDifferentialRequest, ApiVisibilityReview, ApiVisibilityReviewDisposition,
18 HttpProbe, HttpProbeMethod, PayloadSeed, PayloadStrategy, PayloadStrategyLimits,
19 PayloadVariantRole, RuntimeApiVisibilityExecutionError, StandardWebDecisionRuntime,
20 API_AUTHORIZATION_CONTEXT_PAIR_HEADER_NAME,
21};
22
23use super::{
24 assessment_item::{
25 AssessmentCapabilityDescriptor, AssessmentItemProjectionError, AssessmentProjectionContext,
26 },
27 SharedWebRuntimeAuthority,
28};
29
30const ROOT_COMPARISON_ID: &str = "web-assessment-root-authorization-context@1";
31const ANONYMOUS_CONTEXT_ID: &str = "context:web-assessment:anonymous";
32const AUTHORIZED_CONTEXT_ID: &str = "context:web-assessment:host-authorized";
33
34const API_AUTHORIZATION_VISIBILITY_REVIEW: AssessmentCapabilityDescriptor =
35 AssessmentCapabilityDescriptor::differential_review(
36 "api.review.authorization-context.visibility-difference@1",
37 "Authorization contexts returned different JSON visibility",
38 "api-authorization-context",
39 "One atomic anonymous/authorized JSON comparison indicates a visibility difference that may be intentional and requires policy review.",
40 None,
41 1_000_000,
42 None,
43 "api.remediation.authorization-context-policy@1",
44 "Review the intended authorization policy for this resource and verify that each context receives only its permitted fields.",
45 );
46
47pub struct WebAssessmentRootAuthorizationContext {
54 candidate_header_value: String,
55}
56
57impl WebAssessmentRootAuthorizationContext {
58 pub fn new(value: impl Into<Vec<u8>>) -> Result<Self, WebAssessmentAuthorizationContextError> {
60 let limits = PayloadStrategyLimits::default();
61 let seed = PayloadSeed::new(value, limits)
62 .map_err(|_| WebAssessmentAuthorizationContextError::InvalidValue)?;
63 let strategy = ApiAuthorizationContextPairStrategy::new();
64 let control = strategy
65 .derive_one(PayloadVariantRole::Control, &seed, limits)
66 .map_err(|_| WebAssessmentAuthorizationContextError::InvalidValue)?;
67 let candidate = strategy
68 .derive_one(PayloadVariantRole::Candidate, &seed, limits)
69 .map_err(|_| WebAssessmentAuthorizationContextError::InvalidValue)?;
70 if !control.as_bytes().is_empty()
71 || control.role() != PayloadVariantRole::Control
72 || candidate.role() != PayloadVariantRole::Candidate
73 {
74 return Err(WebAssessmentAuthorizationContextError::InvalidValue);
75 }
76 let candidate_header_value = String::from_utf8(candidate.as_bytes().to_vec())
77 .map_err(|_| WebAssessmentAuthorizationContextError::InvalidValue)?;
78 Ok(Self {
79 candidate_header_value,
80 })
81 }
82
83 fn into_candidate_header_value(self) -> String {
84 self.candidate_header_value
85 }
86}
87
88impl fmt::Debug for WebAssessmentRootAuthorizationContext {
89 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90 formatter.write_str("WebAssessmentRootAuthorizationContext(<redacted>)")
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
96#[non_exhaustive]
97pub enum WebAssessmentAuthorizationContextError {
98 #[error("authorization context is not a bounded safe HTTP header value")]
99 InvalidValue,
100}
101
102pub(super) struct RootApiVisibilityRuntime {
103 runtime: StandardWebDecisionRuntime,
104 request: Option<ApiVisibilityDifferentialRequest>,
105}
106
107impl fmt::Debug for RootApiVisibilityRuntime {
108 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109 formatter
110 .debug_struct("RootApiVisibilityRuntime")
111 .field("runtime", &"<shared-authority>")
112 .field("request", &"<redacted>")
113 .finish()
114 }
115}
116
117impl RootApiVisibilityRuntime {
118 pub(super) async fn execute(
119 &mut self,
120 ) -> Result<RootApiVisibilityOutcome, RuntimeApiVisibilityExecutionError> {
121 let request = self
122 .request
123 .take()
124 .ok_or(RuntimeApiVisibilityExecutionError::AlreadyStarted)?;
125 let report = self.runtime.run_api_visibility_pair(request).await?;
126 Ok(RootApiVisibilityOutcome::from_report(&report))
127 }
128}
129
130pub(super) fn build_root_api_visibility_runtime(
131 target: &Url,
132 resource_scope: EntityId,
133 authority: SharedWebRuntimeAuthority,
134 context: WebAssessmentRootAuthorizationContext,
135) -> Result<RootApiVisibilityRuntime, RootApiVisibilityCompositionError> {
136 let control = HttpProbe::new(target.clone(), HttpProbeMethod::Get)
137 .and_then(|probe| probe.with_header("accept", "application/json"))
138 .map_err(|_| RootApiVisibilityCompositionError)?;
139 let candidate = HttpProbe::new(target.clone(), HttpProbeMethod::Get)
140 .and_then(|probe| probe.with_header("accept", "application/json"))
141 .and_then(|probe| {
142 probe.with_header(
143 API_AUTHORIZATION_CONTEXT_PAIR_HEADER_NAME,
144 context.into_candidate_header_value(),
145 )
146 })
147 .map_err(|_| RootApiVisibilityCompositionError)?;
148 let request = ApiVisibilityDifferentialRequest::new(
149 ROOT_COMPARISON_ID,
150 resource_scope,
151 ApiVisibilityContextProbe::new(ANONYMOUS_CONTEXT_ID, control)
152 .map_err(|_| RootApiVisibilityCompositionError)?,
153 ApiVisibilityContextProbe::new(AUTHORIZED_CONTEXT_ID, candidate)
154 .map_err(|_| RootApiVisibilityCompositionError)?,
155 [API_AUTHORIZATION_CONTEXT_PAIR_HEADER_NAME],
156 ApiComparisonProfile::default(),
157 ApiVisibilityDimension::Fields,
158 0,
161 )
162 .map_err(|_| RootApiVisibilityCompositionError)?;
163 let runtime = StandardWebDecisionRuntime::builder(target.clone())
164 .enable_api_reasoning()
165 .build_with_shared_authority(authority)
166 .map_err(|_| RootApiVisibilityCompositionError)?;
167 Ok(RootApiVisibilityRuntime {
168 runtime,
169 request: Some(request),
170 })
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
174#[error("root API authorization-context review could not be composed safely")]
175pub(super) struct RootApiVisibilityCompositionError;
176
177#[derive(Clone)]
178pub(super) struct CommittedAssessmentApiVisibility {
179 commit: ApiObservationCommitReceipt,
180 review: ApiVisibilityReview,
181}
182
183impl fmt::Debug for CommittedAssessmentApiVisibility {
184 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185 formatter
186 .debug_struct("CommittedAssessmentApiVisibility")
187 .field("commit", &"<redacted>")
188 .field("review", &"<redacted>")
189 .finish()
190 }
191}
192
193impl CommittedAssessmentApiVisibility {
194 pub(super) fn commit(&self) -> &ApiObservationCommitReceipt {
195 &self.commit
196 }
197
198 pub(super) fn review(&self) -> &ApiVisibilityReview {
199 &self.review
200 }
201}
202
203#[derive(Debug)]
204pub(super) enum RootApiVisibilityOutcome {
205 NoDifference,
206 NeedsReview(Box<CommittedAssessmentApiVisibility>),
207 Incomplete,
208 Cancelled,
209 RuntimeLimit(crate::RuntimeBudgetDimension),
210 ContractMismatch,
211}
212
213impl RootApiVisibilityOutcome {
214 fn from_report(report: &crate::RuntimeApiVisibilityRunReport) -> Self {
215 match report.disposition() {
216 ApiVisibilityDifferentialDisposition::NoDifferenceObserved => {
217 if exact_committed_review(
218 report,
219 ApiVisibilityReviewDisposition::NoDifferenceObserved,
220 )
221 .is_some()
222 {
223 Self::NoDifference
224 } else {
225 Self::ContractMismatch
226 }
227 },
228 ApiVisibilityDifferentialDisposition::AwaitHumanReview => {
229 exact_committed_review(report, ApiVisibilityReviewDisposition::AwaitHumanReview)
230 .map(|committed| Self::NeedsReview(Box::new(committed)))
231 .unwrap_or(Self::ContractMismatch)
232 },
233 ApiVisibilityDifferentialDisposition::UnresolvedDifference => {
234 if exact_committed_review(
235 report,
236 ApiVisibilityReviewDisposition::UnresolvedDifference,
237 )
238 .is_some()
239 {
240 Self::Incomplete
241 } else {
242 Self::ContractMismatch
243 }
244 },
245 ApiVisibilityDifferentialDisposition::Inconclusive => Self::Incomplete,
246 ApiVisibilityDifferentialDisposition::CancelledByHost => Self::Cancelled,
247 ApiVisibilityDifferentialDisposition::RuntimeBudgetLimit => report
248 .limit_exceeded()
249 .map(|limit| Self::RuntimeLimit(limit.dimension()))
250 .unwrap_or(Self::ContractMismatch),
251 }
252 }
253}
254
255fn exact_committed_review(
256 report: &crate::RuntimeApiVisibilityRunReport,
257 expected: ApiVisibilityReviewDisposition,
258) -> Option<CommittedAssessmentApiVisibility> {
259 let observation = report.observation()?;
260 let review = report.review()?;
261 (review.disposition() == expected
262 && review.resource_scope() == observation.commit().resource_scope()
263 && review.comparison_subject() == observation.commit().comparison_subject()
264 && review.relation_id() == observation.commit().relation_id()
265 && review.evidence().id() == observation.commit().evidence_id())
266 .then(|| CommittedAssessmentApiVisibility {
267 commit: observation.commit().clone(),
268 review: review.clone(),
269 })
270}
271
272pub(super) fn project_api_visibility_item(
273 context: &mut AssessmentProjectionContext,
274 knowledge: &crate::KnowledgeBase,
275 authorized_root_subject: &EntityId,
276 committed: &CommittedAssessmentApiVisibility,
277) -> Result<(), AssessmentItemProjectionError> {
278 context.project_api_visibility_paired_comparison(
279 &API_AUTHORIZATION_VISIBILITY_REVIEW,
280 knowledge,
281 authorized_root_subject,
282 committed.commit(),
283 committed.review(),
284 )
285}