Skip to main content

termivar_scanner/defense/
enforcement.rs

1//! Default-off enforcement of defense-aware planning.
2//!
3//! This is the only place defense evidence is allowed to change the *real* plan,
4//! and only when explicitly enabled. It reuses the side-effect-free shadow layer
5//! to decide what to suppress, then filters the already-authorized baseline
6//! through the distinct [`crate::planner::ExclusionReason::DefenseSuppressed`]
7//! path — never by replanning, selecting an action, raising utility, or reaching
8//! into any store.
9//!
10//! Release discipline: [`DefensePlanningPolicy`] is off by default. While
11//! disabled, this returns the exact plan the planner would produce with no
12//! defense influence, byte for byte.
13
14use termivar_core::EntityId;
15
16use crate::knowledge::KnowledgeSnapshot;
17use crate::planner::{AttackAction, AttackPlan, AttackPlanner, PlannerError, PlanningContext};
18
19use super::shadow_planning::{
20    defense_aware_shadow_plan, DefenseInteractionClass, ResourceDefenseSignal,
21};
22
23/// Whether observed defense is allowed to change the real plan.
24///
25/// Off by default. Enabling it is an explicit, per-release decision.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub struct DefensePlanningPolicy {
28    enabled: bool,
29}
30
31impl DefensePlanningPolicy {
32    /// The default policy: defense observations never change the plan.
33    pub const fn disabled() -> Self {
34        Self { enabled: false }
35    }
36
37    /// A policy that lets defense observations suppress candidates.
38    pub const fn enabled() -> Self {
39        Self { enabled: true }
40    }
41
42    /// Builds a policy from an explicit flag value.
43    pub const fn from_enabled(enabled: bool) -> Self {
44        Self { enabled }
45    }
46
47    /// Returns whether enforcement is enabled.
48    pub const fn is_enabled(self) -> bool {
49        self.enabled
50    }
51}
52
53/// Produces the real plan, applying defense suppression only when enabled.
54///
55/// While the policy is disabled the result is byte-for-byte the plan
56/// [`AttackPlanner::plan_snapshot`] produces, with no defense influence. When
57/// enabled, actions the resource-scoped defense signal suppresses are excluded
58/// with [`crate::planner::ExclusionReason::DefenseSuppressed`] and never become
59/// plan steps, so a defense-denied action never reaches an executor. Defense
60/// never adds an action, raises utility, or reorders otherwise-eligible steps —
61/// it can only remove suppressed candidates.
62pub fn defense_aware_plan(
63    policy: DefensePlanningPolicy,
64    planner: &AttackPlanner,
65    snapshot: &KnowledgeSnapshot,
66    subject: &EntityId,
67    context: PlanningContext,
68    signal: &ResourceDefenseSignal,
69    classify: impl Fn(&AttackAction) -> DefenseInteractionClass,
70) -> Result<AttackPlan, PlannerError> {
71    if !policy.enabled {
72        return planner.plan_snapshot(snapshot, context);
73    }
74
75    // Reuse the shadow layer so enforcement and explanation share one policy
76    // and one already-authorized baseline. Returning its filtered subsequence
77    // cannot admit an action that baseline budget excluded.
78    let shadow = defense_aware_shadow_plan(planner, snapshot, subject, context, signal, classify)?;
79    Ok(shadow.shadow().clone())
80}
81
82#[cfg(test)]
83mod tests {
84    use std::collections::BTreeSet;
85
86    use termivar_core::{
87        BayesianEvidence, ConfidenceScore, Evidence, EvidenceKind, EvidenceSource, EvidenceValue,
88        Hypothesis, HypothesisState, HypothesisStrength, KnowledgePredicate, Probability,
89    };
90
91    use super::*;
92    use crate::defense::shadow_planning::ResourceDefenseObservation;
93    use crate::defense::state::DefenseState;
94    use crate::knowledge::KnowledgeBase;
95    use crate::planner::{
96        ActionCost, BenefitScore, ExclusionReason, HypothesisSelector, RequiredStrength, RiskScore,
97    };
98    use crate::{Expression, KnowledgeLayer};
99
100    fn subject() -> EntityId {
101        EntityId::new("endpoint:https://example.test/api/admin").unwrap()
102    }
103
104    fn other_subject() -> EntityId {
105        EntityId::new("endpoint:https://example.test/public").unwrap()
106    }
107
108    fn stack_predicate() -> KnowledgePredicate {
109        KnowledgePredicate::new("stack", "framework").unwrap()
110    }
111
112    fn stack_value() -> EvidenceValue {
113        EvidenceValue::Text("Laravel".into())
114    }
115
116    fn knowledge_with_hypothesis() -> KnowledgeBase {
117        let knowledge = KnowledgeBase::new();
118        let evidence = Evidence::new(
119            subject(),
120            EvidenceKind::Technology,
121            KnowledgePredicate::new("technology", "framework").unwrap(),
122            stack_value(),
123            EvidenceSource::new("discovery", "framework-header").unwrap(),
124            ConfidenceScore::from_percent(90).unwrap(),
125        );
126        knowledge.insert_evidence(evidence.clone()).unwrap();
127        let mut hypothesis = Hypothesis::with_id(
128            "hypothesis:laravel",
129            subject(),
130            stack_predicate(),
131            stack_value(),
132            Probability::from_percent(50).unwrap(),
133        )
134        .unwrap();
135        hypothesis
136            .observe(
137                BayesianEvidence::new(
138                    evidence.id().clone(),
139                    Probability::from_percent(80).unwrap(),
140                    Probability::from_percent(20).unwrap(),
141                    "framework fingerprint",
142                )
143                .unwrap(),
144            )
145            .unwrap();
146        hypothesis.set_strength(HypothesisStrength::Strong);
147        hypothesis.set_state(HypothesisState::Supported);
148        knowledge.upsert_hypothesis(hypothesis).unwrap();
149        knowledge
150    }
151
152    fn action(id: &str, gain: u8) -> AttackAction {
153        AttackAction::new(
154            id,
155            format!("plugin.{id}"),
156            Expression::equals(KnowledgeLayer::Hypothesis, stack_predicate(), stack_value()),
157            HypothesisSelector::new(
158                stack_predicate(),
159                stack_value(),
160                Probability::from_percent(50).unwrap(),
161                RequiredStrength::Strong,
162            ),
163            BenefitScore::from_percent(gain).unwrap(),
164            ActionCost::new(10).unwrap(),
165            RiskScore::from_percent(20).unwrap(),
166            BTreeSet::new(),
167        )
168        .unwrap()
169    }
170
171    fn classify(action: &AttackAction) -> DefenseInteractionClass {
172        match action.id() {
173            "local.report" => DefenseInteractionClass::LocalOnly,
174            "passive.discovery" => DefenseInteractionClass::Passive,
175            "active.verify" => DefenseInteractionClass::ActiveVerification,
176            "mutating.fuzz" => DefenseInteractionClass::Mutating,
177            other => panic!("unclassified test action {other}"),
178        }
179    }
180
181    fn planner() -> AttackPlanner {
182        let mut planner = AttackPlanner::new();
183        planner.register(action("local.report", 60)).unwrap();
184        planner.register(action("passive.discovery", 58)).unwrap();
185        planner.register(action("active.verify", 54)).unwrap();
186        planner.register(action("mutating.fuzz", 52)).unwrap();
187        planner
188    }
189
190    fn context() -> PlanningContext {
191        PlanningContext::new(
192            BenefitScore::from_percent(90).unwrap(),
193            1_000,
194            RiskScore::from_percent(80).unwrap(),
195        )
196    }
197
198    fn backoff_signal(resource: EntityId) -> ResourceDefenseSignal {
199        let state = DefenseState::observe(429, &[], "slow down");
200        let evidence = vec![termivar_core::EvidenceId::parse("defense/e1").unwrap()];
201        ResourceDefenseSignal::aggregate(
202            resource,
203            &[ResourceDefenseObservation::new(&state, None, evidence)],
204        )
205    }
206
207    fn snapshot() -> KnowledgeSnapshot {
208        // One knowledge base per test so plans built from it share evidence ids.
209        knowledge_with_hypothesis().snapshot_for_subject(&subject())
210    }
211
212    fn plan_with(
213        snapshot: &KnowledgeSnapshot,
214        policy: DefensePlanningPolicy,
215        signal: &ResourceDefenseSignal,
216    ) -> AttackPlan {
217        defense_aware_plan(
218            policy,
219            &planner(),
220            snapshot,
221            &subject(),
222            context(),
223            signal,
224            classify,
225        )
226        .unwrap()
227    }
228
229    fn baseline_plan(snapshot: &KnowledgeSnapshot) -> AttackPlan {
230        planner().plan_snapshot(snapshot, context()).unwrap()
231    }
232
233    fn step_ids(plan: &AttackPlan) -> BTreeSet<&str> {
234        plan.steps().iter().map(|step| step.action_id()).collect()
235    }
236
237    #[test]
238    fn disabled_flag_preserves_existing_plan_byte_for_byte() {
239        // Even a Halt-level signal changes nothing while enforcement is off.
240        let strong = {
241            let first = DefenseState::observe(403, &[], "forbidden");
242            let second = DefenseState::observe(406, &[], "not acceptable");
243            ResourceDefenseSignal::aggregate(
244                subject(),
245                &[
246                    ResourceDefenseObservation::new(&first, None, Vec::new()),
247                    ResourceDefenseObservation::new(&second, None, Vec::new()),
248                ],
249            )
250        };
251        let snapshot = snapshot();
252        assert_eq!(
253            plan_with(&snapshot, DefensePlanningPolicy::disabled(), &strong),
254            baseline_plan(&snapshot)
255        );
256        assert_eq!(
257            plan_with(&snapshot, DefensePlanningPolicy::default(), &strong),
258            baseline_plan(&snapshot)
259        );
260        assert_eq!(
261            serde_json::to_vec(&plan_with(
262                &snapshot,
263                DefensePlanningPolicy::default(),
264                &strong,
265            ))
266            .unwrap(),
267            serde_json::to_vec(&baseline_plan(&snapshot)).unwrap()
268        );
269    }
270
271    #[test]
272    fn enabled_flag_suppresses_defense_actions_with_a_distinct_reason() {
273        let plan = plan_with(
274            &snapshot(),
275            DefensePlanningPolicy::enabled(),
276            &backoff_signal(subject()),
277        );
278        // Active and mutating work is suppressed; passive and local remain.
279        assert!(!step_ids(&plan).contains("active.verify"));
280        assert!(!step_ids(&plan).contains("mutating.fuzz"));
281        assert!(step_ids(&plan).contains("passive.discovery"));
282        assert!(step_ids(&plan).contains("local.report"));
283
284        // The exclusion reason is distinct from a policy suppression.
285        let reasons: Vec<_> = plan
286            .excluded()
287            .iter()
288            .filter(|excluded| {
289                excluded.action_id() == "active.verify" || excluded.action_id() == "mutating.fuzz"
290            })
291            .map(|excluded| excluded.reason().clone())
292            .collect();
293        assert_eq!(reasons.len(), 2);
294        assert!(reasons
295            .iter()
296            .all(|reason| *reason == ExclusionReason::DefenseSuppressed));
297    }
298
299    #[test]
300    fn defense_suppressed_strategy_never_reaches_a_plan_step() {
301        // A defense-suppressed action is never a step, so it is never turned into
302        // a command and never reaches an executor.
303        let plan = plan_with(
304            &snapshot(),
305            DefensePlanningPolicy::enabled(),
306            &backoff_signal(subject()),
307        );
308        for step in plan.steps() {
309            assert_ne!(step.action_id(), "active.verify");
310            assert_ne!(step.action_id(), "mutating.fuzz");
311        }
312    }
313
314    #[test]
315    fn enabled_but_unrelated_resource_preserves_the_plan() {
316        let snapshot = snapshot();
317        let plan = plan_with(
318            &snapshot,
319            DefensePlanningPolicy::enabled(),
320            &backoff_signal(other_subject()),
321        );
322        assert_eq!(plan, baseline_plan(&snapshot));
323    }
324
325    #[test]
326    fn enabled_but_proceed_preserves_the_plan() {
327        let snapshot = snapshot();
328        let proceed = ResourceDefenseSignal::proceed(subject());
329        assert_eq!(
330            plan_with(&snapshot, DefensePlanningPolicy::enabled(), &proceed),
331            baseline_plan(&snapshot)
332        );
333    }
334
335    #[test]
336    fn enforcement_is_deterministic() {
337        let snapshot = snapshot();
338        let signal = backoff_signal(subject());
339        assert_eq!(
340            plan_with(&snapshot, DefensePlanningPolicy::enabled(), &signal),
341            plan_with(&snapshot, DefensePlanningPolicy::enabled(), &signal)
342        );
343    }
344
345    #[test]
346    fn policy_is_disabled_by_default() {
347        assert!(!DefensePlanningPolicy::default().is_enabled());
348    }
349
350    #[test]
351    fn disabled_policy_does_not_evaluate_or_require_defense_metadata() {
352        // While disabled, enforcement must fully bypass the defense path: it must
353        // not classify actions or consult the signal, even if a Halt-level signal
354        // is present. A classifier that panics if called proves the bypass.
355        let snapshot = snapshot();
356        let panicking_classifier = |_: &AttackAction| -> DefenseInteractionClass {
357            panic!("disabled policy classified an action")
358        };
359
360        let halt = {
361            let first = DefenseState::observe(403, &[], "forbidden");
362            let second = DefenseState::observe(406, &[], "not acceptable");
363            ResourceDefenseSignal::aggregate(
364                subject(),
365                &[
366                    ResourceDefenseObservation::new(&first, None, Vec::new()),
367                    ResourceDefenseObservation::new(&second, None, Vec::new()),
368                ],
369            )
370        };
371
372        let plan = defense_aware_plan(
373            DefensePlanningPolicy::disabled(),
374            &planner(),
375            &snapshot,
376            &subject(),
377            context(),
378            &halt,
379            panicking_classifier,
380        )
381        .unwrap();
382        assert_eq!(plan, baseline_plan(&snapshot));
383    }
384
385    #[test]
386    fn defense_suppression_is_not_recorded_as_execution_failure() {
387        // A defense-suppressed action is a planning exclusion, not an execution
388        // outcome: it appears only in `excluded` with the DefenseSuppressed
389        // reason, and never as a plan step. Because it is never dispatched, it can
390        // never be recorded as a failed execution, blocked request, or
391        // verification failure. The reason is also distinct from an adaptive or
392        // operator policy suppression, so downstream learning never conflates them.
393        let snapshot = snapshot();
394        let plan = plan_with(
395            &snapshot,
396            DefensePlanningPolicy::enabled(),
397            &backoff_signal(subject()),
398        );
399
400        let suppressed: Vec<String> = plan
401            .excluded()
402            .iter()
403            .filter(|excluded| excluded.reason() == &ExclusionReason::DefenseSuppressed)
404            .map(|excluded| excluded.action_id().to_owned())
405            .collect();
406        assert!(suppressed.contains(&"active.verify".to_owned()));
407
408        let steps = step_ids(&plan);
409        for action_id in &suppressed {
410            assert!(
411                !steps.contains(action_id.as_str()),
412                "{action_id} was suppressed yet reached a plan step"
413            );
414        }
415        assert!(plan
416            .excluded()
417            .iter()
418            .all(|excluded| excluded.reason() != &ExclusionReason::PolicySuppressed));
419    }
420}