1#![forbid(unsafe_code)]
2#![cfg_attr(
4 not(test),
5 deny(
6 clippy::unwrap_used,
7 clippy::expect_used,
8 clippy::todo,
9 clippy::unimplemented,
10 clippy::panic
11 )
12)]
13#![allow(
14 clippy::module_name_repetitions,
15 clippy::must_use_candidate,
16 clippy::missing_errors_doc
17)]
18
19pub mod bridge;
26
27use async_trait::async_trait;
28use futures::StreamExt;
29use gossan_core::{Config, ScanClient, ScanInput, Scanner, ServiceTarget, Target, WebAssetTarget};
30use secfinding::Finding;
31use std::sync::Arc;
32pub struct TechStackScanner;
34
35#[async_trait]
36impl Scanner for TechStackScanner {
37 fn name(&self) -> &'static str {
38 "techstack"
39 }
40 fn tags(&self) -> &[&'static str] {
41 &["active", "web", "fingerprint"]
42 }
43 fn accepts(&self, target: &Target) -> bool {
44 matches!(target, Target::Service(s) if s.is_web())
45 }
46
47 async fn run(&self, input: ScanInput, config: &Config) -> anyhow::Result<()> {
48 let client = ScanClient::from_config(config, Arc::clone(&input.resolver))?;
49
50 let web_targets: Vec<ServiceTarget> = {
55 let mut rx = input.target_rx.lock().await;
56 let mut buf = Vec::new();
57 while let Ok(t) = rx.try_recv() {
58 if let Target::Service(s) = t {
59 if s.is_web() {
60 buf.push(s);
61 }
62 }
63 }
64 buf
65 };
66
67 let results: Vec<Option<(WebAssetTarget, Vec<Finding>)>> =
68 futures::stream::iter(web_targets)
69 .map(|svc| {
70 let client = client.clone();
71 async move { bridge::probe(&client, svc).await.ok() }
72 })
73 .buffer_unordered(config.concurrency)
74 .collect()
75 .await;
76
77 for item in results.into_iter().flatten() {
78 let (asset, header_findings) = item;
79 tracing::debug!(
80 url = %asset.url,
81 tech = ?asset.tech.iter().map(|t| &t.name).collect::<Vec<_>>(),
82 "web asset"
83 );
84 for f in header_findings {
85 input.emit(f);
86 }
87 input.emit_target(Target::Web(Box::new(asset)));
88 }
89
90 Ok(())
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use gossan_core::{HostTarget, Protocol, Scanner};
98
99 fn service(port: u16, banner: Option<&str>) -> Target {
100 Target::Service(ServiceTarget {
101 host: HostTarget {
102 ip: "127.0.0.1".parse().unwrap(),
103 domain: Some("example.com".into()),
104 },
105 port,
106 protocol: Protocol::Tcp,
107 banner: banner.map(str::to_string),
108 tls: port == 443,
109 })
110 }
111
112 #[test]
113 fn scanner_accepts_only_web_services() {
114 let scanner = TechStackScanner;
115 assert!(scanner.accepts(&service(443, None)));
116 assert!(scanner.accepts(&service(1234, Some("HTTP/1.1 200 OK"))));
117 assert!(!scanner.accepts(&service(22, Some("SSH-2.0"))));
118 }
119
120 #[test]
121 fn scanner_metadata_is_stable() {
122 let scanner = TechStackScanner;
123 assert_eq!(scanner.name(), "techstack");
124 assert_eq!(scanner.tags(), &["active", "web", "fingerprint"]);
125 }
126}