1use sha2::{Digest, Sha256};
21use termivar_core::{
22 ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
23 KnowledgePredicate, ReasoningModelError,
24};
25
26use super::fingerprint::{DefenseProduct, FingerprintConfidence};
27use super::state::{DefensePosture, DefenseState};
28use super::transition::{DefenseTransition, DefenseTransitionKind};
29
30const DEFENSE_EVIDENCE_CATEGORY: &str = "defense";
32
33const PROJECTION_VERSION: u32 = 1;
38
39const EVIDENCE_ID_DOMAIN: &[u8] = b"venom.defense.projection-id.v1\0";
42
43const DIRECT_OBSERVATION_PERCENT: u8 = 90;
47
48#[derive(Debug, Clone, Copy)]
54pub enum ObservedOutcome<'state> {
55 Response(&'state DefenseState),
57 NoResponse,
59}
60
61#[derive(Debug, Clone)]
70pub struct DefenseObservationContext {
71 producer_id: String,
72 resource: EntityId,
73 correlation_id: String,
74 sequence: u64,
75 response_receipt: String,
76 observed_at_ms: u64,
77}
78
79impl DefenseObservationContext {
80 pub fn new(
84 producer_id: impl Into<String>,
85 resource: EntityId,
86 correlation_id: impl Into<String>,
87 sequence: u64,
88 response_receipt: impl Into<String>,
89 observed_at_ms: u64,
90 ) -> Result<Self, ReasoningModelError> {
91 Ok(Self {
92 producer_id: non_empty(producer_id, "defense producer id")?,
93 resource,
94 correlation_id: non_empty(correlation_id, "defense correlation id")?,
95 sequence,
96 response_receipt: non_empty(response_receipt, "defense response receipt")?,
97 observed_at_ms,
98 })
99 }
100
101 pub fn producer_id(&self) -> &str {
103 &self.producer_id
104 }
105
106 pub fn resource(&self) -> &EntityId {
108 &self.resource
109 }
110
111 pub fn correlation_id(&self) -> &str {
113 &self.correlation_id
114 }
115
116 pub const fn sequence(&self) -> u64 {
118 self.sequence
119 }
120
121 pub fn response_receipt(&self) -> &str {
123 &self.response_receipt
124 }
125
126 pub const fn observed_at_ms(&self) -> u64 {
128 self.observed_at_ms
129 }
130}
131
132pub fn project_outcome(
137 outcome: ObservedOutcome<'_>,
138 ctx: &DefenseObservationContext,
139) -> Vec<Evidence> {
140 match outcome {
141 ObservedOutcome::Response(state) => project_defense_state(state, ctx),
142 ObservedOutcome::NoResponse => Vec::new(),
143 }
144}
145
146pub fn project_defense_state(
153 state: &DefenseState,
154 ctx: &DefenseObservationContext,
155) -> Vec<Evidence> {
156 let mut evidence = Vec::new();
157
158 evidence.push(observation(
159 ctx,
160 "defense.posture",
161 posture_slug(state.posture()),
162 "defense-posture",
163 direct_reliability(),
164 ));
165
166 if state.status_signal().is_block() {
167 evidence.push(observation(
168 ctx,
169 "defense.status",
170 "blocked",
171 "defense-status",
172 direct_reliability(),
173 ));
174 }
175
176 if state.is_challenged() {
177 evidence.push(observation(
178 ctx,
179 "defense.challenge",
180 "present",
181 "defense-challenge",
182 direct_reliability(),
183 ));
184 }
185
186 if state.is_rate_limited() {
187 evidence.push(observation(
188 ctx,
189 "defense.rate_limit",
190 "observed",
191 "defense-rate-limit",
192 direct_reliability(),
193 ));
194 }
195
196 if let Some(print) = state.fingerprint() {
197 evidence.push(observation(
198 ctx,
199 "defense.fingerprint",
200 product_slug(print.product()),
201 "defense-fingerprint",
202 fingerprint_reliability(print.confidence()),
203 ));
204 }
205
206 evidence
207}
208
209pub fn project_defense_transition(
211 transition: &DefenseTransition,
212 ctx: &DefenseObservationContext,
213) -> Vec<Evidence> {
214 let mut evidence = vec![observation(
215 ctx,
216 "defense.transition",
217 transition_kind_slug(transition.kind()),
218 "defense-transition",
219 direct_reliability(),
220 )];
221
222 if transition.is_newly_blocking() {
223 evidence.push(observation(
224 ctx,
225 "defense.transition",
226 "newly_blocking",
227 "defense-transition",
228 direct_reliability(),
229 ));
230 }
231 if transition.is_newly_rate_limited() {
232 evidence.push(observation(
233 ctx,
234 "defense.transition",
235 "newly_rate_limited",
236 "defense-transition",
237 direct_reliability(),
238 ));
239 }
240 if transition.fingerprint_changed() {
241 evidence.push(observation(
242 ctx,
243 "defense.transition",
244 "fingerprint_changed",
245 "defense-transition",
246 direct_reliability(),
247 ));
248 }
249
250 evidence
251}
252
253fn observation(
254 ctx: &DefenseObservationContext,
255 namespace: &str,
256 name: &str,
257 method: &str,
258 reliability: ConfidenceScore,
259) -> Evidence {
260 let predicate = KnowledgePredicate::new(namespace, name)
261 .expect("defense predicate components are non-empty");
262 let source = EvidenceSource::new(ctx.producer_id.clone(), method)
263 .expect("defense producer id and method are non-empty")
264 .with_correlation_id(ctx.correlation_id.clone())
265 .expect("defense correlation id is non-empty");
266 let id = evidence_id(ctx, namespace, name);
267
268 Evidence::with_id_at(
269 id,
270 ctx.resource.clone(),
271 EvidenceKind::Custom(DEFENSE_EVIDENCE_CATEGORY.to_owned()),
272 predicate,
273 EvidenceValue::Boolean(true),
274 source,
275 reliability,
276 ctx.observed_at_ms,
277 )
278}
279
280fn evidence_id(ctx: &DefenseObservationContext, namespace: &str, name: &str) -> EvidenceId {
287 let mut hasher = Sha256::new();
288 hasher.update(EVIDENCE_ID_DOMAIN);
289 hasher.update(PROJECTION_VERSION.to_le_bytes());
290 for field in [
291 ctx.producer_id.as_str(),
292 ctx.resource.as_str(),
293 ctx.correlation_id.as_str(),
294 ctx.response_receipt.as_str(),
295 namespace,
296 name,
297 ] {
298 update_framed(&mut hasher, field.as_bytes());
299 }
300 hasher.update(ctx.sequence.to_le_bytes());
301 hasher.update(ctx.observed_at_ms.to_le_bytes());
302
303 EvidenceId::parse(format!(
304 "{DEFENSE_EVIDENCE_CATEGORY}/{}",
305 encode_hex(hasher.finalize().as_slice())
306 ))
307 .expect("defense evidence id is non-empty")
308}
309
310fn update_framed(hasher: &mut Sha256, bytes: &[u8]) {
313 hasher.update((bytes.len() as u64).to_le_bytes());
314 hasher.update(bytes);
315}
316
317fn encode_hex(bytes: &[u8]) -> String {
318 use std::fmt::Write as _;
319 let mut output = String::with_capacity(bytes.len() * 2);
320 for byte in bytes {
321 let _ = write!(output, "{byte:02x}");
322 }
323 output
324}
325
326fn direct_reliability() -> ConfidenceScore {
327 ConfidenceScore::from_percent(DIRECT_OBSERVATION_PERCENT)
328 .expect("direct observation reliability is in range")
329}
330
331fn fingerprint_reliability(confidence: FingerprintConfidence) -> ConfidenceScore {
332 let percent = match confidence {
333 FingerprintConfidence::Weak => 30,
334 FingerprintConfidence::Probable => 60,
335 FingerprintConfidence::Strong => 90,
336 };
337 ConfidenceScore::from_percent(percent).expect("fingerprint reliability is in range")
338}
339
340const fn posture_slug(posture: DefensePosture) -> &'static str {
341 match posture {
342 DefensePosture::Open => "open",
343 DefensePosture::Suspected => "suspected",
344 DefensePosture::Blocking => "blocking",
345 }
346}
347
348const fn transition_kind_slug(kind: DefenseTransitionKind) -> &'static str {
349 match kind {
350 DefenseTransitionKind::NoChange => "unchanged",
351 DefenseTransitionKind::DefenseEngaged => "engaged",
352 DefenseTransitionKind::DefenseRelaxed => "relaxed",
353 DefenseTransitionKind::DefenseReconfigured => "reconfigured",
354 }
355}
356
357const fn product_slug(product: DefenseProduct) -> &'static str {
358 match product {
359 DefenseProduct::Cloudflare => "cloudflare",
360 DefenseProduct::AwsWaf => "aws_waf",
361 DefenseProduct::ModSecurity => "mod_security",
362 DefenseProduct::Akamai => "akamai",
363 DefenseProduct::Imperva => "imperva",
364 DefenseProduct::F5BigIp => "f5_big_ip",
365 DefenseProduct::Barracuda => "barracuda",
366 DefenseProduct::Fortinet => "fortinet",
367 DefenseProduct::Sucuri => "sucuri",
368 DefenseProduct::Wordfence => "wordfence",
369 }
370}
371
372fn non_empty(value: impl Into<String>, field: &'static str) -> Result<String, ReasoningModelError> {
373 let value = value.into();
374 if value.trim().is_empty() {
375 return Err(ReasoningModelError::EmptyValue { field });
376 }
377 Ok(value)
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn resource() -> EntityId {
385 EntityId::new("endpoint:https://example.test/app").unwrap()
386 }
387
388 fn context(sequence: u64) -> DefenseObservationContext {
389 DefenseObservationContext::new(
390 "runtime.defense-projection",
391 resource(),
392 "case:web:1",
393 sequence,
394 "receipt:sha256:abc",
395 1_700_000_000_000,
396 )
397 .unwrap()
398 }
399
400 fn dotted(evidence: &[Evidence]) -> Vec<String> {
401 evidence
402 .iter()
403 .map(|item| item.predicate().dotted())
404 .collect()
405 }
406
407 #[test]
408 fn every_projected_record_carries_full_provenance() {
409 let state = DefenseState::observe(403, &[("CF-RAY", "abc")], "access denied");
410 let ctx = context(7);
411 let evidence = project_defense_state(&state, &ctx);
412
413 assert!(!evidence.is_empty());
414 for item in &evidence {
415 assert_eq!(item.source().component(), "runtime.defense-projection");
416 assert_eq!(item.subject(), &resource());
417 assert_eq!(item.source().correlation_id(), Some("case:web:1"));
418 assert_eq!(item.observed_at_ms(), 1_700_000_000_000);
419 assert_eq!(item.kind(), &EvidenceKind::Custom("defense".to_owned()));
420 let digest = item
423 .id()
424 .as_str()
425 .strip_prefix("defense/")
426 .expect("defense-prefixed id");
427 assert_eq!(digest.len(), 64);
428 assert!(digest
429 .chars()
430 .all(|character| character.is_ascii_hexdigit()));
431 }
432 }
433
434 #[test]
435 fn evidence_id_does_not_contain_raw_resource_or_receipt() {
436 let ctx = DefenseObservationContext::new(
437 "runtime.defense-projection",
438 EntityId::new("endpoint:https://secret.example.test/private/path?token=abc").unwrap(),
439 "case:web:secret-correlation",
440 9,
441 "receipt:opaque:secret-receipt-handle",
442 1_700_000_000_000,
443 )
444 .unwrap();
445 let state = DefenseState::observe(403, &[("CF-RAY", "x")], "access denied");
446
447 for item in project_defense_state(&state, &ctx) {
448 let id = item.id().as_str();
449 for leaked in [
450 "secret.example.test",
451 "private/path",
452 "token=abc",
453 "secret-receipt-handle",
454 "secret-correlation",
455 "runtime.defense-projection",
456 ] {
457 assert!(
458 !id.contains(leaked),
459 "raw provenance {leaked} leaked into id {id}"
460 );
461 }
462 assert_eq!(
464 item.source().correlation_id(),
465 Some("case:web:secret-correlation")
466 );
467 assert_eq!(
468 item.subject().as_str(),
469 "endpoint:https://secret.example.test/private/path?token=abc"
470 );
471 }
472 }
473
474 #[test]
475 fn same_canonical_context_produces_same_evidence_id() {
476 let state = DefenseState::observe(403, &[("CF-RAY", "x")], "access denied");
477 let ids = |seq| {
478 project_defense_state(&state, &context(seq))
479 .iter()
480 .map(|item| item.id().as_str().to_owned())
481 .collect::<Vec<_>>()
482 };
483
484 assert_eq!(ids(4), ids(4));
486 let base = ids(4);
488 let bumped = ids(5);
489 assert_eq!(base.len(), bumped.len());
490 assert!(base.iter().zip(bumped.iter()).all(|(a, b)| a != b));
491 }
492
493 #[test]
494 fn a_fingerprinted_block_projects_status_posture_and_product() {
495 let state = DefenseState::observe(
496 403,
497 &[("Server", "cloudflare"), ("CF-RAY", "x")],
498 "Attention Required!",
499 );
500 let names = dotted(&project_defense_state(&state, &context(1)));
501 assert!(names.contains(&"defense.posture.blocking".to_owned()));
502 assert!(names.contains(&"defense.status.blocked".to_owned()));
503 assert!(names.contains(&"defense.challenge.present".to_owned()));
504 assert!(names.contains(&"defense.fingerprint.cloudflare".to_owned()));
505 }
506
507 #[test]
508 fn single_403_does_not_create_a_confirmed_waf_fact() {
509 let state = DefenseState::observe(403, &[], "forbidden");
512 let evidence = project_defense_state(&state, &context(1));
513 let names = dotted(&evidence);
514
515 assert!(names.contains(&"defense.status.blocked".to_owned()));
516 assert!(
517 !evidence
518 .iter()
519 .any(|item| item.predicate().namespace() == "defense.fingerprint"),
520 "a bare 403 must not fingerprint a product"
521 );
522 assert!(evidence
525 .iter()
526 .all(|item| item.reliability() < ConfidenceScore::MAX));
527 }
528
529 #[test]
530 fn timeouts_and_connection_failures_are_not_learned_as_defense() {
531 assert!(project_outcome(ObservedOutcome::NoResponse, &context(1)).is_empty());
533 }
534
535 #[test]
536 fn an_open_response_still_records_a_posture_observation() {
537 let state = DefenseState::observe(200, &[("Server", "nginx")], "ok");
538 let names = dotted(&project_defense_state(&state, &context(1)));
539 assert_eq!(names, vec!["defense.posture.open".to_owned()]);
540 }
541
542 #[test]
543 fn a_rate_limit_projects_observed_without_a_block() {
544 let state = DefenseState::observe(429, &[], "slow down");
545 let names = dotted(&project_defense_state(&state, &context(1)));
546 assert!(names.contains(&"defense.rate_limit.observed".to_owned()));
547 assert!(!names.contains(&"defense.status.blocked".to_owned()));
548 }
549
550 #[test]
551 fn fingerprint_confidence_is_carried_as_reliability() {
552 let state = DefenseState::observe(200, &[("x-amzn-requestid", "id")], "ok");
554 let evidence = project_defense_state(&state, &context(1));
555 let fingerprint = evidence
556 .iter()
557 .find(|item| item.predicate().namespace() == "defense.fingerprint")
558 .expect("aws fingerprint present");
559 assert_eq!(
560 fingerprint.reliability(),
561 ConfidenceScore::from_percent(30).unwrap()
562 );
563 }
564
565 #[test]
566 fn projection_is_deterministic_and_idempotent() {
567 let state = DefenseState::observe(403, &[("CF-RAY", "x")], "access denied");
568 let ctx = context(3);
569 assert_eq!(
570 project_defense_state(&state, &ctx),
571 project_defense_state(&state, &ctx)
572 );
573 }
574
575 #[test]
576 fn transition_projects_typed_kind_and_flags() {
577 let control = DefenseState::observe(200, &[], "ok");
578 let candidate = DefenseState::observe(403, &[("CF-RAY", "x")], "access denied");
579 let transition = DefenseTransition::between(&control, &candidate);
580 let names = dotted(&project_defense_transition(&transition, &context(2)));
581 assert!(names.contains(&"defense.transition.engaged".to_owned()));
582 assert!(names.contains(&"defense.transition.newly_blocking".to_owned()));
583 }
584
585 #[test]
586 fn context_rejects_empty_provenance() {
587 assert!(DefenseObservationContext::new("", resource(), "case:1", 1, "receipt", 0).is_err());
588 }
589}