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 | 3000 | 3443 | 4443 | 4433 | 5000 | 5443
259                    | 7443 | 8000 | 8080 | 8443 | 8888
260                    | 9000 | 9090 | 9443 | 10443
261            )
262    }
263
264    /// Constructs the base URL for this service (e.g. `https://example.com:8443/`).
265    ///
266    /// Returns `None` if URL construction fails.
267    #[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/// A detected technology fingerprint.
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct Technology {
289    /// Canonical technology name.
290    pub name: String,
291    /// Detected version string when the scanner can infer one.
292    pub version: Option<String>,
293    /// High-level technology category.
294    pub category: TechCategory,
295    /// 0–100 confidence score.
296    pub confidence: u8,
297}
298
299/// Technology category for fingerprinting classification.
300#[allow(clippy::doc_markdown)]
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(rename_all = "snake_case")]
303#[non_exhaustive]
304pub enum TechCategory {
305    /// Content management system (WordPress, Drupal, etc.).
306    Cms,
307    /// Web framework (React, Angular, Rails, etc.).
308    Framework,
309    /// Programming language (PHP, Python, etc.).
310    Language,
311    /// Web server (Nginx, Apache, etc.).
312    Server,
313    /// Content delivery network (Cloudflare, Akamai, etc.).
314    Cdn,
315    /// Analytics and tracking (Google Analytics, Segment, etc.).
316    Analytics,
317    /// Security products (WAF, CAPTCHA, etc.).
318    Security,
319    /// Database engine (MySQL, PostgreSQL, Redis, etc.).
320    Database,
321    /// Operating system.
322    Os,
323    /// Unclassified technology.
324    Other,
325}
326
327/// A confirmed HTTP(S) asset with resolved tech stack.
328#[allow(clippy::doc_markdown)]
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct WebAssetTarget {
331    /// Canonical URL of the confirmed web asset.
332    pub url: Url,
333    /// Network service that served this web asset.
334    pub service: ServiceTarget,
335    /// Technology fingerprints detected on the asset.
336    pub tech: Vec<Technology>,
337    /// HTTP status code observed during probing.
338    pub status: u16,
339    /// HTML title extracted from the response, when present.
340    pub title: Option<String>,
341    /// Shodan-compatible MurmurHash3 of the favicon (i32 signed).
342    /// Use this to pivot on Shodan: `http.favicon.hash:{value}`
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub favicon_hash: Option<i32>,
345    /// HTTP response body hash (SHA-256 hex, first 16 bytes).
346    /// Enables deduplication of identical pages across subdomains.
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub body_hash: Option<String>,
349    /// HTML forms discovered on this page (action URLs, methods, input fields).
350    #[serde(default, skip_serializing_if = "Vec::is_empty")]
351    pub forms: Vec<DiscoveredForm>,
352    /// Query/body parameters observed or brute-forced on this endpoint.
353    #[serde(default, skip_serializing_if = "Vec::is_empty")]
354    pub params: Vec<DiscoveredParam>,
355}
356
357/// An HTML form discovered during crawling.
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct DiscoveredForm {
360    /// The form's action URL (absolute or relative).
361    pub action: String,
362    /// HTTP method (GET, POST, etc.).
363    pub method: String,
364    /// Input field names and types.
365    pub inputs: Vec<(String, String)>,
366}
367
368/// A parameter discovered on an endpoint (query string, POST body, or brute-forced).
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct DiscoveredParam {
371    /// Parameter name.
372    pub name: String,
373    /// Where this parameter appears.
374    pub location: ParamLocation,
375    /// How it was discovered.
376    pub source: ParamSource,
377}
378
379/// Where a parameter is sent.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381#[serde(rename_all = "snake_case")]
382#[non_exhaustive]
383pub enum ParamLocation {
384    /// In the URL query string.
385    Query,
386    /// In the POST body.
387    Body,
388    /// In a URL path segment.
389    Path,
390    /// In an HTTP header.
391    Header,
392}
393
394/// How a parameter was discovered.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396#[serde(rename_all = "snake_case")]
397#[non_exhaustive]
398pub enum ParamSource {
399    /// Extracted from an HTML form.
400    HtmlForm,
401    /// Observed in a URL during crawling.
402    UrlObserved,
403    /// Discovered via brute-force probing.
404    BruteForce,
405    /// Extracted from an OpenAPI/Swagger spec.
406    ApiSpec,
407    /// Extracted from JavaScript source code.
408    JsAnalysis,
409}
410
411/// The single input/output type flowing through the pipeline.
412/// Every scanner consumes a Vec<Target> and emits Vec<Target> + Vec<Finding>.
413#[derive(Debug, Clone, Serialize, Deserialize)]
414#[serde(tag = "kind", rename_all = "snake_case")]
415#[non_exhaustive]
416pub enum Target {
417    /// Target is a Domain.
418    Domain(DomainTarget),
419    /// Target is a Host.
420    Host(HostTarget),
421    /// Target is a Service.
422    Service(ServiceTarget),
423    /// A web asset found during a crawl or via JS analysis.
424    /// Target is a Web.
425    Web(Box<WebAssetTarget>),
426    /// A network range or CIDR subnet (e.g. `192.168.1.0/24`).
427    /// Target is a Network.
428    Network(NetworkTarget),
429    /// A source control repository.
430    /// Target is a Repository.
431    Repository(RepositoryTarget),
432    /// An internal package name found in dependency files.
433    /// Target is an InternalPackage.
434    InternalPackage(InternalPackageTarget),
435}
436
437/// An internal package name discovered in dependency files.
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct InternalPackageTarget {
440    /// Name of the package (e.g. `@myorg/private-utils`).
441    pub name: String,
442    /// URL of the repo where it was found.
443    pub source_repo: Url,
444    /// Ecosystem (npm, pip, go, etc.).
445    pub ecosystem: String,
446}
447
448/// A network range discovered via ASN mapping or cloud metadata.
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct NetworkTarget {
451    /// CIDR notation (e.g. `1.2.3.0/24`).
452    pub cidr: String,
453    /// How this network was discovered.
454    pub source: DiscoverySource,
455}
456
457impl Target {
458    /// Returns the domain name associated with this target, if any.
459    #[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            // Prefer the service's recorded domain when present —
466            // probes that derive bait origins (CORS, host-header, etc.)
467            // need the real DNS name, not whatever happens to be in the
468            // URL (which may be an IP or an internal name). Fall back
469            // to the URL host only when no service domain is set.
470            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    /// Returns the IP address associated with this target, if any.
482    #[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    /// Returns a base URL string for this target (e.g. `https://example.com/`).
493    #[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}