From 533bf6441072f5fdd4f731aafcfeb6b28ed30274 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Wed, 5 Aug 2026 23:33:21 +0200 Subject: [PATCH 1/4] fix: resolve libraries ldd reports as missing instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit record_runtime_libraries filtered ldd output with /=> \//, which only matches lines where the loader resolved the library to a path. A missing one looks like libatomic.so.1 => not found with no path, so it was silently dropped: the recorded list came out short and the runtime image was built without the package, with no error anywhere. The helper exists precisely to avoid hardcoding package names, and this is the case where it quietly stopped doing that. Resolve those with apt-file, which maps a filename to the package providing it without needing the file present. The query is anchored to the multiarch library directory deliberately — a bare basename search for libatomic.so.1 also matches lib32atomic1 and the -cross packages, and libnss3.so matches firefox-esr and thunderbird, so an unanchored head -1 installs something wildly wrong. A library nothing provides now fails the build with an explanation rather than producing an image that dies with a loader error. apt-file and its ~90MB index are only fetched when something is actually missing. Refs #14 --- crates/autopack-providers/src/support.rs | 70 ++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/autopack-providers/src/support.rs b/crates/autopack-providers/src/support.rs index 7dab9e9..42d25c7 100644 --- a/crates/autopack-providers/src/support.rs +++ b/crates/autopack-providers/src/support.rs @@ -159,6 +159,27 @@ pub fn shell_quote(value: &str) -> String { /// Debian release bump — ICU is `libicu72` on bookworm and `libicu76` on /// trixie, and the `t64` transition renamed a whole set of others. Asking the /// linker and then dpkg is release-agnostic. +/// +/// Two lookups, because `ldd` answers in two different shapes: +/// +/// - `libfoo.so.1 => /usr/lib/.../libfoo.so.1` — resolved, so the file is on +/// disk and `dpkg-query -S` names its owner. +/// - `libfoo.so.1 => not found` — absent from the build image too, so there is +/// no path and no file to ask dpkg about. These used to be dropped on the +/// floor by the `=> /` filter, which meant a genuinely missing library +/// produced a short list and an image that failed at run time with a loader +/// error rather than a build that failed with an explanation. +/// +/// The second shape is resolved with `apt-file`, which maps a filename to the +/// package providing it without needing the file present. The query is +/// anchored to the multiarch library directory on purpose: a bare basename +/// search for `libatomic.so.1` also matches `lib32atomic1` and the +/// `-cross` packages, and for `libnss3.so` it matches `firefox-esr` and +/// `thunderbird`, so an unanchored `head -1` installs something wildly wrong. +/// +/// `apt-file` and its index are only fetched when there is something to look +/// up — the index is around 90MB, which is not worth paying for on every +/// build that has nothing missing. pub fn record_runtime_libraries(glob: &str, record_to: &str) -> String { format!( "set -eu; \ @@ -168,6 +189,23 @@ pub fn record_runtime_libraries(glob: &str, record_to: &str) -> String { | xargs -r readlink -f 2>/dev/null | sort -u \ | xargs -r dpkg-query -S 2>/dev/null \ | cut -d: -f1 | sort -u > {record_to}; \ + missing=$(ldd {glob} 2>/dev/null | awk '/not found/ {{ print $1 }}' | sort -u); \ + if [ -n \"$missing\" ]; then \ + apt-get update >/dev/null; \ + apt-get install -y --no-install-recommends apt-file >/dev/null; \ + apt-file update >/dev/null; \ + for lib in $missing; do \ + owner=$(apt-file search -x \"^/usr/lib/[a-z0-9_]*-linux-gnu/$lib$\" 2>/dev/null \ + | cut -d: -f1 | sort -u | head -1); \ + if [ -n \"$owner\" ]; then \ + echo \"$owner\" >> {record_to}; \ + else \ + echo \"autopack: no package provides $lib\" >&2; \ + exit 1; \ + fi; \ + done; \ + sort -u -o {record_to} {record_to}; \ + fi; \ cat {record_to}" ) } @@ -203,6 +241,38 @@ mod tests { (dir, app) } + #[test] + fn missing_libraries_are_resolved_rather_than_dropped() { + let script = record_runtime_libraries("/app/bin/app", "/tmp/deps"); + + // The resolved branch is unchanged: ldd gives a path, dpkg names the + // owner. + assert!(script.contains("dpkg-query -S")); + + // The "not found" branch is the point of this: those lines carry no + // path, so they used to be filtered out and the library silently + // omitted from the runtime image. + assert!(script.contains("/not found/")); + assert!(script.contains("apt-file search")); + + // Anchored to the multiarch directory. A bare basename search for + // libatomic.so.1 also matches lib32atomic1 and the -cross packages, + // and libnss3.so matches firefox-esr and thunderbird, so an + // unanchored match would install something wildly wrong. + assert!(script.contains("^/usr/lib/[a-z0-9_]*-linux-gnu/$lib$")); + + // A library nothing provides fails the build rather than producing a + // short list and an image that dies with a loader error. + assert!(script.contains("no package provides")); + assert!(script.contains("exit 1")); + + // apt-file's index is ~90MB, so it is only fetched when there is + // something to look up. + let index_fetch = script.find("apt-file update").unwrap(); + let guard = script.find("if [ -n \"$missing\" ]").unwrap(); + assert!(guard < index_fetch); + } + #[test] fn parses_the_web_process() { let (_dir, app) = app_with(&[( From c9462c0c60b33cf0b665173fef38be9af03624a9 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 6 Aug 2026 09:49:56 +0200 Subject: [PATCH 2/4] fix: validate sonames and widen the library anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five problems a security review found in the previous commit. A soname reaches this from a binary the app produced, and it was interpolated raw into an extended regex. `|` has the lowest precedence, so DT_NEEDED of `x|usr/sbin/sshd` escaped the path anchor entirely, resolved to openssh-server, and had it installed as root in the runtime image with the build exiting 0. Validate the soname first. The anchor only covered /usr/lib//. On Debian the essential libraries are still recorded unmerged, so libz.so.1, libc.so.6, libgcc_s.so.1 and libtinfo.so.6 all resolved to nothing and failed the build — the opposite of the point. It also missed non-gnu triplets like arm-linux-gnueabihf. `for lib in $missing` was unquoted, so a soname of `*` expanded against the build directory. `set -f`. `head -1` was locale-dependent: the same source picked libavcodec-extra59 under LC_ALL=C and libavcodec59 under en_US.UTF-8. Pin the collation, and stop guessing — 24 sonames in bookworm have several providers, and for libc++.so.1 the first-sorted answer is the oldest ABI. Fail with the candidates and tell the user to pick one. The test pinned the vulnerable pattern as a literal and would not have caught any of this; it now asserts the guarantees instead. --- crates/autopack-providers/src/support.rs | 102 +++++++++++++---------- 1 file changed, 58 insertions(+), 44 deletions(-) diff --git a/crates/autopack-providers/src/support.rs b/crates/autopack-providers/src/support.rs index 42d25c7..9b0e632 100644 --- a/crates/autopack-providers/src/support.rs +++ b/crates/autopack-providers/src/support.rs @@ -151,38 +151,35 @@ pub fn shell_quote(value: &str) -> String { /// Record which Debian packages own the shared libraries `glob` links against. /// -/// Runs in the build stage, where the `-dev` packages are still installed: -/// `ldd` reports a missing library as "not found" with no path, so the lookup -/// cannot be deferred to the runtime image. +/// Runs in the build stage, where the `-dev` packages are still installed. /// /// This exists because hardcoding runtime package names does not survive a /// Debian release bump — ICU is `libicu72` on bookworm and `libicu76` on /// trixie, and the `t64` transition renamed a whole set of others. Asking the -/// linker and then dpkg is release-agnostic. +/// linker and then dpkg is release-agnostic, and it installs exactly what the +/// binary links rather than a hand-maintained superset. /// -/// Two lookups, because `ldd` answers in two different shapes: +/// `ldd` answers in two shapes, and both matter: /// /// - `libfoo.so.1 => /usr/lib/.../libfoo.so.1` — resolved, so the file is on /// disk and `dpkg-query -S` names its owner. /// - `libfoo.so.1 => not found` — absent from the build image too, so there is -/// no path and no file to ask dpkg about. These used to be dropped on the -/// floor by the `=> /` filter, which meant a genuinely missing library -/// produced a short list and an image that failed at run time with a loader -/// error rather than a build that failed with an explanation. +/// no path to ask dpkg about. These are resolved with `apt-file`, which maps +/// a filename to its providing package without needing the file present. /// -/// The second shape is resolved with `apt-file`, which maps a filename to the -/// package providing it without needing the file present. The query is -/// anchored to the multiarch library directory on purpose: a bare basename -/// search for `libatomic.so.1` also matches `lib32atomic1` and the -/// `-cross` packages, and for `libnss3.so` it matches `firefox-esr` and -/// `thunderbird`, so an unanchored `head -1` installs something wildly wrong. +/// A soname reaches this from a binary the app produced, so it is untrusted +/// input. It is validated against the characters a library name can actually +/// contain before it goes anywhere near the regex: `|` alone would otherwise +/// escape the path anchor through alternation and let a crafted `DT_NEEDED` +/// select any package in the archive, which then gets installed as root in the +/// runtime image. /// -/// `apt-file` and its index are only fetched when there is something to look -/// up — the index is around 90MB, which is not worth paying for on every -/// build that has nothing missing. +/// `apt-file` and its ~90MB index are only fetched when something is actually +/// missing. pub fn record_runtime_libraries(glob: &str, record_to: &str) -> String { format!( - "set -eu; \ + "set -euf; \ + export LC_ALL=C; \ mkdir -p \"$(dirname {record_to})\"; \ ldd {glob} 2>/dev/null \ | awk '/=> \\// {{ print $3 }}' | sort -u \ @@ -195,14 +192,25 @@ pub fn record_runtime_libraries(glob: &str, record_to: &str) -> String { apt-get install -y --no-install-recommends apt-file >/dev/null; \ apt-file update >/dev/null; \ for lib in $missing; do \ - owner=$(apt-file search -x \"^/usr/lib/[a-z0-9_]*-linux-gnu/$lib$\" 2>/dev/null \ - | cut -d: -f1 | sort -u | head -1); \ - if [ -n \"$owner\" ]; then \ - echo \"$owner\" >> {record_to}; \ - else \ + case \"$lib\" in \ + *[!A-Za-z0-9._+-]*) \ + echo \"autopack: refusing to look up '$lib': not a library name\" >&2; \ + exit 1 ;; \ + esac; \ + owners=$(apt-file search -x \"^/(usr/)?lib/[a-z0-9_]*-linux-gnu[a-z0-9]*/$lib\\$\" \ + | cut -d: -f1 | sort -u); \ + count=$(printf '%s' \"$owners\" | grep -c . || true); \ + if [ \"$count\" -eq 0 ]; then \ echo \"autopack: no package provides $lib\" >&2; \ exit 1; \ fi; \ + if [ \"$count\" -gt 1 ]; then \ + echo \"autopack: $lib is provided by more than one package:\" >&2; \ + printf ' %s\\n' $owners >&2; \ + echo \"Choose one and add it to apt_packages in autopack.json.\" >&2; \ + exit 1; \ + fi; \ + printf '%s\\n' \"$owners\" >> {record_to}; \ done; \ sort -u -o {record_to} {record_to}; \ fi; \ @@ -242,35 +250,41 @@ mod tests { } #[test] - fn missing_libraries_are_resolved_rather_than_dropped() { + fn missing_libraries_are_resolved_and_hostile_sonames_refused() { let script = record_runtime_libraries("/app/bin/app", "/tmp/deps"); - // The resolved branch is unchanged: ldd gives a path, dpkg names the - // owner. + // Resolved libraries: unchanged path through dpkg. assert!(script.contains("dpkg-query -S")); - - // The "not found" branch is the point of this: those lines carry no - // path, so they used to be filtered out and the library silently - // omitted from the runtime image. + // Missing ones are looked up rather than dropped on the floor. assert!(script.contains("/not found/")); assert!(script.contains("apt-file search")); - // Anchored to the multiarch directory. A bare basename search for - // libatomic.so.1 also matches lib32atomic1 and the -cross packages, - // and libnss3.so matches firefox-esr and thunderbird, so an - // unanchored match would install something wildly wrong. - assert!(script.contains("^/usr/lib/[a-z0-9_]*-linux-gnu/$lib$")); - - // A library nothing provides fails the build rather than producing a - // short list and an image that dies with a loader error. + // A soname comes from a binary the app produced. Anything outside the + // character set a library name can hold is refused before it reaches + // the regex — `|` alone escapes the anchor through alternation. + assert!(script.contains("*[!A-Za-z0-9._+-]*")); + assert!(script.contains("refusing to look up")); + + // Globbing off, so a soname of `*` cannot expand against the build + // directory; deterministic collation, so the same source does not + // resolve differently on two builders. + assert!(script.contains("set -euf")); + assert!(script.contains("LC_ALL=C")); + + // The anchor covers /lib as well as /usr/lib: on Debian the essential + // libraries are still recorded unmerged, so a /usr-only anchor fails + // the build for libc, libz and friends. + assert!(script.contains("^/(usr/)?lib/[a-z0-9_]*-linux-gnu[a-z0-9]*/")); + + // Ambiguity and absence both stop the build with something actionable + // rather than guessing. assert!(script.contains("no package provides")); - assert!(script.contains("exit 1")); + assert!(script.contains("provided by more than one package")); + assert!(script.contains("add it to apt_packages")); - // apt-file's index is ~90MB, so it is only fetched when there is - // something to look up. - let index_fetch = script.find("apt-file update").unwrap(); + // The ~90MB index is only fetched when there is something to look up. let guard = script.find("if [ -n \"$missing\" ]").unwrap(); - assert!(guard < index_fetch); + assert!(guard < script.find("apt-file update").unwrap()); } #[test] From b1ad053e8aba4d4652eaf881ef3c124c9574ca5f Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 6 Aug 2026 09:56:04 +0200 Subject: [PATCH 3/4] fix: keep pathname expansion on for the ldd argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set -f at the top of the script disabled globbing everywhere, including the line whose argument is documented as a glob. No current caller passes one — cobol and crystal name a single binary — but the PHP provider's own copy globs an extension directory, and a caller that inspects several binaries would have silently got no results. Turn expansion off only around the loop over sonames, which is the one place a crafted DT_NEEDED could expand against the build directory. --- crates/autopack-providers/src/support.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/autopack-providers/src/support.rs b/crates/autopack-providers/src/support.rs index 9b0e632..b454563 100644 --- a/crates/autopack-providers/src/support.rs +++ b/crates/autopack-providers/src/support.rs @@ -176,9 +176,14 @@ pub fn shell_quote(value: &str) -> String { /// /// `apt-file` and its ~90MB index are only fetched when something is actually /// missing. +/// +/// `glob` is expanded by the shell, so it may name several binaries — pathname +/// expansion stays on for that line and is turned off only around the loop +/// over sonames, where a `*` from a crafted `DT_NEEDED` would otherwise expand +/// against the build directory. pub fn record_runtime_libraries(glob: &str, record_to: &str) -> String { format!( - "set -euf; \ + "set -eu; \ export LC_ALL=C; \ mkdir -p \"$(dirname {record_to})\"; \ ldd {glob} 2>/dev/null \ @@ -191,6 +196,7 @@ pub fn record_runtime_libraries(glob: &str, record_to: &str) -> String { apt-get update >/dev/null; \ apt-get install -y --no-install-recommends apt-file >/dev/null; \ apt-file update >/dev/null; \ + set -f; \ for lib in $missing; do \ case \"$lib\" in \ *[!A-Za-z0-9._+-]*) \ @@ -268,7 +274,12 @@ mod tests { // Globbing off, so a soname of `*` cannot expand against the build // directory; deterministic collation, so the same source does not // resolve differently on two builders. - assert!(script.contains("set -euf")); + assert!(script.contains("set -eu")); + // Globbing is off for the soname loop but must stay on for `ldd`, + // whose argument is a glob for callers that inspect several binaries. + let ldd = script.find("ldd ").unwrap(); + let disable = script.find("set -f;").unwrap(); + assert!(ldd < disable); assert!(script.contains("LC_ALL=C")); // The anchor covers /lib as well as /usr/lib: on Debian the essential From b31409ee7a94cfaea266f62c01628ffdb210fa15 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 6 Aug 2026 10:51:05 +0200 Subject: [PATCH 4/4] fix: escape regex metacharacters in a soname MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The character guard rejects what a library name cannot contain, but two characters it legitimately can are also regex metacharacters, and both were spliced raw into the apt-file query. `+` is a quantifier, so libxml++-2.6.so.2 matched nothing and the build failed claiming no package provides a library that plainly exists. 238 sonames in bookworm carry a + — libFLAC++, libMagick++, libIce++11. Escaped, they resolve: libxml++2.6-2v5, libflac++10. `.` matches any character including /, so a crafted DT_NEEDED of gio.modules.libgioremote-volume-monitor.so walked two directories below the anchor and reached gvfs, pulling 215 packages into the runtime image as root. A malicious transitive dependency can rewrite DT_NEEDED on a binary under /app, so this was reachable without the app author's knowledge. Escaped, it resolves to nothing and the build stops. Escaping both collapses the query to an exact basename match. --- crates/autopack-providers/src/support.rs | 28 +++++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/autopack-providers/src/support.rs b/crates/autopack-providers/src/support.rs index b454563..8c139ae 100644 --- a/crates/autopack-providers/src/support.rs +++ b/crates/autopack-providers/src/support.rs @@ -168,11 +168,20 @@ pub fn shell_quote(value: &str) -> String { /// a filename to its providing package without needing the file present. /// /// A soname reaches this from a binary the app produced, so it is untrusted -/// input. It is validated against the characters a library name can actually -/// contain before it goes anywhere near the regex: `|` alone would otherwise -/// escape the path anchor through alternation and let a crafted `DT_NEEDED` -/// select any package in the archive, which then gets installed as root in the -/// runtime image. +/// input, and it is handled twice over. +/// +/// It is validated against the characters a library name can actually contain, +/// because `|` would otherwise escape the path anchor through alternation and +/// let a crafted `DT_NEEDED` select any package in the archive, which then gets +/// installed as root in the runtime image. +/// +/// The survivors are then escaped, because two characters a library name +/// legitimately contains are also regex metacharacters. `+` is a quantifier, +/// so `libxml++-2.6.so.2` matches nothing and the build fails claiming no +/// package provides a library that plainly exists — 238 sonames in bookworm +/// carry a `+`. And `.` matches any character including `/`, so +/// `gio.modules.libgioremote-volume-monitor.so` reaches `gvfs` two directories +/// below the anchor. Escaping both collapses the query to the exact basename. /// /// `apt-file` and its ~90MB index are only fetched when something is actually /// missing. @@ -203,7 +212,8 @@ pub fn record_runtime_libraries(glob: &str, record_to: &str) -> String { echo \"autopack: refusing to look up '$lib': not a library name\" >&2; \ exit 1 ;; \ esac; \ - owners=$(apt-file search -x \"^/(usr/)?lib/[a-z0-9_]*-linux-gnu[a-z0-9]*/$lib\\$\" \ + pattern=$(printf '%s' \"$lib\" | sed 's/[.+]/\\\\&/g'); \ + owners=$(apt-file search -x \"^/(usr/)?lib/[a-z0-9_]*-linux-gnu[a-z0-9]*/$pattern\\$\" \ | cut -d: -f1 | sort -u); \ count=$(printf '%s' \"$owners\" | grep -c . || true); \ if [ \"$count\" -eq 0 ]; then \ @@ -271,6 +281,12 @@ mod tests { assert!(script.contains("*[!A-Za-z0-9._+-]*")); assert!(script.contains("refusing to look up")); + // `.` and `+` survive that guard because a library name may contain + // them, and both are regex metacharacters — `+` quantifies, so a C++ + // soname matches nothing, and `.` matches `/`, reaching packages below + // the anchored directory. + assert!(script.contains("sed 's/[.+]/")); + // Globbing off, so a soname of `*` cannot expand against the build // directory; deterministic collation, so the same source does not // resolve differently on two builders.