Skip to main content

gossan_core/
target.rs

1//! Target types for the Gossan scanning pipeline.
2//!
3//! Targets flow through the pipeline as a tagged enum ([`Target`]), with each
4//! scanner stage consuming one variant and emitting the next. The progression
5//! is: `Domain → Host → Service → Web`.
6
7use serde::{Deserialize, Serialize};
8use std::net::IpAddr;
9use url::Url;
10
11/// How a target was discovered — preserved for auditing and deduplication.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
13#[serde(rename_all = "snake_case")]
14#[non_exhaustive]
15pub enum DiscoverySource {
16    /// Discovered via Seed.
17    Seed,
18    /// Discovered via CertificateTransparency.
19    CertificateTransparency,
20    /// Discovered via DnsBruteforce.
21    DnsBruteforce,
22    /// Discovered via PassiveDns.
23    PassiveDns,
24    /// Discovered via PortScan.
25    PortScan,
26    /// Discovered via TechStack.
27    TechStack,
28    /// Discovered via JsAnalysis.
29    JsAnalysis,
30    /// Discovered via HiddenProbe.
31    HiddenProbe,
32    /// From authenticated crawling (link following, form discovery).
33    /// Discovered via Crawl.
34    Crawl,
35    // Passive sources (no API key required)
36    /// Discovered via RapidDns.
37    RapidDns,
38    /// Discovered via AlienVault.
39    AlienVault,
40    /// Discovered via UrlScan.
41    UrlScan,
42    /// Discovered via CommonCrawl.
43    CommonCrawl,
44    // Passive sources (API key required)
45    /// Discovered via VirusTotal.
46    VirusTotal,
47    /// Discovered via SecurityTrails.
48    SecurityTrails,
49    /// Discovered via Shodan.
50    Shodan,
51    /// Discovered via GitHub.
52    GitHub,
53    /// Discovered via Censys.
54    Censys,
55    /// Discovered via BinaryEdge.
56    BinaryEdge,
57    /// Discovered via FullHunt.
58    FullHunt,
59    /// Discovered via Chaos.
60    Chaos,
61    /// Discovered via Bevigil.
62    Bevigil,
63    /// Discovered via Fofa.
64    Fofa,
65    /// Discovered via HunterIo.
66    HunterIo,
67    /// Discovered via Netlas.
68    Netlas,
69    /// Discovered via ZoomEye.
70    ZoomEye,
71    /// Discovered via C99.
72    C99,
73    /// Discovered via Quake.
74    Quake,
75    /// Discovered via ThreatBook.
76    ThreatBook,
77    /// Discovered via Anubis.
78    Anubis,
79    /// Discovered via Asn.
80    Asn,
81    /// Discovered via AsnLookup (Horizontal).
82    AsnLookup,
83    /// Discovered via ScmMapping (GitHub/GitLab).
84    ScmMapping,
85    // Passive sources (no API key / scraping)
86    /// Discovered via DnsDumpster.
87    DnsDumpster,
88    /// Discovered via CloudDiscovery.
89    CloudDiscovery,
90    /// Discovered via BufferOver.
91    BufferOver,
92    /// Discovered via Google CT.
93    GoogleCt,
94    /// Discovered via Facebook CT.
95    FacebookCt,
96    /// Discovered via Apple CT.
97    AppleCt,
98    /// Discovered via Cloudflare CT.
99    CloudflareCt,
100    /// Discovered via DigiCert CT.
101    DigiCertCt,
102    /// Discovered via Sectigo CT.
103    SectigoCt,
104    /// Discovered via IdenTrust CT.
105    IdenTrustCt,
106    /// Discovered via Entrust CT.
107    EntrustCt,
108    /// Discovered via GoDaddy CT.
109    GoDaddyCt,
110    /// Discovered via Amazon CT.
111    AmazonCt,
112    /// Discovered via PassiveTotal.
113    PassiveTotal,
114    /// Discovered via Spyse.
115    Spyse,
116    /// Discovered via Dnslytics.
117    Dnslytics,
118    /// Discovered via ThreatMiner.
119    ThreatMiner,
120    /// Discovered via PtrArchive.
121    PtrArchive,
122    /// Discovered via Riddler.
123    Riddler,
124    /// Discovered via SiteDossier.
125    SiteDossier,
126    /// Discovered via SonarSearch.
127    SonarSearch,
128    /// Discovered via Circl.
129    Circl,
130    /// Discovered via Mnemonic.
131    Mnemonic,
132    /// Discovered via FarsightDnsdb.
133    FarsightDnsdb,
134    /// Discovered via Sublist3r.
135    Sublist3r,
136    /// Discovered via Omnisint.
137    Omnisint,
138    /// Discovered via Digitorus.
139    Digitorus,
140    /// Discovered via Columbus.
141    Columbus,
142    /// Discovered via Crobat.
143    Crobat,
144    /// Discovered via ThreatCrowd.
145    ThreatCrowd,
146    /// Discovered via Rook.
147    Rook,
148    /// Discovered via Pugrecon.
149    Pugrecon,
150    /// Discovered via SubdomainCenter.
151    SubdomainCenter,
152    /// Discovered via Synapsint.
153    Synapsint,
154    /// Discovered via Jter.
155    Jter,
156    /// Discovered via Bing.
157    Bing,
158    /// Discovered via Baidu.
159    Baidu,
160    /// Discovered via DuckDuckGo.
161    DuckDuckGo,
162    /// Discovered via Yahoo.
163    Yahoo,
164    /// Discovered via Exalead.
165    Exalead,
166    /// Discovered via Ask.
167    Ask,
168    /// Discovered via GreyNoise.
169    GreyNoise,
170    /// Discovered via IpInfo.
171    IpInfo,
172    /// Discovered via ViewDns.
173    ViewDns,
174    /// Discovered via ZoneWalk.
175    ZoneWalk,
176}
177
178/// A discovered domain — the entry point for most scans.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct DomainTarget {
181    /// Fully qualified domain name (e.g. `api.example.com`).
182    pub domain: String,
183    /// How this domain was discovered.
184    pub source: DiscoverySource,
185}
186
187/// A source control repository (GitHub, GitLab, etc.).
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct RepositoryTarget {
190    /// The canonical URL of the repository.
191    pub url: Url,
192    /// Which SM platform hosts it.
193    pub service: ScmService,
194    /// How this repository was discovered.
195    pub source: DiscoverySource,
196    /// Optional branch/ref to scan.
197    pub branch: Option<String>,
198}
199
200/// Supported Source Control Management platforms.
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
202#[serde(rename_all = "lowercase")]
203#[non_exhaustive]
204pub enum ScmService {
205    /// GitHub.com or GitHub Enterprise.
206    GitHub,
207    /// GitLab.com or self-hosted GitLab.
208    GitLab,
209}
210
211/// A resolved host — an IP address with an optional reverse-DNS domain.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct HostTarget {
214    /// Resolved IP address (v4 or v6).
215    pub ip: IpAddr,
216    /// Associated domain, if known.
217    pub domain: Option<String>,
218}
219
220/// Transport layer protocol.
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
222#[serde(rename_all = "lowercase")]
223#[non_exhaustive]
224pub enum Protocol {
225    /// Transmission Control Protocol.
226    Tcp,
227    /// User Datagram Protocol.
228    Udp,
229}
230
231/// A network service discovered on a host — port, protocol, optional banner.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct ServiceTarget {
234    /// The host this service runs on.
235    pub host: HostTarget,
236    /// TCP/UDP port number.
237    pub port: u16,
238    /// Transport protocol.
239    pub protocol: Protocol,
240    /// Raw banner grabbed from the service (first ~1 KB).
241    pub banner: Option<String>,
242    /// Whether the service speaks TLS.
243    pub tls: bool,
244}
245
246impl ServiceTarget {
247    /// Returns `true` if this service is likely HTTP/HTTPS based on banner or port.
248    ///
249    /// Banner detection is prioritized over port matching since it's more reliable.
250    #[must_use]
251    pub fn is_web(&self) -> bool {
252        // Banner check first — most reliable signal
253        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    /// Constructs the base URL for this service (e.g. `https://example.com:8443/`).
278    ///
279    /// Returns `None` if URL construction fails.
280    #[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/// A detected technology fingerprint.
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct Technology {
302    /// Canonical technology name.
303    pub name: String,
304    /// Detected version string when the scanner can infer one.
305    pub version: Option<String>,
306    /// High-level technology category.
307    pub category: TechCategory,
308    /// 0–100 confidence score.
309    pub confidence: u8,
310}
311
312/// Technology category for fingerprinting classification.
313#[allow(clippy::doc_markdown)]
314#[derive(Debug, Clone, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case")]
316#[non_exhaustive]
317pub enum TechCategory {
318    /// Content management system (WordPress, Drupal, etc.).
319    Cms,
320    /// Web framework (React, Angular, Rails, etc.).
321    Framework,
322    /// Programming language (PHP, Python, etc.).
323    Language,
324    /// Web server (Nginx, Apache, etc.).
325    Server,
326    /// Content delivery network (Cloudflare, Akamai, etc.).
327    Cdn,
328    /// Analytics and tracking (Google Analytics, Segment, etc.).
329    Analytics,
330    /// Security products (WAF, CAPTCHA, etc.).
331    Security,
332    /// Database engine (MySQL, PostgreSQL, Redis, etc.).
333    Database,
334    /// Operating system.
335    Os,
336    /// Unclassified technology.
337    Other,
338}
339
340/// A confirmed HTTP(S) asset with resolved tech stack.
341#[allow(clippy::doc_markdown)]
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct WebAssetTarget {
344    /// Canonical URL of the confirmed web asset.
345    pub url: Url,
346    /// Network service that served this web asset.
347    pub service: ServiceTarget,
348    /// Technology fingerprints detected on the asset.
349    pub tech: Vec<Technology>,
350    /// HTTP status code observed during probing.
351    pub status: u16,
352    /// HTML title extracted from the response, when present.
353    pub title: Option<String>,
354    /// Shodan-compatible MurmurHash3 of the favicon (i32 signed).
355    /// Use this to pivot on Shodan: `http.favicon.hash:{value}`
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub favicon_hash: Option<i32>,
358    /// HTTP response body hash (SHA-256 hex, first 16 bytes).
359    /// Enables deduplication of identical pages across subdomains.
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub body_hash: Option<String>,
362    /// HTML forms discovered on this page (action URLs, methods, input fields).
363    #[serde(default, skip_serializing_if = "Vec::is_empty")]
364    pub forms: Vec<DiscoveredForm>,
365    /// Query/body parameters observed or brute-forced on this endpoint.
366    #[serde(default, skip_serializing_if = "Vec::is_empty")]
367    pub params: Vec<DiscoveredParam>,
368}
369
370/// An HTML form discovered during crawling.
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct DiscoveredForm {
373    /// The form's action URL (absolute or relative).
374    pub action: String,
375    /// HTTP method (GET, POST, etc.).
376    pub method: String,
377    /// Input field names and types.
378    pub inputs: Vec<(String, String)>,
379}
380
381/// A parameter discovered on an endpoint (query string, POST body, or brute-forced).
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct DiscoveredParam {
384    /// Parameter name.
385    pub name: String,
386    /// Where this parameter appears.
387    pub location: ParamLocation,
388    /// How it was discovered.
389    pub source: ParamSource,
390}
391
392/// Where a parameter is sent.
393#[derive(Debug, Clone, Serialize, Deserialize)]
394#[serde(rename_all = "snake_case")]
395#[non_exhaustive]
396pub enum ParamLocation {
397    /// In the URL query string.
398    Query,
399    /// In the POST body.
400    Body,
401    /// In a URL path segment.
402    Path,
403    /// In an HTTP header.
404    Header,
405}
406
407/// How a parameter was discovered.
408#[derive(Debug, Clone, Serialize, Deserialize)]
409#[serde(rename_all = "snake_case")]
410#[non_exhaustive]
411pub enum ParamSource {
412    /// Extracted from an HTML form.
413    HtmlForm,
414    /// Observed in a URL during crawling.
415    UrlObserved,
416    /// Discovered via brute-force probing.
417    BruteForce,
418    /// Extracted from an OpenAPI/Swagger spec.
419    ApiSpec,
420    /// Extracted from JavaScript source code.
421    JsAnalysis,
422}
423
424/// The single input/output type flowing through the pipeline.
425/// Every scanner consumes a `Vec<Target>` and emits `Vec<Target>` + `Vec<Finding>`.
426#[derive(Debug, Clone, Serialize, Deserialize)]
427#[serde(tag = "kind", rename_all = "snake_case")]
428#[non_exhaustive]
429pub enum Target {
430    /// Target is a Domain.
431    Domain(DomainTarget),
432    /// Target is a Host.
433    Host(HostTarget),
434    /// Target is a Service.
435    Service(ServiceTarget),
436    /// A web asset found during a crawl or via JS analysis.
437    /// Target is a Web.
438    Web(Box<WebAssetTarget>),
439    /// A network range or CIDR subnet (e.g. `192.168.1.0/24`).
440    /// Target is a Network.
441    Network(NetworkTarget),
442    /// A source control repository.
443    /// Target is a Repository.
444    Repository(RepositoryTarget),
445    /// An internal package name found in dependency files.
446    /// Target is an InternalPackage.
447    InternalPackage(InternalPackageTarget),
448}
449
450/// An internal package name discovered in dependency files.
451#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct InternalPackageTarget {
453    /// Name of the package (e.g. `@myorg/private-utils`).
454    pub name: String,
455    /// URL of the repo where it was found.
456    pub source_repo: Url,
457    /// Ecosystem (npm, pip, go, etc.).
458    pub ecosystem: String,
459}
460
461/// A network range discovered via ASN mapping or cloud metadata.
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct NetworkTarget {
464    /// CIDR notation (e.g. `1.2.3.0/24`).
465    pub cidr: String,
466    /// How this network was discovered.
467    pub source: DiscoverySource,
468}
469
470impl Target {
471    /// Returns the domain name associated with this target, if any.
472    #[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            // Prefer the service's recorded domain when present —
479            // probes that derive bait origins (CORS, host-header, etc.)
480            // need the real DNS name, not whatever happens to be in the
481            // URL (which may be an IP or an internal name). Fall back
482            // to the URL host only when no service domain is set.
483            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    /// Returns the IP address associated with this target, if any.
495    #[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    /// Returns a base URL string for this target (e.g. `https://example.com/`).
506    #[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}