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

nordisk/heimdall

crates/heimdall-scan/src/report.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! The findings document a static-scanner guest emits ([`crate::REPORT_NAME`]).
//! Heimdall is a defensive sandbox.
//!
//! One [`CheckResult`] per check the job asked for, in [`Check`] order. Its
//! verdict follows from its contents, and [`Report::validate`] proves it:
//!
//! * `findings` when it has at least one finding (even if the check could
//!   not finish: then `unknown` says why as well),
//! * otherwise `unknown` when it could not judge (`unknown` says why),
//! * otherwise `clean`.
//!
//! The overall verdict is `findings` if any check has findings, else
//! `unknown` if any check is unknown, else `clean`. A caller holds the
//! package on anything but `clean`. The JSON is canonical: fields in the
//! declared order, data sorted by name, no floats, compact.

use std::fmt;

use serde::{Deserialize, Serialize};

use crate::input::DataKind;

/// A check a static scanner runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Check {
    /// Install hooks, network at install, URLs, entropy, sensitive APIs
    /// (modgunn::behavior), and the archive's own shape (traversal names,
    /// links, bombs).
    Behavior,
    /// A name one edit from a popular name (modgunn::scip::typosquat_findings).
    Typosquat,
    /// An internal-only name resolved from a public registry (same function).
    DependencyConfusion,
    /// A yanked, deprecated or retracted version (modgunn::registry).
    Registry,
    /// A package on the malicious-package feed (modgunn::feeds::parse_malicious).
    Malicious,
}

impl Check {
    pub const ALL: [Check; 5] = [
        Check::Behavior,
        Check::Typosquat,
        Check::DependencyConfusion,
        Check::Registry,
        Check::Malicious,
    ];

    pub fn name(self) -> &'static str {
        match self {
            Check::Behavior => "behavior",
            Check::Typosquat => "typosquat",
            Check::DependencyConfusion => "dependency-confusion",
            Check::Registry => "registry",
            Check::Malicious => "malicious",
        }
    }

    pub fn from_name(s: &str) -> Option<Check> {
        Check::ALL.into_iter().find(|c| c.name() == s)
    }

    /// The data snapshot the check needs, if any.
    pub fn needs(self) -> Option<DataKind> {
        match self {
            Check::Behavior => None,
            Check::Typosquat => Some(DataKind::PopularNames),
            Check::DependencyConfusion => Some(DataKind::InternalNamespaces),
            Check::Registry => Some(DataKind::Registry),
            Check::Malicious => Some(DataKind::Malicious),
        }
    }
}

impl fmt::Display for Check {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.name())
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Verdict {
    Clean,
    Findings,
    Unknown,
}

impl Verdict {
    /// Fold two verdicts: findings over unknown over clean.
    pub fn worst(self, other: Verdict) -> Verdict {
        use Verdict::*;
        match (self, other) {
            (Findings, _) | (_, Findings) => Findings,
            (Unknown, _) | (_, Unknown) => Unknown,
            _ => Clean,
        }
    }

    pub fn name(self) -> &'static str {
        match self {
            Verdict::Clean => "clean",
            Verdict::Findings => "findings",
            Verdict::Unknown => "unknown",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Severity {
    Info,
    Low,
    Medium,
    High,
    Critical,
}

/// One thing a check found.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Finding {
    /// Stable id: modgunn's (`MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL`, a `MAL-…`
    /// id, …) or the guest's own (`HEIMDALL-SCAN-…`).
    pub id: String,
    pub severity: Severity,
    pub summary: String,
    /// The archive member it is about, when there is one (escaped, at most
    /// 256 characters).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
}

/// Why a check could not judge. `code` is stable, `detail` is for people.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UnknownReason {
    pub code: String,
    pub detail: String,
}

/// The stable `unknown` codes.
pub mod unknown {
    /// The check's data snapshot is not in the envelope.
    pub const DATA_MISSING: &str = "data-missing";
    /// The snapshot is there but unreadable, empty where it may not be, or
    /// about another package or version.
    pub const DATA_INVALID: &str = "data-invalid";
    /// The check has no reader for this ecosystem or artifact kind.
    pub const UNSUPPORTED_ECOSYSTEM: &str = "unsupported-ecosystem";
    /// The artifact is not an archive the guest reads, or it is corrupt.
    pub const ARCHIVE_UNREADABLE: &str = "archive-unreadable";
    /// The artifact unpacks past a limit (bytes, entries, a manifest's size).
    pub const ARCHIVE_OVER_LIMITS: &str = "archive-over-limits";
    /// The archive has no manifest the check reads (no package.json, no
    /// setup.py / pyproject.toml / setup.cfg, no wheel metadata).
    pub const NO_MANIFEST: &str = "no-manifest";
    /// The purl has no version and the check needs one.
    pub const NO_VERSION: &str = "no-version";
    /// Dependency confusion needs the `origin` parameter.
    pub const ORIGIN_MISSING: &str = "origin-missing";

    pub const ALL: [&str; 8] = [
        DATA_MISSING,
        DATA_INVALID,
        UNSUPPORTED_ECOSYSTEM,
        ARCHIVE_UNREADABLE,
        ARCHIVE_OVER_LIMITS,
        NO_MANIFEST,
        NO_VERSION,
        ORIGIN_MISSING,
    ];
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CheckResult {
    pub check: Check,
    pub verdict: Verdict,
    pub findings: Vec<Finding>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub unknown: Option<UnknownReason>,
}

impl CheckResult {
    /// A result whose verdict follows from its findings and unknown reason.
    pub fn new(check: Check, findings: Vec<Finding>, unknown: Option<UnknownReason>) -> Self {
        let verdict = if !findings.is_empty() {
            Verdict::Findings
        } else if unknown.is_some() {
            Verdict::Unknown
        } else {
            Verdict::Clean
        };
        CheckResult {
            check,
            verdict,
            findings,
            unknown,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScannerId {
    pub name: String,
    pub version: String,
    /// The modgunn version whose code judged.
    pub modgunn: String,
}

/// What was scanned.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Subject {
    /// The `purl` parameter, verbatim.
    pub purl: String,
    pub ecosystem: String,
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    /// SHA-256 of the artifact bytes, lower-case hex.
    pub sha256: String,
    pub bytes: u64,
}

/// One data snapshot the checks read, by digest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DataDigest {
    pub name: String,
    pub sha256: String,
    pub bytes: u64,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Report {
    pub scanner: ScannerId,
    pub subject: Subject,
    pub data: Vec<DataDigest>,
    pub checks: Vec<CheckResult>,
    pub verdict: Verdict,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReportError {
    Json(String),
    NotCanonical,
    NoChecks,
    ChecksOutOfOrder,
    CheckVerdict(Check),
    UnknownCode(Check, String),
    Overall,
    DataOrder,
    SubjectMismatch(&'static str),
}

impl fmt::Display for ReportError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ReportError::Json(e) => write!(f, "findings document is not valid JSON: {e}"),
            ReportError::NotCanonical => write!(f, "findings document is not canonical"),
            ReportError::NoChecks => write!(f, "findings document has no checks"),
            ReportError::ChecksOutOfOrder => {
                write!(f, "checks are not unique and in contract order")
            }
            ReportError::CheckVerdict(c) => {
                write!(
                    f,
                    "check {c}: its verdict does not follow from its contents"
                )
            }
            ReportError::UnknownCode(c, code) => write!(f, "check {c}: unknown code {code:?}"),
            ReportError::Overall => write!(f, "the overall verdict is not the fold of the checks"),
            ReportError::DataOrder => write!(f, "data digests are not unique and sorted"),
            ReportError::SubjectMismatch(w) => {
                write!(f, "the document is about another package ({w})")
            }
        }
    }
}

impl std::error::Error for ReportError {}

impl Report {
    /// Fold the checks into the overall verdict.
    pub fn fold(checks: &[CheckResult]) -> Verdict {
        checks
            .iter()
            .fold(Verdict::Clean, |v, c| v.worst(c.verdict))
    }

    /// The canonical bytes (compact JSON, declared field order).
    pub fn to_canonical_json(&self) -> Vec<u8> {
        serde_json::to_vec(self).expect("a Report always serializes")
    }

    /// Parse and [`validate`](Self::validate) a document; it must also be
    /// byte-for-byte canonical, so one result has one encoding.
    pub fn from_canonical_json(b: &[u8]) -> Result<Report, ReportError> {
        let r: Report = serde_json::from_slice(b).map_err(|e| ReportError::Json(e.to_string()))?;
        if r.to_canonical_json() != b {
            return Err(ReportError::NotCanonical);
        }
        r.validate()?;
        Ok(r)
    }

    /// Every rule of the module docs.
    pub fn validate(&self) -> Result<(), ReportError> {
        if self.checks.is_empty() {
            return Err(ReportError::NoChecks);
        }
        if !self.checks.windows(2).all(|w| w[0].check < w[1].check) {
            return Err(ReportError::ChecksOutOfOrder);
        }
        for c in &self.checks {
            let want = CheckResult::new(c.check, c.findings.clone(), c.unknown.clone()).verdict;
            if c.verdict != want {
                return Err(ReportError::CheckVerdict(c.check));
            }
            if let Some(u) = &c.unknown {
                if !unknown::ALL.contains(&u.code.as_str()) {
                    return Err(ReportError::UnknownCode(c.check, u.code.clone()));
                }
            }
        }
        if Report::fold(&self.checks) != self.verdict {
            return Err(ReportError::Overall);
        }
        if !self.data.windows(2).all(|w| w[0].name < w[1].name) {
            return Err(ReportError::DataOrder);
        }
        Ok(())
    }

    /// The caller's check that the document is about what it sent: the purl
    /// it passed and the SHA-256 of the artifact it put in the envelope.
    pub fn check_subject(&self, purl: &str, artifact_sha256_hex: &str) -> Result<(), ReportError> {
        if self.subject.purl != purl {
            return Err(ReportError::SubjectMismatch("purl"));
        }
        if !self
            .subject
            .sha256
            .eq_ignore_ascii_case(artifact_sha256_hex)
        {
            return Err(ReportError::SubjectMismatch("sha256"));
        }
        Ok(())
    }

    /// The result of one check, if it ran.
    pub fn check(&self, c: Check) -> Option<&CheckResult> {
        self.checks.iter().find(|r| r.check == c)
    }
}