diff --git a/CMakeLists.txt b/CMakeLists.txt index 015e984..eefcaa9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -142,6 +142,7 @@ if(Python3_Interpreter_FOUND) test_boot test_sbom test_cve + test_apk_distro test_licenses test_kconfig test_kconfig_recovery diff --git a/src/component.hpp b/src/component.hpp index 9e61b3f..242ed9e 100644 --- a/src/component.hpp +++ b/src/component.hpp @@ -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 diff --git a/src/cve.cpp b/src/cve.cpp index 6559a12..3e02a26 100644 --- a/src/cve.cpp +++ b/src/cve.cpp @@ -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"; @@ -91,16 +91,19 @@ std::vector cve_join(const Index& db, const std::vector& co const std::string& distro_id, const std::string& distro_release) { std::vector 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; diff --git a/src/sbom.cpp b/src/sbom.cpp index 33b451d..68e1351 100644 --- a/src/sbom.cpp +++ b/src/sbom.cpp @@ -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; diff --git a/src/walk.cpp b/src/walk.cpp index d2621bc..4a7f8ec 100644 --- a/src/walk.cpp +++ b/src/walk.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include "binver.hpp" @@ -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 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 data, std::string& id, std::string& version) { std::string_view text(reinterpret_cast(data.data()), data.size()); @@ -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; @@ -219,6 +239,7 @@ Report scan_path(const std::string& root, const Passes& passes, unsigned nthread std::error_code ec; std::vector paths; std::vector sym_components; // libc versions read off symlink targets + std::unordered_set unknown_apk_roots; fs::path root_path(root); fs::path rel_root; @@ -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. @@ -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 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 seen_components; for (auto& fh : results) { rep.bytes_scanned += fh.size; @@ -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)); diff --git a/tests/run.sh b/tests/run.sh index 4fc300e..cbe3d42 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -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" diff --git a/tests/test_apk_distro.py b/tests/test_apk_distro.py new file mode 100644 index 0000000..28360c0 --- /dev/null +++ b/tests/test_apk_distro.py @@ -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() diff --git a/tests/test_sbom.py b/tests/test_sbom.py index 1d6125c..43c8da2 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -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: diff --git a/tests/unit/unit_tests.cpp b/tests/unit/unit_tests.cpp index c9f6b12..cbf1056 100644 --- a/tests/unit/unit_tests.cpp +++ b/tests/unit/unit_tests.cpp @@ -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 @@ -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; }; @@ -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());