Skip to main content

termivar_scanner/
web_planning.rs

1//! Standard planner actions driven by web reasoning hypotheses.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** always/default.
6//! - **Execution:** Surface B (deterministic decision runtime).
7//! - **Default `termivar scan`:** yes, through `StandardWebDecisionRuntime`.
8//! - **Support:** implemented and tested.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! The standard runtime composes this profile by default. Direct lower-level
13//! hosts may choose whether to install it. The profile declares executor-routable
14//! candidates and utility metadata, and never performs network I/O.
15
16use std::collections::BTreeSet;
17
18use serde::Serialize;
19use termivar_core::{
20    EvidenceValue, PredicateDescriptor, Probability, ReasoningModelError, WebKnowledgePredicate,
21};
22use thiserror::Error;
23
24use crate::{
25    planner::{
26        ActionCost, AttackAction, AttackPlanner, BenefitScore, HypothesisSelector, PlannerError,
27        PlannerWrite, RequiredStrength, RiskScore, VerificationTarget,
28    },
29    rules::{Expression, KnowledgeLayer},
30};
31
32pub use crate::web_actions::{
33    StandardWebActionKind, STANDARD_WEB_ACTION_COUNT, STANDARD_WEB_DISCOVERY_ACTIONS,
34    STANDARD_WEB_DISCOVERY_ACTION_COUNT,
35};
36
37/// Failures while constructing or installing the standard action profile.
38#[derive(Debug, Error)]
39#[non_exhaustive]
40pub enum StandardWebPlanningError {
41    /// A reasoning value used by an action selector was invalid.
42    #[error(transparent)]
43    Reasoning(#[from] ReasoningModelError),
44
45    /// An action definition or planner identity conflicted.
46    #[error(transparent)]
47    Planner(#[from] PlannerError),
48}
49
50/// Count of new actions written by an idempotent profile installation.
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
52pub struct StandardWebAttackInstallReport {
53    actions_inserted: usize,
54}
55
56impl StandardWebAttackInstallReport {
57    /// Returns the number of newly registered action identities.
58    pub fn actions_inserted(self) -> usize {
59        self.actions_inserted
60    }
61}
62
63/// Validated utility-planner actions for the standard web reasoning profile.
64///
65/// Hosts must register the executor IDs returned by
66/// [`StandardWebActionKind::executor_id`] before a selected command is handed
67/// to the decision runner. Keeping executor ownership outside this profile
68/// preserves the planner/execution boundary.
69///
70/// # Examples
71///
72/// ```rust
73/// use termivar_scanner::{AttackPlanner, StandardWebAttackProfile};
74///
75/// let profile = StandardWebAttackProfile::new()?;
76/// let mut planner = AttackPlanner::new();
77/// let installed = profile.install(&mut planner)?;
78///
79/// assert_eq!(installed.actions_inserted(), profile.actions().len());
80/// # Ok::<(), Box<dyn std::error::Error>>(())
81/// ```
82#[derive(Debug, Clone)]
83pub struct StandardWebAttackProfile {
84    actions: Vec<AttackAction>,
85}
86
87impl StandardWebAttackProfile {
88    /// Builds and validates every standard action definition.
89    pub fn new() -> Result<Self, StandardWebPlanningError> {
90        let actions = StandardWebActionKind::all()
91            .into_iter()
92            .map(build_action)
93            .collect::<Result<Vec<_>, _>>()?;
94        debug_assert_eq!(actions.len(), STANDARD_WEB_ACTION_COUNT);
95        Ok(Self { actions })
96    }
97
98    /// Installs every action idempotently after preflighting identity conflicts.
99    pub fn install(
100        &self,
101        planner: &mut AttackPlanner,
102    ) -> Result<StandardWebAttackInstallReport, StandardWebPlanningError> {
103        let mut prospective = planner.clone();
104        let mut actions_inserted = 0;
105        for action in &self.actions {
106            actions_inserted += usize::from(matches!(
107                prospective.register(action.clone())?,
108                PlannerWrite::Inserted
109            ));
110        }
111        *planner = prospective;
112        Ok(StandardWebAttackInstallReport { actions_inserted })
113    }
114
115    /// Returns actions in stable declaration order.
116    pub fn actions(&self) -> &[AttackAction] {
117        &self.actions
118    }
119
120    /// Returns the distinct executor identities required by this profile.
121    pub fn executor_ids(&self) -> BTreeSet<&str> {
122        self.actions.iter().map(AttackAction::executor).collect()
123    }
124}
125
126struct ActionDefinition {
127    predicate: PredicateDescriptor,
128    value: &'static str,
129    minimum_posterior: u8,
130    required_strength: RequiredStrength,
131    gain: u8,
132    cost: u32,
133    risk: u8,
134    prerequisites: &'static [StandardWebActionKind],
135}
136
137fn build_action(kind: StandardWebActionKind) -> Result<AttackAction, StandardWebPlanningError> {
138    let definition = action_definition(kind);
139    let predicate = definition.predicate.into_knowledge();
140    let value = EvidenceValue::Text(definition.value.to_owned());
141    let prerequisites = definition
142        .prerequisites
143        .iter()
144        .map(|kind| kind.action_id().to_owned())
145        .collect();
146    let action = AttackAction::new(
147        kind.action_id(),
148        kind.executor_id(),
149        Expression::equals(KnowledgeLayer::Hypothesis, predicate.clone(), value.clone()),
150        HypothesisSelector::new(
151            predicate,
152            value,
153            Probability::from_percent(definition.minimum_posterior)?,
154            definition.required_strength,
155        ),
156        BenefitScore::from_percent(definition.gain)?,
157        ActionCost::new(definition.cost)?,
158        RiskScore::from_percent(definition.risk)?,
159        prerequisites,
160    )?;
161    Ok(match kind {
162        StandardWebActionKind::PhpInputDiscovery | StandardWebActionKind::SanctumAuthBoundary => {
163            action.with_verification_target(VerificationTarget::KnowledgeOnly)
164        },
165        _ => action,
166    })
167}
168
169fn action_definition(kind: StandardWebActionKind) -> ActionDefinition {
170    match kind {
171        StandardWebActionKind::NginxConfiguration => ActionDefinition {
172            predicate: WebKnowledgePredicate::TECHNOLOGY_WEB_SERVER,
173            value: "nginx",
174            minimum_posterior: 70,
175            required_strength: RequiredStrength::Any,
176            gain: 55,
177            cost: 20,
178            risk: 8,
179            prerequisites: &[],
180        },
181        StandardWebActionKind::ApacheConfiguration => ActionDefinition {
182            predicate: WebKnowledgePredicate::TECHNOLOGY_WEB_SERVER,
183            value: "apache-http-server",
184            minimum_posterior: 70,
185            required_strength: RequiredStrength::Any,
186            gain: 55,
187            cost: 20,
188            risk: 8,
189            prerequisites: &[],
190        },
191        StandardWebActionKind::PhpInputDiscovery => ActionDefinition {
192            predicate: WebKnowledgePredicate::TECHNOLOGY_LANGUAGE,
193            value: "php",
194            minimum_posterior: 70,
195            required_strength: RequiredStrength::Any,
196            gain: 65,
197            cost: 35,
198            risk: 12,
199            prerequisites: &[],
200        },
201        StandardWebActionKind::LaravelRouteDiscovery => ActionDefinition {
202            predicate: WebKnowledgePredicate::TECHNOLOGY_FRAMEWORK,
203            value: "laravel",
204            minimum_posterior: 80,
205            required_strength: RequiredStrength::Strong,
206            gain: 70,
207            cost: 40,
208            risk: 10,
209            prerequisites: &[],
210        },
211        StandardWebActionKind::LaravelInputAnalysis => ActionDefinition {
212            predicate: WebKnowledgePredicate::TECHNOLOGY_FRAMEWORK,
213            value: "laravel",
214            minimum_posterior: 80,
215            required_strength: RequiredStrength::Strong,
216            gain: 95,
217            cost: 35,
218            risk: 15,
219            prerequisites: &[StandardWebActionKind::LaravelRouteDiscovery],
220        },
221        StandardWebActionKind::LivewireComponentDiscovery => ActionDefinition {
222            predicate: WebKnowledgePredicate::TECHNOLOGY_UI_FRAMEWORK,
223            value: "livewire",
224            minimum_posterior: 60,
225            required_strength: RequiredStrength::Any,
226            gain: 90,
227            cost: 35,
228            risk: 15,
229            prerequisites: &[],
230        },
231        StandardWebActionKind::SanctumAuthBoundary => ActionDefinition {
232            predicate: WebKnowledgePredicate::AUTHENTICATION_MECHANISM,
233            value: "sanctum",
234            minimum_posterior: 50,
235            required_strength: RequiredStrength::Any,
236            gain: 95,
237            cost: 60,
238            risk: 35,
239            prerequisites: &[],
240        },
241        StandardWebActionKind::HttpBasicAuthBoundary => ActionDefinition {
242            predicate: WebKnowledgePredicate::AUTHENTICATION_MECHANISM,
243            value: "http-basic",
244            minimum_posterior: 90,
245            required_strength: RequiredStrength::Strong,
246            gain: 75,
247            cost: 25,
248            risk: 20,
249            prerequisites: &[],
250        },
251        StandardWebActionKind::HttpBearerAuthBoundary => ActionDefinition {
252            predicate: WebKnowledgePredicate::AUTHENTICATION_MECHANISM,
253            value: "http-bearer",
254            minimum_posterior: 90,
255            required_strength: RequiredStrength::Strong,
256            gain: 90,
257            cost: 35,
258            risk: 25,
259            prerequisites: &[],
260        },
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use termivar_core::{
267        ConfidenceScore, EntityId, Evidence, EvidenceKind, EvidenceSource, Hypothesis,
268        HypothesisState, HypothesisStrength, KnowledgePredicate,
269    };
270
271    use super::*;
272    #[cfg(feature = "scanning")]
273    use crate::{
274        AdaptationLimits, DecisionLoop, DecisionLoopCommand, DecisionLoopConfig, DecisionSession,
275        ExperiencePolicy, ExperienceStore,
276    };
277    use crate::{ExclusionReason, KnowledgeBase, PlanningContext, StandardWebReasoning};
278
279    fn subject() -> EntityId {
280        EntityId::new("endpoint:https://example.test").unwrap()
281    }
282
283    fn evidence(namespace: &str, name: &str, value: &str) -> Evidence {
284        Evidence::new(
285            subject(),
286            EvidenceKind::Technology,
287            KnowledgePredicate::new(namespace, name).unwrap(),
288            EvidenceValue::Text(value.to_owned()),
289            EvidenceSource::new("http.evidence", "test-observation").unwrap(),
290            ConfidenceScore::MAX,
291        )
292    }
293
294    fn planning_context(budget: u64, maximum_risk: u8) -> PlanningContext {
295        PlanningContext::new(
296            BenefitScore::from_percent(90).unwrap(),
297            budget,
298            RiskScore::from_percent(maximum_risk).unwrap(),
299        )
300    }
301
302    fn reason(knowledge: &KnowledgeBase) -> (crate::RuleEngine, AttackPlanner) {
303        let mut rules = crate::RuleEngine::new();
304        StandardWebReasoning::new()
305            .unwrap()
306            .install(knowledge, &mut rules)
307            .unwrap();
308        rules.apply(knowledge, &subject()).unwrap();
309        let mut planner = AttackPlanner::new();
310        StandardWebAttackProfile::new()
311            .unwrap()
312            .install(&mut planner)
313            .unwrap();
314        (rules, planner)
315    }
316
317    #[test]
318    fn profile_installs_idempotently_with_distinct_executor_contracts() {
319        let profile = StandardWebAttackProfile::new().unwrap();
320        let mut planner = AttackPlanner::new();
321
322        let first = profile.install(&mut planner).unwrap();
323        let second = profile.install(&mut planner).unwrap();
324
325        assert_eq!(first.actions_inserted(), STANDARD_WEB_ACTION_COUNT);
326        assert_eq!(second, StandardWebAttackInstallReport::default());
327        assert_eq!(planner.len(), STANDARD_WEB_ACTION_COUNT);
328        assert_eq!(profile.executor_ids().len(), STANDARD_WEB_ACTION_COUNT);
329        for action in profile.actions() {
330            let expected = if [
331                StandardWebActionKind::PhpInputDiscovery.action_id(),
332                StandardWebActionKind::SanctumAuthBoundary.action_id(),
333            ]
334            .contains(&action.id())
335            {
336                &VerificationTarget::KnowledgeOnly
337            } else {
338                &VerificationTarget::Motivation
339            };
340            assert_eq!(action.verification_target(), expected, "{}", action.id());
341        }
342        assert_eq!(
343            StandardWebActionKind::LaravelRouteDiscovery.executor_id(),
344            "web.probe.laravel-routes"
345        );
346    }
347
348    #[test]
349    fn laravel_and_sanctum_hypotheses_produce_dependency_safe_plan() {
350        let knowledge = KnowledgeBase::new();
351        knowledge
352            .insert_evidence_batch(vec![
353                evidence("http.cookie", "name", "laravel_session"),
354                evidence("http.cookie", "name", "XSRF-TOKEN"),
355            ])
356            .unwrap();
357        let (_, planner) = reason(&knowledge);
358
359        let plan = planner
360            .plan(&knowledge, &subject(), planning_context(200, 100))
361            .unwrap();
362        let selected: Vec<_> = plan.steps().iter().map(|step| step.action_id()).collect();
363
364        assert_eq!(
365            selected,
366            vec![
367                StandardWebActionKind::LaravelRouteDiscovery.action_id(),
368                StandardWebActionKind::LaravelInputAnalysis.action_id(),
369                StandardWebActionKind::SanctumAuthBoundary.action_id(),
370            ]
371        );
372        assert_eq!(
373            plan.steps()[0].executor(),
374            StandardWebActionKind::LaravelRouteDiscovery.executor_id()
375        );
376        assert!(plan.steps()[1]
377            .prerequisites()
378            .contains(StandardWebActionKind::LaravelRouteDiscovery.action_id()));
379        assert_eq!(
380            plan.steps()[0].utility().confidence(),
381            plan.steps()[1].utility().confidence()
382        );
383    }
384
385    #[test]
386    fn low_risk_and_budget_policies_explain_exclusions() {
387        let knowledge = KnowledgeBase::new();
388        knowledge
389            .insert_evidence_batch(vec![
390                evidence("http.cookie", "name", "laravel_session"),
391                evidence("http.cookie", "name", "XSRF-TOKEN"),
392            ])
393            .unwrap();
394        let (_, planner) = reason(&knowledge);
395
396        let plan = planner
397            .plan(&knowledge, &subject(), planning_context(60, 30))
398            .unwrap();
399        let sanctum = plan
400            .excluded()
401            .iter()
402            .find(|item| item.action_id() == StandardWebActionKind::SanctumAuthBoundary.action_id())
403            .unwrap();
404        let input = plan
405            .excluded()
406            .iter()
407            .find(|item| {
408                item.action_id() == StandardWebActionKind::LaravelInputAnalysis.action_id()
409            })
410            .unwrap();
411
412        assert!(matches!(
413            sanctum.reason(),
414            ExclusionReason::RiskLimitExceeded { .. }
415        ));
416        assert!(matches!(
417            input.reason(),
418            ExclusionReason::BudgetExceeded { .. }
419        ));
420        assert_eq!(
421            plan.steps()[0].action_id(),
422            StandardWebActionKind::LaravelRouteDiscovery.action_id()
423        );
424    }
425
426    #[test]
427    fn unrelated_stack_does_not_activate_framework_or_auth_actions() {
428        let knowledge = KnowledgeBase::new();
429        knowledge
430            .insert_evidence(evidence("http.header", "server", "nginx/1.26"))
431            .unwrap();
432        let (_, planner) = reason(&knowledge);
433
434        let plan = planner
435            .plan(&knowledge, &subject(), planning_context(200, 100))
436            .unwrap();
437
438        assert_eq!(plan.steps().len(), 1);
439        assert_eq!(
440            plan.steps()[0].action_id(),
441            StandardWebActionKind::NginxConfiguration.action_id()
442        );
443        assert!(plan
444            .excluded()
445            .iter()
446            .all(|item| matches!(item.reason(), ExclusionReason::RequirementsNotMet)));
447    }
448
449    #[test]
450    fn weak_laravel_claim_cannot_unlock_strong_actions() {
451        let knowledge = KnowledgeBase::new();
452        let mut weak = Hypothesis::with_id(
453            "manual:weak-laravel",
454            subject(),
455            KnowledgePredicate::new("technology", "framework").unwrap(),
456            EvidenceValue::Text("laravel".to_owned()),
457            Probability::from_percent(90).unwrap(),
458        )
459        .unwrap();
460        weak.set_strength(HypothesisStrength::Weak);
461        weak.set_state(HypothesisState::Supported);
462        knowledge.upsert_hypothesis(weak).unwrap();
463        let mut planner = AttackPlanner::new();
464        StandardWebAttackProfile::new()
465            .unwrap()
466            .install(&mut planner)
467            .unwrap();
468
469        let plan = planner
470            .plan(&knowledge, &subject(), planning_context(200, 100))
471            .unwrap();
472
473        assert!(plan.steps().is_empty());
474        for kind in [
475            StandardWebActionKind::LaravelRouteDiscovery,
476            StandardWebActionKind::LaravelInputAnalysis,
477        ] {
478            assert!(matches!(
479                plan.excluded()
480                    .iter()
481                    .find(|item| item.action_id() == kind.action_id())
482                    .unwrap()
483                    .reason(),
484                ExclusionReason::NoEligibleHypothesis
485            ));
486        }
487    }
488
489    #[test]
490    #[cfg(feature = "scanning")]
491    fn decision_loop_emits_executor_selected_from_reasoning() {
492        let knowledge = KnowledgeBase::new();
493        knowledge
494            .insert_evidence_batch(vec![
495                evidence("http.cookie", "name", "laravel_session"),
496                evidence("http.cookie", "name", "XSRF-TOKEN"),
497            ])
498            .unwrap();
499        let config = DecisionLoopConfig::new(
500            planning_context(200, 100),
501            AdaptationLimits::default(),
502            ExperiencePolicy::new(3).unwrap(),
503            8,
504        )
505        .unwrap();
506        let mut decision_loop = DecisionLoop::new(config);
507        StandardWebReasoning::new()
508            .unwrap()
509            .install(&knowledge, decision_loop.rules_mut())
510            .unwrap();
511        StandardWebAttackProfile::new()
512            .unwrap()
513            .install(decision_loop.planner_mut())
514            .unwrap();
515        let experience = ExperienceStore::new();
516        let mut session = DecisionSession::new(subject());
517
518        let report = decision_loop
519            .plan_next(&knowledge, &experience, &mut session)
520            .unwrap();
521
522        assert_eq!(
523            report.rule_applications().len(),
524            crate::STANDARD_WEB_RULE_COUNT
525        );
526        assert!(matches!(
527            report.command(),
528            DecisionLoopCommand::ExecuteAction {
529                executor: Some(executor),
530                ..
531            } if executor == StandardWebActionKind::LaravelRouteDiscovery.executor_id()
532        ));
533        assert_eq!(
534            report.plan().steps()[0].action_id(),
535            StandardWebActionKind::LaravelRouteDiscovery.action_id()
536        );
537    }
538}