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