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