Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ if(Python3_Interpreter_FOUND)
test_boot
test_sbom
test_cve
test_apk_distro
test_licenses
test_kconfig
test_kconfig_recovery
Expand Down
4 changes: 4 additions & 0 deletions src/component.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ struct Component {
// down-rank unbounded-range matches, since a fork's feature/backport state is
// unknown (a 0.8.x_rtw hostapd predates the SAE code some CVEs live in).
std::string fork;

// APK is shared by multiple distributions. Set only from this package
// database's own filesystem root; an empty ID must not imply Alpine.
std::string distro_id;
};

} // namespace ft
11 changes: 7 additions & 4 deletions src/cve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ std::string source_to_eco(const std::string& source, const std::string& distro_i
// dpkg is Debian by default, Ubuntu when the image's os-release id says so
// (Ubuntu forks Debian and patches on its own schedule / version scheme).
if (source == "dpkg") return lower(distro_id) == "ubuntu" ? "Ubuntu" : "Debian";
if (source == "apk") return "Alpine";
if (source == "apk") return lower(distro_id) == "alpine" ? "Alpine" : "";
if (source == "rpm") return rpm_eco(distro_id);
// Language ecosystems (from langmanifest).
if (source == "npm") return "npm";
Expand Down Expand Up @@ -91,16 +91,19 @@ std::vector<CveMatch> cve_join(const Index& db, const std::vector<Component>& co
const std::string& distro_id, const std::string& distro_release) {
std::vector<CveMatch> out;
for (const auto& c : components) {
std::string eco = source_to_eco(c.source, distro_id);
std::string eco = source_to_eco(c.source, c.source == "apk" ? c.distro_id : distro_id);
if (eco.empty() || c.name.empty() || c.version.empty()) continue;
// APK matching remains release-agnostic. A different root's global
// Debian/Ubuntu release must not filter Alpine advisories.
const std::string release = c.source == "apk" ? "" : distro_release;
// OsvDb::lookup returns a const ref; OsvIndex::lookup a temporary vector.
// Binding to a const ref keeps the temporary alive for the loop.
const auto& vulns = db.lookup(eco, c.name);
for (const auto& v : vulns) {
// Release precision: if both the image and the vuln name a release,
// require them to agree; otherwise match release-agnostically.
bool precise = !distro_release.empty() && !v.release.empty();
if (precise && v.release != distro_release) continue;
bool precise = !release.empty() && !v.release.empty();
if (precise && v.release != release) continue;
if (!osv_is_affected(c.version, v.events)) continue;
CveMatch m;
m.cve_id = v.id;
Expand Down
5 changes: 2 additions & 3 deletions src/sbom.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,16 @@ void for_each_line(std::string_view text, F cb) {

std::string make_purl(const std::string& source, const std::string& name, const std::string& version,
const std::string& arch) {
std::string ptype = "generic", ns;
std::string ptype = "generic";
if (source == "dpkg") {
ptype = "deb";
} else if (source == "opkg") {
ptype = "opkg";
} else if (source == "apk") {
ptype = "apk";
ns = "alpine";
}
std::string purl = "pkg:" + ptype + "/";
if (!ns.empty()) purl += ns + "/";
// The walker adds an APK namespace only after resolving the owning root.
purl += name;
if (!version.empty()) purl += "@" + version;
if (!arch.empty()) purl += "?arch=" + arch;
Expand Down
58 changes: 56 additions & 2 deletions src/walk.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <optional>
#include <string_view>
#include <thread>
#include <unordered_map>
#include <unordered_set>

#include "binver.hpp"
Expand Down Expand Up @@ -52,6 +53,25 @@ struct FileHits {
std::string kconfig_text, kconfig_source; // verbatim recovered .config, if any
};

// Match a root-relative canonical path, without borrowing an ancestor's or
// sibling image's identity when a scan contains multiple extracted roots.
std::optional<std::string> owning_root(std::string_view path, std::string_view suffix) {
if (path == suffix) return std::string{};
if (path.size() <= suffix.size() || !path.ends_with(suffix)) return std::nullopt;
const size_t split = path.size() - suffix.size() - 1;
if (path[split] != '/') return std::nullopt;
return std::string(path.substr(0, split));
}

std::string apk_distro_id(const std::string& id) {
// os-release ID syntax also keeps the package URL namespace unambiguous.
const bool valid = !id.empty() && std::all_of(id.begin(), id.end(), [](char c) {
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '_' || c == '.' || c == '-';
});
return valid ? id : std::string{};
}

// Parse an os-release file for ID and VERSION_ID (quotes stripped).
void parse_os_release(std::span<const uint8_t> data, std::string& id, std::string& version) {
std::string_view text(reinterpret_cast<const char*>(data.data()), data.size());
Expand Down Expand Up @@ -183,8 +203,8 @@ FileHits scan_one(const fs::path& path, const fs::path& root, const Passes& pass
if (passes.boot) fh.boot = scan_boot(fh.path, fm.span());
if (passes.keys) fh.keys = scan_keys(fh.path, fm.span(), &fh.rsa_keys);

// Detect the distro/release from os-release (for CVE release precision).
if (passes.cve && fm.size() < (1u << 16)) {
// Distro identity is also needed for APK package URLs in SBOM-only scans.
if ((passes.sbom || passes.cve) && fm.size() < (1u << 16)) {
std::string_view p = fh.path;
auto ends = [&](std::string_view s) {
return p.size() >= s.size() && p.compare(p.size() - s.size(), s.size(), s) == 0;
Expand Down Expand Up @@ -219,6 +239,7 @@ Report scan_path(const std::string& root, const Passes& passes, unsigned nthread
std::error_code ec;
std::vector<fs::path> paths;
std::vector<Component> sym_components; // libc versions read off symlink targets
std::unordered_set<std::string> unknown_apk_roots;
fs::path root_path(root);
fs::path rel_root;

Expand All @@ -230,6 +251,21 @@ Report scan_path(const std::string& root, const Passes& passes, unsigned nthread
!ec && it != fs::recursive_directory_iterator(); it.increment(ec)) {
if (it->is_symlink()) {
it.disable_recursion_pending();
if (passes.sbom || passes.cve) {
const auto rel = it->path().lexically_relative(rel_root).generic_string();
if (auto r = owning_root(rel, "etc")) unknown_apk_roots.insert(*r);
if (auto r = owning_root(rel, "etc/os-release")) {
std::error_code sec;
const auto target = fs::read_symlink(it->path(), sec);
const auto resolved = target.is_absolute()
? fs::path(*r) / target.relative_path()
: fs::path(rel).parent_path() / target;
// Recognize the standard alias without following links.
// Every other alias is unknown, never an Alpine fallback.
if (sec || resolved.lexically_normal() != fs::path(*r) / "usr/lib/os-release")
unknown_apk_roots.insert(*r);
}
}
// A libc symlink (libc.so.0 -> libuClibc-0.9.30.so) carries the
// version in its target name even when the target file was not
// extracted. Read the link (no follow) and version off the target.
Expand Down Expand Up @@ -291,6 +327,16 @@ Report scan_path(const std::string& root, const Passes& passes, unsigned nthread
for (unsigned t = 0; t < nthreads; ++t) pool.emplace_back(worker);
for (auto& th : pool) th.join();

std::unordered_map<std::string, std::string> apk_distros;
for (const auto& fh : results)
if (auto r = owning_root(fh.path, "usr/lib/os-release"))
apk_distros[*r] = apk_distro_id(fh.distro_id);
// /etc takes precedence, including an unreadable or invalid identity.
for (const auto& fh : results)
if (auto r = owning_root(fh.path, "etc/os-release"))
apk_distros[*r] = apk_distro_id(fh.distro_id);
for (const auto& r : unknown_apk_roots) apk_distros[r].clear();

std::unordered_set<std::string> seen_components;
for (auto& fh : results) {
rep.bytes_scanned += fh.size;
Expand All @@ -306,6 +352,14 @@ Report scan_path(const std::string& root, const Passes& passes, unsigned nthread
// (path-sorted, deterministic). purl keeps distinct provenance apart
// (pkg:deb/openssl vs pkg:generic/openssl are different components).
for (auto& c : fh.components) {
if (c.source == "apk") {
if (auto r = owning_root(fh.path, "lib/apk/db/installed")) {
auto distro = apk_distros.find(*r);
if (distro != apk_distros.end()) c.distro_id = distro->second;
}
if (!c.distro_id.empty() && c.purl.starts_with("pkg:apk/"))
c.purl.insert(8, c.distro_id + "/");
}
if (c.name == "linux_kernel" && rep.kernel_version.empty()) rep.kernel_version = c.version;
std::string k = c.name + "\x1f" + c.version + "\x1f" + c.purl;
if (seen_components.insert(std::move(k)).second) rep.components.push_back(std::move(c));
Expand Down
1 change: 1 addition & 0 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ python3 tests/test_sbom.py "./$BUILD/mithril"

echo "== integration: cve (fixture db) =="
python3 tests/test_cve.py "./$BUILD/mithril"
python3 tests/test_apk_distro.py "./$BUILD/mithril"

echo "== integration: licenses =="
python3 tests/test_licenses.py "./$BUILD/mithril"
Expand Down
163 changes: 163 additions & 0 deletions tests/test_apk_distro.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Offline APK identity and distro-advisory regression checks."""

import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest

BINARY = Path(sys.argv.pop(1)).resolve() if len(sys.argv) > 1 else Path("build/mithril").resolve()


class ApkDistroTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.storage = tempfile.TemporaryDirectory()
cls.db = Path(cls.storage.name) / "db"
cls.db.mkdir()
rows = []
for cve, release in [("CVE-2099-0001", ""), ("CVE-2099-0319", "3.19"), ("CVE-2099-0320", "3.20")]:
rows.append({"id": cve, "eco": "Alpine", "pkg": "busybox", "rel": release,
"ev": [["i", "0"], ["f", "1.38.0-r0"]], "sev": "", "sum": "fixture"})
source = cls.db / "osv.json"
source.write_text(json.dumps({"schema": 1, "vulns": rows}))
subprocess.run([str(BINARY.with_name("mkosvindex")), str(source), str(cls.db / "osv-index.mdb")],
check=True, capture_output=True)
(cls.db / "nvd-index.json").write_text('{"schema":1,"vulns":[]}')
(cls.db / "epss.txt").write_text("")

@classmethod
def tearDownClass(cls):
cls.storage.cleanup()

def setUp(self):
self.storage = tempfile.TemporaryDirectory()
self.addCleanup(self.storage.cleanup)
self.root = Path(self.storage.name)

def tree(self, name, distro=None, release="3.20", os_path="etc/os-release"):
root = self.root / name
db = root / "lib/apk/db/installed"
db.parent.mkdir(parents=True)
db.write_text("P:busybox\nV:1.37.0-r6\nA:x86_64\n\n")
if distro is not None:
identity = root / os_path
identity.parent.mkdir(parents=True, exist_ok=True)
identity.write_text(f'ID={distro}\nVERSION_ID="{release}"\n')
return root

def scan(self, root, mode="--cve"):
env = dict(os.environ, MITHRIL_DB=str(self.db), MITHRIL_NO_FETCH="1")
result = subprocess.run([str(BINARY), "-j", mode, str(root)], capture_output=True,
text=True, env=env, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
return json.loads(result.stdout)

def test_openwrt_does_not_borrow_alpine_advisories(self):
report = self.scan(self.tree("openwrt", "openwrt", "25.12.6"))
self.assertEqual(report["components"][0]["purl"], "pkg:apk/openwrt/busybox@1.37.0-r6?arch=x86_64")
self.assertEqual(report["cves"], [])

def test_alpine_keeps_its_advisory_matches(self):
report = self.scan(self.tree("alpine", "alpine"))
self.assertEqual({c["cve"] for c in report["cves"]}, {"CVE-2099-0001", "CVE-2099-0319", "CVE-2099-0320"})
self.assertEqual(report["components"][0]["purl"], "pkg:apk/alpine/busybox@1.37.0-r6?arch=x86_64")

def test_unknown_identity_does_not_assume_alpine(self):
report = self.scan(self.tree("unknown"))
self.assertEqual(report["components"][0]["purl"], "pkg:apk/busybox@1.37.0-r6?arch=x86_64")
self.assertEqual(report["cves"], [])

def test_mixed_roots_do_not_borrow_the_first_distro_or_collapse(self):
for alpine_name, openwrt_name in [("a", "z"), ("z", "a")]:
with self.subTest(alpine=alpine_name):
group = alpine_name + openwrt_name
self.tree(group + "/" + alpine_name, "alpine")
self.tree(group + "/" + openwrt_name, "openwrt", "25.12.6")
self.tree(group + "/unknown")
report = self.scan(self.root / group)
self.assertEqual(len(report["components"]), 3)
self.assertEqual({c["cve"] for c in report["cves"]}, {"CVE-2099-0001", "CVE-2099-0319", "CVE-2099-0320"})
self.assertTrue(all(c["purl"].startswith("pkg:apk/alpine/") for c in report["cves"]))

def test_unrelated_debian_release_does_not_filter_alpine_advisories(self):
self.tree("z-alpine", "alpine")
identity = self.root / "a-debian/etc/os-release"
identity.parent.mkdir(parents=True)
identity.write_text("ID=debian\nVERSION_ID=12\n")
report = self.scan(self.root)
self.assertEqual({c["cve"] for c in report["cves"]},
{"CVE-2099-0001", "CVE-2099-0319", "CVE-2099-0320"})

def test_etc_identity_overrides_usr_lib(self):
root = self.tree("override", "alpine", os_path="usr/lib/os-release")
(root / "etc").mkdir()
(root / "etc/os-release").write_text("ID=openwrt\nVERSION_ID=25.12.6\n")
self.assertEqual(self.scan(root)["cves"], [])

def test_usr_lib_identity_is_used_when_etc_is_absent(self):
report = self.scan(self.tree("fallback", "alpine", os_path="usr/lib/os-release"))
self.assertEqual({c["cve"] for c in report["cves"]}, {"CVE-2099-0001", "CVE-2099-0319", "CVE-2099-0320"})

def test_custom_etc_symlink_does_not_assume_usr_lib_identity(self):
root = self.tree("custom-link", "alpine", os_path="usr/lib/os-release")
(root / "etc").mkdir()
(root / "etc/openwrt-os-release").write_text("ID=openwrt\n")
(root / "etc/os-release").symlink_to("openwrt-os-release")
report = self.scan(root)
self.assertEqual(report["cves"], [])
self.assertEqual(report["components"][0]["purl"], "pkg:apk/busybox@1.37.0-r6?arch=x86_64")

def test_dangling_etc_symlink_keeps_identity_unknown(self):
root = self.tree("dangling-link", "alpine", os_path="usr/lib/os-release")
(root / "etc").mkdir()
(root / "etc/os-release").symlink_to("missing-os-release")
self.assertEqual(self.scan(root)["cves"], [])

def test_canonical_etc_symlinks_keep_own_root_identity(self):
for name, target in [("relative", "../usr/lib/os-release"), ("absolute", "/usr/lib/os-release")]:
with self.subTest(target=target):
root = self.tree(name, "alpine", os_path="usr/lib/os-release")
(root / "etc").mkdir()
(root / "etc/os-release").symlink_to(target)
report = self.scan(root)
self.assertEqual({c["cve"] for c in report["cves"]},
{"CVE-2099-0001", "CVE-2099-0319", "CVE-2099-0320"})

def test_foreign_root_etc_symlink_does_not_borrow_identity(self):
root = self.tree("foreign-link", "alpine", os_path="usr/lib/os-release")
self.tree("other", "openwrt")
(root / "etc").mkdir()
(root / "etc/os-release").symlink_to("../../other/etc/os-release")
self.assertEqual(self.scan(root)["cves"], [])

def test_symlinked_etc_directory_keeps_identity_unknown(self):
root = self.tree("directory-link", "alpine", os_path="usr/lib/os-release")
(root / "openwrt-etc").mkdir()
(root / "openwrt-etc/os-release").write_text("ID=openwrt\n")
(root / "etc").symlink_to("openwrt-etc", target_is_directory=True)
self.assertEqual(self.scan(root)["cves"], [])

def test_nested_root_does_not_borrow_parent_identity(self):
root = self.tree("outer", "alpine")
self.tree("outer/nested")
report = self.scan(root)
self.assertEqual(len(report["components"]), 2)
nested = next(c for c in report["components"] if c["origin_path"].startswith("nested/"))
self.assertEqual(nested["purl"], "pkg:apk/busybox@1.37.0-r6?arch=x86_64")

def test_invalid_distro_id_does_not_become_a_namespace(self):
report = self.scan(self.tree("invalid", "alpine/other"))
self.assertEqual(report["components"][0]["purl"], "pkg:apk/busybox@1.37.0-r6?arch=x86_64")
self.assertEqual(report["cves"], [])

def test_sbom_only_keeps_the_same_package_identity(self):
report = self.scan(self.tree("sbom", "openwrt", "25.12.6"), "--sbom")
self.assertEqual(report["components"][0]["purl"], "pkg:apk/openwrt/busybox@1.37.0-r6?arch=x86_64")


if __name__ == "__main__":
unittest.main()
3 changes: 3 additions & 0 deletions tests/test_sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@


def build_tree(d):
os.makedirs(os.path.join(d, "etc"))
with open(os.path.join(d, "etc/os-release"), "w") as f:
f.write("ID=alpine\nVERSION_ID=3.20\n")
os.makedirs(os.path.join(d, "var/lib/dpkg"))
os.makedirs(os.path.join(d, "lib/apk/db"))
with open(os.path.join(d, "var/lib/dpkg/status"), "w") as f:
Expand Down
10 changes: 9 additions & 1 deletion tests/unit/unit_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ static void test_sbom_apk() {
"P:musl\nV:1.2.4-r2\nA:x86_64\nL:MIT\n\nP:busybox\nV:1.36.1-r5\nA:x86_64\n");
CHECK(cs.size() == 2);
const ft::Component* m = find_comp(cs, "musl");
CHECK(m && m->license == "MIT" && m->purl == "pkg:apk/alpine/musl@1.2.4-r2?arch=x86_64");
CHECK(m && m->license == "MIT" && m->purl == "pkg:apk/musl@1.2.4-r2?arch=x86_64");
}

// ---------------------------------------------------------------- credential stores
Expand Down Expand Up @@ -1158,6 +1158,7 @@ static void test_cve() {
c.name = name;
c.version = ver;
c.source = src;
if (src == "apk") c.distro_id = "alpine";
c.purl = "pkg:x/" + name + "@" + ver;
return c;
};
Expand All @@ -1176,6 +1177,13 @@ static void test_cve() {
}
CHECK(bb && ossl);

// The caller's global distro cannot turn a foreign or unknown APK into Alpine.
auto foreign = mk("openssl", "1.1.1m-r0", "apk");
foreign.distro_id = "openwrt";
CHECK(ft::cve_join(db, {foreign}, "alpine").empty());
foreign.distro_id.clear();
CHECK(ft::cve_join(db, {foreign}, "alpine").empty());

// A patched component yields no CVE.
auto none = ft::cve_join(db, {mk("busybox", "1:1.30.1-6", "dpkg")});
CHECK(none.empty());
Expand Down
Loading