1use serde::{Deserialize, Serialize};
8use std::net::IpAddr;
9use url::Url;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
13#[serde(rename_all = "snake_case")]
14#[non_exhaustive]
15pub enum DiscoverySource {
16 Seed,
18 CertificateTransparency,
20 DnsBruteforce,
22 PassiveDns,
24 PortScan,
26 TechStack,
28 JsAnalysis,
30 HiddenProbe,
32 Crawl,
35 RapidDns,
38 AlienVault,
40 UrlScan,
42 CommonCrawl,
44 VirusTotal,
47 SecurityTrails,
49 Shodan,
51 GitHub,
53 Censys,
55 BinaryEdge,
57 FullHunt,
59 Chaos,
61 Bevigil,
63 Fofa,
65 HunterIo,
67 Netlas,
69 ZoomEye,
71 C99,
73 Quake,
75 ThreatBook,
77 Anubis,
79 Asn,
81 AsnLookup,
83 ScmMapping,
85 DnsDumpster,
88 CloudDiscovery,
90 BufferOver,
92 GoogleCt,
94 FacebookCt,
96 AppleCt,
98 CloudflareCt,
100 DigiCertCt,
102 SectigoCt,
104 IdenTrustCt,
106 EntrustCt,
108 GoDaddyCt,
110 AmazonCt,
112 PassiveTotal,
114 Spyse,
116 Dnslytics,
118 ThreatMiner,
120 PtrArchive,
122 Riddler,
124 SiteDossier,
126 SonarSearch,
128 Circl,
130 Mnemonic,
132 FarsightDnsdb,
134 Sublist3r,
136 Omnisint,
138 Digitorus,
140 Columbus,
142 Crobat,
144 ThreatCrowd,
146 Rook,
148 Pugrecon,
150 SubdomainCenter,
152 Synapsint,
154 Jter,
156 Bing,
158 Baidu,
160 DuckDuckGo,
162 Yahoo,
164 Exalead,
166 Ask,
168 GreyNoise,
170 IpInfo,
172 ViewDns,
174 ZoneWalk,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct DomainTarget {
181 pub domain: String,
183 pub source: DiscoverySource,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct RepositoryTarget {
190 pub url: Url,
192 pub service: ScmService,
194 pub source: DiscoverySource,
196 pub branch: Option<String>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
202#[serde(rename_all = "lowercase")]
203#[non_exhaustive]
204pub enum ScmService {
205 GitHub,
207 GitLab,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct HostTarget {
214 pub ip: IpAddr,
216 pub domain: Option<String>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
222#[serde(rename_all = "lowercase")]
223#[non_exhaustive]
224pub enum Protocol {
225 Tcp,
227 Udp,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct ServiceTarget {
234 pub host: HostTarget,
236 pub port: u16,
238 pub protocol: Protocol,
240 pub banner: Option<String>,
242 pub tls: bool,
244}
245
246impl ServiceTarget {
247 #[must_use]
251 pub fn is_web(&self) -> bool {
252 self.banner
254 .as_deref()
255 .is_some_and(|b| b.starts_with("HTTP"))
256 || matches!(
257 self.port,
258 80 | 443
259 | 3000
260 | 3443
261 | 4443
262 | 4433
263 | 5000
264 | 5443
265 | 7443
266 | 8000
267 | 8080
268 | 8443
269 | 8888
270 | 9000
271 | 9090
272 | 9443
273 | 10443
274 )
275 }
276
277 #[must_use]
281 pub fn base_url(&self) -> Option<Url> {
282 let scheme = if self.tls || self.port == 443 || self.port == 8443 {
283 "https"
284 } else {
285 "http"
286 };
287 let host = match &self.host.domain {
288 Some(d) => d.clone(),
289 None => self.host.ip.to_string(),
290 };
291 let port_str = match (scheme, self.port) {
292 ("https", 443) | ("http", 80) => String::new(),
293 _ => format!(":{}", self.port),
294 };
295 Url::parse(&format!("{scheme}://{host}{port_str}")).ok()
296 }
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct Technology {
302 pub name: String,
304 pub version: Option<String>,
306 pub category: TechCategory,
308 pub confidence: u8,
310}
311
312#[allow(clippy::doc_markdown)]
314#[derive(Debug, Clone, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case")]
316#[non_exhaustive]
317pub enum TechCategory {
318 Cms,
320 Framework,
322 Language,
324 Server,
326 Cdn,
328 Analytics,
330 Security,
332 Database,
334 Os,
336 Other,
338}
339
340#[allow(clippy::doc_markdown)]
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct WebAssetTarget {
344 pub url: Url,
346 pub service: ServiceTarget,
348 pub tech: Vec<Technology>,
350 pub status: u16,
352 pub title: Option<String>,
354 #[serde(skip_serializing_if = "Option::is_none")]
357 pub favicon_hash: Option<i32>,
358 #[serde(skip_serializing_if = "Option::is_none")]
361 pub body_hash: Option<String>,
362 #[serde(default, skip_serializing_if = "Vec::is_empty")]
364 pub forms: Vec<DiscoveredForm>,
365 #[serde(default, skip_serializing_if = "Vec::is_empty")]
367 pub params: Vec<DiscoveredParam>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct DiscoveredForm {
373 pub action: String,
375 pub method: String,
377 pub inputs: Vec<(String, String)>,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct DiscoveredParam {
384 pub name: String,
386 pub location: ParamLocation,
388 pub source: ParamSource,
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
394#[serde(rename_all = "snake_case")]
395#[non_exhaustive]
396pub enum ParamLocation {
397 Query,
399 Body,
401 Path,
403 Header,
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
409#[serde(rename_all = "snake_case")]
410#[non_exhaustive]
411pub enum ParamSource {
412 HtmlForm,
414 UrlObserved,
416 BruteForce,
418 ApiSpec,
420 JsAnalysis,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
427#[serde(tag = "kind", rename_all = "snake_case")]
428#[non_exhaustive]
429pub enum Target {
430 Domain(DomainTarget),
432 Host(HostTarget),
434 Service(ServiceTarget),
436 Web(Box<WebAssetTarget>),
439 Network(NetworkTarget),
442 Repository(RepositoryTarget),
445 InternalPackage(InternalPackageTarget),
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct InternalPackageTarget {
453 pub name: String,
455 pub source_repo: Url,
457 pub ecosystem: String,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct NetworkTarget {
464 pub cidr: String,
466 pub source: DiscoverySource,
468}
469
470impl Target {
471 #[must_use]
473 pub fn domain(&self) -> Option<&str> {
474 match self {
475 Target::Domain(d) => Some(&d.domain),
476 Target::Host(h) => h.domain.as_deref(),
477 Target::Service(s) => s.host.domain.as_deref(),
478 Target::Web(w) => w
484 .service
485 .host
486 .domain
487 .as_deref()
488 .or_else(|| w.url.host_str()),
489 Target::Repository(r) => r.url.host_str(),
490 Target::Network(_) | Target::InternalPackage(_) => None,
491 }
492 }
493
494 #[must_use]
496 pub fn ip(&self) -> Option<IpAddr> {
497 match self {
498 Target::Host(h) => Some(h.ip),
499 Target::Service(s) => Some(s.host.ip),
500 Target::Web(w) => Some(w.service.host.ip),
501 _ => None,
502 }
503 }
504
505 #[must_use]
507 pub fn base_url(&self) -> Option<String> {
508 match self {
509 Target::Domain(d) => Some(format!("https://{}/", d.domain)),
510 Target::Host(h) => Some(format!("http://{}/", h.ip)),
511 Target::Service(s) => s.base_url().map(|u| u.to_string()),
512 Target::Web(w) => {
513 let mut u = w.url.clone();
514 u.set_path("/");
515 u.set_query(None);
516 u.set_fragment(None);
517 Some(u.to_string())
518 }
519 Target::Repository(r) => Some(r.url.to_string()),
520 Target::Network(_) | Target::InternalPackage(_) => None,
521 }
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use serde_json::json;
529
530 fn host(domain: Option<&str>) -> HostTarget {
531 HostTarget {
532 ip: "203.0.113.10".parse().unwrap(),
533 domain: domain.map(str::to_string),
534 }
535 }
536
537 fn service(port: u16, tls: bool, banner: Option<&str>, domain: Option<&str>) -> ServiceTarget {
538 ServiceTarget {
539 host: host(domain),
540 port,
541 protocol: Protocol::Tcp,
542 banner: banner.map(str::to_string),
543 tls,
544 }
545 }
546
547 #[test]
548 fn service_is_web_for_common_ports() {
549 for port in [80, 443, 8080, 8443, 8000, 8888] {
550 assert!(
551 service(port, false, None, Some("example.com")).is_web(),
552 "port {port}"
553 );
554 }
555 }
556
557 #[test]
558 fn service_is_web_for_http_banner_even_on_nonstandard_port() {
559 assert!(service(12345, false, Some("HTTP/1.1 200 OK"), Some("example.com")).is_web());
560 }
561
562 #[test]
563 fn service_is_not_web_for_non_http_ports_without_banner_hint() {
564 assert!(!service(22, false, Some("SSH-2.0-OpenSSH_9.7"), Some("example.com")).is_web());
565 }
566
567 #[test]
568 fn base_url_uses_https_for_tls_services() {
569 let url = service(9443, true, None, Some("example.com"))
570 .base_url()
571 .unwrap();
572 assert_eq!(url.as_str(), "https://example.com:9443/");
573 }
574
575 #[test]
576 fn base_url_uses_https_for_implicit_tls_ports() {
577 let url = service(8443, false, None, Some("example.com"))
578 .base_url()
579 .unwrap();
580 assert_eq!(url.as_str(), "https://example.com:8443/");
581 }
582
583 #[test]
584 fn base_url_omits_default_ports() {
585 assert_eq!(
586 service(80, false, None, Some("example.com"))
587 .base_url()
588 .unwrap()
589 .as_str(),
590 "http://example.com/"
591 );
592 assert_eq!(
593 service(443, false, None, Some("example.com"))
594 .base_url()
595 .unwrap()
596 .as_str(),
597 "https://example.com/"
598 );
599 }
600
601 #[test]
602 fn base_url_falls_back_to_ip_when_domain_missing() {
603 let url = service(8080, false, None, None).base_url().unwrap();
604 assert_eq!(url.as_str(), "http://203.0.113.10:8080/");
605 }
606
607 #[test]
608 fn target_domain_returns_expected_value_for_each_variant() {
609 let domain = Target::Domain(DomainTarget {
610 domain: "example.com".into(),
611 source: DiscoverySource::Seed,
612 });
613 let host = Target::Host(host(Some("host.example.com")));
614 let svc = Target::Service(service(443, true, None, Some("svc.example.com")));
615 let web = Target::Web(Box::new(WebAssetTarget {
616 url: Url::parse("https://web.example.com/admin").unwrap(),
617 service: service(443, true, None, Some("web.example.com")),
618 tech: vec![],
619 status: 200,
620 title: Some("Admin".into()),
621 favicon_hash: Some(123),
622 body_hash: Some("abcd".into()),
623 forms: vec![],
624 params: vec![],
625 }));
626
627 assert_eq!(domain.domain(), Some("example.com"));
628 assert_eq!(host.domain(), Some("host.example.com"));
629 assert_eq!(svc.domain(), Some("svc.example.com"));
630 assert_eq!(web.domain(), Some("web.example.com"));
631 }
632
633 #[test]
634 fn target_domain_is_none_for_host_and_service_without_domain() {
635 assert_eq!(Target::Host(host(None)).domain(), None);
636 assert_eq!(
637 Target::Service(service(22, false, None, None)).domain(),
638 None
639 );
640 }
641
642 #[test]
643 fn protocol_serializes_lowercase() {
644 assert_eq!(serde_json::to_value(Protocol::Tcp).unwrap(), json!("tcp"));
645 assert_eq!(serde_json::to_value(Protocol::Udp).unwrap(), json!("udp"));
646 }
647
648 #[test]
649 fn discovery_source_serializes_snake_case() {
650 assert_eq!(
651 serde_json::to_value(DiscoverySource::CertificateTransparency).unwrap(),
652 json!("certificate_transparency")
653 );
654 assert_eq!(
655 serde_json::to_value(DiscoverySource::HiddenProbe).unwrap(),
656 json!("hidden_probe")
657 );
658 }
659
660 #[test]
661 fn target_serializes_with_kind_tag() {
662 let target = Target::Domain(DomainTarget {
663 domain: "example.com".into(),
664 source: DiscoverySource::UrlScan,
665 });
666 let value = serde_json::to_value(target).unwrap();
667 assert_eq!(value["kind"], json!("domain"));
668 assert_eq!(value["source"], json!("url_scan"));
669 }
670}