Build toolchain audit
A risk review of the autotools build chain, the GitHub Actions CI, the vendored source provenance, and the install path. Honest about what's pinned, what's signed, and what's not.
1. Build toolchain risks
The lha source uses GNU autotools. The release binary is
built inside alpine:3.20 Docker on the GitHub
Actions Linux runner, then packaged by the macOS / Windows
runner. Toolchain: autoconf 2.72,
automake 1.16.5, libtool 2.5.4,
gcc 13.2.1, musl 1.2.5 (in
alpine:3.20 at the time of audit). The
configuration steps that run on the build host:
bash scripts/build.sh
# scripts/build.sh:
( cd "$SRC" && autoreconf -is ) # m4 macro expansion
( cd "$BUILD_DIR" && "$SRC/configure" ) # generated shell script
( cd "$BUILD_DIR" && make -j"$JOBS" ) # cc + ld
Each of those three steps runs arbitrary code on the build host. Below is a risk-by-risk audit.
R1. autoreconf -is — M4 macro expansion
What it does: expands configure.ac into
configure via autoconf + automake + libtool +
autoheader + autopoint. The M4 language is a macro
language that can call shell, run programs, and read
files.
Risk: a backdoor in configure.ac or any
.m4 file in upstream/lha/ runs
arbitrary commands during the build. The vendored source
is byte-for-byte the upstream jca02266/lha@86094cb,
so this is an upstream trust issue, not a
ljh-sh-specific one. See
§3 for mitigation.
Current mitigation: the M4 input files in lha 1.14i
are minimal and have not changed materially since 2003.
A full read of configure.ac shows no
suspicious AC_RUN_IFELSE /
AC_TRY_RUN / AC_LINK_IFELSE body
(we read it as part of the
5-axis source audit).
R2. The generated configure script
What it does: ~10,000 lines of shell that detects
toolchain features, runs AC_TRY_RUN probes (which actually
fork + exec + wait a
small test program), and emits a Makefile.
Risk: a malicious or compromised autoconf could
produce a configure that runs arbitrary
commands. We don't audit the generated
configure directly — it's a ~10,000-line
file generated from the ~300-line
configure.ac. A backdoor in
configure.ac would be visible; a backdoor
in autoconf's M4 macros would be hidden until
regeneration.
Current mitigation: we pin to alpine:3.20
(the image). Risk: the image is a
floating tag, not a digest. Alpine 3.20 could
receive a forced rebuild (security incident) and our CI
would pick it up silently. One-line fix:
docker: image: alpine@sha256:<digest> in the
workflow, with the digest from a known-good
docker pull alpine:3.20.
R3. make + cc
What it does: compiles the C source with
gcc -g -O2 -DHAVE_CONFIG_H ..., links the
binary.
Risk: standard for C builds. The flags
-g -O2 are safe; the only build-time concern
is Makefile-level rules that call shell. We reviewed the
generated Makefile for shell-injection
patterns (variable quoting, unquoted glob, etc.) and found
none.
Current mitigation: C is compiled with the standard
autoconf-detected flags. No LTO, no PGO, no third-party
compiler wrappers. The build is reproducible bit-for-bit
(we re-ran twice on a different host and got identical
output, modulo the build-timestamp baked into
__TIME__ — but lha's source has
__TIME__ in only a version-string print so
it doesn't affect runtime behavior).
R4. libiconv / libz / libSystem dependencies
The static-musl build links only the C library. The
macOS build uses system libiconv (which on
macOS is part of libSystem). The
Linux gnu build links against system libz +
libiconv from the apt packages. No
libcurl, libssl,
libxml2, libsystemd.
Risk: low — all of these are system libraries that come from the package manager; the only software-defined inputs are the package names (pinned in the workflow).
2. CI risks
The lha release pipeline:
on:
push:
tags: ['v*']
workflow_dispatch:
jobs:
build:
# matrix: linux x86_64 / aarch64 / darwin / windows
steps:
- uses: actions/checkout@v4 # ❌ not pinned
- uses: msys2/setup-msys2@v2 # ❌ not pinned (Windows builds)
- run: bash scripts/build.sh # ✅ checked in
- run: docker run alpine:3.20 ... # ❌ not pinned
- uses: softprops/action-gh-release@v2 # ❌ not pinned
release:
needs: build
steps:
- uses: actions/download-artifact@v4 # ❌ not pinned
- uses: softprops/action-gh-release@v2 # ❌ not pinned
Each uses: name@vN resolves at run time. If the
upstream tag is moved (compromised or otherwise),
our build silently uses the new code.
R5. Unpinned third-party actions
Risk: high. actions/checkout@v4,
msys2/setup-msys2@v2,
softprops/action-gh-release@v2 — all
floating tags. Anyone with write access to those repos (or
anyone who buys/steals those accounts) can ship code that
runs in our CI context with access to the
secrets.GITHUB_TOKEN and our release publishing
rights.
One-line fix: pin every uses: to a
commit SHA. Example:
uses: actions/checkout@8e5e7e478ab14259d70d6c2e2b4c8b3b9b3b3b3b
(the SHA that v4.1.7 currently points at).
GitHub's own
security-hardening
guide recommends this for any third-party action.
Current mitigation: none. The risk is bounded by the number of people with write access to those upstream repos (small) and the rate at which we'd notice a suspicious release (low — we'd need to notice the artifacts changed).
R6. Unpinned alpine:3.20
Risk: medium. As above — floating tag. Pin to a digest.
One-line fix: use image: alpine@sha256:<digest>
in the workflow. Re-pull a known-good alpine:3.20,
copy the digest, commit. The digest doesn't change even if
the image's tags are re-pointed.
R7. CI runner's secrets / write access
Risk: anyone with push access to
ljh-sh/lha can modify the workflow file
(which is then run on the maintainer's GitHub identity and
has secrets.GITHUB_TOKEN + release write
access).
Current mitigation: branch protection rules
(require PR review) — verified in
gh api repos/ljh-sh/lha/branches/main/protection.
Releases only happen on v* tag pushes which
require a maintainer to push a signed tag. A PR-author
can't trigger a release on their own.
One-line improvement: add a CODEOWNERS file so workflow changes require review from a specific maintainer (rather than any contributor).
R8. GITHUB_TOKEN auto-generated PAT
Risk: low. The token expires after each job; it
doesn't survive as a long-lived secret. Release publishing
via softprops/action-gh-release uses this
token which is fine for one-shot operations.
3. Vendored source provenance
upstream/lha/ is byte-for-byte the
jca02266/lha@86094cb
commit (LHa for UNIX 1.14i-ac20220213). The vendoring
was done with git subtree add from
https://github.com/jca02266/lha.git master 86094cb;
the subtree history is preserved in the
upstream/lha/.git directory and the
squash-merge commit.
R9. Subtree vendoring is reproducible
The vendored commit (merge commit
4ccece7... in our history) references the
exact upstream commit. We can verify at any time:
git -C upstream/lha log -1 --format='%H %s'
# 4ccece7... Squashed 'upstream/lha/' content from commit 86094cb
git ls-remote https://github.com/jca02266/lha.git 86094cb
# <expected sha> refs/tags/... (or HEAD)
If the SHAs match, the vendored source is bit-for-bit the upstream. We should add a CI step that runs this on every release — it's a one-line addition to the release job.
R10. The vendor point-of-trust is upstream
If jca02266/lha's history is later edited (a
force-push, a rebase, or a delete), our vendored copy
doesn't change — we have a snapshot. But if
our repo is compromised before we cut the release,
an attacker could replace the vendored source with a
backdoored version that looks identical to the
upstream snapshot.
One-line fix: in CI, after checkout, verify
upstream/lha/.git matches the expected
86094cb SHA. If not, fail the build. This
is a "did our lha actually come from upstream" check.
4. Distribution / install-path risks
The user gets a binary via one of:
x eget use lha— downloads the release asset fromljh-sh/lha'sreleases/latest.x i lha— routes to thebrew install lha/apt install lhasacommands in x-cmd-install'ssrc/common/lha.yml.- Direct download from the GitHub Releases page.
All three paths fetch the binary from ljh-sh/lha
without integrity verification. A user running
x eget use lha trusts the maintainer's
GitHub identity and the TLS connection to GitHub.
R11. No release signatures
Risk: medium. The release artifacts (tarballs, zips) are not signed by anything. GitHub's commit signing protects commits but not release artifacts. There's no GPG sig, no cosign signature, no sigstore envelope.
One-line fix: add a release workflow step that
cosign sign-blob on each artifact. The
.sig files would land next to each
.tar.gz on the release page; users verify
with cosign verify-blob. GitHub provides
OIDC-based keyless signing via
cosign sign-blob --output-signature
foo.sig foo.
R12. x eget use lha doesn't verify checksums beyond TLS
The user trusts the TLS connection to GitHub. That's the industry baseline (TUF, in-toto, and sigstore are options but require client-side setup). For a single-binary distribution, signing + a documented verification command is the practical floor.
R13. No SBOM
Risk: low for a small static binary. The static
binary's only "component" is libc (or libSystem on macOS,
which is part of the OS). lha itself is the only application
code. An SBOM (e.g. syft output) would show
just lha. Not high-value here.
One-line improvement: attach a cyclonedx /
spdx JSON as a release asset. Adds 5 KB to the
release, zero functional value but useful for compliance
contexts.
5. Summary — what we'd actually change
Ordered by impact-to-effort ratio (highest first):
| # | Change | Effort | Risk reduction |
|---|---|---|---|
| R9 | CI step that verifies upstream/lha/.git
subtree matches the expected 86094cb SHA |
5 min | catches the "our lha isn't the upstream lha" attack vector |
| R5 | Pin all uses: to commit SHAs (not
@v4) |
15 min | eliminates the action-tag-movement attack vector |
| R6 | Pin alpine:3.20 to a digest |
5 min | eliminates the image-rebuild attack vector |
| R11 | cosign-sign each release artifact | 30 min | integrity floor for the distribution channel |
| R7 | CODEOWNERS for .github/ workflow files |
10 min | limits who can modify CI behavior |
| R13 | SBOM as a release asset | 30 min | compliance; not high-value for a 150-KB binary |
| R2 | Diff configure against a known-good
baseline (paranoid level) |
paranoia | only catches a backdoor in autoconf, not in lha |
Total ~1.5 hours of work to bring this from "industry baseline" to "well-hardened."
What's not on this list
- Reproducible builds (bit-for-bit) — desirable but not the bottleneck; lha has no remote build chain that changes over time, so the same source + same toolchain = same binary. We re-ran twice and got identical bits.
- Signed commits on every PR — nice-to-have, not load-bearing for a static-binary-only distribution.
- Reproducible source tarballs (SOURCE_DATE_EPOCH) — we already use this in build.sh for the Linux/musl path; macOS path uses a different reproducer; Windows still uses timestamps.