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

nordisk/holger

server/inspect/src/binmeta.rs

//! **What a binary says it was built from**: Go build info and cargo-auditable.
//! It runs in the scan worker over an executable from an image layer, bounded:
//! every offset is checked against the file and every decompression is capped.
//!
//! * **Go** (1.18+): the `\xff Go buildinf:` header, 16-byte aligned, flag 2
//!   (the inline-string format): the Go version, then the module info (`path`,
//!   `mod`, `dep`, `=>` replacements). It yields the main module, every
//!   dependency (a replacement takes its place) and `stdlib` at the Go version,
//!   in the OSV `Go` ecosystem's spelling (`go`, versions without a leading `v`).
//!   The older pointer format is reported as [`GO_OLD_FORMAT`].
//! * **Rust** (`cargo auditable`): the ELF `.dep-v0` section, zlib-compressed
//!   JSON; its crates.io packages that are not build-only, ecosystem `cargo`.

use std::io::Read as _;

/// The Go build-info magic.
pub const GO_MAGIC: &[u8; 14] = b"\xff Go buildinf:";
/// The most bytes a `.dep-v0` section may inflate to.
pub const MAX_DEP_JSON: u64 = 8 << 20;

/// One package a binary names.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct BinPackage {
    /// `go` or `cargo`.
    pub ecosystem: &'static str,
    pub name: String,
    pub version: String,
    /// `go-buildinfo` or `cargo-auditable`.
    pub source: &'static str,
}

/// What reading one binary found.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Read {
    pub packages: Vec<BinPackage>,
    /// Build info that is there but not read (`go build info (pre-1.18 format)`).
    pub not_read: Vec<String>,
}

/// The words for Go build info in the older pointer format.
pub const GO_OLD_FORMAT: &str = "go build info (pre-1.18 pointer format)";

/// Is this the start of an ELF file?
pub fn is_elf(head: &[u8]) -> bool {
    head.starts_with(b"\x7fELF")
}

/// **Read a binary**: its Go build info and its cargo-auditable section.
pub fn read(bin: &[u8]) -> Read {
    let mut out = Read::default();
    match go_buildinfo(bin) {
        Some(Ok(p)) => out.packages.extend(p),
        Some(Err(why)) => out.not_read.push(why),
        None => {}
    }
    if is_elf(bin) {
        if let Some(sec) = elf_section(bin, ".dep-v0") {
            match cargo_auditable(sec) {
                Ok(p) => out.packages.extend(p),
                Err(e) => out.not_read.push(format!("cargo-auditable section: {e}")),
            }
        }
    }
    out.packages.sort();
    out.packages.dedup();
    out
}

fn uvarint(b: &[u8], at: &mut usize) -> Option<u64> {
    let mut v: u64 = 0;
    for shift in (0..64).step_by(7) {
        let x = *b.get(*at)?;
        *at += 1;
        v |= ((x & 0x7f) as u64) << shift;
        if x & 0x80 == 0 {
            return Some(v);
        }
    }
    None
}

fn go_string<'a>(b: &'a [u8], at: &mut usize) -> Option<&'a [u8]> {
    let n = uvarint(b, at)? as usize;
    let s = b.get(*at..at.checked_add(n)?)?;
    *at += n;
    Some(s)
}

/// The Go build info, when the binary has the header: `None` without one.
fn go_buildinfo(bin: &[u8]) -> Option<Result<Vec<BinPackage>, String>> {
    let mut i = 0;
    let at = loop {
        if i + 32 > bin.len() {
            return None;
        }
        if &bin[i..i + 14] == GO_MAGIC {
            break i;
        }
        i += 16;
    };
    let flags = bin[at + 15];
    if flags & 2 == 0 {
        return Some(Err(GO_OLD_FORMAT.to_string()));
    }
    let mut p = at + 32;
    let (Some(ver), Some(info)) = (go_string(bin, &mut p), go_string(bin, &mut p)) else {
        return Some(Err("go build info: its strings run past the file".into()));
    };
    let ver = String::from_utf8_lossy(ver).into_owned();
    // The module info is framed by 16-byte sentinels.
    let info = if info.len() >= 33 && info[info.len() - 17] == b'\n' {
        &info[16..info.len() - 16]
    } else {
        info
    };
    Some(Ok(go_modules(&ver, &String::from_utf8_lossy(info))))
}

/// The packages of a Go version string and its module info text.
pub fn go_modules(go_version: &str, modinfo: &str) -> Vec<BinPackage> {
    let pkg = |name: &str, version: &str| BinPackage {
        ecosystem: "go",
        name: name.to_string(),
        version: version.trim_start_matches('v').to_string(),
        source: "go-buildinfo",
    };
    let mut out = Vec::new();
    if let Some(v) = go_version
        .split_whitespace()
        .next()
        .and_then(|v| v.strip_prefix("go"))
    {
        if !v.is_empty() {
            out.push(pkg("stdlib", v));
        }
    }
    // Whether the line before pushed a package (a `=>` replaces exactly that one).
    let mut pushed = false;
    for line in modinfo.lines() {
        let f: Vec<&str> = line.split('\t').collect();
        let usable = |v: &str| !v.is_empty() && v != "(devel)";
        match f.as_slice() {
            ["mod", path, ver, ..] | ["dep", path, ver, ..] => {
                pushed = usable(ver);
                if pushed {
                    out.push(pkg(path, ver));
                }
            }
            ["=>", path, ver, ..] => {
                if pushed {
                    out.pop();
                }
                pushed = usable(ver);
                if pushed {
                    out.push(pkg(path, ver));
                }
            }
            _ => pushed = false,
        }
    }
    out
}

/// The bytes of ELF section `name`, when the file has it inside its bounds.
pub fn elf_section<'a>(b: &'a [u8], name: &str) -> Option<&'a [u8]> {
    let class = *b.get(4)?;
    let le = match *b.get(5)? {
        1 => true,
        2 => false,
        _ => return None,
    };
    let rd = |at: usize, n: usize| -> Option<u64> {
        let s = b.get(at..at.checked_add(n)?)?;
        let mut v: u64 = 0;
        for k in 0..n {
            let byte = if le { s[n - 1 - k] } else { s[k] };
            v = (v << 8) | byte as u64;
        }
        Some(v)
    };
    let (shoff, shentsize, shnum, shstrndx, off_at, size_at, word) = match class {
        2 => (
            rd(0x28, 8)?,
            rd(0x3a, 2)?,
            rd(0x3c, 2)?,
            rd(0x3e, 2)?,
            0x18,
            0x20,
            8,
        ),
        1 => (
            rd(0x20, 4)?,
            rd(0x2e, 2)?,
            rd(0x30, 2)?,
            rd(0x32, 2)?,
            0x10,
            0x14,
            4,
        ),
        _ => return None,
    };
    if shnum == 0 || shnum > 0x10000 || shentsize < (size_at + word) as u64 {
        return None;
    }
    let sh = |i: u64| -> Option<(u64, u64, u64)> {
        let base = usize::try_from(shoff.checked_add(i.checked_mul(shentsize)?)?).ok()?;
        Some((
            rd(base, 4)?,
            rd(base + off_at, word)?,
            rd(base + size_at, word)?,
        ))
    };
    let slice = |off: u64, size: u64| -> Option<&'a [u8]> {
        let off = usize::try_from(off).ok()?;
        b.get(off..off.checked_add(usize::try_from(size).ok()?)?)
    };
    let (_, str_off, str_size) = sh(shstrndx)?;
    let strtab = slice(str_off, str_size)?;
    for i in 0..shnum {
        let (n, off, size) = sh(i)?;
        let s = strtab.get(n as usize..)?;
        let end = s.iter().position(|c| *c == 0)?;
        if &s[..end] == name.as_bytes() {
            return slice(off, size);
        }
    }
    None
}

/// The crates.io packages of a `.dep-v0` section.
pub fn cargo_auditable(section: &[u8]) -> Result<Vec<BinPackage>, String> {
    let mut json = Vec::new();
    flate2::read::ZlibDecoder::new(section)
        .take(MAX_DEP_JSON + 1)
        .read_to_end(&mut json)
        .map_err(|e| format!("does not inflate: {e}"))?;
    if json.len() as u64 > MAX_DEP_JSON {
        return Err(format!("inflates past {MAX_DEP_JSON} bytes"));
    }
    let v: serde_json::Value =
        serde_json::from_slice(&json).map_err(|e| format!("is not JSON: {e}"))?;
    let list = v["packages"].as_array().ok_or("names no packages")?;
    Ok(list
        .iter()
        .filter(|p| {
            p["source"].as_str().unwrap_or("crates.io") == "crates.io"
                && p["kind"].as_str() != Some("build")
        })
        .filter_map(|p| {
            Some(BinPackage {
                ecosystem: "cargo",
                name: p["name"].as_str()?.to_string(),
                version: p["version"].as_str()?.to_string(),
                source: "cargo-auditable",
            })
        })
        .collect())
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;
    use std::io::Write as _;

    fn varint(mut n: usize, out: &mut Vec<u8>) {
        while n >= 0x80 {
            out.push((n as u8) | 0x80);
            n >>= 7;
        }
        out.push(n as u8);
    }

    /// A Go 1.18+ build-info blob at a 16-byte boundary, inside `pad` bytes.
    pub(crate) fn go_blob(version: &str, modinfo: &str, pad: usize) -> Vec<u8> {
        let mut b = vec![0u8; pad];
        b.extend_from_slice(GO_MAGIC);
        b.push(8);
        b.push(2);
        b.extend_from_slice(&[0u8; 16]);
        varint(version.len(), &mut b);
        b.extend_from_slice(version.as_bytes());
        let framed = format!("{}{modinfo}\n{}", "0".repeat(16), "1".repeat(16));
        varint(framed.len(), &mut b);
        b.extend_from_slice(framed.as_bytes());
        b.extend_from_slice(&[0u8; 64]);
        b
    }

    /// A minimal little-endian ELF64 with one named section holding `data`.
    pub(crate) fn elf_with(section: &str, data: &[u8]) -> Vec<u8> {
        let strtab = format!("\0{section}\0.shstrtab\0");
        let data_off = 64usize;
        let str_off = data_off + data.len();
        let sh_off = (str_off + strtab.len() + 7) & !7;
        let mut b = vec![0u8; sh_off + 3 * 64];
        b[..4].copy_from_slice(b"\x7fELF");
        b[4] = 2;
        b[5] = 1;
        b[6] = 1;
        b[0x28..0x30].copy_from_slice(&(sh_off as u64).to_le_bytes());
        b[0x3a..0x3c].copy_from_slice(&64u16.to_le_bytes());
        b[0x3c..0x3e].copy_from_slice(&3u16.to_le_bytes());
        b[0x3e..0x40].copy_from_slice(&2u16.to_le_bytes());
        b[data_off..data_off + data.len()].copy_from_slice(data);
        b[str_off..str_off + strtab.len()].copy_from_slice(strtab.as_bytes());
        let mut put = |i: usize, name: u32, off: usize, size: usize| {
            let h = sh_off + i * 64;
            b[h..h + 4].copy_from_slice(&name.to_le_bytes());
            b[h + 0x18..h + 0x20].copy_from_slice(&(off as u64).to_le_bytes());
            b[h + 0x20..h + 0x28].copy_from_slice(&(size as u64).to_le_bytes());
        };
        put(1, 1, data_off, data.len());
        put(2, (section.len() + 2) as u32, str_off, strtab.len());
        b
    }

    pub(crate) fn auditable(json: &str) -> Vec<u8> {
        let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
        z.write_all(json.as_bytes()).unwrap();
        elf_with(".dep-v0", &z.finish().unwrap())
    }

    #[test]
    fn go_build_info_names_stdlib_the_main_module_and_its_dependencies() {
        let info = "path\texample.com/app\nmod\texample.com/app\t(devel)\t\ndep\tgolang.org/x/net\tv0.17.0\th1:abc=\ndep\tgithub.com/old/thing\tv1.0.0\th1:x=\n=>\tgithub.com/new/thing\tv1.2.3\th1:y=\nbuild\t-compiler=gc";
        let r = read(&go_blob("go1.22.1", info, 48));
        let got: Vec<(&str, &str)> = r
            .packages
            .iter()
            .map(|p| (p.name.as_str(), p.version.as_str()))
            .collect();
        assert!(got.contains(&("stdlib", "1.22.1")), "{got:?}");
        assert!(got.contains(&("golang.org/x/net", "0.17.0")), "{got:?}");
        assert!(
            got.contains(&("github.com/new/thing", "1.2.3")),
            "the replacement stands: {got:?}"
        );
        assert!(
            !got.iter().any(|(n, _)| *n == "github.com/old/thing"),
            "the replaced module is gone: {got:?}"
        );
        assert!(
            !got.iter().any(|(n, _)| *n == "example.com/app"),
            "a (devel) main module has no version: {got:?}"
        );
        assert!(r
            .packages
            .iter()
            .all(|p| p.ecosystem == "go" && p.source == "go-buildinfo"));
    }

    #[test]
    fn the_old_go_format_is_named_not_read() {
        let mut b = go_blob("go1.17", "", 0);
        b[15] = 0;
        let r = read(&b);
        assert!(r.packages.is_empty());
        assert_eq!(r.not_read, vec![GO_OLD_FORMAT.to_string()]);
    }

    #[test]
    fn a_go_header_whose_strings_run_off_the_file_is_not_a_panic() {
        let mut b = go_blob("go1.22.1", "", 0);
        b.truncate(40);
        b[32] = 0x7f;
        let r = read(&b);
        assert!(r.packages.is_empty() && !r.not_read.is_empty(), "{r:?}");
    }

    #[test]
    fn cargo_auditable_names_the_runtime_crates_io_packages() {
        let json = r#"{"packages":[
            {"name":"app","version":"0.1.0","source":"local","root":true},
            {"name":"serde","version":"1.0.188","source":"crates.io","dependencies":[]},
            {"name":"cc","version":"1.0.83","source":"crates.io","kind":"build"},
            {"name":"time","version":"0.3.20","source":"crates.io","kind":"runtime"}]}"#;
        let r = read(&auditable(json));
        let got: Vec<(&str, &str)> = r
            .packages
            .iter()
            .map(|p| (p.name.as_str(), p.version.as_str()))
            .collect();
        assert_eq!(got, vec![("serde", "1.0.188"), ("time", "0.3.20")]);
        assert!(r
            .packages
            .iter()
            .all(|p| p.ecosystem == "cargo" && p.source == "cargo-auditable"));
    }

    #[test]
    fn a_broken_elf_or_section_is_not_a_panic() {
        let mut b = auditable(r#"{"packages":[]}"#);
        // The section header offset points far past the end.
        b[0x28..0x30].copy_from_slice(&u64::MAX.to_le_bytes());
        assert_eq!(read(&b), Read::default());
        let bad = elf_with(".dep-v0", b"not zlib");
        assert!(read(&bad).not_read[0].contains("does not inflate"));
        assert_eq!(read(b"\x7fELF\x02"), Read::default());
    }
}