Skip to main content

termivar_scanner/api_observation/
review.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use termivar_core::{
4    ApiEvidencePredicate, ApiKnowledgePredicate, ApiVisibilityBoundaryKind, ApiVisibilityDimension,
5    ConfidenceScore, EntityId, Evidence, EvidenceKind, EvidenceValue, Hypothesis, HypothesisState,
6    HypothesisStrength, KnowledgePredicate, KnowledgeRelation, RelationId, RelationKind,
7};
8
9#[cfg(feature = "scanning")]
10use crate::api_observation::model::ApiObservationCommitReceipt;
11use crate::{
12    api_observation::{
13        API_VISIBILITY_EVIDENCE_KIND, API_VISIBILITY_RELATION, API_VISIBILITY_SOURCE_METHOD,
14        AUTHORIZATION_BOUNDARY_RULE, COMPARISON_EVIDENCE_PREFIX, COMPARISON_RELATION_PREFIX,
15        COMPARISON_SUBJECT_PREFIX, MAX_API_VISIBILITY_REVIEW_RATIONALE_BYTES,
16        MAX_API_VISIBILITY_SOURCE_COMPONENT_BYTES, UI_API_BOUNDARY_RULE,
17    },
18    knowledge::KnowledgeBase,
19    rules::hypothesis_id_for_rule,
20};
21
22/// Canonical paired observation and its reviewable boundary hypotheses.
23///
24/// An equivalent comparison remains visible with an empty hypothesis list. A
25/// difference can contain one canonical-shaped boundary hypothesis for that
26/// isolated comparison subject. The projection validates the standard rule ID
27/// and semantic fields, but does not attest which rule installation produced
28/// the record. Surface and response-format hypotheses are intentionally
29/// excluded from this resource-scoped read model.
30#[derive(Clone, PartialEq, Eq, Serialize)]
31pub struct ApiVisibilityReview {
32    resource_scope: EntityId,
33    comparison_subject: EntityId,
34    relation_id: RelationId,
35    evidence: Evidence,
36    boundary_hypotheses: Vec<Hypothesis>,
37}
38
39/// Deterministic handling state for one canonical API visibility review.
40///
41/// This is a review disposition, not a vulnerability verdict and not a
42/// [`crate::DecisionLoopCommand`]. A difference reaches [`Self::AwaitHumanReview`]
43/// only when the standard reasoning profile produced the exact weak, supported,
44/// evidence-bound boundary hypothesis. Missing reasoning remains explicitly
45/// unresolved instead of being promoted to a security finding.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48#[non_exhaustive]
49pub enum ApiVisibilityReviewDisposition {
50    /// The canonical comparison evidence described equivalent views.
51    NoDifferenceObserved,
52    /// A difference exists but no canonical review hypothesis was materialized.
53    UnresolvedDifference,
54    /// A canonical weak boundary hypothesis requires an authorized human review.
55    AwaitHumanReview,
56}
57
58impl fmt::Debug for ApiVisibilityReview {
59    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60        formatter
61            .debug_struct("ApiVisibilityReview")
62            .field("resource_scope", &"<redacted>")
63            .field("comparison_subject", &"<redacted>")
64            .field("relation_id", &"<redacted>")
65            .field("evidence", &"<redacted>")
66            .field("boundary_hypothesis_count", &self.boundary_hypotheses.len())
67            .finish()
68    }
69}
70
71impl ApiVisibilityReview {
72    /// Returns the resource selected by the host's pairing contract.
73    pub fn resource_scope(&self) -> &EntityId {
74        &self.resource_scope
75    }
76
77    /// Returns the isolated comparison subject.
78    pub fn comparison_subject(&self) -> &EntityId {
79        &self.comparison_subject
80    }
81
82    /// Returns the resource-scope relation identity.
83    pub fn relation_id(&self) -> &RelationId {
84        &self.relation_id
85    }
86
87    /// Returns the structurally canonical paired-comparison evidence.
88    pub fn evidence(&self) -> &Evidence {
89        &self.evidence
90    }
91
92    /// Returns only canonical-shaped API visibility-boundary hypotheses.
93    pub fn boundary_hypotheses(&self) -> &[Hypothesis] {
94        &self.boundary_hypotheses
95    }
96
97    /// Classifies this read model without turning a difference into a finding.
98    pub fn disposition(&self) -> ApiVisibilityReviewDisposition {
99        if expected_boundary_rule(&self.evidence).is_none() {
100            ApiVisibilityReviewDisposition::NoDifferenceObserved
101        } else if self.boundary_hypotheses.len() == 1 {
102            ApiVisibilityReviewDisposition::AwaitHumanReview
103        } else {
104            ApiVisibilityReviewDisposition::UnresolvedDifference
105        }
106    }
107}
108
109#[cfg(feature = "scanning")]
110pub(crate) fn api_visibility_review_for_commit(
111    knowledge: &KnowledgeBase,
112    commit: &ApiObservationCommitReceipt,
113) -> Option<ApiVisibilityReview> {
114    let relation = knowledge.relation(commit.relation_id())?;
115    project_api_visibility_review(knowledge, commit.resource_scope(), &relation).filter(|review| {
116        review.comparison_subject() == commit.comparison_subject()
117            && review.evidence().id() == commit.evidence_id()
118    })
119}
120
121pub(super) fn project_api_visibility_review(
122    knowledge: &KnowledgeBase,
123    resource_scope: &EntityId,
124    relation: &KnowledgeRelation,
125) -> Option<ApiVisibilityReview> {
126    if !matches!(relation.kind(), RelationKind::Custom(kind) if kind == API_VISIBILITY_RELATION)
127        || relation.to() != resource_scope
128        || relation.evidence_ids().len() != 1
129    {
130        return None;
131    }
132    let evidence_id = relation.evidence_ids().iter().next()?;
133    let evidence = knowledge
134        .inspect_evidence(evidence_id, |evidence| {
135            (is_canonical_comparison(evidence, relation) && is_bounded_review_evidence(evidence))
136                .then(|| evidence.clone())
137        })
138        .flatten()?;
139    let boundary_hypotheses = canonical_boundary_hypothesis(knowledge, &evidence)
140        .into_iter()
141        .collect();
142    Some(ApiVisibilityReview {
143        resource_scope: resource_scope.clone(),
144        comparison_subject: evidence.subject().clone(),
145        relation_id: relation.id().clone(),
146        evidence,
147        boundary_hypotheses,
148    })
149}
150
151fn is_bounded_review_evidence(evidence: &Evidence) -> bool {
152    evidence.source().component().len() <= MAX_API_VISIBILITY_SOURCE_COMPONENT_BYTES
153}
154
155fn is_canonical_comparison(
156    evidence: &Evidence,
157    relation: &termivar_core::KnowledgeRelation,
158) -> bool {
159    let Some(digest) = evidence
160        .subject()
161        .as_str()
162        .strip_prefix(COMPARISON_SUBJECT_PREFIX)
163    else {
164        return false;
165    };
166    digest.len() == 64
167        && digest
168            .bytes()
169            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
170        && evidence.id().as_str() == format!("{COMPARISON_EVIDENCE_PREFIX}{digest}")
171        && relation.id().as_str() == format!("{COMPARISON_RELATION_PREFIX}{digest}")
172        && relation.from() == evidence.subject()
173        && relation.evidence_ids().len() == 1
174        && relation.evidence_ids().contains(evidence.id())
175        && relation.confidence() == evidence.reliability()
176        && evidence.reliability() != ConfidenceScore::NONE
177        && matches!(evidence.kind(), EvidenceKind::Custom(kind) if kind == API_VISIBILITY_EVIDENCE_KIND)
178        && evidence.source().method() == API_VISIBILITY_SOURCE_METHOD
179        && evidence.source().correlation_id() == Some(evidence.subject().as_str())
180        && is_visibility_predicate(evidence.predicate())
181        && is_visibility_dimension(evidence.value())
182}
183
184fn is_visibility_predicate(predicate: &KnowledgePredicate) -> bool {
185    [
186        ApiEvidencePredicate::JSON_UI_API_DIFFERENCE,
187        ApiEvidencePredicate::JSON_UI_API_EQUIVALENT,
188        ApiEvidencePredicate::JSON_AUTHORIZATION_CONTEXT_DIFFERENCE,
189        ApiEvidencePredicate::JSON_AUTHORIZATION_CONTEXT_EQUIVALENT,
190        ApiEvidencePredicate::GRAPHQL_UI_API_DIFFERENCE,
191        ApiEvidencePredicate::GRAPHQL_UI_API_EQUIVALENT,
192        ApiEvidencePredicate::GRAPHQL_AUTHORIZATION_CONTEXT_DIFFERENCE,
193        ApiEvidencePredicate::GRAPHQL_AUTHORIZATION_CONTEXT_EQUIVALENT,
194    ]
195    .into_iter()
196    .any(|descriptor| descriptor.into_knowledge() == *predicate)
197}
198
199fn is_visibility_dimension(value: &EvidenceValue) -> bool {
200    ApiVisibilityDimension::all()
201        .into_iter()
202        .any(|dimension| EvidenceValue::from(dimension) == *value)
203}
204
205fn expected_boundary_rule(
206    evidence: &Evidence,
207) -> Option<(ApiVisibilityBoundaryKind, &'static str)> {
208    match evidence.predicate() {
209        predicate
210            if predicate == &ApiEvidencePredicate::JSON_UI_API_DIFFERENCE.into_knowledge()
211                || predicate
212                    == &ApiEvidencePredicate::GRAPHQL_UI_API_DIFFERENCE.into_knowledge() =>
213        {
214            Some((ApiVisibilityBoundaryKind::UiApi, UI_API_BOUNDARY_RULE))
215        },
216        predicate
217            if predicate
218                == &ApiEvidencePredicate::JSON_AUTHORIZATION_CONTEXT_DIFFERENCE
219                    .into_knowledge()
220                || predicate
221                    == &ApiEvidencePredicate::GRAPHQL_AUTHORIZATION_CONTEXT_DIFFERENCE
222                        .into_knowledge() =>
223        {
224            Some((
225                ApiVisibilityBoundaryKind::AuthorizationContext,
226                AUTHORIZATION_BOUNDARY_RULE,
227            ))
228        },
229        _ => None,
230    }
231}
232
233fn canonical_boundary_hypothesis(
234    knowledge: &KnowledgeBase,
235    evidence: &Evidence,
236) -> Option<Hypothesis> {
237    let (_, rule_id) = expected_boundary_rule(evidence)?;
238    let hypothesis_id = hypothesis_id_for_rule(rule_id, evidence.subject());
239    knowledge
240        .inspect_hypothesis(&hypothesis_id, |hypothesis| {
241            (is_canonical_boundary_hypothesis(hypothesis, evidence)
242                && is_bounded_boundary_hypothesis(hypothesis))
243            .then(|| hypothesis.clone())
244        })
245        .flatten()
246}
247
248fn is_bounded_boundary_hypothesis(hypothesis: &Hypothesis) -> bool {
249    hypothesis.belief().evidence().iter().all(|observation| {
250        observation.rationale().len() <= MAX_API_VISIBILITY_REVIEW_RATIONALE_BYTES
251    }) && hypothesis
252        .belief()
253        .updates()
254        .iter()
255        .all(|update| update.rationale().len() <= MAX_API_VISIBILITY_REVIEW_RATIONALE_BYTES)
256}
257
258fn is_canonical_boundary_hypothesis(hypothesis: &Hypothesis, evidence: &Evidence) -> bool {
259    let Some((boundary, rule_id)) = expected_boundary_rule(evidence) else {
260        return false;
261    };
262    if hypothesis.subject() != evidence.subject()
263        || hypothesis.predicate() != &ApiKnowledgePredicate::VISIBILITY_BOUNDARY.into_knowledge()
264        || hypothesis.strength() != HypothesisStrength::Weak
265        || hypothesis.state() != HypothesisState::Supported
266        || hypothesis.belief().evidence().len() != 1
267        || hypothesis.belief().evidence()[0].evidence_id() != evidence.id()
268    {
269        return false;
270    }
271
272    hypothesis.value() == &EvidenceValue::from(boundary)
273        && hypothesis.id() == hypothesis_id_for_rule(rule_id, evidence.subject())
274}