Repository navigation
Releases: schubydoo/scratchsmith
Release list
1.9.0 (2026-10-29)
Features
A pack whose SBOM 2.0 changes now warns (#403)
For a pack with --sbom or --scan and no --packages value, pack prints one warning line
to standard error. It does so only on a host with a dpkg or rpm database. Scratchsmith 2.0 adds sbom to the
default --packages, so the SBOM and the scan will see the bundled packages. The run still
succeeds and the exit code does not change.
Deprecated
The default --packages gains sbom in 2.0 (#403)
Scratchsmith 2.0 changes the default --packages from report to report,sbom. A
--scan-fail-on gate can then find more in the bundled packages. To keep today's result, set
--packages report. The
Deprecations page has the detail.
1.8.0 (2026-10-24)
Features
unpack reports the setuid and setgid bits it clears (#353)
For each path in the final image that had a setuid or setgid bit, unpack prints a warning line on standard error. The new stripped_setid field of --format json lists the same paths.
Fixes
index warns when the registry names another digest (#369)
When the registry's answer names a digest other than the index just pushed, index --sign refuses to sign, but index without --sign said nothing. It now reports a warning, as pack --push does: a warning: line in the text output, and a new warnings array in --format json. The push and the exit code are unchanged.
install.sh says why a download failed (#365)
With wget, install.sh hid the reason a download failed, so a missing release, a proxy and a dead network looked the same. It now prints wget's last error lines when a fetch fails, and stays quiet when it works. The cosign signature bundle download no longer hides its error with either tool, so a refusal over a missing bundle shows why the fetch failed.
unpack reads an OCI archive whose entries start with ./ (#356)
tar -cf img.tar . inside an OCI layout directory stores ./index.json and ./blobs/sha256/…. unpack failed on such an archive with "archive has no index.json". It now reads those entries as the same files.
Security
unpack and diff bound the memory they use on a hostile archive (#344)
unpack read each entry of the outer OCI archive into memory. A small GNU sparse entry could expand to gigabytes of zeros and go around the decompression limit. Scratchsmith now skips entries it does not use, refuses a sparse entry that it must read, and reads no more than the archive's own size. diff now hashes files as a stream instead of loading each whole file.
graph, pack, index and diff escape the rest of their untrusted text (#359)
Some output still printed text from a binary, an image or a registry without escaping it: the names, paths and interpreter in graph text output, the loader line and the size table in the pack text report, the duplicate-platform and multi-arch errors from index, and file-walk errors from diff. A line break or a terminal escape sequence in that text could forge output lines. Scratchsmith now escapes it. JSON output and exit codes do not change.
diff, index and the musl check escape control characters (#340)
A file name in an image, a platform string from a registry, or a string in a musl binary can hold a newline or a terminal escape sequence. diff and index text output and the musl error printed that text raw, so it could forge report lines or drive the terminal. Scratchsmith now escapes those characters. JSON output and exit codes do not change.
unpack escapes archive text in its error messages (#345)
unpack put text from the archive into its error messages raw: media types, digests, blob names, entry paths and the tar error. A hostile archive could then forge lines or send terminal escape sequences to standard error. Scratchsmith now escapes that text and caps each string at 8 KiB. Exit codes do not change.
install.sh stops when cosign is installed and the signature bundle is missing (#348)
With cosign installed, install.sh warned and installed anyway when it could not fetch checksums.txt.sigstore.json. An attacker who swaps the tarball and the checksums can also withhold the bundle. The script now stops the install in that case. Set INSECURE_ALLOW_MISSING_SIGNATURE=1 to install with the SHA-256 check only.
pack and graph stop reading a device after 256 MiB (#339)
A DT_NEEDED entry can name a device such as /dev/zero, and the resolver read it until the host ran out of memory. Scratchsmith now stops after 256 MiB of a character or block device and exits 1 with an error that names the file.
pack and graph refuse a library or a loader that cannot load (#360)
A DT_NEEDED entry or a program interpreter (PT_INTERP) can name any host file that is an ELF, such as a core dump, and scratchsmith copied that file into the image. Scratchsmith now refuses a library that is not a shared object (ET_DYN), and an interpreter that is not ET_DYN or ET_EXEC, and exits 1. A PIE executable is ET_DYN, so this check does not refuse one.
pack and graph no longer hang on a FIFO (#361)
A DT_NEEDED entry can name a FIFO, and the read of it waited forever for a writer, or read a writer's endless stream. Scratchsmith now refuses a library that is not a regular file before it opens it, and exits 1. A FIFO that you name with --include no longer hangs graph after its read, stops at 256 MiB like a device, and pack refuses it at once, because it cannot be staged.
pack and graph refuse a non-ELF file named by the binary (#338)
A DT_NEEDED entry or a program interpreter (PT_INTERP) can name any host file, such as ~/.docker/config.json, and scratchsmith copied that file into the image. Scratchsmith now refuses a library that is not an ELF, and an interpreter that is not a regular ELF file, and exits 1. A data file that you name with --include stages as before.
--sign signs the manifest digest that scratchsmith pushed (#346)
pack --push --sign and index --sign signed the digest that the registry named in its Location header. A hostile or broken registry could name a different image, and scratchsmith then signed and attested that image. Scratchsmith now hashes the exact manifest bytes it uploads, signs that digest, and exits 1 when the registry names a different one. A push without --sign behaves as before.
pack keeps soname symlinks inside the image root (#342)
A DT_NEEDED name such as ../../../tmp/libx.so comes from the packed binary, and scratchsmith created its soname symlink at that path joined under the staging directory. The link could land outside that directory. Scratchsmith now walks the name inside the image, where .. at the root stays at the root. A link already in a reused --output directory is read the same way, so an absolute link value names a path in the image, not on the host.
pack --no-build keeps every write inside a reused output directory (#352)
A reused --output directory can hold a link with an absolute target, such as lib -> /usr/lib. Scratchsmith joined each staged path under the directory and let the host follow that link, so a library copy, a --strip, or a generated file such as /etc/passwd could land on the host's own files. Scratchsmith now reads every link in the directory the way the image will, so each write stays inside the directory.
pack keeps the staged loader inside the image root (#341)
The program interpreter path comes from the packed binary, and scratchsmith joined it under the staging directory as text. A path such as /../../tmp/x put the loader copy outside that directory. Scratchsmith now walks the path the way the kernel does inside the image, where .. at the root stays at the root. A link already in a reused --output directory is read the same way, so an absolute link value names a path in the image, not on the host.
unpack limits each archive entry and a piped archive (#362)
A large or sparse archive file on disk, or an archive read from a pipe, let one entry fill memory, and a pipe had no total limit. unpack now refuses an entry larger than 2 GiB plus 2 MiB, and a pipe that yields more than 16 GiB in all, and exits 1. An uncompressed layer over 2 GiB is now refused, as a gzip layer that decompresses past 2 GiB already was.
unpack clears setuid and setgid bits (#343)
unpack kept the setuid and setgid bits of each file in an image layer. A hostile image could then plant a setuid program owned by the user who unpacked it, or by root when unpack ran as root. Scratchsmith now clears both bits on every file it writes. Owners and contents do not change.
unpack deletes a whiteout's target through the directory it opened (#363)
unpack checked a whiteout's directory and then deleted through its path again, so a local user who could write inside the output directory could swap in a symlink and redirect the deletion outside it. unpack now opens the directory one step at a time and deletes relative to it. A symlink in that path now resolves inside the image, as / there, so a whiteout under a usrmerge lib -> /usr/lib link deletes the file that the image means.
1.7.2 (2026-10-21)
Security
Dependency update for RUSTSEC-2026-0382, RUSTSEC-2026-0383 (#327)
The lockfile moves to rustls-webpki 0.103.16, webpki-root-certs 1.0.10, which fixes RUSTSEC-2026-0382, RUSTSEC-2026-0383.
1.7.1 (2026-10-18)
Security
rustls 0.23.46 for registry TLS (#315)
The TLS library that --push uses to talk to a registry moves from rustls 0.23.45 to 0.23.46.
That release fixes RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 and
RUSTSEC-2026-0349. The worst of them is rated medium.
1.7.0 (2026-10-09)
Features
packages GitHub Action input (#296)
The action now exposes --packages as an input. Set packages: report,sbom to make the SBOM
and the scan see the distribution packages, or packages: image to keep the package records
in the image. Set packages: none to turn the lookup off. Empty keeps the default, report.
Fixes
--packages sbom with nothing to read it now warns (#298)
--packages sbom shows the packages to --sbom and --scan. With neither of them on, the pack
looked the packages up, wrote them nowhere, and said nothing. It now prints a warning and
skips that lookup. The pack still succeeds.
1.6.0 (2026-10-09)
Features
index and pack --push work on a host with no certificate store (#273)
On a host with no CA trust store, such as a FROM scratch container, index and pack --push
failed before they reached the registry. They now fall back to the Mozilla root certificates
compiled into the binary, and print one warning to standard error to say so. A host that has a
store sees no change. If SSL_CERT_FILE or SSL_CERT_DIR is set, your choice holds and there
is no fallback.
pack --watch packs again each time the binary changes (#272)
scratchsmith pack --watch ./app packs one time and then packs again after every change to the
binary, until you press Ctrl-C. It waits until the file stops changing, and a pack that fails
does not end the watch. It works with the default load and --oci-archive, and it refuses
--push, --no-build and --format json. Only the binary is watched.
--packages names the distribution package behind each bundled library (#276)
A scratch image has no package database. An SBOM of it named almost none of the libraries that
scratchsmith copied in, and --scan did not see them. On a dpkg or rpm host, scratchsmith now
asks which package owns each bundled file, and the --format json report gains a packages
list. A pack with the text report skips that lookup unless you set --packages or the
packages configuration key.
Two more outputs are opt-in. --packages report,sbom makes --sbom and --scan see those
packages. A scan gate can then fail on vulnerabilities that it did not see before.
--packages image keeps the package records and /etc/os-release in the image. If you ask for
sbom or image on a host with neither database, the pack fails. On an rpm host with a
Berkeley DB database, such as Rocky Linux 8, an image with the records is not reproducible.
A pack into an output directory that is not empty now warns (#285)
If the output directory already holds files, pack --no-build --output <dir> prints one
warning line to standard error. The pack merges into that directory, so a file from an earlier pack can
stay in it. The run still succeeds and the exit code does not change.
The JSON pack report carries the time of each phase (#275)
pack --format json gains a timings object: the milliseconds spent to resolve, stage, generate
the SBOM, scan, deliver, smoke-run and sign, plus the total. A phase that did not run is null.
Use it to find the slow part of a pack in CI, or to build trace spans from it. The text report
does not change.
The resolver follows the loader's RUNPATH rule (#281)
A library that has its own RUNPATH does not search an RPATH inherited from the program.
Scratchsmith searched it anyway. When a second copy of a library was in a system directory, the
image got a different copy than the program uses on the host. Scratchsmith now stages the copy
that the loader uses.
If a library is found only through an inherited RPATH, the pack still works and prints a
warning.
Two silently tolerated resolver inputs now warn (#270)
pack and graph print one warning line to standard error for a library that starts like an ELF
and cannot be parsed. They do the same for a $PLATFORM token in a search path on an
architecture that has no value for it. The run still succeeds and the exit code does not change.
A file that is not an ELF at all stays quiet.
Fixes
The GitHub Action no longer hangs on a stalled download (#269)
The action downloaded the release with no network timeout, so a connection that stalled held the
step until the job timed out. If no data arrives for 30 seconds, the download now stops, and the
step fails after three more tries. A slow connection that still moves data is not cut off.
--oci-archive replaces the archive in one step (#274)
pack --oci-archive emptied the destination file first and then wrote the new archive into it.
A tool that read the file at that moment got a short archive. Scratchsmith now writes a file
beside the destination and renames it into place, so a reader gets the old archive or the new
one. A destination that is not a regular file, such as /dev/stdout, is written directly as
before.
Two things follow from the rename. The pack now needs write permission on the directory that
holds the archive, not only on the archive. If you interrupt a pack, a file named
.scratchsmith-oci-*.tmp can remain beside the archive, and you can delete it. An archive that
replaces an existing one keeps that file's permissions.
doctor and --help name things as they are (#278)
doctor described syft and cosign with a "v0.2" label from an early plan. It now names the flags
that use them, --sbom and --sign. The --max-size help text said "packed payload", and it now
says what the check measures: the whole staged image. The README and the docs home page link to
the Deprecations page.
doctor shows the package tools, and says when an rpm is too old (#288)
doctor has two new rows, dpkg-query and rpm, for the tools behind --packages. CentOS 7
and Amazon Linux 2 have rpm 4.11, and --packages sbom and --packages image fail there. On
such a host the rpm row starts with warn and names the outputs that fail.
A missing library name can no longer forge a line of output (#283)
When a library that a binary needs is not found, scratchsmith prints its name in the error and in
the graph tree. That name comes from the binary, and it was printed as it was. With a line
break or a terminal escape in the name, a crafted binary was able to fake a line of
scratchsmith's output. Scratchsmith now escapes those characters. The JSON output was already
safe.
install.sh no longer hangs on a stalled connection (#268)
The installer set no network timeout, so a connection that stalled left it waiting with no output.
If no data arrives for 30 seconds, it now gives up and prints the download error. A slow
connection that still moves data is not cut off.
A second pack into the same directory no longer fails on a read-only file (#285)
If the output directory held a read-only file from an earlier pack, and the user was not root,
pack --no-build --output <dir> failed with Permission denied. A binary with mode 0555 and a
--ca-certs bundle with mode 0444 both caused it. Scratchsmith now replaces the staged file.
Deprecated
--no-build into a directory that is not empty (#285)
A second pack into the same --output directory merges with the first, and a file that the new
pack does not stage stays there. Scratchsmith 2.0 refuses a directory that is not empty. Today
the pack works and prints a warning. Use a new directory, or empty it first.
The Deprecations page has the
detail.
A library found only through an inherited RPATH (#281)
A library with its own RUNPATH does not search the program's RPATH. If a file that it needs
is only in that RPATH directory, the loader does not look there. Scratchsmith 2.0 reports that
file as missing. Today the pack works and prints a warning. Put the directory in the RUNPATH of the
library that needs the file.
The Deprecations page has the
detail.
A damaged library, or a $PLATFORM search path with no value (#270)
Scratchsmith 2.0 rejects a resolved library that is truncated or corrupt. It also rejects a
$PLATFORM token in an RPATH or RUNPATH on an architecture other than x86_64 and aarch64.
Today both pack, and the image can ship without the correct libraries. The
Deprecations page carries the
fix for each.
1.5.1 (2026-09-25)
Fixes
install.sh no longer stops silently when it resolves the latest release (#232)
Without VERSION, the installer sometimes exited with no message right after "resolving the latest
release". The cause was a race in a shell pipe. The installer now reads the full GitHub answer
before it parses the tag. If GitHub cannot be reached, it prints an error that names VERSION.
1.5.0 (2026-09-21)
Features
locale and symlinks GitHub Action inputs (#196)
The action now takes locale and symlinks directly, instead of through the args escape
hatch. locale takes one locale name per line. symlinks takes one of copy-all, preserve,
copy-unsafe, or skip-unsafe. Both pass straight to the pack flag of the same name.
Under a symlinks mode that stages a link, an add-file source that is a link to a directory
no longer fails. skip-unsafe stages nothing for a link it cannot honor.
Three silently accepted input shapes now warn (#207)
--label NAME and --env NAME without an =, and a nested [profile.a.profile.b] table, each
print one warning line to standard error. The pack still succeeds and the exit code does not
change. A well-formed pair, including a deliberate empty value like --label build=, stays
quiet.
Fixes
A rootfs entry that cannot be read now fails the pack (#200)
Building the image layer skipped unreadable files, and everything under an unreadable directory.
The pack reported success, and under --push --sign it signed the short image. It failed at run
time instead, on a library that was never there.
The build now stops at the file and names its path in the staged rootfs under TMPDIR.
index and --push now report a missing certificate store (#216)
On a host with no CA trust store, which covers most minimal containers and every FROM scratch
image, both commands died with no message. They now fail with an error that names the cause and
what to install. Nothing changes where a trust store is present.
A broken credential helper no longer pushes anonymously (#202)
A failed credential lookup counted as "no credential here", so the push went ahead anonymously.
You saw a bare 401, or a push that worked under an identity you did not choose.
A lookup that fails now stops the push and names the registry. No credential is still anonymous,
which is how you push to a public or local registry.
Separately, a token response body that cannot be read now reports the dropped connection instead
of a token-decoding problem.
A library that cannot be read now fails the pack (#201)
The resolver skipped a library it cannot read and carried on, so nothing that library needs was
resolved or staged. graph then printed a tree with a branch missing. pack failed later, at
the copy, naming the wrong thing.
It now stops at the library and names the path.
Deprecated
A label, an environment entry, or a profile table that scratchsmith cannot use (#207)
Scratchsmith 2.0 rejects --label NAME and --env NAME without an =, and a nested
[profile.a.profile.b] table. Write --label NAME=value and --env NAME=value, and move a
nested profile to a top-level [profile.<name>] table. The new
Deprecations page carries the
migration for each.
1.4.0 (2026-09-20)
Features
add-file GitHub Action input (#188)
The action now exposes --add-file as a first-class input. Each line is one SRC:DST spec, or
one bare absolute SRC that keeps its own path in the image. The action passes each line to the
pack flag of the same name, so the rules do not change. Regular files only. A missing source, a
directory, or a destination already in the image fails the pack. Before this, the flag was
reachable only through the args escape hatch.
--locale: stage a glibc locale in the image (#189)
pack --locale en_US.UTF-8 stages one compiled locale at /usr/lib/locale/en_US.UTF-8. With no
LOCPATH set, glibc reads exactly that path. Until now a scratch image carried no locale data.
Every packed binary ran in the C locale, and setlocale failed for any other.
The data comes from a matching host directory under /usr/lib/locale. When there is none,
localedef compiles the locale from the glibc sources at /usr/share/i18n. When neither
source is available, the pack fails and names what is missing. scratchsmith doctor now
probes localedef too.
The host locale-archive is never copied, because one archive holds every locale the host has.
A single locale costs about 3 MB, and most of that is the collation table LC_COLLATE.
Staging the data does not select it, so set LANG, LC_ALL, or a per-category LC_* entry
with --env. If nothing selects a staged locale, the pack warns. If a selector names a locale
the pack did not stage, the pack warns as well. glibc answers that mismatch with a silent
fallback to the C locale.
The flag repeats and takes a locale name, not a path. The locale key in scratchsmith.toml
takes the same list.
The pack report names the loader (#192)
pack --format json gains an interpreter field, and the text output gains a loader line.
Both carry the image path of the dynamic loader, which is the one staged path the binary's
PT_INTERP chooses rather than scratchsmith. An ELF that carries no PT_INTERP reports null,
and the text line is absent. A static binary is the usual case for that, but not the only
one. Read the field as "no PT_INTERP", not as "static".
Until now a pack recorded every other fact about the image and stayed silent about the loader.
A job that wanted to assert which loader shipped had to unpack the image and look. It can now
read one field.
The field is additive, so an existing consumer of the JSON is unaffected.
--symlinks: keep a named symlink as a symlink (#191)
Scratchsmith copies the content of a symlink you name, so the image gets a regular file and
not the link. That loses the path itself. Packing /usr/bin/python3, a link to python3.13,
produced an image with the real file and nothing at /usr/bin/python3. A container that ran
the name rather than the target then failed to start.
pack --symlinks <mode> chooses what happens. copy-all copies the content to the named
path, which is the default and what every earlier release did. preserve recreates the link.
copy-unsafe recreates a link whose target is in the image and copies the content for one
whose target is not. skip-unsafe recreates the safe link and stages nothing for the other.
A dangling or skipped link warns and names the target.
The mode covers the packed binary's own path and each --add-file source. Resolved libraries
are not in scope, because the loader decides those and the stager already recreates the soname
links it needs. Preserving a link never pulls its target into the image, so an image still
gains only what you asked for. The packed binary is the exception: its real file is always
staged, so a preserved link to it always resolves. The symlinks key in scratchsmith.toml
takes the same value.
A mode other than copy-all relaxes two --add-file rules, and only for a source that is
itself a symlink. A preserved link is staged as a link, so a link to a directory no longer
fails. Under skip-unsafe an entry the pack cannot honor stages nothing and warns. Every link
the stager writes carries a relative value, so a staged tree resolves inside itself.
1.3.0 (2026-09-19)
Features
--add-file: copy any host file into the image (#181)
pack --add-file SRC:DST copies a host file into the image at DST. A bare absolute
--add-file SRC keeps the host path. Until now --ca-certs and --tz each added one fixed
file, and there was no way to add another. A program that reads a configuration file or a data
file at runtime now packs without a wrapper step. The flag repeats and takes regular files
only. The add-file key in scratchsmith.toml takes the same list. A missing source, a
directory, or a DST that is already in the image fails the pack and names the path. The
layer writer canonicalizes modes, so in an image the file lands owned by uid 0. Its mode is
0644. When the source is executable, the mode is 0755.