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
11 changes: 11 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# The x86_64 musl target links a static PIE by itself. The aarch64 musl
# target does not (rustc 1.98 lacks the flag for it), and the plain static
# executable it produces has no ASLR: podup-linux-arm64 and the arm64 .deb
# shipped that way through 5.7.0. For that target the link goes to rust-lld
# without rustc's own CRT objects, and build.rs supplies the static-PIE set
# and flags; see the comment there. This applies to every cargo invocation
# in the tree: the release build, the Debian build, tests and the CI job
# that runs the result on an arm64 runner.
[target.aarch64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "link-self-contained=no"]
70 changes: 70 additions & 0 deletions .github/scripts/check-hardening.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
#
# Assert the hardening the Linux release binaries are documented with, over
# every ELF named on the command line. Five properties, each read with readelf:
#
# static PIE ET_DYN with the PIE flag and no PT_INTERP: the binary
# relocates on every exec and brings its own libc
# full RELRO a GNU_RELRO segment and BIND_NOW, so the GOT is read-only
# before main runs
# NX stack GNU_STACK without the E flag
# stripped no .symtab; the release profile says strip = true
#
# These come from rustc's defaults for the musl target and from
# [profile.release]; nothing in the repository asks for them by name, so a
# toolchain bump or a RUSTFLAGS in a build step could drop any one and the
# release would still sign and publish. This is where that is caught.
#
# One line per file, "ok" or "FAIL <property>", and a non-zero exit when any
# file fails any property. readelf output is read under LC_ALL=C because the
# headings are translated ("Tipo:" for "Type:").
#
# Requires: readelf (binutils), grep, awk.
set -u
export LC_ALL=C

if [ "$#" -eq 0 ]; then
echo "usage: $0 <elf>..." >&2
exit 2
fi

status=0
for f in "$@"; do
if ! header=$(readelf -hW "$f" 2>/dev/null); then
echo "FAIL not-elf $f"
status=1
continue
fi
segments=$(readelf -lW "$f")
dynamic=$(readelf -dW "$f" 2>/dev/null || true)
sections=$(readelf -SW "$f")
failed=""

type=$(printf '%s\n' "$header" | awk '/^ *Type:/ {print $2}')
if [ "$type" != "DYN" ] || ! printf '%s\n' "$dynamic" | grep -q 'FLAGS_1.*PIE'; then
failed="$failed pie"
fi
if printf '%s\n' "$segments" | grep -q 'INTERP'; then
failed="$failed static"
fi
if ! printf '%s\n' "$segments" | grep -q 'GNU_RELRO' \
|| ! printf '%s\n' "$dynamic" | grep -q 'BIND_NOW'; then
failed="$failed relro"
fi
stack=$(printf '%s\n' "$segments" | awk '/GNU_STACK/ {print $7}')
case "$stack" in
RW) ;;
*) failed="$failed nx-stack" ;;
esac
if printf '%s\n' "$sections" | grep -q '\.symtab'; then
failed="$failed stripped"
fi

if [ -z "$failed" ]; then
echo "ok $f"
else
echo "FAIL ${failed# } $f"
status=1
fi
done
exit "$status"
15 changes: 15 additions & 0 deletions .github/workflows/asset-contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ jobs:
# pin the L7 error class on Linux.
shell-signing-fixture:
runs-on: ubuntu-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 16s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down Expand Up @@ -120,6 +123,9 @@ jobs:
# same L7 error class.
powershell-signing-fixture:
runs-on: windows-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 40s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down Expand Up @@ -155,6 +161,9 @@ jobs:
# the strict-equality-with-optional-v comparison fails here.
shell-version-self-test:
runs-on: ubuntu-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 6s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -167,6 +176,9 @@ jobs:
# comparison or in the staged-file-removal on failure fails here.
powershell-version-self-test:
runs-on: windows-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 35s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -182,6 +194,9 @@ jobs:
# release.yml sets up the release build.
rust-self-test-reference:
runs-on: ubuntu-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 88s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ permissions: {}
jobs:
smoke:
runs-on: ubuntu-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 17s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 10
permissions:
contents: read
steps:
Expand Down Expand Up @@ -70,6 +73,9 @@ jobs:

benchmark:
runs-on: [self-hosted, bench]
# Bound: no successful scheduled run in the last three to measure; bounded generously and to be tightened from the first. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 90
if: github.event_name == 'workflow_dispatch'
permissions:
contents: read
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/binary-budget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ jobs:
size:
name: Release binary fits its budget
runs-on: ubuntu-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 174s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint-shell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ jobs:
# one. Naming the interpreter removes the dependency instead of detecting
# it, which is also what reusable-shell-ci.yml's exec-bit guard already
# says about this repository.
test-command: bash ./tests/shell/locale-pinned.test.sh && bash ./tests/shell/ci-runs-every-test.test.sh
test-command: bash ./tests/shell/locale-pinned.test.sh && bash ./tests/shell/ci-runs-every-test.test.sh && bash ./tests/shell/check-hardening.test.sh
86 changes: 86 additions & 0 deletions .github/workflows/linux-hardening.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Linux release binaries are hardened

# The release verifies the Linux binaries are static PIE, full RELRO, NX
# stack and stripped before it signs them (.github/scripts/check-hardening.sh).
# That gate fires at tag time, when a failure costs a release run. This job
# builds the two Linux release targets the way release.yml does, on the same
# runners, runs the binary once and reads the same properties off it, so the
# change that would fail the release fails here first. The aarch64 leg is the
# reason it exists: that target is not static PIE by rustc's defaults, and
# nothing on an x86_64 host can run what .cargo/config.toml makes of it.

on:
push:
branches:
- develop
paths:
- ".cargo/**"
- "Cargo.toml"
- ".github/scripts/check-hardening.sh"
- ".github/workflows/linux-hardening.yml"
- ".github/workflows/release.yml"
pull_request:
branches:
- main
- develop
paths:
- ".cargo/**"
- "Cargo.toml"
- ".github/scripts/check-hardening.sh"
- ".github/workflows/linux-hardening.yml"
- ".github/workflows/release.yml"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: linux-hardening-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
hardened:
name: ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
# Bound: a release build leg measured 347 s at worst on 2026-09-02, and
# this is one such leg plus a readelf. Ten times that, rounded up.
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
target: x86_64-unknown-linux-musl
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install the pinned toolchain and the musl target
env:
TARGET: ${{ matrix.target }}
# Keep in step with release.yml's build job.
RUST_TOOLCHAIN: "1.98"
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq musl-tools
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
rustup target add "$TARGET"

- name: Build the release binary the way the release does
env:
TARGET: ${{ matrix.target }}
run: cargo build --release --locked --target "$TARGET"

- name: Run it once and read its hardening
env:
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
bin="target/$TARGET/release/podup"
"$bin" --version
bash .github/scripts/check-hardening.sh "$bin"
3 changes: 3 additions & 0 deletions .github/workflows/pin-watch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ permissions:
jobs:
watch:
runs-on: ubuntu-24.04
# Bound: worst of the last three successful runs on 2026-09-02 was 9s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/podman-lane.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ jobs:
decide:
name: Decide whether the VM has anything to look at
runs-on: ubuntu-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 8s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 10
outputs:
engine: ${{ steps.files.outputs.engine }}
steps:
Expand Down Expand Up @@ -554,6 +557,9 @@ jobs:
# check does not block a merge — the exact failure this job exists to prevent.
if: always()
runs-on: ubuntu-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 4s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 10
steps:
- name: Reflect the matrix result
env:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/podman-watch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ permissions:
jobs:
watch:
runs-on: ubuntu-24.04
# Bound: worst of the last three successful runs on 2026-09-02 was 7s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
name: release

# Trigger is push on a v* tag, plus workflow_dispatch with a tag input. A tag
Expand All @@ -20,6 +20,9 @@
verify:
name: Verify tag matches crate version
runs-on: ubuntu-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 189s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 30
permissions:
contents: read
steps:
Expand Down Expand Up @@ -128,6 +131,9 @@
name: Build ${{ matrix.asset }}
needs: verify
runs-on: ${{ matrix.runner }}
# Bound: worst of the last three successful runs on 2026-09-02 was 347s; the slowest leg is Windows arm64. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 60
permissions:
contents: read
id-token: write
Expand Down Expand Up @@ -205,6 +211,14 @@
- name: Stage artifact
run: cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "${{ matrix.asset }}"

- name: Run the binary once
# Every leg builds on the architecture it ships for, so the binary can
# be executed where it was made. A link that produced something the
# loader refuses would otherwise be signed, attested and published;
# the static-PIE link the aarch64 musl target gets from
# .cargo/config.toml is the case this was written for.
run: ./"${{ matrix.asset }}" --version

- name: Sign binary
env:
GLYNDOR_RELEASE_ED25519_KEY: ${{ secrets.GLYNDOR_RELEASE_ED25519_KEY }}
Expand Down Expand Up @@ -250,6 +264,9 @@
name: Build Debian package (${{ matrix.arch }})
needs: verify
runs-on: ${{ matrix.runner }}
# Bound: worst of the last three successful runs on 2026-09-02 was 438s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 60
# debian:trixie, digest-pinned. Sid was a moving tag:
# two releases built from identical source could land in different
# environments with nothing in history recording which, and Dependabot's
Expand Down Expand Up @@ -368,6 +385,9 @@
permissions:
contents: read
runs-on: ubuntu-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 216s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 40
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down Expand Up @@ -454,6 +474,9 @@
name: Publish release
needs: [build, build-deb, sbom]
runs-on: ubuntu-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 33s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 10
permissions:
contents: write

Expand Down Expand Up @@ -485,6 +508,27 @@
fi
done

- name: Verify the Linux binaries carry the hardening they are documented with
run: |
set -euo pipefail
# Static PIE, full RELRO, non-executable stack, stripped: measured by
# hand on 5.7.0 and documented in docs/threat-model.md, and until this
# step nothing in the release asked for them. They come from rustc's
# musl defaults and [profile.release], not from anything named here,
# so a toolchain bump or a stray RUSTFLAGS in a build step would drop
# one and the release would sign and publish it. Checked over the
# two raw binaries and the binary inside each .deb, before signing,
# so a failure burns nothing: the release is not immutable yet.
# The script's own tests build one control per property.
for arch in amd64 arm64; do
dpkg-deb -x podup_*_"${arch}".deb "$RUNNER_TEMP/deb-${arch}"
done
bash .github/scripts/check-hardening.sh \
podup-linux-x86_64 \
podup-linux-arm64 \
"$RUNNER_TEMP/deb-amd64/usr/bin/podup" \
"$RUNNER_TEMP/deb-arm64/usr/bin/podup"

- name: Generate checksums
run: |
sha256sum \
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/reusable-dco.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ jobs:
dco:
name: Signed-off-by present on every commit
runs-on: ubuntu-latest
# Bound: worst of the last three successful runs on 2026-09-02 was 7s. Ten times that, rounded up, never under ten
# minutes; the six-hour default is what a hang would otherwise cost.
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
Loading
Loading