1use 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#[derive(Debug, Error)]
39#[non_exhaustive]
40pub enum StandardWebPlanningError {
41 #[error(transparent)]
43 Reasoning(#[from] ReasoningModelError),
44
45 #[error(transparent)]
47 Planner(#[from] PlannerError),
48}
49
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
52pub struct StandardWebAttackInstallReport {
53 actions_inserted: usize,
54}
55
56impl StandardWebAttackInstallReport {
57 pub fn actions_inserted(self) -> usize {
59 self.actions_inserted
60 }
61}
62
63#[derive(Debug, Clone)]
83pub struct StandardWebAttackProfile {
84 actions: Vec<AttackAction>,
85}
86
87impl StandardWebAttackProfile {
88 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 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 pub fn actions(&self) -> &[AttackAction] {
117 &self.actions
118 }
119
120 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}