1use std::fmt;
9
10use thiserror::Error;
11
12pub const TEMPLATE_EVALUATION_REVIEW_AUDIT_SCHEMA: &str =
14 "security.template-evaluation-review-audit/v1";
15pub const TEMPLATE_EVALUATION_REVIEW_POLICY_ID: &str = "termivar.template-evaluation-review/v1";
17pub const TEMPLATE_EVALUATION_REVIEW_FAMILY_ID: &str =
19 "web.review.template-evaluation.family.jinja-compatible-benign-expression@1";
20pub const TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID: &str =
22 "web.review.template-evaluation.jinja-compatible-semantics@1";
23pub(crate) const TEMPLATE_EVALUATION_REVIEW_TITLE: &str =
25 "Candidate-specific behavior consistent with Jinja-compatible expression semantics";
26pub(crate) const TEMPLATE_EVALUATION_REVIEW_CATEGORY: &str = "Template expression evaluation";
28pub(crate) const TEMPLATE_EVALUATION_REVIEW_SUMMARY: &str = "Two bounded candidate/replay cases produced candidate-specific benign expression semantics while controls remained negative; template engine identity, operating-system execution, file access, outbound interaction, and impact remain unestablished.";
30pub(crate) const TEMPLATE_EVALUATION_REVIEW_REMEDIATION_ID: &str =
32 "web.remediation.template-evaluation-review@1";
33pub(crate) const TEMPLATE_EVALUATION_REVIEW_REMEDIATION_SUMMARY: &str = "Review server-side template construction and bind untrusted values as data rather than compiling them as template source.";
35
36pub const TEMPLATE_EVALUATION_ENGINE_IDENTITY: &str = "not_established";
38pub const TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED: &str = "not_performed";
40pub const TEMPLATE_EVALUATION_SELECTED_CASES: u8 = 2;
42pub const TEMPLATE_EVALUATION_MAX_REQUESTS: u8 = 4;
44pub const TEMPLATE_EVALUATION_MAX_ACTIVE_REQUESTS: u8 = 2;
46
47pub const TEMPLATE_EVALUATION_REVIEW_CLAIM_LIMITS: [&str; 7] = [
49 "engine_identity_not_established",
50 "candidate_specific_evaluation_does_not_establish_template_engine_identity",
51 "outbound_interaction_not_performed",
52 "os_execution_not_performed",
53 "file_access_not_performed",
54 "impact_validation_not_performed",
55 "vulnerability_confirmation_not_established",
56];
57
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum TemplateEvaluationReviewOutcome {
61 ParentNotObserved,
63 LiteralOrEscaped,
65 UnsupportedRepresentation,
67 ReplayMismatch,
69 CandidateSpecificEvaluation,
71 Incomplete,
73}
74
75impl TemplateEvaluationReviewOutcome {
76 pub const fn as_str(self) -> &'static str {
78 match self {
79 Self::ParentNotObserved => "parent_not_observed",
80 Self::LiteralOrEscaped => "literal_or_escaped",
81 Self::UnsupportedRepresentation => "unsupported_representation",
82 Self::ReplayMismatch => "replay_mismatch",
83 Self::CandidateSpecificEvaluation => "candidate_specific_evaluation",
84 Self::Incomplete => "incomplete",
85 }
86 }
87}
88
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub(crate) struct TemplateEvaluationReviewAuditFacts {
92 pub(crate) outcome: TemplateEvaluationReviewOutcome,
93 pub(crate) selected_case_count: u8,
94 pub(crate) attempted_request_count: u8,
95 pub(crate) active_request_count: u8,
96 pub(crate) completed_response_count: u8,
97 pub(crate) committed_response_count: u8,
98 pub(crate) projected_item_count: u8,
99}
100
101#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
103pub(crate) enum TemplateEvaluationReviewAuditError {
104 #[error("template-evaluation review count exceeds its compiled limit")]
106 CountLimitExceeded,
107 #[error("template-evaluation review counts are inconsistent")]
109 CountOrderingMismatch,
110 #[error("template-evaluation review outcome is inconsistent with its counts")]
112 OutcomeMismatch,
113}
114
115#[derive(Clone, Eq, PartialEq)]
117pub struct TemplateEvaluationReviewAudit {
118 outcome: TemplateEvaluationReviewOutcome,
119 selected_case_count: u8,
120 attempted_request_count: u8,
121 active_request_count: u8,
122 completed_response_count: u8,
123 committed_response_count: u8,
124 projected_item_count: u8,
125}
126
127impl TemplateEvaluationReviewAudit {
128 pub(crate) fn from_runtime(
131 facts: TemplateEvaluationReviewAuditFacts,
132 ) -> Result<Self, TemplateEvaluationReviewAuditError> {
133 validate_facts(facts)?;
134 Ok(Self {
135 outcome: facts.outcome,
136 selected_case_count: facts.selected_case_count,
137 attempted_request_count: facts.attempted_request_count,
138 active_request_count: facts.active_request_count,
139 completed_response_count: facts.completed_response_count,
140 committed_response_count: facts.committed_response_count,
141 projected_item_count: facts.projected_item_count,
142 })
143 }
144
145 pub const fn schema(&self) -> &'static str {
147 TEMPLATE_EVALUATION_REVIEW_AUDIT_SCHEMA
148 }
149
150 pub const fn policy_id(&self) -> &'static str {
152 TEMPLATE_EVALUATION_REVIEW_POLICY_ID
153 }
154
155 pub const fn family_id(&self) -> &'static str {
157 TEMPLATE_EVALUATION_REVIEW_FAMILY_ID
158 }
159
160 pub const fn capability_id(&self) -> &'static str {
162 TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID
163 }
164
165 pub const fn outcome(&self) -> TemplateEvaluationReviewOutcome {
167 self.outcome
168 }
169
170 pub const fn selected_case_count(&self) -> u8 {
172 self.selected_case_count
173 }
174
175 pub const fn attempted_request_count(&self) -> u8 {
177 self.attempted_request_count
178 }
179
180 pub const fn active_request_count(&self) -> u8 {
182 self.active_request_count
183 }
184
185 pub const fn completed_response_count(&self) -> u8 {
187 self.completed_response_count
188 }
189
190 pub const fn committed_response_count(&self) -> u8 {
192 self.committed_response_count
193 }
194
195 pub const fn projected_item_count(&self) -> u8 {
197 self.projected_item_count
198 }
199
200 pub const fn engine_identity(&self) -> &'static str {
202 TEMPLATE_EVALUATION_ENGINE_IDENTITY
203 }
204
205 pub const fn outbound_interaction(&self) -> &'static str {
207 TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED
208 }
209
210 pub const fn os_execution(&self) -> &'static str {
212 TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED
213 }
214
215 pub const fn file_access(&self) -> &'static str {
217 TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED
218 }
219
220 pub const fn impact_validation(&self) -> &'static str {
222 TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED
223 }
224
225 pub const fn claim_limits(&self) -> &'static [&'static str; 7] {
227 &TEMPLATE_EVALUATION_REVIEW_CLAIM_LIMITS
228 }
229}
230
231impl fmt::Debug for TemplateEvaluationReviewAudit {
232 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
233 formatter
234 .debug_struct("TemplateEvaluationReviewAudit")
235 .field("schema", &self.schema())
236 .field("policy_id", &self.policy_id())
237 .field("family_id", &self.family_id())
238 .field("outcome", &self.outcome)
239 .field("selected_case_count", &self.selected_case_count)
240 .field("attempted_request_count", &self.attempted_request_count)
241 .field("active_request_count", &self.active_request_count)
242 .field("completed_response_count", &self.completed_response_count)
243 .field("committed_response_count", &self.committed_response_count)
244 .field("projected_item_count", &self.projected_item_count)
245 .field("engine_identity", &self.engine_identity())
246 .field("operations", &TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED)
247 .finish()
248 }
249}
250
251fn validate_facts(
252 facts: TemplateEvaluationReviewAuditFacts,
253) -> Result<(), TemplateEvaluationReviewAuditError> {
254 if !matches!(
255 facts.selected_case_count,
256 0 | TEMPLATE_EVALUATION_SELECTED_CASES
257 ) || facts.attempted_request_count > TEMPLATE_EVALUATION_MAX_REQUESTS
258 || facts.active_request_count > TEMPLATE_EVALUATION_MAX_ACTIVE_REQUESTS
259 || facts.completed_response_count > TEMPLATE_EVALUATION_MAX_REQUESTS
260 || facts.committed_response_count > TEMPLATE_EVALUATION_MAX_REQUESTS
261 || facts.projected_item_count > 1
262 {
263 return Err(TemplateEvaluationReviewAuditError::CountLimitExceeded);
264 }
265 if facts.completed_response_count > facts.attempted_request_count
266 || facts.committed_response_count > facts.completed_response_count
267 || facts.active_request_count > facts.attempted_request_count
268 {
269 return Err(TemplateEvaluationReviewAuditError::CountOrderingMismatch);
270 }
271
272 let valid_outcome = match facts.outcome {
273 TemplateEvaluationReviewOutcome::ParentNotObserved => {
274 facts.selected_case_count == 0
275 && facts.attempted_request_count == 0
276 && facts.active_request_count == 0
277 && facts.completed_response_count == 0
278 && facts.committed_response_count == 0
279 && facts.projected_item_count == 0
280 },
281 TemplateEvaluationReviewOutcome::CandidateSpecificEvaluation => {
282 facts.selected_case_count == TEMPLATE_EVALUATION_SELECTED_CASES
283 && facts.attempted_request_count == TEMPLATE_EVALUATION_MAX_REQUESTS
284 && facts.active_request_count == TEMPLATE_EVALUATION_MAX_ACTIVE_REQUESTS
285 && facts.completed_response_count == TEMPLATE_EVALUATION_MAX_REQUESTS
286 && facts.committed_response_count == TEMPLATE_EVALUATION_MAX_REQUESTS
287 && facts.projected_item_count == 1
288 },
289 TemplateEvaluationReviewOutcome::LiteralOrEscaped
290 | TemplateEvaluationReviewOutcome::UnsupportedRepresentation
291 | TemplateEvaluationReviewOutcome::ReplayMismatch => {
292 facts.selected_case_count == TEMPLATE_EVALUATION_SELECTED_CASES
293 && facts.attempted_request_count == TEMPLATE_EVALUATION_MAX_REQUESTS
294 && facts.active_request_count == TEMPLATE_EVALUATION_MAX_ACTIVE_REQUESTS
295 && facts.completed_response_count == TEMPLATE_EVALUATION_MAX_REQUESTS
296 && facts.committed_response_count == TEMPLATE_EVALUATION_MAX_REQUESTS
297 && facts.projected_item_count == 0
298 },
299 TemplateEvaluationReviewOutcome::Incomplete => {
300 facts.selected_case_count == TEMPLATE_EVALUATION_SELECTED_CASES
301 && facts.projected_item_count == 0
302 },
303 };
304 if !valid_outcome {
305 return Err(TemplateEvaluationReviewAuditError::OutcomeMismatch);
306 }
307 Ok(())
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 fn facts(outcome: TemplateEvaluationReviewOutcome) -> TemplateEvaluationReviewAuditFacts {
315 TemplateEvaluationReviewAuditFacts {
316 outcome,
317 selected_case_count: TEMPLATE_EVALUATION_SELECTED_CASES,
318 attempted_request_count: TEMPLATE_EVALUATION_MAX_REQUESTS,
319 active_request_count: TEMPLATE_EVALUATION_MAX_ACTIVE_REQUESTS,
320 completed_response_count: TEMPLATE_EVALUATION_MAX_REQUESTS,
321 committed_response_count: TEMPLATE_EVALUATION_MAX_REQUESTS,
322 projected_item_count: 0,
323 }
324 }
325
326 #[test]
327 fn selected_empty_and_positive_states_are_exact() {
328 let empty =
329 TemplateEvaluationReviewAudit::from_runtime(TemplateEvaluationReviewAuditFacts {
330 outcome: TemplateEvaluationReviewOutcome::ParentNotObserved,
331 selected_case_count: 0,
332 attempted_request_count: 0,
333 active_request_count: 0,
334 completed_response_count: 0,
335 committed_response_count: 0,
336 projected_item_count: 0,
337 })
338 .unwrap();
339 assert_eq!(empty.outcome().as_str(), "parent_not_observed");
340 assert_eq!(empty.selected_case_count(), 0);
341
342 let mut positive = facts(TemplateEvaluationReviewOutcome::CandidateSpecificEvaluation);
343 positive.projected_item_count = 1;
344 let positive = TemplateEvaluationReviewAudit::from_runtime(positive).unwrap();
345 assert_eq!(positive.projected_item_count(), 1);
346 assert_eq!(positive.engine_identity(), "not_established");
347 assert_eq!(positive.claim_limits().len(), 7);
348 }
349
350 #[test]
351 fn all_nonpositive_outcomes_remain_item_free() {
352 for outcome in [
353 TemplateEvaluationReviewOutcome::LiteralOrEscaped,
354 TemplateEvaluationReviewOutcome::UnsupportedRepresentation,
355 TemplateEvaluationReviewOutcome::ReplayMismatch,
356 TemplateEvaluationReviewOutcome::Incomplete,
357 ] {
358 let audit = TemplateEvaluationReviewAudit::from_runtime(facts(outcome)).unwrap();
359 assert_eq!(audit.projected_item_count(), 0);
360 assert_eq!(audit.outbound_interaction(), "not_performed");
361 assert_eq!(audit.os_execution(), "not_performed");
362 assert_eq!(audit.file_access(), "not_performed");
363 assert_eq!(audit.impact_validation(), "not_performed");
364 }
365
366 let partial =
367 TemplateEvaluationReviewAudit::from_runtime(TemplateEvaluationReviewAuditFacts {
368 outcome: TemplateEvaluationReviewOutcome::Incomplete,
369 selected_case_count: TEMPLATE_EVALUATION_SELECTED_CASES,
370 attempted_request_count: 3,
371 active_request_count: 1,
372 completed_response_count: 2,
373 committed_response_count: 2,
374 projected_item_count: 0,
375 })
376 .unwrap();
377 assert_eq!(
378 partial.outcome(),
379 TemplateEvaluationReviewOutcome::Incomplete
380 );
381 }
382
383 #[test]
384 fn invalid_limits_ordering_and_outcome_shapes_fail_closed() {
385 let mut too_many = facts(TemplateEvaluationReviewOutcome::Incomplete);
386 too_many.attempted_request_count = 5;
387 assert_eq!(
388 TemplateEvaluationReviewAudit::from_runtime(too_many),
389 Err(TemplateEvaluationReviewAuditError::CountLimitExceeded)
390 );
391
392 let mut out_of_order = facts(TemplateEvaluationReviewOutcome::Incomplete);
393 out_of_order.completed_response_count = 3;
394 out_of_order.committed_response_count = 4;
395 assert_eq!(
396 TemplateEvaluationReviewAudit::from_runtime(out_of_order),
397 Err(TemplateEvaluationReviewAuditError::CountOrderingMismatch)
398 );
399
400 let mut false_positive =
401 facts(TemplateEvaluationReviewOutcome::CandidateSpecificEvaluation);
402 false_positive.projected_item_count = 0;
403 assert_eq!(
404 TemplateEvaluationReviewAudit::from_runtime(false_positive),
405 Err(TemplateEvaluationReviewAuditError::OutcomeMismatch)
406 );
407
408 let mut projected_negative = facts(TemplateEvaluationReviewOutcome::ReplayMismatch);
409 projected_negative.projected_item_count = 1;
410 assert_eq!(
411 TemplateEvaluationReviewAudit::from_runtime(projected_negative),
412 Err(TemplateEvaluationReviewAuditError::OutcomeMismatch)
413 );
414
415 for outcome in [
416 TemplateEvaluationReviewOutcome::LiteralOrEscaped,
417 TemplateEvaluationReviewOutcome::UnsupportedRepresentation,
418 TemplateEvaluationReviewOutcome::ReplayMismatch,
419 ] {
420 let mut incomplete_terminal = facts(outcome);
421 incomplete_terminal.attempted_request_count = 0;
422 incomplete_terminal.active_request_count = 0;
423 incomplete_terminal.completed_response_count = 0;
424 incomplete_terminal.committed_response_count = 0;
425 assert_eq!(
426 TemplateEvaluationReviewAudit::from_runtime(incomplete_terminal),
427 Err(TemplateEvaluationReviewAuditError::OutcomeMismatch)
428 );
429 }
430 }
431}