Skip to main content

termivar_scanner/defense/
projection.rs

1//! Projection of observed defense state into provenance-carrying evidence.
2//!
3//! This adapter reuses the existing observation contracts — [`DefenseState`],
4//! [`DefenseTransition`], and the fingerprint types — and projects them into
5//! immutable [`termivar_core::Evidence`] records that a knowledge store can retain
6//! with full provenance. It is deliberately projection-only:
7//!
8//! - it produces **evidence** (observations), never a [`termivar_core::Fact`] or
9//!   hypothesis, so a single block never becomes a "confirmed WAF" claim;
10//! - it derives from an actual response only — a timeout or connection failure
11//!   has no [`DefenseState`], so it is never learned as a defense signal;
12//! - it selects no payload, issues no request, and reads no clock or randomness:
13//!   identity and timestamp come from the caller's context, so the projection is
14//!   a pure, deterministic, idempotent function of its inputs.
15//!
16//! Callers ingest the result through the existing
17//! `KnowledgeBase::insert_evidence_batch`; this module does not touch the store,
18//! the planner, the executor, or any runtime configuration.
19
20use 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
30/// Broad category recorded for every projected defense observation.
31const DEFENSE_EVIDENCE_CATEGORY: &str = "defense";
32
33/// Canonical projection-identity version folded into every evidence id.
34///
35/// Bumping this deliberately changes every derived id, so a replay never
36/// confuses two projection schemes.
37const PROJECTION_VERSION: u32 = 1;
38
39/// Domain separator for the evidence-id digest, so a defense id can never
40/// collide with a digest computed for another purpose.
41const EVIDENCE_ID_DOMAIN: &[u8] = b"venom.defense.projection-id.v1\0";
42
43/// Reliability of a direct, unambiguous response observation (status, challenge,
44/// rate limit, posture, transition). This is the reliability of the observation
45/// itself, not a claim that a WAF exists.
46const DIRECT_OBSERVATION_PERCENT: u8 = 90;
47
48/// The outcome of one execution turn, from the projection's point of view.
49///
50/// Only an actual response carries a [`DefenseState`]. Transport failures,
51/// timeouts, and connection errors are [`ObservedOutcome::NoResponse`] and yield
52/// no defense evidence, so they can never be learned as a defensive signal.
53#[derive(Debug, Clone, Copy)]
54pub enum ObservedOutcome<'state> {
55    /// A response was received and observed into a defense state.
56    Response(&'state DefenseState),
57    /// No response was received (timeout, connection failure, not applicable).
58    NoResponse,
59}
60
61/// Immutable provenance a projected defense observation is stamped with.
62///
63/// Every emitted evidence record carries the producer, the resource it concerns,
64/// and the action/case correlation in their dedicated fields. The observation
65/// sequence and supporting response receipt bind the record through the evidence
66/// id, which is a versioned SHA-256 digest of the canonical identity — the raw
67/// values never appear in the id itself. Supplying the timestamp keeps the
68/// projection deterministic and idempotent.
69#[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    /// Creates a validated observation context.
81    ///
82    /// `producer_id`, `correlation_id`, and `response_receipt` must be non-empty.
83    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    /// Returns the producing component identity.
102    pub fn producer_id(&self) -> &str {
103        &self.producer_id
104    }
105
106    /// Returns the resource this observation concerns.
107    pub fn resource(&self) -> &EntityId {
108        &self.resource
109    }
110
111    /// Returns the action/case correlation identity.
112    pub fn correlation_id(&self) -> &str {
113        &self.correlation_id
114    }
115
116    /// Returns the monotonic observation sequence.
117    pub const fn sequence(&self) -> u64 {
118        self.sequence
119    }
120
121    /// Returns the supporting response receipt reference.
122    pub fn response_receipt(&self) -> &str {
123        &self.response_receipt
124    }
125
126    /// Returns the observation timestamp in Unix milliseconds.
127    pub const fn observed_at_ms(&self) -> u64 {
128        self.observed_at_ms
129    }
130}
131
132/// Projects one execution outcome into defense evidence.
133///
134/// A [`ObservedOutcome::NoResponse`] yields an empty vector: a non-response is
135/// never learned as a defensive signal.
136pub 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
146/// Projects one observed defense state into provenance-carrying evidence.
147///
148/// Always records the posture. Records specific signals (block, challenge, rate
149/// limit) only when observed, and a product fingerprint only when one matched —
150/// so a bare block never yields a product claim. Every record is an observation;
151/// none is a confirmed fact.
152pub 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
209/// Projects one control-to-candidate transition into defense evidence.
210pub 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
280/// Derives the deterministic, provenance-free evidence id.
281///
282/// The id is `defense/<sha256>` over a domain-separated, length-framed canonical
283/// identity. Raw provenance (resource, correlation, producer, receipt) is kept
284/// only in the evidence's dedicated fields and never leaks into the id, which is
285/// printed in reports, JSON, and logs.
286fn 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
310/// Length-prefixes `bytes` before hashing so distinct field boundaries can never
311/// alias (e.g. `("ab", "c")` and `("a", "bc")` hash differently).
312fn 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            // Raw provenance stays in the dedicated fields; the id is a digest of
421            // the form `defense/<64-hex>` with no raw components.
422            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            // The raw values remain available in their provenance fields.
463            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        // Identical canonical inputs yield identical ids.
485        assert_eq!(ids(4), ids(4));
486        // A different observation sequence changes every derived id.
487        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        // A bare 403 with no product tells: no fingerprint is claimed, and the
510        // projection only ever produces observations (Evidence), never a Fact.
511        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        // No record asserts a WAF exists; every record is a status/posture
523        // observation, and the block reliability is not maximal certainty.
524        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        // A non-response has no defense state, so nothing is projected.
532        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        // A weak Amazon request-id signal keeps a low reliability.
553        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}