Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

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)
}