guests/static-scan/src/lib.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! The static-scanner guest. **Heimdall is a defensive sandbox.**
//!
//! One wasm guest, five checks (docs/API.md, "Static-scanner guests"):
//! behavior (modgunn::behavior), typosquat and dependency confusion
//! (modgunn::scip::typosquat_findings), registry (modgunn::registry) and
//! malicious (modgunn::feeds::parse_malicious). The input is a
//! [`heimdall_scan::ScanInput`] envelope; the `purl` parameter names the
//! package. The guest emits one canonical [`heimdall_scan::Report`] as
//! [`heimdall_scan::REPORT_NAME`] and returns `ok`; it refuses (`err`, with a
//! bracketed `heimdall-scan-<n>` code) only when it cannot read the job at all.
//! A check that cannot judge is `unknown`, never `clean`.
pub mod archive;
pub mod checks;
pub mod params;
mod zip;
#[cfg(target_arch = "wasm32")]
mod wasm;
use heimdall_scan::{codes, Check, DataDigest, DataKind, Report, ScanInputRef, ScannerId, Subject};
use sha2::{Digest, Sha256};
/// The scanner's name in every report.
pub const SCANNER: &str = "heimdall-static-scan";
/// The modgunn release whose code judges (pinned `=` in Cargo.toml; a test
/// checks the two agree).
pub const MODGUNN_VERSION: &str = "0.0.8";
/// What one run gives: the report (and its canonical bytes), or a refusal.
/// One per job, so the size difference of the variants does not matter.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
pub enum Outcome {
Report { report: Report, json: Vec<u8> },
Refused { reason: String },
}
impl Outcome {
/// The note of an accepted run.
pub fn note(report: &Report) -> String {
let per: Vec<String> = report
.checks
.iter()
.map(|c| format!("{}={}", c.check, c.verdict.name()))
.collect();
format!(
"{}: {} ({})",
heimdall_scan::REPORT_NAME,
report.verdict.name(),
per.join(", ")
)
}
}
fn hex(b: &[u8]) -> String {
let mut s = String::with_capacity(b.len() * 2);
for x in b {
s.push_str(&format!("{x:02x}"));
}
s
}
fn sha256_hex(b: &[u8]) -> String {
hex(&Sha256::digest(b))
}
/// Run the scanner on an envelope with the job's string pairs.
pub fn scan_pairs<'a>(
input: &[u8],
pairs: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Outcome {
match scan_inner(input, pairs) {
Ok(report) => {
let json = report.to_canonical_json();
Outcome::Report { report, json }
}
Err(reason) => Outcome::Refused { reason },
}
}
fn scan_inner<'a>(
input: &[u8],
pairs: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Result<Report, String> {
let p = params::parse(pairs)?;
let env = ScanInputRef::decode(input).map_err(|e| params::refusal(codes::BAD_INPUT, e))?;
let sha = sha256_hex(env.artifact);
let data = |k: DataKind| env.data.get(&k).copied();
// The archive is walked once, and only when a check reads it.
let walk = p
.checks
.contains(&Check::Behavior)
.then(|| archive::walk(env.artifact, &p.limits));
let checks = p
.checks
.iter()
.map(|c| match c {
Check::Behavior => checks::behavior(&p.purl, walk.as_ref().unwrap()),
Check::Typosquat => checks::typosquat(&p.purl, data(DataKind::PopularNames)),
Check::DependencyConfusion => {
checks::dependency_confusion(&p.purl, data(DataKind::InternalNamespaces), p.origin)
}
Check::Registry => checks::registry(&p.purl, data(DataKind::Registry), &sha),
Check::Malicious => checks::malicious(&p.purl, data(DataKind::Malicious), &sha),
})
.collect::<Vec<_>>();
let report = Report {
scanner: ScannerId {
name: SCANNER.into(),
version: env!("CARGO_PKG_VERSION").into(),
modgunn: MODGUNN_VERSION.into(),
},
subject: Subject {
purl: p.purl_text.clone(),
ecosystem: p.purl.ecosystem().into(),
name: p.purl.package_name(),
version: p.purl.version.clone(),
sha256: sha,
bytes: env.artifact.len() as u64,
},
data: {
let mut d: Vec<DataDigest> = env
.data
.iter()
.map(|(k, v)| DataDigest {
name: k.name().into(),
sha256: sha256_hex(v),
bytes: v.len() as u64,
})
.collect();
d.sort_by(|a, b| a.name.cmp(&b.name));
d
},
verdict: Report::fold(&checks),
checks,
};
debug_assert!(report.validate().is_ok());
Ok(report)
}