server/inspect/src/lib.rs
//! **Holger's file and archive inspector, and its malware hook.**
//!
//! Every artifact the intake chain ingests passes [`Inspector::inspect`]. The
//! inspector unpacks every container the INGEST walk meets (zip, tar, gzip, bzip2,
//! xz, zstd, ar and so a .deb, an rpm's cpio payload, a .conda, and any of them
//! nested) and runs the checks on what the archive contains, not on its name. An
//! archive it cannot open (7z) BLOCKS as [`FindingKind::NotUnpacked`] (lane HOL-NCSC):
//! members nobody inspected never pass. xz is decoded in memory (pure-Rust lzma-rs),
//! so it is held to `max_nested_bytes` as well:
//!
//! | Check | Finding | Default |
//! |---|---|---|
//! | Zip bomb: one stream expands past `max_ratio`× its compressed size | [`FindingKind::ZipBomb`] | 100×, once a stream is past 16 MiB |
//! | Total expansion of everything unpacked | [`FindingKind::ExpansionLimit`] | 4 GiB |
//! | One member's size | [`FindingKind::EntryTooLarge`] | 1 GiB |
//! | Member count, all levels | [`FindingKind::TooManyEntries`] | 100 000 |
//! | Nested archives | [`FindingKind::NestingTooDeep`] | 3 levels below the artifact |
//! | A nested archive held in memory to open it | [`FindingKind::NestedTooLarge`] | 256 MiB |
//! | `..` components, NUL bytes | [`FindingKind::PathTraversal`] | always |
//! | Absolute paths (`/x`, `\x`, `C:`) | [`FindingKind::AbsolutePath`] | always |
//! | Symlinks and hard links that leave the archive root | [`FindingKind::SymlinkEscape`] | always |
//! | Magic bytes against the declared format and the extension | [`FindingKind::MimeMismatch`] | always |
//! | ELF / PE / Mach-O binaries, `#!` scripts | [`FindingKind::Executable`], [`FindingKind::Script`] | per format, see [`ExecPolicy::default_for`] |
//!
//! The limits are [`Limits`], every one configurable from the RON config, each
//! with the default above. The numbers follow update-bundle's archive limits
//! (`MAX_EXPANSION_RATIO = 100`, `MAX_MEMBERS = 100_000`); the floor is lower
//! than the bundle's 64 MiB because a package, unlike a bundle, has no reason
//! to carry tens of megabytes of near-empty data.
//!
//! The malware hook is [`malware`]: a [`malware::MalwareScanner`] trait with a
//! ClamAV adapter (clamd socket or `clamscan`) and a YARA adapter. Both are
//! optional. An engine that is not installed reports "not configured", and a
//! scanner marked `required` that is not configured blocks.
//!
//! The crate is a leaf: it knows nothing of the server, the intake states or
//! the evidence store. It returns [`Findings`], and the caller turns their
//! [`Verdict`] into evidence.
pub mod binmeta;
pub mod image;
pub mod magic;
pub mod malware;
pub mod rpmdb;
mod walk;
pub use walk::Visit;
pub mod yara_lite;
use std::collections::BTreeMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
pub use magic::Kind;
// ── the declared format ─────────────────────────────────────────────────────
/// The format an artifact says it is, from its name (and its repository).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Format {
/// A Python wheel (`.whl`, a zip).
Wheel,
/// A Python source distribution (`.tar.gz` or `.zip` in a pip repository).
Sdist,
/// A Java archive (`.jar`, `.war`, `.ear`, `.aar`).
Jar,
/// A Rust `.crate` (a tar.gz).
Crate,
/// An npm tarball (`.tgz`).
Npm,
/// A NuGet package (`.nupkg`, a zip).
Nuget,
/// A Ruby gem (`.gem`, a tar).
Gem,
/// A Helm chart (`.tgz` in a helm repository).
Helm,
/// A Debian package (`.deb`, an ar).
Deb,
/// An RPM package.
Rpm,
/// A conda package (`.conda` zip or `.tar.bz2`).
Conda,
/// An OCI image layout (`.oci.tar`, a tar of `index.json` and
/// `blobs/sha256/…`): an image a pull-through brought in whole. Its layers
/// are a root file system, so a symlink inside a layer resolves in the
/// container's own root (`etc/mtab -> /proc/mounts`), never on this host.
OciImage,
/// Anything else: only the extension promises anything.
Generic,
}
impl Format {
/// Every format, for the per-format policy table.
pub const ALL: [Format; 13] = [
Format::Wheel,
Format::Sdist,
Format::Jar,
Format::Crate,
Format::Npm,
Format::Nuget,
Format::Gem,
Format::Helm,
Format::Deb,
Format::Rpm,
Format::Conda,
Format::OciImage,
Format::Generic,
];
pub fn name(&self) -> &'static str {
match self {
Format::Wheel => "wheel",
Format::Sdist => "sdist",
Format::Jar => "jar",
Format::Crate => "crate",
Format::Npm => "npm",
Format::Nuget => "nuget",
Format::Gem => "gem",
Format::Helm => "helm",
Format::Deb => "deb",
Format::Rpm => "rpm",
Format::Conda => "conda",
Format::OciImage => "oci_image",
Format::Generic => "generic",
}
}
/// The format a file name and its repository's ecosystem declare.
/// `ecosystem` is holger's lowercase format name (`pip`, `npm`, `helm`,
/// `maven3`, `raw`, …); an empty string means "unknown".
pub fn declared(name: &str, ecosystem: &str) -> Format {
let n = name.to_ascii_lowercase();
let eco = ecosystem.to_ascii_lowercase();
let ends = |s: &str| n.ends_with(s);
if ends(".oci.tar") {
Format::OciImage
} else if ends(".whl") {
Format::Wheel
} else if ends(".jar") || ends(".war") || ends(".ear") || ends(".aar") {
Format::Jar
} else if ends(".crate") {
Format::Crate
} else if ends(".nupkg") {
Format::Nuget
} else if ends(".gem") {
Format::Gem
} else if ends(".deb") {
Format::Deb
} else if ends(".rpm") {
Format::Rpm
} else if ends(".conda") || (eco == "conda" && ends(".tar.bz2")) {
Format::Conda
} else if ends(".tgz") && eco == "helm" {
Format::Helm
} else if ends(".tgz") && eco == "npm" {
Format::Npm
} else if eco == "pip" && (ends(".tar.gz") || ends(".zip")) {
Format::Sdist
} else {
Format::Generic
}
}
/// The container kinds this format must be, or `None` when the format
/// promises nothing beyond its extension.
pub fn expected_kinds(&self) -> Option<&'static [Kind]> {
Some(match self {
Format::Wheel | Format::Jar | Format::Nuget => &[Kind::Zip],
Format::Sdist => &[Kind::Gzip, Kind::Zip],
Format::Crate | Format::Npm | Format::Helm => &[Kind::Gzip],
Format::Gem => &[Kind::Tar],
Format::Deb => &[Kind::Ar],
Format::Rpm => &[Kind::Rpm],
Format::Conda => &[Kind::Zip, Kind::Bzip2],
Format::OciImage => &[Kind::Tar],
Format::Generic => return None,
})
}
}
// ── the limits ──────────────────────────────────────────────────────────────
/// Every expansion limit. Each field has a safe default and can be set alone
/// in the RON config: `limits: (max_depth: 2)` keeps the other defaults.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Limits {
/// A stream may expand to at most this many times its compressed size…
pub max_ratio: u64,
/// …once it is past this many bytes. Below it the ratio is not enforced,
/// since a small, highly compressible file legitimately has a huge ratio.
pub ratio_floor_bytes: u64,
/// Bytes unpacked in total, every level together.
pub max_total_expanded: u64,
/// Bytes in one member.
pub max_entry_bytes: u64,
/// Members in total, every level together.
pub max_entries: u64,
/// How many archives may nest below the artifact itself. A gzip layer
/// around a tar is one level, not two.
pub max_depth: u32,
/// A nested archive is read into memory to open it; at most this many bytes.
pub max_nested_bytes: u64,
/// A member is handed to the scanners that read members (YARA) when it is at
/// most this big.
pub max_scan_member_bytes: u64,
}
impl Limits {
pub const DEFAULT_MAX_RATIO: u64 = 100;
pub const DEFAULT_RATIO_FLOOR_BYTES: u64 = 16 << 20;
pub const DEFAULT_MAX_TOTAL_EXPANDED: u64 = 4 << 30;
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 1 << 30;
pub const DEFAULT_MAX_ENTRIES: u64 = 100_000;
pub const DEFAULT_MAX_DEPTH: u32 = 3;
pub const DEFAULT_MAX_NESTED_BYTES: u64 = 256 << 20;
pub const DEFAULT_MAX_SCAN_MEMBER_BYTES: u64 = 16 << 20;
}
impl Default for Limits {
fn default() -> Self {
Self {
max_ratio: Self::DEFAULT_MAX_RATIO,
ratio_floor_bytes: Self::DEFAULT_RATIO_FLOOR_BYTES,
max_total_expanded: Self::DEFAULT_MAX_TOTAL_EXPANDED,
max_entry_bytes: Self::DEFAULT_MAX_ENTRY_BYTES,
max_entries: Self::DEFAULT_MAX_ENTRIES,
max_depth: Self::DEFAULT_MAX_DEPTH,
max_nested_bytes: Self::DEFAULT_MAX_NESTED_BYTES,
max_scan_member_bytes: Self::DEFAULT_MAX_SCAN_MEMBER_BYTES,
}
}
}
// ── the per-format policy ───────────────────────────────────────────────────
/// What one rule does when it matches.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Rule {
Allow,
Warn,
Block,
}
/// Where a format allows native binaries and scripts.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecPolicy {
/// An ELF / PE / Mach-O executable (anything native that is not a library).
pub executables: Rule,
/// A native shared library with a library's name (`.so`, `.pyd`, `.dll`, …).
pub libraries: Rule,
/// A `#!` script outside `script_dirs`.
pub scripts: Rule,
/// Directories where scripts are always allowed. One `*` per path segment
/// matches any text in that segment: `*.data/scripts`.
#[serde(default)]
pub script_dirs: Vec<String>,
/// **A Windows script launcher** (lane HOL-310-CHECK): a PE executable named `*.exe` of at
/// most [`WINDOWS_LAUNCHER_MAX_BYTES`]. Python's packaging tools carry these small stubs
/// (setuptools' `cli-64.exe`/`gui-64.exe`, distlib's `t64.exe`/`w64.exe` vendored in pip) and
/// copy one next to each console script on a Windows install; nothing runs them on a build
/// host or a Linux client. `None`: such a file follows `executables`, as every other native
/// executable does. An operator's own policy that leaves this out gets `None`, the strict rule.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub windows_launchers: Option<Rule>,
}
/// The words every Windows-launcher finding carries (SEC-REVIEW 2026-11-01 W1): the server's
/// auto-approve looks for them, since such a WARN excuses what `executables` would block.
pub const WINDOWS_LAUNCHER_FINDING: &str = "Windows script launcher";
/// The launcher names Python's packaging tools ship (SEC-REVIEW 2026-11-01 W1): setuptools'
/// `{cli,gui}{,-32,-64,-arm64}.exe` and distlib's `{t,w}{32,64,64-arm}.exe` (vendored in pip).
/// Only these are excused; any other `*.exe` follows `executables`.
pub const WINDOWS_LAUNCHER_NAMES: &[&str] = &[
"cli.exe",
"cli-32.exe",
"cli-64.exe",
"cli-arm64.exe",
"gui.exe",
"gui-32.exe",
"gui-64.exe",
"gui-arm64.exe",
"t32.exe",
"t64.exe",
"t64-arm.exe",
"w32.exe",
"w64.exe",
"w64-arm.exe",
];
/// The largest PE `.exe` [`ExecPolicy::windows_launchers`] covers. The launchers Python's
/// tools ship are 11 KiB (setuptools `cli-32.exe`) to 183 KiB (distlib `t64-arm.exe`, pip 26.2).
pub const WINDOWS_LAUNCHER_MAX_BYTES: u64 = 256 * 1024;
impl ExecPolicy {
fn new(executables: Rule, libraries: Rule, scripts: Rule, dirs: &[&str]) -> Self {
Self {
executables,
libraries,
scripts,
script_dirs: dirs.iter().map(|s| s.to_string()).collect(),
windows_launchers: None,
}
}
fn with_windows_launchers(mut self, rule: Rule) -> Self {
self.windows_launchers = Some(rule);
self
}
/// The default for each format.
///
/// * wheel: executables block; extension modules allowed; scripts warn
/// outside `*.data/scripts` (many modules start with a `#!` line).
/// * sdist: source only, so executables AND libraries block.
/// * wheel and sdist: a Windows script launcher (a PE `*.exe` of at most 256 KiB) warns
/// (`windows_launchers`, lane HOL-310-CHECK): setuptools and pip carry them, and blocking
/// them blocked every sdist build and pip itself. The malware scanners still read them.
/// * jar: executables block; JNI libraries allowed; scripts warn.
/// * crate: executables block; libraries warn; scripts allowed (build scripts).
/// * npm, gem: executables block; prebuilt addons allowed; scripts allowed.
/// * nuget: executables warn (tool packages ship them); libraries allowed.
/// * helm: a chart is text, so binaries block and scripts warn.
/// * deb, rpm, conda, generic: binary packages; everything allowed.
pub fn default_for(format: Format) -> Self {
use Rule::*;
match format {
Format::Wheel => {
Self::new(Block, Allow, Warn, &["*.data/scripts"]).with_windows_launchers(Warn)
}
Format::Sdist => Self::new(Block, Block, Allow, &[]).with_windows_launchers(Warn),
Format::Jar => Self::new(Block, Allow, Warn, &[]),
Format::Crate => Self::new(Block, Warn, Allow, &[]),
Format::Npm | Format::Gem => Self::new(Block, Allow, Allow, &[]),
Format::Nuget => Self::new(Warn, Allow, Allow, &[]),
Format::Helm => Self::new(Block, Block, Warn, &[]),
Format::Deb | Format::Rpm | Format::Conda | Format::OciImage | Format::Generic => {
Self::new(Allow, Allow, Allow, &[])
}
}
}
/// Is `path` inside one of the script directories?
pub fn in_script_dir(&self, path: &str) -> bool {
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
self.script_dirs.iter().any(|dir| {
let pat: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect();
!pat.is_empty()
&& segs.len() > pat.len()
&& segs
.windows(pat.len())
.take(segs.len() - pat.len())
.any(|w| w.iter().zip(&pat).all(|(s, p)| glob_segment(p, s)))
})
}
}
/// One path segment against a pattern with at most one `*`.
fn glob_segment(pat: &str, s: &str) -> bool {
match pat.split_once('*') {
None => pat == s,
Some((pre, post)) => {
s.len() >= pre.len() + post.len() && s.starts_with(pre) && s.ends_with(post)
}
}
}
// ── the configuration ───────────────────────────────────────────────────────
/// The `inspect:` block: the limits, any per-format policy overrides, and the
/// optional malware scanners.
///
/// ```ron
/// inspect: (
/// limits: (max_depth: 2, max_total_expanded: 1073741824),
/// policies: { wheel: (executables: block, libraries: block, scripts: warn) },
/// // The sandboxed clamd (Holger's server refuses any other in a real build: holger-sandbox-15).
/// clamav: Some((socket: Some("/data/holger/sandbox/clamd/clamd.sock"), required: true)),
/// yara: Some((rules: "/etc/holger/yara", required: false)),
/// )
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct InspectConfig {
pub limits: Limits,
/// Replaces [`ExecPolicy::default_for`] for the formats it names.
pub policies: BTreeMap<Format, ExecPolicy>,
pub clamav: Option<malware::ClamAvConfig>,
pub yara: Option<malware::YaraConfig>,
}
// ── the findings ────────────────────────────────────────────────────────────
/// How bad one finding is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Info,
Warn,
Block,
}
/// What a finding is about.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingKind {
ZipBomb,
ExpansionLimit,
EntryTooLarge,
TooManyEntries,
NestingTooDeep,
NestedTooLarge,
PathTraversal,
AbsolutePath,
SymlinkEscape,
MimeMismatch,
Executable,
Script,
CorruptArchive,
Encrypted,
NotUnpacked,
Malware,
ScannerNotConfigured,
ScannerError,
}
impl FindingKind {
pub fn name(&self) -> &'static str {
match self {
FindingKind::ZipBomb => "zip-bomb",
FindingKind::ExpansionLimit => "expansion-limit",
FindingKind::EntryTooLarge => "entry-too-large",
FindingKind::TooManyEntries => "too-many-entries",
FindingKind::NestingTooDeep => "nesting-too-deep",
FindingKind::NestedTooLarge => "nested-too-large",
FindingKind::PathTraversal => "path-traversal",
FindingKind::AbsolutePath => "absolute-path",
FindingKind::SymlinkEscape => "symlink-escape",
FindingKind::MimeMismatch => "mime-mismatch",
FindingKind::Executable => "executable",
FindingKind::Script => "script",
FindingKind::CorruptArchive => "corrupt-archive",
FindingKind::Encrypted => "encrypted",
FindingKind::NotUnpacked => "not-unpacked",
FindingKind::Malware => "malware",
FindingKind::ScannerNotConfigured => "scanner-not-configured",
FindingKind::ScannerError => "scanner-error",
}
}
}
/// One finding: what, how bad, where (`artifact!member!inner`), and why.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Finding {
pub kind: FindingKind,
pub severity: Severity,
pub path: String,
pub detail: String,
}
impl std::fmt::Display for Finding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}: {}", self.kind.name(), self.path, self.detail)
}
}
/// The conclusion, in the intake chain's three words.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Pass,
Warn,
Fail,
}
/// Everything one inspection found, and what it walked.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Findings {
pub format: Format,
/// What the artifact's own bytes are.
pub detected: Kind,
pub findings: Vec<Finding>,
/// Members seen, every level together.
pub entries: u64,
/// Bytes unpacked, every level together.
pub expanded_bytes: u64,
/// The deepest archive level opened.
pub max_depth_seen: u32,
/// The scanners that ran, by name.
pub scanners: Vec<String>,
}
impl Findings {
fn new(format: Format, detected: Kind) -> Self {
Self {
format,
detected,
findings: Vec::new(),
entries: 0,
expanded_bytes: 0,
max_depth_seen: 0,
scanners: Vec::new(),
}
}
pub(crate) fn push(
&mut self,
kind: FindingKind,
severity: Severity,
path: &str,
detail: impl Into<String>,
) {
self.findings.push(Finding {
kind,
severity,
path: path.to_string(),
detail: detail.into(),
});
}
/// Fail on any blocking finding, Warn on any warning, else Pass.
pub fn verdict(&self) -> Verdict {
match self.findings.iter().map(|f| f.severity).max() {
Some(Severity::Block) => Verdict::Fail,
Some(Severity::Warn) => Verdict::Warn,
_ => Verdict::Pass,
}
}
/// The blocking findings.
pub fn blocking(&self) -> impl Iterator<Item = &Finding> {
self.findings
.iter()
.filter(|f| f.severity == Severity::Block)
}
/// Is there a finding of this kind at this severity or worse?
pub fn has(&self, kind: FindingKind, at_least: Severity) -> bool {
self.findings
.iter()
.any(|f| f.kind == kind && f.severity >= at_least)
}
/// One line for an audit record or an evidence reference.
pub fn summary(&self) -> String {
let count = |s| self.findings.iter().filter(|f| f.severity == s).count();
format!(
"inspect {}: {:?}, {} entries, {} bytes unpacked, depth {}, {} block / {} warn / {} info",
self.format.name(),
self.verdict(),
self.entries,
self.expanded_bytes,
self.max_depth_seen,
count(Severity::Block),
count(Severity::Warn),
count(Severity::Info),
)
}
}
// ── the inspector ───────────────────────────────────────────────────────────
/// The inspector of one server: limits, the per-format policies, and the
/// malware scanners. Built once from [`InspectConfig`] and shared.
#[derive(Clone)]
pub struct Inspector {
limits: Limits,
policies: BTreeMap<Format, ExecPolicy>,
scanners: Vec<Arc<dyn malware::MalwareScanner>>,
}
impl Default for Inspector {
fn default() -> Self {
Self::new(Limits::default())
}
}
impl std::fmt::Debug for Inspector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Inspector")
.field("limits", &self.limits)
.field(
"scanners",
&self
.scanners
.iter()
.map(|s| s.name().to_string())
.collect::<Vec<_>>(),
)
.finish()
}
}
impl Inspector {
/// An inspector with these limits, the default policies and no scanners.
pub fn new(limits: Limits) -> Self {
Self {
limits,
policies: Format::ALL
.into_iter()
.map(|f| (f, ExecPolicy::default_for(f)))
.collect(),
scanners: Vec::new(),
}
}
/// The inspector the config declares, scanners included.
pub fn from_config(cfg: &InspectConfig) -> Self {
let mut me = Self::new(cfg.limits.clone());
for (f, p) in &cfg.policies {
me.policies.insert(*f, p.clone());
}
if let Some(c) = &cfg.clamav {
me = me.with_scanner(Arc::new(malware::ClamAv::from_config(c)));
}
if let Some(y) = &cfg.yara {
me = me.with_scanner(Arc::new(malware::Yara::from_config(y)));
}
me
}
/// Replace one format's policy.
pub fn with_policy(mut self, format: Format, policy: ExecPolicy) -> Self {
self.policies.insert(format, policy);
self
}
/// Add a malware scanner.
pub fn with_scanner(mut self, s: Arc<dyn malware::MalwareScanner>) -> Self {
self.scanners.push(s);
self
}
pub fn limits(&self) -> &Limits {
&self.limits
}
pub fn policy(&self, format: Format) -> &ExecPolicy {
self.policies
.get(&format)
.expect("every format has a policy")
}
pub fn scanners(&self) -> &[Arc<dyn malware::MalwareScanner>] {
&self.scanners
}
/// Inspect one artifact. `name` is its file name; `format` what it declares.
pub fn inspect(&self, bytes: &[u8], format: Format, name: &str) -> Findings {
let mut out = walk::walk(self, bytes, format, name);
malware::scan_artifact(&self.scanners, bytes, name, &mut out);
out
}
/// **Walk one artifact and hand every member to `visit`**: the same walk as
/// [`Inspector::inspect`] (the same limits; archives opened level by level,
/// gzip, tar and zip, image layers included), with every member that is not
/// itself an archive passed as `(name, path, bytes)`, the artifact itself
/// too when it is no archive. The malware scanners do not run. Returns the
/// walk's findings and how many members were larger than
/// `max_scan_member_bytes` and so not handed over. The secrets scanner reads
/// packages through it.
pub fn visit(
&self,
bytes: &[u8],
format: Format,
name: &str,
visit: Visit<'_>,
) -> (Findings, u64) {
let me = Self {
scanners: Vec::new(),
..self.clone()
};
walk::walk_visiting(&me, bytes, format, name, Some(visit))
}
}
/// Inspect with the default limits and policies and no scanners.
pub fn inspect(bytes: &[u8], format: Format) -> Findings {
Inspector::default().inspect(bytes, format, "")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_declared_format_comes_from_the_name_and_the_ecosystem() {
assert_eq!(
Format::declared("demo-1.0-py3-none-any.whl", "raw"),
Format::Wheel
);
assert_eq!(Format::declared("demo-1.0.tar.gz", "pip"), Format::Sdist);
assert_eq!(Format::declared("demo-1.0.tar.gz", "raw"), Format::Generic);
assert_eq!(Format::declared("chart-1.0.tgz", "helm"), Format::Helm);
assert_eq!(Format::declared("serde-1.0.crate", ""), Format::Crate);
}
#[test]
fn script_dirs_match_one_segment_wildcard() {
let p = ExecPolicy::default_for(Format::Wheel);
assert!(p.in_script_dir("demo-1.0.data/scripts/run"));
assert!(!p.in_script_dir("demo/scripts/run"));
assert!(
!p.in_script_dir("demo-1.0.data/scripts"),
"the directory itself is not a member in it"
);
}
#[test]
fn every_limit_reads_alone_from_ron() {
let l: Limits = ron::from_str("(max_depth: 1)").unwrap();
assert_eq!(l.max_depth, 1);
assert_eq!(l.max_ratio, Limits::DEFAULT_MAX_RATIO);
let c: InspectConfig = ron::from_str(
"(policies: { wheel: (executables: block, libraries: block, scripts: warn) })",
)
.unwrap();
assert_eq!(c.policies[&Format::Wheel].libraries, Rule::Block);
assert!(
ron::from_str::<Limits>("(max_dept: 1)").is_err(),
"a misspelt limit is refused"
);
}
#[test]
fn a_file_name_and_its_ecosystem_declare_the_format() {
let cases = [
("img.OCI.tar", "", Format::OciImage),
("demo-1.0-py3-none-any.whl", "raw", Format::Wheel),
("app.jar", "maven3", Format::Jar),
("app.WAR", "", Format::Jar),
("app.ear", "", Format::Jar),
("lib.aar", "", Format::Jar),
("serde-1.0.crate", "rust", Format::Crate),
("pkg.1.0.nupkg", "nuget", Format::Nuget),
("rack-3.0.gem", "gem", Format::Gem),
("tool_1.0_amd64.deb", "deb", Format::Deb),
("tool-1.0.x86_64.rpm", "rpm", Format::Rpm),
("numpy-2.0.conda", "", Format::Conda),
("numpy-1.0.tar.bz2", "CONDA", Format::Conda),
("numpy-1.0.tar.bz2", "raw", Format::Generic),
("chart-1.0.tgz", "helm", Format::Helm),
("left-pad-1.0.tgz", "npm", Format::Npm),
("left-pad-1.0.tgz", "raw", Format::Generic),
("demo-1.0.tar.gz", "pip", Format::Sdist),
("demo-1.0.zip", "pip", Format::Sdist),
("demo-1.0.tar.gz", "raw", Format::Generic),
("README", "", Format::Generic),
];
for (name, eco, want) in cases {
assert_eq!(Format::declared(name, eco), want, "{name} in {eco:?}");
}
// Every format has a name, a policy and, but for the generic and image ones, a container.
let insp = Inspector::default();
for f in Format::ALL {
assert!(!f.name().is_empty());
let _ = insp.policy(f);
}
assert!(Format::Generic.expected_kinds().is_none());
}
#[test]
fn the_config_builds_its_scanners_and_policies() {
let cfg: InspectConfig = ron::from_str(
r#"(
policies: { wheel: (executables: warn, libraries: allow, scripts: allow) },
clamav: Some((socket: Some("/nonexistent/holger/clamd.ctl"), required: true)),
yara: Some((rules: None)),
)"#,
)
.unwrap();
let insp = Inspector::from_config(&cfg);
let names: Vec<&str> = insp.scanners().iter().map(|s| s.name()).collect();
assert_eq!(names, vec!["clamav", "yara"]);
assert_eq!(insp.policy(Format::Wheel).executables, Rule::Warn);
assert_eq!(
insp.policy(Format::Jar),
&ExecPolicy::default_for(Format::Jar)
);
assert_eq!(insp.limits(), &Limits::default());
let dbg = format!("{insp:?}");
assert!(dbg.contains("clamav") && dbg.contains("yara"), "{dbg}");
// The required clamd is absent: every artifact blocks, and the summary says so.
let f = insp.inspect(b"plain bytes", Format::Generic, "x.bin");
assert_eq!(f.verdict(), Verdict::Fail);
assert_eq!(f.blocking().count(), 1);
assert!(f.has(FindingKind::ScannerNotConfigured, Severity::Block));
assert!(f.has(FindingKind::ScannerNotConfigured, Severity::Info));
assert!(!f.has(FindingKind::Malware, Severity::Info));
let s = f.summary();
assert!(
s.starts_with("inspect generic: Fail") && s.contains("1 block / 0 warn / 1 info"),
"{s}"
);
assert_eq!(
FindingKind::ScannerNotConfigured.name(),
"scanner-not-configured"
);
// with_policy replaces one format's policy and no other.
let strict = ExecPolicy {
executables: Rule::Block,
libraries: Rule::Block,
scripts: Rule::Block,
script_dirs: vec![],
windows_launchers: None,
};
let insp = Inspector::default().with_policy(Format::Npm, strict.clone());
assert_eq!(insp.policy(Format::Npm), &strict);
assert_eq!(
insp.policy(Format::Wheel),
&ExecPolicy::default_for(Format::Wheel)
);
}
}