Skip to main content

gossan_techstack/
bridge.rs

1//! Bridge between the standalone `truestack` crate and panoram's internal types.
2//!
3//! Converts `truestack::Technology` → `gossan_core::Technology` and
4//! `truestack::HeaderFinding` → `secfinding::Finding`.
5
6use gossan_core::{ServiceTarget, Target, TechCategory, Technology, WebAssetTarget};
7use secfinding::Finding;
8
9/// Cap response body text to a safe maximum (2 MB).
10async fn bounded_text(resp: reqwest::Response, limit: usize) -> anyhow::Result<String> {
11    let mut buf = Vec::with_capacity(limit.min(4096));
12    let mut stream = resp.bytes_stream();
13    while let Some(chunk) = futures::StreamExt::next(&mut stream).await {
14        let chunk = chunk?;
15        let remaining = limit.saturating_sub(buf.len());
16        if remaining == 0 {
17            break;
18        }
19        let take = chunk.len().min(remaining);
20        buf.extend_from_slice(&chunk[..take]);
21    }
22    Ok(String::from_utf8_lossy(&buf).to_string())
23}
24
25/// Probe a single web service target and return a [`WebAssetTarget`] plus any
26/// security-header findings.
27pub async fn probe(
28    client: &reqwest::Client,
29    svc: ServiceTarget,
30) -> anyhow::Result<(WebAssetTarget, Vec<Finding>)> {
31    let base = svc
32        .base_url()
33        .ok_or_else(|| anyhow::anyhow!("no base url"))?;
34    let resp = client.get(base.as_str()).send().await?;
35
36    let status = resp.status().as_u16();
37    let headers: Vec<(String, String)> = resp
38        .headers()
39        .iter()
40        .map(|(k, v)| {
41            (
42                k.to_string(),
43                String::from_utf8_lossy(v.as_bytes()).to_string(),
44            )
45        })
46        .collect();
47
48    let body = bounded_text(resp, 2 * 1024 * 1024)
49        .await
50        .unwrap_or_default();
51    let title = truestack::html::extract_title(&body);
52
53    // ── Technology detection via truestack ────────────────────────────────
54    let mut ts_techs = truestack::fingerprints::detect(&headers, &body);
55
56    // Behavioral probing
57    truestack::behavior::identify(client, base.as_str(), &mut ts_techs)
58        .await
59        .ok();
60
61    // Post-process: excludes, requires, dedup, implied. truestack's
62    // `postprocess::apply` takes ownership of the Vec and returns the
63    // pruned/expanded set.
64    let rules = &truestack::fingerprints::RuleEngine::embedded().rules;
65    let mut ts_techs = truestack::postprocess::apply(ts_techs, rules);
66
67    // Version intel confidence adjustment
68    truestack::version_intel::assess(&mut ts_techs, &headers);
69
70    let tech: Vec<Technology> = ts_techs.into_iter().map(convert_technology).collect();
71
72    // ── Body hash — first 8 bytes of SHA-256, hex-encoded ────────────────
73    let body_hash = {
74        use sha2::{Digest, Sha256};
75        let hash = Sha256::digest(body.as_bytes());
76        Some(hex::encode(&hash[..8]))
77    };
78
79    // ── Favicon hash — async, best-effort ────────────────────────────────
80    let favicon_hash =
81        truestack::favicon::fetch_hash_limited(client, base.as_str(), 5 * 1024 * 1024).await;
82
83    // ── Security header audit via truestack ───────────────────────────────
84    // Rebuild each truestack-emitted Finding through secfinding's builder so
85    // the scanner name and target are stamped as panoram-side metadata
86    // (truestack doesn't know it's running under panoram). Finding's fields
87    // are immutable through accessors — the builder is the only way to
88    // re-stamp them.
89    let ts_findings = truestack::security_headers::audit(&headers);
90    let web_target = Target::Service(svc.clone());
91    let panoram_target = web_target.domain().unwrap_or("?").to_string();
92    let header_findings: Vec<Finding> = ts_findings
93        .into_iter()
94        .filter_map(|f| {
95            let mut builder = Finding::builder("techstack", panoram_target.clone(), f.severity())
96                .title(f.title().to_string())
97                .detail(f.detail().to_string())
98                .kind(f.kind());
99            for ev in f.evidence() {
100                builder = builder.evidence(ev.clone());
101            }
102            for tag in f.tags() {
103                builder = builder.tag(tag.to_string());
104            }
105            for cve in f.cve_ids() {
106                builder = builder.cve(cve.to_string());
107            }
108            if let Some(hint) = f.exploit_hint() {
109                builder = builder.exploit_hint(hint.to_string());
110            }
111            builder.build().ok()
112        })
113        .collect();
114
115    Ok((
116        WebAssetTarget {
117            url: base,
118            service: svc,
119            tech,
120            status,
121            title,
122            favicon_hash,
123            body_hash,
124            forms: vec![],
125            params: vec![],
126        },
127        header_findings,
128    ))
129}
130
131/// Convert a `truestack::Technology` into `gossan_core::Technology`.
132fn convert_technology(t: truestack::Technology) -> Technology {
133    Technology {
134        name: t.name,
135        version: t.version,
136        category: match t.category {
137            truestack::TechCategory::Cms => TechCategory::Cms,
138            truestack::TechCategory::Framework => TechCategory::Framework,
139            truestack::TechCategory::Language => TechCategory::Language,
140            truestack::TechCategory::Server => TechCategory::Server,
141            truestack::TechCategory::Cdn => TechCategory::Cdn,
142            truestack::TechCategory::Analytics => TechCategory::Analytics,
143            truestack::TechCategory::Security => TechCategory::Security,
144            truestack::TechCategory::Database => TechCategory::Database,
145            truestack::TechCategory::Os => TechCategory::Os,
146            truestack::TechCategory::Other => TechCategory::Other,
147        },
148        confidence: t.confidence,
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn convert_technology_preserves_name_version_and_confidence() {
158        let tech = convert_technology(truestack::Technology {
159            name: "nginx".into(),
160            version: Some("1.25".into()),
161            category: truestack::TechCategory::Server,
162            confidence: 92,
163        });
164        assert_eq!(tech.name, "nginx");
165        assert_eq!(tech.version.as_deref(), Some("1.25"));
166        assert!(matches!(tech.category, TechCategory::Server));
167        assert_eq!(tech.confidence, 92);
168    }
169}