termivar_scanner/api_observation/
review.rs1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48#[non_exhaustive]
49pub enum ApiVisibilityReviewDisposition {
50 NoDifferenceObserved,
52 UnresolvedDifference,
54 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 pub fn resource_scope(&self) -> &EntityId {
74 &self.resource_scope
75 }
76
77 pub fn comparison_subject(&self) -> &EntityId {
79 &self.comparison_subject
80 }
81
82 pub fn relation_id(&self) -> &RelationId {
84 &self.relation_id
85 }
86
87 pub fn evidence(&self) -> &Evidence {
89 &self.evidence
90 }
91
92 pub fn boundary_hypotheses(&self) -> &[Hypothesis] {
94 &self.boundary_hypotheses
95 }
96
97 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}