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 | 3000 | 3443 | 4443 | 4433 | 5000 | 5443
259 | 7443 | 8000 | 8080 | 8443 | 8888
260 | 9000 | 9090 | 9443 | 10443
261 )
262 }
263
264 #[must_use]
268 pub fn base_url(&self) -> Option<Url> {
269 let scheme = if self.tls || self.port == 443 || self.port == 8443 {
270 "https"
271 } else {
272 "http"
273 };
274 let host = match &self.host.domain {
275 Some(d) => d.clone(),
276 None => self.host.ip.to_string(),
277 };
278 let port_str = match (scheme, self.port) {
279 ("https", 443) | ("http", 80) => String::new(),
280 _ => format!(":{}", self.port),
281 };
282 Url::parse(&format!("{scheme}://{host}{port_str}")).ok()
283 }
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct Technology {
289 pub name: String,
291 pub version: Option<String>,
293 pub category: TechCategory,
295 pub confidence: u8,
297}
298
299#[allow(clippy::doc_markdown)]
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(rename_all = "snake_case")]
303#[non_exhaustive]
304pub enum TechCategory {
305 Cms,
307 Framework,
309 Language,
311 Server,
313 Cdn,
315 Analytics,
317 Security,
319 Database,
321 Os,
323 Other,
325}
326
327#[allow(clippy::doc_markdown)]
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct WebAssetTarget {
331 pub url: Url,
333 pub service: ServiceTarget,
335 pub tech: Vec<Technology>,
337 pub status: u16,
339 pub title: Option<String>,
341 #[serde(skip_serializing_if = "Option::is_none")]
344 pub favicon_hash: Option<i32>,
345 #[serde(skip_serializing_if = "Option::is_none")]
348 pub body_hash: Option<String>,
349 #[serde(default, skip_serializing_if = "Vec::is_empty")]
351 pub forms: Vec<DiscoveredForm>,
352 #[serde(default, skip_serializing_if = "Vec::is_empty")]
354 pub params: Vec<DiscoveredParam>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct DiscoveredForm {
360 pub action: String,
362 pub method: String,
364 pub inputs: Vec<(String, String)>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct DiscoveredParam {
371 pub name: String,
373 pub location: ParamLocation,
375 pub source: ParamSource,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
381#[serde(rename_all = "snake_case")]
382#[non_exhaustive]
383pub enum ParamLocation {
384 Query,
386 Body,
388 Path,
390 Header,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
396#[serde(rename_all = "snake_case")]
397#[non_exhaustive]
398pub enum ParamSource {
399 HtmlForm,
401 UrlObserved,
403 BruteForce,
405 ApiSpec,
407 JsAnalysis,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
414#[serde(tag = "kind", rename_all = "snake_case")]
415#[non_exhaustive]
416pub enum Target {
417 Domain(DomainTarget),
419 Host(HostTarget),
421 Service(ServiceTarget),
423 Web(Box<WebAssetTarget>),
426 Network(NetworkTarget),
429 Repository(RepositoryTarget),
432 InternalPackage(InternalPackageTarget),
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct InternalPackageTarget {
440 pub name: String,
442 pub source_repo: Url,
444 pub ecosystem: String,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct NetworkTarget {
451 pub cidr: String,
453 pub source: DiscoverySource,
455}
456
457impl Target {
458 #[must_use]
460 pub fn domain(&self) -> Option<&str> {
461 match self {
462 Target::Domain(d) => Some(&d.domain),
463 Target::Host(h) => h.domain.as_deref(),
464 Target::Service(s) => s.host.domain.as_deref(),
465 Target::Web(w) => w
471 .service
472 .host
473 .domain
474 .as_deref()
475 .or_else(|| w.url.host_str()),
476 Target::Repository(r) => r.url.host_str(),
477 Target::Network(_) | Target::InternalPackage(_) => None,
478 }
479 }
480
481 #[must_use]
483 pub fn ip(&self) -> Option<IpAddr> {
484 match self {
485 Target::Host(h) => Some(h.ip),
486 Target::Service(s) => Some(s.host.ip),
487 Target::Web(w) => Some(w.service.host.ip),
488 _ => None,
489 }
490 }
491
492 #[must_use]
494 pub fn base_url(&self) -> Option<String> {
495 match self {
496 Target::Domain(d) => Some(format!("https://{}/", d.domain)),
497 Target::Host(h) => Some(format!("http://{}/", h.ip)),
498 Target::Service(s) => s.base_url().map(|u| u.to_string()),
499 Target::Web(w) => {
500 let mut u = w.url.clone();
501 u.set_path("/");
502 u.set_query(None);
503 u.set_fragment(None);
504 Some(u.to_string())
505 }
506 Target::Repository(r) => Some(r.url.to_string()),
507 Target::Network(_) | Target::InternalPackage(_) => None,
508 }
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use serde_json::json;
516
517 fn host(domain: Option<&str>) -> HostTarget {
518 HostTarget {
519 ip: "203.0.113.10".parse().unwrap(),
520 domain: domain.map(str::to_string),
521 }
522 }
523
524 fn service(port: u16, tls: bool, banner: Option<&str>, domain: Option<&str>) -> ServiceTarget {
525 ServiceTarget {
526 host: host(domain),
527 port,
528 protocol: Protocol::Tcp,
529 banner: banner.map(str::to_string),
530 tls,
531 }
532 }
533
534 #[test]
535 fn service_is_web_for_common_ports() {
536 for port in [80, 443, 8080, 8443, 8000, 8888] {
537 assert!(
538 service(port, false, None, Some("example.com")).is_web(),
539 "port {port}"
540 );
541 }
542 }
543
544 #[test]
545 fn service_is_web_for_http_banner_even_on_nonstandard_port() {
546 assert!(service(12345, false, Some("HTTP/1.1 200 OK"), Some("example.com")).is_web());
547 }
548
549 #[test]
550 fn service_is_not_web_for_non_http_ports_without_banner_hint() {
551 assert!(!service(22, false, Some("SSH-2.0-OpenSSH_9.7"), Some("example.com")).is_web());
552 }
553
554 #[test]
555 fn base_url_uses_https_for_tls_services() {
556 let url = service(9443, true, None, Some("example.com"))
557 .base_url()
558 .unwrap();
559 assert_eq!(url.as_str(), "https://example.com:9443/");
560 }
561
562 #[test]
563 fn base_url_uses_https_for_implicit_tls_ports() {
564 let url = service(8443, false, None, Some("example.com"))
565 .base_url()
566 .unwrap();
567 assert_eq!(url.as_str(), "https://example.com:8443/");
568 }
569
570 #[test]
571 fn base_url_omits_default_ports() {
572 assert_eq!(
573 service(80, false, None, Some("example.com"))
574 .base_url()
575 .unwrap()
576 .as_str(),
577 "http://example.com/"
578 );
579 assert_eq!(
580 service(443, false, None, Some("example.com"))
581 .base_url()
582 .unwrap()
583 .as_str(),
584 "https://example.com/"
585 );
586 }
587
588 #[test]
589 fn base_url_falls_back_to_ip_when_domain_missing() {
590 let url = service(8080, false, None, None).base_url().unwrap();
591 assert_eq!(url.as_str(), "http://203.0.113.10:8080/");
592 }
593
594 #[test]
595 fn target_domain_returns_expected_value_for_each_variant() {
596 let domain = Target::Domain(DomainTarget {
597 domain: "example.com".into(),
598 source: DiscoverySource::Seed,
599 });
600 let host = Target::Host(host(Some("host.example.com")));
601 let svc = Target::Service(service(443, true, None, Some("svc.example.com")));
602 let web = Target::Web(Box::new(WebAssetTarget {
603 url: Url::parse("https://web.example.com/admin").unwrap(),
604 service: service(443, true, None, Some("web.example.com")),
605 tech: vec![],
606 status: 200,
607 title: Some("Admin".into()),
608 favicon_hash: Some(123),
609 body_hash: Some("abcd".into()),
610 forms: vec![],
611 params: vec![],
612 }));
613
614 assert_eq!(domain.domain(), Some("example.com"));
615 assert_eq!(host.domain(), Some("host.example.com"));
616 assert_eq!(svc.domain(), Some("svc.example.com"));
617 assert_eq!(web.domain(), Some("web.example.com"));
618 }
619
620 #[test]
621 fn target_domain_is_none_for_host_and_service_without_domain() {
622 assert_eq!(Target::Host(host(None)).domain(), None);
623 assert_eq!(
624 Target::Service(service(22, false, None, None)).domain(),
625 None
626 );
627 }
628
629 #[test]
630 fn protocol_serializes_lowercase() {
631 assert_eq!(serde_json::to_value(Protocol::Tcp).unwrap(), json!("tcp"));
632 assert_eq!(serde_json::to_value(Protocol::Udp).unwrap(), json!("udp"));
633 }
634
635 #[test]
636 fn discovery_source_serializes_snake_case() {
637 assert_eq!(
638 serde_json::to_value(DiscoverySource::CertificateTransparency).unwrap(),
639 json!("certificate_transparency")
640 );
641 assert_eq!(
642 serde_json::to_value(DiscoverySource::HiddenProbe).unwrap(),
643 json!("hidden_probe")
644 );
645 }
646
647 #[test]
648 fn target_serializes_with_kind_tag() {
649 let target = Target::Domain(DomainTarget {
650 domain: "example.com".into(),
651 source: DiscoverySource::UrlScan,
652 });
653 let value = serde_json::to_value(target).unwrap();
654 assert_eq!(value["kind"], json!("domain"));
655 assert_eq!(value["source"], json!("url_scan"));
656 }
657}