From ef4482027fc99f24023375201daf49a7d40bc76b Mon Sep 17 00:00:00 2001 From: Sorah Fukumori Date: Sat, 31 Oct 2026 07:38:00 +0900 Subject: [PATCH 1/3] hardening: install a hardened copy with mairu harden Adds `mairu harden` (install, --from with sigstore attestation verification, --uninstall/--purge, harden home), re-exec of the hardened copy from unhardened installations with a version-skew warning, a periodic harden suggestion, hardened copies and root-reachability warnings in doctor, the Debian postinst and sysusers/tmpfiles snippets, and a linux-hardened CI job. Attestations are verified with sigstore-verify/sigstore-trust-root, which verify bundles only and fetch the trust root through TUF from an embedded root, with system TLS roots. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM --- .github/scripts/linux-hardened.sh | 61 ++ .github/workflows/_test.yml | 10 + CHANGELOG.md | 4 + Cargo.lock | 616 ++++++++++++++++++-- Cargo.toml | 13 +- README.md | 61 ++ pkg/debian/postinst | 6 + pkg/sysusers.d/mairu.conf | 1 + pkg/tmpfiles.d/mairu.conf | 2 + src/attestation.rs | 332 +++++++++++ src/cmd/doctor.rs | 172 ++++++ src/cmd/harden.rs | 628 +++++++++++++++++++++ src/cmd/mod.rs | 1 + src/config.rs | 68 ++- src/hardening.rs | 12 + src/hardening/candidate.rs | 329 +++++++++++ src/hardening/home.rs | 143 +++++ src/hardening/reexec.rs | 224 ++++++++ src/hardening/suggestion.rs | 255 +++++++++ src/helpers.rs | 8 +- src/lib.rs | 3 + src/main.rs | 11 + testdata/attestation/mise-v2026.10.24.json | 16 + 23 files changed, 2930 insertions(+), 46 deletions(-) create mode 100755 .github/scripts/linux-hardened.sh create mode 100644 pkg/sysusers.d/mairu.conf create mode 100644 pkg/tmpfiles.d/mairu.conf create mode 100644 src/attestation.rs create mode 100644 src/cmd/harden.rs create mode 100644 src/hardening/candidate.rs create mode 100644 src/hardening/home.rs create mode 100644 src/hardening/reexec.rs create mode 100644 src/hardening/suggestion.rs create mode 100644 testdata/attestation/mise-v2026.10.24.json diff --git a/.github/scripts/linux-hardened.sh b/.github/scripts/linux-hardened.sh new file mode 100755 index 0000000..6745216 --- /dev/null +++ b/.github/scripts/linux-hardened.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Integration checks for a hardened Linux host: classification, peer verification both ways, unverified-agent +# replacement, re-exec and `mairu harden` itself. Runs as a user with passwordless sudo, after `cargo build`. +set -uo pipefail +cd "$(dirname "$0")/../.." || exit 1 + +failures=0 +check() { local d="$1"; shift; if "$@"; then echo "PASS: $d"; else echo "FAIL: $d"; failures=$((failures + 1)); fi; } +has() { grep -q -- "$1" <<<"$2"; } +# Through a file: an auto-spawned agent inherits stderr and would keep a $(...) pipe open. +capture() { local f; f="$(mktemp)"; "$@" >"$f" 2>&1; rc=$?; out="$(cat "$f")"; rm -f "$f"; echo "$out"; } + +dev=./target/debug/mairu +hardened=/usr/local/libexec/mairu/mairu +scratch=/opt/mairu-ci + +echo "--- harden" +capture sudo "$dev" harden +check "harden succeeds" test "$rc" -eq 0 +check "hardened copy root:mairu 2755" test "$(stat -c '%U:%G %a' "$hardened")" = "root:mairu 2755" +check "state directory root:mairu 2770" test "$(sudo stat -c '%U:%G %a' /var/lib/mairu)" = "root:mairu 2770" + +echo "--- classification" +capture "$hardened" doctor +check "hardened copy classifies as hardened" has "^client hardened" "$out" +capture "$dev" doctor +check "debug build in the dev profile stays unhardened" has "^client unhardened" "$out" + +echo "--- re-exec" +capture env MAIRU_PROFILE=default "$dev" doctor +check "user copy re-execs the hardened copy" has "^client hardened $hardened" "$out" +capture env MAIRU_PROFILE=default MAIRU_NO_HARDENED_REEXEC=1 "$dev" doctor +check "MAIRU_NO_HARDENED_REEXEC=1 stays unhardened" has "^client unhardened" "$out" + +echo "--- unverified-agent replacement and peer verification" +"$hardened" kill-agent >/dev/null 2>&1 +capture "$dev" agent --daemonize +sleep 1 +capture "$hardened" list-sessions +check "hardened client replaces an unhardened agent" has "Replacing an agent that is not hardened" "$out" +capture "$hardened" doctor +# shellcheck disable=SC2016 +check "agent is hardened and verified" bash -c 'grep "^agent hardened" <<<"$1" | grep -q " verified$"' _ "$out" +check "caller verified" has "^caller verified" "$out" +capture "$dev" list-sessions +check "hardened agent refuses an unhardened client" has "the agent refused this request" "$out" +"$hardened" kill-agent >/dev/null 2>&1 + +echo "--- harden in a scratch home" +capture sudo "$dev" harden --home "$scratch" +check "fresh install" test -x "$scratch/libexec/mairu/mairu" -a -f "$scratch/libexec/mairu/.harden-manifest" +capture sudo "$dev" harden --home "$scratch" +check "re-run" test "$rc" -eq 0 +capture sudo "$dev" harden --uninstall --home "$scratch" +check "uninstall" test ! -e "$scratch/libexec/mairu" +capture sudo "$dev" harden --uninstall --purge +check "uninstall --purge" bash -c 'test ! -e /usr/local/libexec/mairu && ! sudo test -e /var/lib/mairu' +check "group left in place" getent group mairu + +echo "== $failures failure(s)" +[[ $failures -eq 0 ]] diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index fc82106..e331581 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -45,6 +45,16 @@ jobs: - run: cargo check --lib --no-default-features - run: cargo clippy --lib --no-default-features -- -D warnings + linux-hardened: + name: linux-hardened + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + with: { target: x86_64-unknown-linux-gnu } + - run: cargo build + - run: .github/scripts/linux-hardened.sh + build: if: ${{ inputs.build }} name: cargo build diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b6136..b7f4a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Release archives carry a GitHub artifact attestation of `mairu` as `mairu.sigstore.json`; verify with `gh attestation verify mairu --repo sorah/mairu`. Debian packages are attested too. - When mairu is installed setgid to a dedicated `mairu` group, the agent and its clients verify each other through that group. The agent serves only verified clients, apart from diagnostics; a client sends nothing to an agent it cannot verify, and replaces it with a verified one. A hardened agent also ignores proxies, `AWS_*` variables and `~/.aws/config`. - New `mairu doctor` command shows how the client and the agent see each other. +- New `mairu harden` command (Linux, as root) installs a hardened copy at `/usr/local/libexec/mairu/mairu`, creates the `mairu` group and `/var/lib/mairu`, and removes them again with `--uninstall [--purge]`. `--home`, `MAIRU_HARDEN_HOME` or `harden_home` in `config.json` choose another prefix. `harden --from FILE` upgrades the hardened copy after verifying the GitHub artifact attestation of `FILE`. +- Other installations (mise, cargo, aqua, tarballs) run the hardened copy when one exists, and warn when its version differs. `MAIRU_NO_HARDENED_REEXEC=1` opts out. Without a hardened copy, mairu suggests `harden` at most once an hour on a terminal (`MAIRU_NO_HARDEN_SUGGESTION=1` or `harden_suggestion` in `config.json`). +- The Debian package installs `/usr/bin/mairu` setgid to the `mairu` group and creates `/var/lib/mairu`. `pkg/sysusers.d` and `pkg/tmpfiles.d` carry the same for other packagers. +- `mairu doctor` lists hardened copies and their versions, and warns when `sudo -n true` succeeds or the user is in `docker`, `lxd`, `incus-admin` or `disk`. ### Miscellaneous diff --git a/Cargo.lock b/Cargo.lock index b287f80..c6fd3b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,6 +199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -593,6 +594,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.8.0" @@ -733,12 +740,40 @@ dependencies = [ "cc", ] +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const_format" version = "0.2.36" @@ -864,6 +899,30 @@ dependencies = [ "syn", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.5" @@ -885,6 +944,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -957,6 +1037,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "fnv" version = "1.0.7" @@ -1125,6 +1211,12 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "h2" version = "0.3.27" @@ -1181,7 +1273,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", "http 1.4.0", @@ -1401,7 +1493,7 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1669,6 +1761,55 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.17", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1712,15 +1853,24 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.178" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -1764,7 +1914,7 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "axum", - "base64", + "base64 0.22.1", "base64ct", "cfg-if", "chrono", @@ -1786,7 +1936,7 @@ dependencies = [ "prost", "prost-types", "rand 0.9.3", - "reqwest", + "reqwest 0.12.26", "rustix", "secrecy", "serde", @@ -1794,6 +1944,9 @@ dependencies = [ "serde_with", "sha2", "shadow-rs", + "sigstore-trust-root", + "sigstore-verify", + "snap", "subtle", "thiserror 2.0.17", "tokio", @@ -1873,17 +2026,17 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.2.2", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -1949,12 +2102,12 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "getrandom 0.2.16", "http 1.4.0", "rand 0.8.5", - "reqwest", + "reqwest 0.12.26", "serde", "serde_json", "serde_path_to_error", @@ -2018,6 +2171,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + [[package]] name = "openssl-sys" version = "0.9.116" @@ -2030,6 +2189,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "outref" version = "0.5.2" @@ -2059,6 +2224,25 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2281,6 +2465,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -2393,6 +2578,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.17", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -2454,7 +2649,7 @@ version = "0.12.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -2492,6 +2687,47 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.7", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.35", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -2502,7 +2738,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -2529,9 +2765,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags", "errno", @@ -2573,22 +2809,49 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework", ] [[package]] name = "rustls-pki-types" -version = "1.13.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.35", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.16", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -2596,7 +2859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2608,7 +2871,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2623,6 +2886,21 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -2669,7 +2947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2682,19 +2960,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.5.1" @@ -2767,6 +3032,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f7bb64f8c7e9f783fdfe883b5fe93a90bee1597c63705baa91842c5f698620" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -2796,7 +3072,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", @@ -2878,6 +3154,179 @@ dependencies = [ "libc", ] +[[package]] +name = "sigstore-bundle" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442bc4f672eef040397e65d4ae34ca1b1dc8140b1a35b1246e0b219ce5f4e571" +dependencies = [ + "jiff", + "sigstore-types", + "thiserror 2.0.17", +] + +[[package]] +name = "sigstore-crypto" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045039ae523e86a8b856470a6fc8e9a4567bb61e723ec08a4d23fa8ce40167e9" +dependencies = [ + "aws-lc-rs", + "const-oid", + "der", + "futures-io", + "jiff", + "rustls-pki-types", + "sigstore-types", + "spki", + "thiserror 2.0.17", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22923d7f1a03e21a3313a4a2e6a6485959327a2a084df09b4fff45810e46f08a" +dependencies = [ + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.17", +] + +[[package]] +name = "sigstore-rekor" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a46ca1954e5e92b8a35eeefb745f55a1eff2d82c6b80f946e733671b33508540" +dependencies = [ + "base64 0.23.1", + "hex", + "jiff", + "serde", + "serde_json", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.17", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a49e390a5f52e0b05f1a17820be5ac747a3890bb9f54156d8a1147508420a7" +dependencies = [ + "directories", + "jiff", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-tuf", + "sigstore-types", + "thiserror 2.0.17", + "tokio", +] + +[[package]] +name = "sigstore-tsa" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36aa9c47f3480420ccb90753c5f3aae07823c489ab20762f0c96070ba35cd5cb" +dependencies = [ + "cms", + "const-oid", + "der", + "hex", + "jiff", + "rustls-pki-types", + "rustls-webpki 0.103.16", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.17", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-tuf" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb4ea7f5d83c3d2d06397d153de059ff5b6f594fb04c3d8dc1912d127cf105db" +dependencies = [ + "glob", + "hex", + "jiff", + "reqwest 0.13.5", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "tempfile", + "thiserror 2.0.17", + "tracing", + "url", +] + +[[package]] +name = "sigstore-types" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec330f1cdb4c7ec6628ef43165c42978480d4213af70567e67e2891ec9072d8" +dependencies = [ + "base64 0.23.1", + "hex", + "jiff", + "pem", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "sigstore-verify" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465f37d73c80fc6a2fa519d1337befab2026fce47f490181f72842aa56d401e1" +dependencies = [ + "base64 0.23.1", + "const-oid", + "futures-io", + "hex", + "jiff", + "rustls-pki-types", + "rustls-webpki 0.103.16", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.17", + "tls_codec", + "tracing", + "x509-cert", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.11" @@ -2890,6 +3339,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "snap" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" + [[package]] name = "socket2" version = "0.5.10" @@ -2910,6 +3365,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2982,9 +3447,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.3.4", @@ -3100,6 +3565,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio" version = "1.48.0" @@ -3190,7 +3676,7 @@ checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2 0.4.12", "http 1.4.0", @@ -3417,6 +3903,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -3487,6 +3979,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3589,6 +4091,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3605,6 +4116,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3849,6 +4369,18 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", + "tls_codec", +] + [[package]] name = "xmlparser" version = "0.13.6" diff --git a/Cargo.toml b/Cargo.toml index 8574e83..55f5415 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,14 +62,17 @@ cli = [ "dep:process_path", "dep:reqwest", "dep:serde_with", + "dep:sigstore-trust-root", + "dep:sigstore-verify", + "dep:snap", "dep:tracing-appender", "tokio/full", "tracing-subscriber/json", "tracing-subscriber/local-time", "tracing-subscriber/time", ] -native-tls = ["cli", "reqwest/default-tls"] -rustls = ["cli", "reqwest/rustls-tls-native-roots"] +native-tls = ["cli", "reqwest/default-tls", "sigstore-trust-root?/native-tls"] +rustls = ["cli", "reqwest/rustls-tls-native-roots", "sigstore-trust-root?/rustls"] [[bin]] name = "mairu" @@ -130,5 +133,11 @@ url = { version = "2.5.7", features = ["serde"] } zeroize = { version = "1.8.2", features = ["std", "aarch64", "zeroize_derive", "serde", "derive"] } shadow-rs = { version = "2", default-features = false } +# `mairu harden --from` verifies release attestations. +[target.'cfg(target_os = "linux")'.dependencies] +sigstore-trust-root = { version = "0.15.1", default-features = false, features = ["tuf", "aws-lc-rs"], optional = true } +sigstore-verify = { version = "0.15.1", default-features = false, features = ["aws-lc-rs"], optional = true } +snap = { version = "1.1.1", optional = true } + [target.'cfg(any(target_os = "freebsd", target_os = "dragonfly"))'.dependencies] rustix = { version = "1.1.2", default-features = false, features = ["std", "process"] } diff --git a/README.md b/README.md index fb32d06..8e1bbad 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Mairu can retrieve credentials from __AWS SSO (AWS IAM Identity Center),__ or a * Keep every file of the archive next to `mairu`, as it may ship helpers. * Each `mairu` binary carries a GitHub artifact attestation. Verify a download with `gh attestation verify mairu --repo sorah/mairu`. +On Linux, harden the installation afterwards with `sudo mairu harden`; see [Hardening](#hardening). + ## Get started Mairu can be used like the following cases. In any case, Mairu automatically retrieves a AWS credential for specified role and prompts user to login when server token is expired or doesn't exist yet. @@ -138,6 +140,8 @@ List roles in `disallow_roles` to make Mairu refuse to vend credentials for them This is a local safety net against accidental use, not an access control; permissions are still governed by your credential server. +`harden_home` and `harden_suggestion` are described under [Hardening](#hardening). + ### Local port for callback URL Mairu uses ephemeral port number for OAuth 2.0 callback URLs. Otherwise, you can specify `--local-port` (or `.oauth.local_port`, `.aws_sso.local_port`) to use fixed port number. @@ -230,6 +234,63 @@ Debug builds (`cargo build`) use a separate `dev` profile, so a development buil `mairu doctor` shows how the client and the agent see each other, and warns about setups that weaken them. +## Hardening + +On Linux, `mairu harden` lets the agent tell genuine mairu clients apart from other programs running as you. It installs a root-owned copy of mairu, setgid to a dedicated `mairu` group, and every mairu command then runs through that copy. Root is needed once per host, and again for upgrades. + +### First install + +Run `harden` from a copy of mairu you trust, as `sudo` runs it as root: + +```sh +gh attestation verify mairu --repo sorah/mairu # a release binary, from its archive +sudo ./mairu harden +``` + +The Debian package from the releases page is hardened on install, and needs no `harden`. + +`harden` creates the `mairu` system group (it must have no members), installs `/usr/local/libexec/mairu/mairu` as `root:mairu 2755` with `cap_ipc_lock`, along with the helpers shipped next to it, and creates `/var/lib/mairu`, which only hardened mairu can open. It is safe to run again. The next mairu command replaces an agent started before hardening; `mairu kill-agent` does it right away. + +Other installations (mise, cargo, aqua, tarballs) keep working: they run the hardened copy instead of themselves, and say so when its version differs. `MAIRU_NO_HARDENED_REEXEC=1` turns this off, for development; debug builds never do it unless `MAIRU_PROFILE=default` is set. `mairu doctor` shows which mairu ran and which hardened copies exist. + +### Harden home + +The hardened copy goes to `/libexec/mairu/`, with `/usr/local` as the default harden home. To use another prefix where root-owned software lives, pass `--home ` to `harden`, or set `MAIRU_HARDEN_HOME` or `harden_home` in `~/.config/mairu/config.json`; mairu commands find the hardened copy through the same settings. `/usr/bin/mairu` from a package is used when no copy exists in the harden home. + +### Upgrading + +Upgrade through the hardened copy, which verifies the GitHub artifact attestation of the new binary and never runs it: + +```sh +sudo /usr/local/libexec/mairu/mairu harden --from ~/Downloads/mairu +mairu kill-agent +``` + +The attestation is read from `mairu.sigstore.json` next to the file, as release archives ship it, or else fetched from GitHub. A release older than the hardened copy is refused unless `--allow-downgrade` is given. A build from source has no attestation; install it with `--unattested`. + +### Reminders + +Until a host is hardened, mairu suggests it at most once an hour on a terminal. Set `MAIRU_NO_HARDEN_SUGGESTION=1`, or configure it in `config.json`; `interval_secs: 0` shows it once per terminal session instead: + +```jsonc +{"harden_suggestion": {"enabled": true, "interval_secs": 3600}} +``` + +### Uninstalling + +`sudo mairu harden --uninstall` removes what `harden` installed in the harden home; `--purge` also removes `/var/lib/mairu`. The group stays; remove it with `sudo groupdel mairu`. + +### Root + +Root can replace the hardened copy and read the agent's memory, so hardening protects your credentials only as well as root is protected from programs running as you. `mairu doctor` warns when `sudo -n true` succeeds or you are in `docker`, `lxd`, `incus-admin` or `disk`. + +- Avoid `NOPASSWD` sudo rules and root-equivalent groups for your user. +- Back `sudo` and polkit's `polkit-1` PAM stack with something a captured password cannot replay, such as `pam_u2f` locally, or `pam_ssh_agent_auth` with an approval-protected key over SSH. + +### Packaging + +Packages install `/usr/bin/mairu` as `root:mairu 2755` with `cap_ipc_lock` (setcap after chgrp, which clears file capabilities), and create `/var/lib/mairu` and `/var/lib/mairu/users` as `root:mairu 2770`. [pkg/debian/postinst](./pkg/debian/postinst) does this for the Debian package; [pkg/sysusers.d](./pkg/sysusers.d/mairu.conf) and [pkg/tmpfiles.d](./pkg/tmpfiles.d/mairu.conf) declare the group and directories for systemd distributions. + ## Credential Server API If you don't have the AWS SSO instance, you need to run your own credential vending server to serve AWS credentials for your Mairu users. You may use a known compatible implementations, or implement your own. diff --git a/pkg/debian/postinst b/pkg/debian/postinst index d8dccd6..afa752b 100755 --- a/pkg/debian/postinst +++ b/pkg/debian/postinst @@ -1,2 +1,8 @@ #!/bin/sh +set -e +getent group mairu >/dev/null || groupadd --system mairu +chgrp mairu /usr/bin/mairu +chmod 2755 /usr/bin/mairu +# After chgrp, which clears file capabilities. setcap cap_ipc_lock=+ep /usr/bin/mairu +install -d -o root -g mairu -m 2770 /var/lib/mairu /var/lib/mairu/users diff --git a/pkg/sysusers.d/mairu.conf b/pkg/sysusers.d/mairu.conf new file mode 100644 index 0000000..284bcbb --- /dev/null +++ b/pkg/sysusers.d/mairu.conf @@ -0,0 +1 @@ +g mairu - diff --git a/pkg/tmpfiles.d/mairu.conf b/pkg/tmpfiles.d/mairu.conf new file mode 100644 index 0000000..a8f652d --- /dev/null +++ b/pkg/tmpfiles.d/mairu.conf @@ -0,0 +1,2 @@ +d /var/lib/mairu 2770 root mairu - +d /var/lib/mairu/users 2770 root mairu - diff --git a/src/attestation.rs b/src/attestation.rs new file mode 100644 index 0000000..6ed682d --- /dev/null +++ b/src/attestation.rs @@ -0,0 +1,332 @@ +//! Verifies the sigstore attestation (GitHub artifact attestation, build provenance) of a mairu release binary, +//! for `mairu harden --from`. + +const ISSUER: &str = "https://token.actions.githubusercontent.com"; +const GITHUB_API: &str = "https://api.github.com"; +const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Who must have signed the attestation: a release workflow run for a version tag. +#[derive(Debug, Clone, Copy)] +pub struct Signer<'a> { + pub repository: &'a str, + pub workflow: &'a str, +} + +impl Signer<'static> { + pub const MAIRU: Self = Signer { + repository: "sorah/mairu", + workflow: ".github/workflows/release.yml", + }; +} + +impl Signer<'_> { + /// The release version from a certificate identity such as + /// `https://github.com/sorah/mairu/.github/workflows/release.yml@refs/tags/v0.13.0`. + pub fn version_from_identity(&self, identity: &str) -> Option { + let prefix = format!( + "https://github.com/{}/{}@refs/tags/v", + self.repository, self.workflow + ); + let version = identity.strip_prefix(&prefix)?; + crate::version::at_least(version, "0.0.0").then(|| version.to_owned()) + } +} + +/// A verified attestation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Attested { + pub version: String, + pub identity: String, +} + +/// Verifies `artifact` against each bundle in turn and returns the first one signed by `signer`. +pub fn verify_bundles( + root: &sigstore_verify::trust_root::TrustedRoot, + signer: Signer<'_>, + artifact: sigstore_verify::types::Artifact<'_>, + bundles: &[sigstore_verify::types::Bundle], +) -> Result { + let verifier = sigstore_verify::Verifier::new(root)?; + let policy = sigstore_verify::VerificationPolicy::any_identity().require_issuer(ISSUER); + let mut failures = vec![]; + for bundle in bundles { + match verifier.verify(artifact.clone(), bundle, &policy) { + Ok(result) => match attested(&result, signer) { + Ok(a) => return Ok(a), + Err(e) => failures.push(e), + }, + Err(e) => failures.push(e.to_string()), + } + } + if failures.is_empty() { + anyhow::bail!("no attestation found"); + } + anyhow::bail!("no valid attestation: {}", failures.join("; ")) +} + +fn attested( + result: &sigstore_verify::VerificationResult, + signer: Signer<'_>, +) -> Result { + if !(result.certificate_verified() && result.tlog_verified()) { + return Err("the certificate or the transparency log entry was not verified".to_owned()); + } + let identity = match result.identity() { + Some(sigstore_verify::SubjectAltName::Uri(uri)) => uri.clone(), + other => return Err(format!("unexpected certificate identity {other:?}")), + }; + match signer.version_from_identity(&identity) { + Some(version) => Ok(Attested { version, identity }), + None => Err(format!( + "signed by {identity}, not by {}/{} for a version tag", + signer.repository, signer.workflow + )), + } +} + +/// Verifies `artifact`, read from `path`, against the sigstore public-good trust root fetched through TUF. The +/// bundle comes from `.sigstore.json`, or else from GitHub's attestation API by digest. Fails closed. +pub fn verify_file(path: &std::path::Path, artifact: &[u8]) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let client = sigstore_trust_root::reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .user_agent(concat!("mairu/", env!("CARGO_PKG_VERSION"))) + .build()?; + let bundles = match local_bundle(path)? { + Some(bundle) => vec![bundle], + None => fetch_bundles(&client, Signer::MAIRU, artifact).await?, + }; + let config = sigstore_trust_root::TufConfig::production() + .without_cache() + .with_http_client(client); + let root = sigstore_trust_root::TrustedRoot::from_tuf(config) + .await + .map_err(|e| anyhow::anyhow!("failed to update the sigstore trust root: {e}"))?; + verify_bundles(&root, Signer::MAIRU, artifact.into(), &bundles) + }) +} + +fn local_bundle( + path: &std::path::Path, +) -> Result, anyhow::Error> { + let mut bundle_path = path.as_os_str().to_owned(); + bundle_path.push(".sigstore.json"); + let bundle_path = std::path::PathBuf::from(bundle_path); + match std::fs::read_to_string(&bundle_path) { + Ok(json) => { + let bundle = sigstore_verify::types::Bundle::from_json(&json) + .map_err(|e| anyhow::anyhow!("{}: {e}", bundle_path.display()))?; + eprintln!("Using the attestation in {}", bundle_path.display()); + Ok(Some(bundle)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(anyhow::anyhow!("{}: {e}", bundle_path.display())), + } +} + +#[derive(serde::Deserialize)] +struct AttestationsResponse { + #[serde(default)] + attestations: Vec, +} + +#[derive(serde::Deserialize)] +struct AttestationEntry { + bundle: Option, + bundle_url: Option, +} + +async fn fetch_bundles( + client: &sigstore_trust_root::reqwest::Client, + signer: Signer<'_>, + artifact: &[u8], +) -> Result, anyhow::Error> { + use sha2::Digest; + let digest = hex_encode(&sha2::Sha256::digest(artifact)); + let url = format!( + "{GITHUB_API}/repos/{}/attestations/sha256:{digest}", + signer.repository + ); + eprintln!("Fetching the attestation from {url}"); + let response = client + .get(&url) + .header("accept", "application/vnd.github+json") + .send() + .await?; + if response.status() == 404 { + anyhow::bail!( + "GitHub has no attestation for this file (sha256:{digest}) in {}", + signer.repository + ); + } + let response: AttestationsResponse = response.error_for_status()?.json().await?; + let mut bundles = vec![]; + for entry in response.attestations { + let json = match (entry.bundle, entry.bundle_url) { + (Some(bundle), _) => bundle.to_string(), + (None, Some(url)) => fetch_bundle_url(client, &url).await?, + (None, None) => continue, + }; + bundles.push(parse_bundle(&json)?); + } + Ok(bundles) +} + +/// A bundle URL serves the bundle compressed with raw snappy. +async fn fetch_bundle_url( + client: &sigstore_trust_root::reqwest::Client, + url: &str, +) -> Result { + let body = client + .get(url) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + let raw = snap::raw::Decoder::new().decompress_vec(&body)?; + Ok(String::from_utf8(raw)?) +} + +fn parse_bundle(json: &str) -> Result { + sigstore_verify::types::Bundle::from_json(json) + .map_err(|e| anyhow::anyhow!("malformed attestation bundle: {e}")) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // GitHub's attestation API response for mise-v2026.10.24-linux-x64-musl.tar.gz: an immutable-release + // attestation and a build provenance attestation signed by jdx/mise's release workflow. + const MISE_RESPONSE: &str = include_str!("../testdata/attestation/mise-v2026.10.24.json"); + const MISE_DIGEST: &str = "257bc6521a2401470fc38cf6be45bbf5783c0443c80298b61fb13f3354f7e06e"; + const MISE: Signer<'static> = Signer { + repository: "jdx/mise", + workflow: ".github/workflows/release.yml", + }; + + fn mise_bundles() -> Vec { + let response: AttestationsResponse = serde_json::from_str(MISE_RESPONSE).unwrap(); + response + .attestations + .into_iter() + .map(|a| parse_bundle(&a.bundle.unwrap().to_string()).unwrap()) + .collect() + } + + fn embedded_root() -> sigstore_verify::trust_root::TrustedRoot { + sigstore_verify::trust_root::TrustedRoot::from_embedded( + sigstore_verify::trust_root::SigstoreInstance::PublicGood, + ) + .unwrap() + } + + fn verify_digest( + signer: Signer<'_>, + digest: &str, + bundles: &[sigstore_verify::types::Bundle], + ) -> Result { + let digest = sigstore_verify::types::Sha256Hash::from_hex(digest)?; + verify_bundles(&embedded_root(), signer, digest.into(), bundles) + } + + #[test] + fn test_version_from_identity() { + let mairu = Signer::MAIRU; + assert_eq!( + mairu.version_from_identity( + "https://github.com/sorah/mairu/.github/workflows/release.yml@refs/tags/v0.13.0" + ), + Some("0.13.0".to_owned()) + ); + for identity in [ + "https://github.com/sorah/mairu/.github/workflows/release.yml@refs/heads/main", + "https://github.com/sorah/mairu/.github/workflows/ci.yml@refs/tags/v0.13.0", + "https://github.com/evil/mairu/.github/workflows/release.yml@refs/tags/v0.13.0", + "https://github.com/sorah/mairu-fork/.github/workflows/release.yml@refs/tags/v0.13.0", + "https://github.com/sorah/mairu/.github/workflows/release.yml@refs/tags/v0.13", + "https://github.com/sorah/mairu/.github/workflows/release.yml@refs/tags/vgarbage", + "https://github.com/sorah/mairu/.github/workflows/release.yml@refs/tags/0.13.0", + ] { + assert_eq!(mairu.version_from_identity(identity), None, "{identity}"); + } + } + + #[test] + fn test_verify_release_attestation() { + let attested = verify_digest(MISE, MISE_DIGEST, &mise_bundles()).unwrap(); + assert_eq!(attested.version, "2026.10.24"); + assert_eq!( + attested.identity, + "https://github.com/jdx/mise/.github/workflows/release.yml@refs/tags/v2026.10.24" + ); + } + + #[test] + fn test_verify_wrong_digest() { + let other = "0000000000000000000000000000000000000000000000000000000000000000"; + assert!(verify_digest(MISE, other, &mise_bundles()).is_err()); + } + + #[test] + fn test_verify_wrong_signer() { + let err = verify_digest(Signer::MAIRU, MISE_DIGEST, &mise_bundles()).unwrap_err(); + assert!(err.to_string().contains("not by sorah/mairu"), "{err}"); + } + + #[test] + fn test_verify_bundles_bytes() { + let err = verify_bundles(&embedded_root(), MISE, b"not mise".into(), &mise_bundles()); + assert!(err.is_err()); + let err = verify_bundles(&embedded_root(), MISE, b"not mise".into(), &[]).unwrap_err(); + assert_eq!(err.to_string(), "no attestation found"); + } + + #[test] + fn test_hex_encode() { + assert_eq!(hex_encode(&[0x00, 0xab, 0xff]), "00abff"); + } + + /// Fetches the trust root through TUF and the bundles from GitHub. `cargo test -- --ignored` with network. + #[test] + #[ignore] + fn test_online_trust_root_and_api() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let client = sigstore_trust_root::reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .user_agent("mairu-test") + .build() + .unwrap(); + let config = sigstore_trust_root::TufConfig::production() + .without_cache() + .with_http_client(client.clone()); + sigstore_trust_root::TrustedRoot::from_tuf(config) + .await + .unwrap(); + let url = format!("{GITHUB_API}/repos/jdx/mise/attestations/sha256:{MISE_DIGEST}"); + let response: AttestationsResponse = client + .get(&url) + .header("accept", "application/vnd.github+json") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let url = response.attestations[0].bundle_url.as_ref().unwrap(); + parse_bundle(&fetch_bundle_url(&client, url).await.unwrap()).unwrap(); + }); + } +} diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index b78aee4..0d63bab 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -18,6 +18,11 @@ pub async fn run(_args: &DoctorArgs) -> Result<(), anyhow::Error> { ); warnings.extend(client_warnings(state)); warnings.extend(crate::platform::process().notes()); + #[cfg(target_os = "linux")] + { + warnings.extend(install::report()); + warnings.extend(root_reachability_warnings()); + } let path = crate::config::socket_path(); match crate::agent::connect_to_agent_with_path(&path).await { @@ -153,3 +158,170 @@ fn client_warnings(state: &crate::hardening::HardeningState) -> Vec { } warnings } + +/// Groups whose members can become root without a password. +#[cfg(target_os = "linux")] +const ROOT_EQUIVALENT_GROUPS: &[&str] = &["docker", "lxd", "incus-admin", "disk"]; + +/// Every guarantee is bounded by how easily code running as this user becomes root. +#[cfg(target_os = "linux")] +fn root_reachability_warnings() -> Vec { + let mut warnings = vec![]; + let sudo = crate::hardening::command("sudo") + .args(["-n", "true"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + if sudo.is_ok_and(|s| s.success()) { + warnings.push("`sudo -n true` succeeds (NOPASSWD or a cached sudo timestamp), so any program running as you can become root and read the agent's memory".to_owned()); + } + let names: Vec = nix::unistd::getgroups() + .unwrap_or_default() + .into_iter() + .filter_map(|gid| nix::unistd::Group::from_gid(gid).ok().flatten()) + .map(|g| g.name) + .collect(); + let groups = root_equivalent_groups(&names); + if !groups.is_empty() { + warnings.push(format!( + "you are a member of {}, which can become root without a password", + groups.join(", ") + )); + } + warnings +} + +#[cfg(target_os = "linux")] +fn root_equivalent_groups(names: &[String]) -> Vec<&'static str> { + ROOT_EQUIVALENT_GROUPS + .iter() + .copied() + .filter(|g| names.iter().any(|n| n == g)) + .collect() +} + +#[cfg(target_os = "linux")] +mod install { + const INSTALLS: &str = "/var/lib/mairu/installs"; + + /// Prints the harden home and every known hardened copy; returns warnings. + pub fn report() -> Vec { + let mut warnings = vec![]; + let home = crate::hardening::home::HardenHome::for_client(); + println!("harden home {} ({})", home.path.display(), home.source); + + let mut copies = vec![home.binary()]; + match read_installs() { + Ok(homes) => copies.extend(homes.into_iter().map(|h| { + crate::hardening::home::HardenHome { + path: h.into(), + source: crate::hardening::home::HardenHomeSource::Default, + } + .binary() + })), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => println!("installs {INSTALLS} not readable ({e})"), + } + copies.push(crate::hardening::home::DISTRO_BINARY.into()); + let mut seen = std::collections::HashSet::new(); + copies.retain(|c| seen.insert(c.clone())); + + let marker = crate::hardening::reexec::marker_group_gid(); + for copy in copies { + if !copy.exists() { + continue; + } + let validity = match marker { + Some(gid) => match crate::hardening::candidate::hardened_candidate(©, gid) { + Ok(_) => "hardened".to_owned(), + Err(e) => format!("not usable: {e}"), + }, + None => format!( + "not usable: no `{}` group", + crate::hardening::MARKER_GROUP_NAME + ), + }; + let version = copy_version(©); + println!( + "copy {} {} {validity}", + copy.display(), + version.as_deref().unwrap_or("(unknown version)") + ); + if let Some(v) = version.as_deref() { + for (kind, minimum) in [ + ("client", crate::version::MINIMUM_CLIENT_VERSION), + ("agent", crate::version::MINIMUM_AGENT_VERSION), + ] { + if !crate::version::at_least(v, minimum) { + warnings.push(format!( + "{} is mairu {v}, older than the minimum {kind} version {minimum}; upgrade it, or remove it with `sudo mairu harden --uninstall --home `", + copy.display() + )); + break; + } + } + } + } + warnings + } + + /// Only the marker gid can traverse /var/lib/mairu. + fn read_installs() -> std::io::Result> { + let content = crate::hardening::with_privileged_gid(|| std::fs::read_to_string(INSTALLS))?; + Ok(content + .lines() + .filter(|l| l.starts_with('/')) + .map(ToOwned::to_owned) + .collect()) + } + + fn copy_version(path: &std::path::Path) -> Option { + let output = crate::hardening::command(path) + .arg("--version") + .env("MAIRU_NO_HARDENED_REEXEC", "1") + .env("MAIRU_NO_HARDEN_SUGGESTION", "1") + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + parse_version_output(&String::from_utf8_lossy(&output.stdout)) + } + + /// `mairu 0.13.0 (abc1234)` to `0.13.0`. + pub(super) fn parse_version_output(output: &str) -> Option { + let version = output.split_whitespace().nth(1)?; + crate::version::at_least(version, "0.0.0").then(|| version.to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "linux")] + #[test] + fn test_root_equivalent_groups() { + let names: Vec = ["alice", "wheel", "docker", "disk"] + .iter() + .map(|s| (*s).to_owned()) + .collect(); + assert_eq!(root_equivalent_groups(&names), vec!["docker", "disk"]); + assert!(root_equivalent_groups(&["alice".to_owned()]).is_empty()); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_parse_version_output() { + assert_eq!( + install::parse_version_output("mairu 0.13.0 (abc1234)\n"), + Some("0.13.0".to_owned()) + ); + assert_eq!( + install::parse_version_output("mairu 0.13.0\n"), + Some("0.13.0".to_owned()) + ); + assert_eq!(install::parse_version_output("garbage"), None); + assert_eq!(install::parse_version_output(""), None); + } +} diff --git a/src/cmd/harden.rs b/src/cmd/harden.rs new file mode 100644 index 0000000..3547fc4 --- /dev/null +++ b/src/cmd/harden.rs @@ -0,0 +1,628 @@ +#[derive(clap::Args)] +pub struct HardenArgs { + /// Install under /libexec/mairu. Defaults to MAIRU_HARDEN_HOME, then harden_home in config.json, then + /// /usr/local + #[arg(long, value_name = "PATH", value_parser = absolute_path)] + pub home: Option, + + /// Install FILE instead of this binary, after verifying its release attestation + #[arg(long, value_name = "FILE", conflicts_with = "uninstall")] + pub from: Option, + + /// With --from, install FILE without an attestation, such as a build from source + #[arg(long, requires = "from")] + pub unattested: bool, + + /// With --from, allow installing a release older than this mairu + #[arg(long, requires = "from")] + pub allow_downgrade: bool, + + /// Remove the files a previous `mairu harden` installed in the harden home + #[arg(long)] + pub uninstall: bool, + + /// With --uninstall, also remove /var/lib/mairu + #[arg(long, requires = "uninstall")] + pub purge: bool, +} + +fn absolute_path(s: &str) -> Result { + let path = std::path::PathBuf::from(s); + if path.is_absolute() { + Ok(path) + } else { + Err("must be an absolute path".to_owned()) + } +} + +#[cfg(target_os = "linux")] +pub fn run(args: &HardenArgs) -> Result<(), anyhow::Error> { + linux::run(args) +} + +#[cfg(not(target_os = "linux"))] +pub fn run(_args: &HardenArgs) -> Result<(), anyhow::Error> { + anyhow::bail!("mairu harden is only supported on Linux") +} + +#[cfg(target_os = "linux")] +mod linux { + use anyhow::Context as _; + + const STATE_DIR: &str = "/var/lib/mairu"; + const USERS_DIR: &str = "/var/lib/mairu/users"; + const INSTALLS: &str = "installs"; + const MANIFEST_NAME: &str = ".harden-manifest"; + const SETCAP: &[&str] = &["/usr/sbin/setcap", "/usr/bin/setcap"]; + + /// The image to install, held in memory so that the bytes checked are the bytes written. + struct Source { + bytes: Vec, + /// Where helpers to copy along are looked up. + exe: std::path::PathBuf, + version: Option, + } + + impl Source { + /// `/proc/self/exe` is the exact image already running as root, so nothing can swap it before the copy. + fn running() -> Result { + let bytes = read_regular_file(std::path::Path::new("/proc/self/exe"))?; + Ok(Self { + bytes, + exe: std::env::current_exe()?.canonicalize()?, + version: Some(crate::version::VERSION.to_owned()), + }) + } + + /// The new file never runs: its attestation is verified over the bytes read once here. + fn from_file( + path: &std::path::Path, + unattested: bool, + allow_downgrade: bool, + ) -> Result { + let bytes = read_regular_file(path)?; + let version = if unattested { + use sha2::Digest as _; + eprintln!( + "warning: installing {} (sha256:{:x}) without verifying its attestation", + path.display(), + sha2::Sha256::digest(&bytes) + ); + None + } else { + let attested = crate::attestation::verify_file(path, &bytes).with_context(|| { + format!( + "cannot verify the attestation of {}; pass --unattested to install a build from source", + path.display() + ) + })?; + println!( + "Verified {}: mairu {} signed by {}", + path.display(), + attested.version, + attested.identity + ); + check_version(&attested.version, crate::version::VERSION, allow_downgrade)?; + Some(attested.version) + }; + Ok(Self { + bytes, + exe: path.canonicalize()?, + version, + }) + } + } + + fn check_version( + new: &str, + installed: &str, + allow_downgrade: bool, + ) -> Result<(), anyhow::Error> { + if allow_downgrade || crate::version::at_least(new, installed) { + return Ok(()); + } + anyhow::bail!( + "mairu {new} is older than this mairu ({installed}); pass --allow-downgrade to install it anyway" + ) + } + + pub fn run(args: &super::HardenArgs) -> Result<(), anyhow::Error> { + if !nix::unistd::geteuid().is_root() { + let extra: Vec = std::env::args_os() + .skip(2) + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + let extra = if extra.is_empty() { + String::new() + } else { + format!(" {}", extra.join(" ")) + }; + eprintln!( + "mairu harden must run as root; {}", + crate::hardening::reexec::harden_instruction(&extra) + ); + return Err(crate::Error::FailureButSilentlyExit.into()); + } + let invoker = invoking_user(); + let home = resolve_home(args.home.as_deref(), invoker.as_ref()); + println!("Harden home: {} ({})", home.path.display(), home.source); + + if args.uninstall { + return uninstall(&home, args.purge); + } + + let source = match &args.from { + Some(path) => Source::from_file(path, args.unattested, args.allow_downgrade)?, + None => Source::running()?, + }; + let group = ensure_group(invoker.as_ref())?; + let dir = prepare_libexec_dir(&home.libexec_dir())?; + + let binary = dir.join("mairu"); + install_file(&dir, "mairu", &source.bytes, group.gid, 0o2755) + .with_context(|| format!("failed to install {}", binary.display()))?; + println!( + "Installed {} (root:{} 2755)", + binary.display(), + crate::hardening::MARKER_GROUP_NAME + ); + set_ipc_lock_capability(&binary); + let mut written = vec!["mairu".to_owned()]; + written.extend(install_helpers(&dir, &source.exe)); + + prepare_state_dir(group.gid)?; + write_manifest(&dir, &written)?; + record_install(&home.path, group.gid)?; + + println!(); + println!( + "mairu {} is hardened at {}.", + source.version.as_deref().unwrap_or("(unattested)"), + binary.display() + ); + println!( + "Other mairu installations on this host now run it. The next mairu command replaces a running unhardened agent; or run `mairu kill-agent` now." + ); + Ok(()) + } + + fn invoking_user() -> Option { + let uid: u32 = std::env::var("SUDO_UID").ok()?.parse().ok()?; + nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid)) + .ok() + .flatten() + } + + /// `sudo` usually drops `MAIRU_HARDEN_HOME`, so the invoking user's config.json counts too. + fn resolve_home( + flag: Option<&std::path::Path>, + invoker: Option<&nix::unistd::User>, + ) -> crate::hardening::home::HardenHome { + let config_path = match invoker { + Some(user) => user.dir.join(".config").join("mairu").join("config.json"), + None => crate::config::UserConfig::path(), + }; + // Only a regular file, so that a FIFO in the user's home cannot stall root. + let config = std::fs::metadata(&config_path) + .ok() + .filter(|m| m.is_file()) + .and_then(|_| crate::config::UserConfig::load_from(&config_path).ok()) + .and_then(|c| c.harden_home); + crate::hardening::home::HardenHome::resolve( + flag, + std::env::var_os(crate::hardening::home::HARDEN_HOME_ENV).as_deref(), + config.as_deref(), + ) + } + + fn ensure_group( + invoker: Option<&nix::unistd::User>, + ) -> Result { + let name = crate::hardening::MARKER_GROUP_NAME; + let group = match nix::unistd::Group::from_name(name)? { + Some(g) => g, + None => { + create_group(name)?; + println!("Created the system group `{name}`"); + nix::unistd::Group::from_name(name)? + .with_context(|| format!("group `{name}` is missing after creating it"))? + } + }; + if !group.mem.is_empty() { + anyhow::bail!( + "the `{name}` group has members ({}), who could forge the marker; remove them with `gpasswd -d {name}` and run harden again", + group.mem.join(", ") + ); + } + if let Some(user) = invoker + && user.gid == group.gid + { + anyhow::bail!( + "the primary group of {} is `{name}`, so they could forge the marker; change it with `usermod -g {}` and run harden again", + user.name, + user.name + ); + } + let gshadow = std::fs::read_to_string("/etc/gshadow").ok(); + if group_has_password(name, &group.passwd.to_string_lossy(), gshadow.as_deref()) { + let status = crate::hardening::command("gpasswd") + .args(["-r", name]) + .status() + .context("failed to run gpasswd")?; + if !status.success() { + anyhow::bail!("`gpasswd -r {name}` failed ({status})"); + } + println!( + "Removed the password of the `{name}` group; with it, anyone knowing it could hold the marker" + ); + } + Ok(group) + } + + fn create_group(name: &str) -> Result<(), anyhow::Error> { + let mut errors = vec![]; + // `addgroup -S` is the busybox form. + for (program, args) in [("groupadd", ["--system", name]), ("addgroup", ["-S", name])] { + match crate::hardening::command(program).args(args).status() { + Ok(s) if s.success() => return Ok(()), + Ok(s) => errors.push(format!("{program}: {s}")), + Err(e) => errors.push(format!("{program}: {e}")), + } + } + anyhow::bail!( + "failed to create the group `{name}` ({})", + errors.join(", ") + ) + } + + /// Whether the group database gives the group a password usable with `newgrp`. + fn group_has_password(name: &str, group_field: &str, gshadow: Option<&str>) -> bool { + let is_set = |pw: &str| !pw.is_empty() && !pw.starts_with('!') && !pw.starts_with('*'); + let shadow = gshadow.and_then(|content| { + content.lines().find_map(|line| { + let mut fields = line.split(':'); + (fields.next() == Some(name)).then(|| fields.next().unwrap_or("")) + }) + }); + match shadow { + Some(pw) => is_set(pw), + None => group_field != "x" && is_set(group_field), + } + } + + /// Creates the directory as `root:root 0755` and checks that only root can modify it or its ancestors. + fn prepare_libexec_dir(dir: &std::path::Path) -> Result { + let mut missing = vec![]; + let mut cursor = dir; + while !cursor.try_exists()? { + missing.push(cursor); + cursor = cursor + .parent() + .with_context(|| format!("{} has no existing ancestor", dir.display()))?; + } + crate::hardening::candidate::root_owned_dir(cursor).map_err(|e| { + anyhow::anyhow!( + "{} is not safe for a hardened copy: {e}; choose another harden home with --home", + cursor.display() + ) + })?; + for path in missing.into_iter().rev() { + std::fs::create_dir(path) + .with_context(|| format!("failed to create {}", path.display()))?; + set_mode(path, 0o755)?; + } + let dir = crate::hardening::candidate::root_owned_dir(dir).map_err(|e| { + anyhow::anyhow!("{} is not safe for a hardened copy: {e}", dir.display()) + })?; + std::os::unix::fs::chown(&dir, Some(0), Some(0))?; + set_mode(&dir, 0o755)?; + Ok(dir) + } + + /// Writes `name` in `dir` through a temporary file and an atomic rename. + fn install_file( + dir: &std::path::Path, + name: &str, + contents: &[u8], + gid: nix::unistd::Gid, + mode: u32, + ) -> std::io::Result<()> { + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt as _; + use std::os::unix::fs::PermissionsExt as _; + let tmp = dir.join(format!(".{name}.tmp.{}", std::process::id())); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&tmp)?; + let result = (|| { + file.write_all(contents)?; + // chown clears the setgid bit, so the mode comes after it. + std::os::unix::fs::fchown(&file, Some(0), Some(gid.as_raw()))?; + file.set_permissions(std::fs::Permissions::from_mode(mode))?; + file.sync_all()?; + std::fs::rename(&tmp, dir.join(name))?; + std::fs::File::open(dir)?.sync_all() + })(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result + } + + /// So that the agent's `mlockall` succeeds without RLIMIT_MEMLOCK headroom. Must follow the chown, which clears + /// file capabilities. + fn set_ipc_lock_capability(path: &std::path::Path) { + let Some(setcap) = SETCAP.iter().find(|p| std::path::Path::new(p).exists()) else { + println!("setcap not found; skipping cap_ipc_lock"); + return; + }; + match crate::hardening::command(setcap) + .arg("cap_ipc_lock=+ep") + .arg(path) + .status() + { + Ok(s) if s.success() => println!("Granted cap_ipc_lock to {}", path.display()), + Ok(s) => { + eprintln!("warning: setcap failed ({s}); the agent may fail to lock its memory") + } + Err(e) => eprintln!("warning: failed to run {setcap} ({e})"), + } + } + + /// Copies the helpers found next to `exe`, root-owned. A helper that fails is skipped with a warning. + fn install_helpers(dir: &std::path::Path, exe: &std::path::Path) -> Vec { + let mut written = vec![]; + for name in crate::helpers::SHIPPED { + let Some(path) = crate::helpers::locate_for_executable(exe, name) else { + continue; + }; + let result = (|| -> Result<(), anyhow::Error> { + use std::os::unix::fs::PermissionsExt as _; + let file = std::fs::File::open(&path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + anyhow::bail!("not a regular file"); + } + let bytes = read_all(file)?; + let mode = metadata.permissions().mode() & 0o755; + install_file(dir, name, &bytes, nix::unistd::Gid::from_raw(0), mode)?; + Ok(()) + })(); + match result { + Ok(()) => { + println!("Installed {}", dir.join(name).display()); + written.push((*name).to_owned()); + } + Err(e) => eprintln!("warning: skipping the helper {}: {e}", path.display()), + } + } + written + } + + fn prepare_state_dir(gid: nix::unistd::Gid) -> Result<(), anyhow::Error> { + for path in [STATE_DIR, USERS_DIR] { + let path = std::path::Path::new(path); + match std::fs::symlink_metadata(path) { + Ok(m) if m.is_dir() => {} + Ok(_) => anyhow::bail!("{} exists and is not a directory", path.display()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + use std::os::unix::fs::DirBuilderExt as _; + std::fs::DirBuilder::new() + .mode(0o700) + .create(path) + .with_context(|| format!("failed to create {}", path.display()))?; + } + Err(e) => return Err(e.into()), + } + std::os::unix::fs::lchown(path, Some(0), Some(gid.as_raw()))?; + set_mode(path, 0o2770)?; + } + println!( + "Prepared {STATE_DIR} and {USERS_DIR} (root:{} 2770)", + crate::hardening::MARKER_GROUP_NAME + ); + Ok(()) + } + + fn write_manifest(dir: &std::path::Path, written: &[String]) -> Result<(), anyhow::Error> { + let mut entries = read_manifest(dir).unwrap_or_default(); + for name in written { + if !entries.contains(name) { + entries.push(name.clone()); + } + } + install_file( + dir, + MANIFEST_NAME, + join_lines(&entries).as_bytes(), + nix::unistd::Gid::from_raw(0), + 0o644, + ) + .context("failed to write the manifest") + } + + /// The manifest counts only when it and its directory are root-owned and not group/other-writable. + fn read_manifest(dir: &std::path::Path) -> Result, anyhow::Error> { + let dir = crate::hardening::candidate::root_owned_dir(dir)?; + let manifest = crate::hardening::candidate::root_owned_file(&dir.join(MANIFEST_NAME))?; + if manifest.parent() != Some(dir.as_path()) { + anyhow::bail!("{} is not inside {}", manifest.display(), dir.display()); + } + Ok(parse_manifest(&std::fs::read_to_string(&manifest)?)) + } + + /// Plain file names only, so that a manifest can never name a file outside its directory. + fn parse_manifest(content: &str) -> Vec { + content + .lines() + .filter(|l| { + !l.is_empty() && *l != "." && *l != ".." && !l.contains('/') && !l.contains('\0') + }) + .map(ToOwned::to_owned) + .collect() + } + + fn join_lines(lines: &[String]) -> String { + lines.iter().map(|l| format!("{l}\n")).collect() + } + + fn installs_path() -> std::path::PathBuf { + std::path::Path::new(STATE_DIR).join(INSTALLS) + } + + /// /var/lib/mairu is writable by the marker gid, so only the file itself is checked. + fn read_installs() -> Vec { + use std::os::unix::fs::MetadataExt as _; + let path = installs_path(); + let trusted = std::fs::symlink_metadata(&path) + .is_ok_and(|m| m.is_file() && m.uid() == 0 && m.mode() & 0o022 == 0); + if !trusted { + return vec![]; + } + std::fs::read_to_string(&path) + .map(|c| { + c.lines() + .filter(|l| !l.is_empty()) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default() + } + + fn write_installs(entries: &[String], gid: nix::unistd::Gid) -> Result<(), anyhow::Error> { + install_file( + std::path::Path::new(STATE_DIR), + INSTALLS, + join_lines(entries).as_bytes(), + gid, + 0o640, + ) + .with_context(|| format!("failed to write {}", installs_path().display())) + } + + fn record_install(home: &std::path::Path, gid: nix::unistd::Gid) -> Result<(), anyhow::Error> { + let home = home.to_string_lossy().into_owned(); + let mut entries = read_installs(); + if !entries.contains(&home) { + entries.push(home); + } + write_installs(&entries, gid) + } + + fn uninstall( + home: &crate::hardening::home::HardenHome, + purge: bool, + ) -> Result<(), anyhow::Error> { + let dir = home.libexec_dir(); + if dir.try_exists()? { + let entries = read_manifest(&dir).map_err(|e| { + anyhow::anyhow!("refusing to use the manifest in {}: {e}", dir.display()) + })?; + for name in entries { + let path = dir.join(&name); + match std::fs::remove_file(&path) { + Ok(()) => println!("Removed {}", path.display()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => eprintln!("warning: failed to remove {}: {e}", path.display()), + } + } + std::fs::remove_file(dir.join(MANIFEST_NAME))?; + match std::fs::remove_dir(&dir) { + Ok(()) => println!("Removed {}", dir.display()), + Err(e) => println!("Left {} in place ({e})", dir.display()), + } + } else { + println!("Nothing is installed at {}", dir.display()); + } + + if purge { + match std::fs::remove_dir_all(STATE_DIR) { + Ok(()) => println!("Removed {STATE_DIR}"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(anyhow::anyhow!("failed to remove {STATE_DIR}: {e}")), + } + } else if let Ok(Some(group)) = + nix::unistd::Group::from_name(crate::hardening::MARKER_GROUP_NAME) + { + let home = home.path.to_string_lossy(); + let entries = read_installs(); + if entries.iter().any(|e| *e == home) { + let remaining: Vec = entries.into_iter().filter(|e| *e != home).collect(); + write_installs(&remaining, group.gid)?; + } + } + println!( + "The `{0}` group is left in place; remove it with `groupdel {0}` once nothing uses it.", + crate::hardening::MARKER_GROUP_NAME + ); + Ok(()) + } + + fn read_regular_file(path: &std::path::Path) -> Result, anyhow::Error> { + let file = std::fs::File::open(path) + .with_context(|| format!("failed to open {}", path.display()))?; + if !file.metadata()?.is_file() { + anyhow::bail!("{} is not a regular file", path.display()); + } + Ok(read_all(file)?) + } + + fn read_all(mut file: std::fs::File) -> std::io::Result> { + use std::io::Read as _; + let mut bytes = vec![]; + file.read_to_end(&mut bytes)?; + Ok(bytes) + } + + fn set_mode(path: &std::path::Path, mode: u32) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_group_has_password() { + let gshadow = "root:*::\nmairu:!::\nlocked:!$6$abc::\nopen:$6$abc$def::\nempty:::\n"; + assert!(!group_has_password("mairu", "x", Some(gshadow))); + assert!(!group_has_password("locked", "x", Some(gshadow))); + assert!(group_has_password("open", "x", Some(gshadow))); + assert!(!group_has_password("empty", "x", Some(gshadow))); + // Without a gshadow entry, /etc/group's own field counts. + assert!(!group_has_password("missing", "x", Some(gshadow))); + assert!(group_has_password("missing", "$6$abc$def", Some(gshadow))); + assert!(group_has_password("mairu", "$6$abc$def", None)); + assert!(!group_has_password("mairu", "", None)); + assert!(!group_has_password("mairu", "!", None)); + } + + #[test] + fn test_parse_manifest() { + assert_eq!( + parse_manifest( + "mairu\nmairu-windows-hello.exe\n\n../../etc/passwd\n/etc/shadow\n.\n..\nsub/file\n" + ), + vec!["mairu", "mairu-windows-hello.exe"] + ); + } + + #[test] + fn test_check_version() { + assert!(check_version("0.13.0", "0.13.0", false).is_ok()); + assert!(check_version("0.14.0", "0.13.0", false).is_ok()); + assert!(check_version("0.12.0", "0.13.0", false).is_err()); + assert!(check_version("0.12.0", "0.13.0", true).is_ok()); + } + + #[test] + fn test_join_lines() { + assert_eq!(join_lines(&["a".to_owned(), "b".to_owned()]), "a\nb\n"); + assert_eq!(join_lines(&[]), ""); + } + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index dcf8ba3..df86c3a 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -2,6 +2,7 @@ pub mod agent; pub mod credential_process; pub mod doctor; pub mod exec; +pub mod harden; pub mod kill_agent; pub mod list_roles; pub mod list_sessions; diff --git a/src/config.rs b/src/config.rs index c0ee589..66f86f0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,7 +38,7 @@ impl Profile { }) } - fn resolve(hardened: bool, requested: Option<&str>) -> Self { + pub(crate) fn resolve(hardened: bool, requested: Option<&str>) -> Self { if hardened { return Self::build_default(); } @@ -170,6 +170,37 @@ fn socket_path_from( pub struct UserConfig { #[serde(default)] pub disallow_roles: Vec, + #[serde(default)] + pub harden_suggestion: HardenSuggestionConfig, + /// Prefix of the hardened copy, `/libexec/mairu/mairu`. `MAIRU_HARDEN_HOME` overrides it. + #[serde(default)] + pub harden_home: Option, +} + +#[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct HardenSuggestionConfig { + #[serde(default = "default_true")] + pub enabled: bool, + /// 0 shows the suggestion once per terminal session instead. + #[serde(default = "default_harden_suggestion_interval_secs")] + pub interval_secs: u64, +} + +impl Default for HardenSuggestionConfig { + fn default() -> Self { + Self { + enabled: true, + interval_secs: default_harden_suggestion_interval_secs(), + } + } +} + +fn default_true() -> bool { + true +} + +fn default_harden_suggestion_interval_secs() -> u64 { + 3600 } #[derive(serde::Deserialize, Debug)] @@ -191,6 +222,19 @@ impl UserConfig { } } + /// For startup, before a runtime exists. + pub fn load_sync() -> crate::Result { + Self::load_from(&Self::path()) + } + + pub fn load_from(path: &std::path::Path) -> crate::Result { + match std::fs::read(path) { + Ok(data) => Ok(serde_json::from_slice(&data)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(e.into()), + } + } + pub fn is_role_disallowed(&self, server: &Server, role: &str) -> bool { self.disallow_roles .iter() @@ -789,6 +833,28 @@ mod tests { assert!(!config.is_role_disallowed(&server, "111111111111/ReadOnly")); } + #[test] + fn test_harden_settings() { + let config: UserConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(config.harden_suggestion, HardenSuggestionConfig::default()); + assert!(config.harden_suggestion.enabled); + assert_eq!(config.harden_suggestion.interval_secs, 3600); + assert_eq!(config.harden_home, None); + + let config: UserConfig = serde_json::from_str( + r#"{"harden_suggestion": {"interval_secs": 0}, "harden_home": "/opt"}"#, + ) + .unwrap(); + assert!(config.harden_suggestion.enabled); + assert_eq!(config.harden_suggestion.interval_secs, 0); + assert_eq!(config.harden_home, Some("/opt".into())); + + let config: UserConfig = + serde_json::from_str(r#"{"harden_suggestion": {"enabled": false}}"#).unwrap(); + assert!(!config.harden_suggestion.enabled); + assert_eq!(config.harden_suggestion.interval_secs, 3600); + } + #[test] fn test_profile_build_default() { let expected = if cfg!(debug_assertions) { diff --git a/src/hardening.rs b/src/hardening.rs index 9e66c66..777b96f 100644 --- a/src/hardening.rs +++ b/src/hardening.rs @@ -3,6 +3,18 @@ //! A hardened process keeps the marker only in its saved gid, raises it with [`with_privileged_gid`] and drops it //! for good before exec'ing anything. +#[cfg(target_os = "linux")] +pub mod candidate; +#[cfg(feature = "cli")] +pub mod home; +#[cfg(all(feature = "cli", target_os = "linux"))] +pub mod reexec; +#[cfg(all(feature = "cli", target_os = "linux"))] +pub mod suggestion; + +#[cfg(all(feature = "cli", target_os = "linux"))] +pub use reexec::reexec_hardened_if_available; + /// The name the marker group must have. pub const MARKER_GROUP_NAME: &str = "mairu"; diff --git a/src/hardening/candidate.rs b/src/hardening/candidate.rs new file mode 100644 index 0000000..cd4672f --- /dev/null +++ b/src/hardening/candidate.rs @@ -0,0 +1,329 @@ +//! Ownership checks for a hardened copy and the directories it lives in. The rules are pure functions over stat +//! results, so they can be tested without root. + +const FUSE_SUPER_MAGIC: i64 = 0x65735546; + +/// The parts of `stat` the rules look at. `mode` includes the file type bits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Inode { + pub uid: u32, + pub gid: u32, + pub mode: u32, +} + +/// The parts of `statfs`/`statvfs` the rules look at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Filesystem { + pub magic: i64, + pub nosuid: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Rejection { + #[error("not a regular file")] + NotRegularFile, + #[error("not a directory")] + NotDirectory, + #[error("owned by uid {0}, not root")] + NotRootOwned(u32), + #[error("writable by group or others (mode {0:o})")] + Writable(u32), + #[error("not setgid")] + NotSetgid, + #[error("its group is gid {0}, not the `mairu` group")] + WrongGroup(u32), + #[error("not executable by others")] + NotExecutable, + #[error("on a FUSE filesystem")] + Fuse, + #[error("on a nosuid mount")] + NoSuid, + #[error("parent directory {path}: {reason}")] + Ancestor { + path: std::path::PathBuf, + reason: Box, + }, + #[error("{0}")] + Io(String), +} + +impl Inode { + pub fn check_root_owned_file(&self) -> Result<(), Rejection> { + if self.mode & libc::S_IFMT != libc::S_IFREG { + return Err(Rejection::NotRegularFile); + } + self.check_owner() + } + + pub fn check_root_owned_dir(&self) -> Result<(), Rejection> { + if self.mode & libc::S_IFMT != libc::S_IFDIR { + return Err(Rejection::NotDirectory); + } + self.check_owner() + } + + /// A hardened copy: root-owned, setgid to the marker group, and executable by the users it serves. + pub fn check_candidate(&self, marker: u32) -> Result<(), Rejection> { + self.check_root_owned_file()?; + if self.gid != marker { + return Err(Rejection::WrongGroup(self.gid)); + } + if self.mode & libc::S_ISGID == 0 { + return Err(Rejection::NotSetgid); + } + if self.mode & 0o001 == 0 { + return Err(Rejection::NotExecutable); + } + Ok(()) + } + + fn check_owner(&self) -> Result<(), Rejection> { + if self.uid != 0 { + return Err(Rejection::NotRootOwned(self.uid)); + } + if self.mode & 0o022 != 0 { + return Err(Rejection::Writable(self.mode & 0o7777)); + } + Ok(()) + } + + fn stat(path: &std::path::Path) -> Result { + let st = nix::sys::stat::lstat(path).map_err(|e| io_rejection(path, e))?; + Ok(Self { + uid: st.st_uid, + gid: st.st_gid, + mode: st.st_mode, + }) + } +} + +impl Filesystem { + /// A setgid binary only carries the marker from a mount that honors setgid bits. + pub fn check(&self, needs_suid: bool) -> Result<(), Rejection> { + if self.magic == FUSE_SUPER_MAGIC { + return Err(Rejection::Fuse); + } + if needs_suid && self.nosuid { + return Err(Rejection::NoSuid); + } + Ok(()) + } + + fn stat(path: &std::path::Path) -> Result { + let fs = nix::sys::statfs::statfs(path).map_err(|e| io_rejection(path, e))?; + let vfs = nix::sys::statvfs::statvfs(path).map_err(|e| io_rejection(path, e))?; + Ok(Self { + #[allow(clippy::unnecessary_cast)] // the width of f_type differs by architecture and libc + magic: fs.filesystem_type().0 as i64, + nosuid: vfs + .flags() + .contains(nix::sys::statvfs::FsFlags::ST_NOSUID), + }) + } +} + +/// Validates a hardened copy at `path` for the marker gid `marker`, returning its canonical path. +pub fn hardened_candidate( + path: &std::path::Path, + marker: u32, +) -> Result { + let path = canonicalize(path)?; + Filesystem::stat(&path)?.check(true)?; + Inode::stat(&path)?.check_candidate(marker)?; + check_ancestors(&path)?; + Ok(path) +} + +/// Validates that the file at `path` and its ancestors cannot be modified by anyone but root. +pub fn root_owned_file(path: &std::path::Path) -> Result { + let path = canonicalize(path)?; + Filesystem::stat(&path)?.check(false)?; + Inode::stat(&path)?.check_root_owned_file()?; + check_ancestors(&path)?; + Ok(path) +} + +/// Validates that the directory at `path` and its ancestors cannot be modified by anyone but root. +pub fn root_owned_dir(path: &std::path::Path) -> Result { + let path = canonicalize(path)?; + Filesystem::stat(&path)?.check(false)?; + Inode::stat(&path)?.check_root_owned_dir()?; + check_ancestors(&path)?; + Ok(path) +} + +fn check_ancestors(path: &std::path::Path) -> Result<(), Rejection> { + for ancestor in path.ancestors().skip(1) { + Inode::stat(ancestor)? + .check_root_owned_dir() + .map_err(|reason| Rejection::Ancestor { + path: ancestor.to_owned(), + reason: Box::new(reason), + })?; + } + Ok(()) +} + +fn canonicalize(path: &std::path::Path) -> Result { + std::fs::canonicalize(path).map_err(|e| Rejection::Io(format!("{}: {e}", path.display()))) +} + +fn io_rejection(path: &std::path::Path, e: nix::Error) -> Rejection { + Rejection::Io(format!("{}: {e}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MARKER: u32 = 973; + + fn file(uid: u32, gid: u32, perm: u32) -> Inode { + Inode { + uid, + gid, + mode: libc::S_IFREG | perm, + } + } + + fn dir(uid: u32, perm: u32) -> Inode { + Inode { + uid, + gid: 0, + mode: libc::S_IFDIR | perm, + } + } + + #[test] + fn test_candidate_valid() { + assert_eq!(file(0, MARKER, 0o2755).check_candidate(MARKER), Ok(())); + assert_eq!(file(0, MARKER, 0o2711).check_candidate(MARKER), Ok(())); + } + + #[test] + fn test_candidate_not_root_owned() { + assert_eq!( + file(1000, MARKER, 0o2755).check_candidate(MARKER), + Err(Rejection::NotRootOwned(1000)) + ); + } + + #[test] + fn test_candidate_wrong_group() { + assert_eq!( + file(0, 0, 0o2755).check_candidate(MARKER), + Err(Rejection::WrongGroup(0)) + ); + } + + #[test] + fn test_candidate_not_setgid() { + assert_eq!( + file(0, MARKER, 0o0755).check_candidate(MARKER), + Err(Rejection::NotSetgid) + ); + } + + #[test] + fn test_candidate_writable() { + assert_eq!( + file(0, MARKER, 0o2775).check_candidate(MARKER), + Err(Rejection::Writable(0o2775)) + ); + assert_eq!( + file(0, MARKER, 0o2757).check_candidate(MARKER), + Err(Rejection::Writable(0o2757)) + ); + } + + #[test] + fn test_candidate_not_executable() { + assert_eq!( + file(0, MARKER, 0o2750).check_candidate(MARKER), + Err(Rejection::NotExecutable) + ); + } + + #[test] + fn test_candidate_not_regular_file() { + let symlink = Inode { + uid: 0, + gid: MARKER, + mode: libc::S_IFLNK | 0o2755, + }; + assert_eq!( + symlink.check_candidate(MARKER), + Err(Rejection::NotRegularFile) + ); + assert_eq!( + dir(0, 0o2755).check_candidate(MARKER), + Err(Rejection::NotRegularFile) + ); + } + + #[test] + fn test_root_owned_file() { + assert_eq!(file(0, 0, 0o755).check_root_owned_file(), Ok(())); + assert_eq!(file(0, 50, 0o644).check_root_owned_file(), Ok(())); + assert_eq!( + file(1000, 1000, 0o755).check_root_owned_file(), + Err(Rejection::NotRootOwned(1000)) + ); + assert_eq!( + file(0, 0, 0o666).check_root_owned_file(), + Err(Rejection::Writable(0o666)) + ); + } + + #[test] + fn test_ancestor() { + assert_eq!(dir(0, 0o755).check_root_owned_dir(), Ok(())); + assert_eq!( + dir(1000, 0o755).check_root_owned_dir(), + Err(Rejection::NotRootOwned(1000)) + ); + assert_eq!( + dir(0, 0o1777).check_root_owned_dir(), + Err(Rejection::Writable(0o1777)) + ); + assert_eq!( + dir(0, 0o775).check_root_owned_dir(), + Err(Rejection::Writable(0o775)) + ); + assert_eq!( + file(0, 0, 0o755).check_root_owned_dir(), + Err(Rejection::NotDirectory) + ); + } + + #[test] + fn test_filesystem() { + let ext4 = Filesystem { + magic: 0xef53, + nosuid: false, + }; + assert_eq!(ext4.check(true), Ok(())); + let fuse = Filesystem { + magic: FUSE_SUPER_MAGIC, + nosuid: false, + }; + assert_eq!(fuse.check(true), Err(Rejection::Fuse)); + assert_eq!(fuse.check(false), Err(Rejection::Fuse)); + let nosuid = Filesystem { + magic: 0xef53, + nosuid: true, + }; + assert_eq!(nosuid.check(true), Err(Rejection::NoSuid)); + assert_eq!(nosuid.check(false), Ok(())); + } + + #[test] + fn test_user_owned_file_rejected() { + let exe = std::env::current_exe().unwrap(); + if nix::unistd::geteuid().is_root() { + return; + } + assert!(hardened_candidate(&exe, MARKER).is_err()); + assert!(root_owned_file(&exe).is_err()); + } +} diff --git a/src/hardening/home.rs b/src/hardening/home.rs new file mode 100644 index 0000000..cecfefe --- /dev/null +++ b/src/hardening/home.rs @@ -0,0 +1,143 @@ +//! The harden home: the prefix `mairu harden` installs the hardened copy under, and where clients look for it. + +pub const DEFAULT_HARDEN_HOME: &str = "/usr/local"; +pub const HARDEN_HOME_ENV: &str = "MAIRU_HARDEN_HOME"; +/// Where distribution packages install the hardened binary. +pub const DISTRO_BINARY: &str = "/usr/bin/mairu"; +const MANIFEST_NAME: &str = ".harden-manifest"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HardenHomeSource { + Flag, + Environment, + Config, + Default, +} + +impl std::fmt::Display for HardenHomeSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + HardenHomeSource::Flag => "--home", + HardenHomeSource::Environment => HARDEN_HOME_ENV, + HardenHomeSource::Config => "harden_home in config.json", + HardenHomeSource::Default => "default", + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HardenHome { + pub path: std::path::PathBuf, + pub source: HardenHomeSource, +} + +impl HardenHome { + /// The first value set wins. A relative path is ignored with a warning. + pub fn resolve( + flag: Option<&std::path::Path>, + env: Option<&std::ffi::OsStr>, + config: Option<&std::path::Path>, + ) -> Self { + let values = [ + (flag, HardenHomeSource::Flag), + (env.map(std::path::Path::new), HardenHomeSource::Environment), + (config, HardenHomeSource::Config), + ]; + for (value, source) in values { + let Some(path) = value.filter(|p| !p.as_os_str().is_empty()) else { + continue; + }; + if path.is_absolute() { + return Self { + path: path.to_owned(), + source, + }; + } + eprintln!( + ":: mairu :: Ignoring the harden home {} from {source}: not an absolute path", + path.display() + ); + } + Self { + path: DEFAULT_HARDEN_HOME.into(), + source: HardenHomeSource::Default, + } + } + + /// As a client resolves it: `MAIRU_HARDEN_HOME`, then its own config.json. + pub fn for_client() -> Self { + let config = crate::config::UserConfig::load_sync() + .ok() + .and_then(|c| c.harden_home); + Self::resolve( + None, + std::env::var_os(HARDEN_HOME_ENV).as_deref(), + config.as_deref(), + ) + } + + pub fn libexec_dir(&self) -> std::path::PathBuf { + self.path.join("libexec").join("mairu") + } + + pub fn binary(&self) -> std::path::PathBuf { + self.libexec_dir().join("mairu") + } + + pub fn manifest(&self) -> std::path::PathBuf { + self.libexec_dir().join(MANIFEST_NAME) + } + + /// Re-exec candidates, in order. + pub fn candidates(&self) -> [std::path::PathBuf; 2] { + [self.binary(), DISTRO_BINARY.into()] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(s: &str) -> &std::path::Path { + std::path::Path::new(s) + } + + #[test] + fn test_default() { + let home = HardenHome::resolve(None, None, None); + assert_eq!(home.path, p("/usr/local")); + assert_eq!(home.source, HardenHomeSource::Default); + assert_eq!(home.binary(), p("/usr/local/libexec/mairu/mairu")); + assert_eq!( + home.manifest(), + p("/usr/local/libexec/mairu/.harden-manifest") + ); + } + + #[test] + fn test_precedence() { + let home = HardenHome::resolve(Some(p("/a")), Some("/b".as_ref()), Some(p("/c"))); + assert_eq!( + (home.path.as_path(), home.source), + (p("/a"), HardenHomeSource::Flag) + ); + let home = HardenHome::resolve(None, Some("/b".as_ref()), Some(p("/c"))); + assert_eq!( + (home.path.as_path(), home.source), + (p("/b"), HardenHomeSource::Environment) + ); + let home = HardenHome::resolve(None, None, Some(p("/c"))); + assert_eq!( + (home.path.as_path(), home.source), + (p("/c"), HardenHomeSource::Config) + ); + } + + #[test] + fn test_relative_and_empty_ignored() { + let home = HardenHome::resolve(None, Some("opt".as_ref()), Some(p("/c"))); + assert_eq!(home.source, HardenHomeSource::Config); + let home = HardenHome::resolve(None, Some("".as_ref()), Some(p("rel"))); + assert_eq!(home.source, HardenHomeSource::Default); + } +} diff --git a/src/hardening/reexec.rs b/src/hardening/reexec.rs new file mode 100644 index 0000000..f746f88 --- /dev/null +++ b/src/hardening/reexec.rs @@ -0,0 +1,224 @@ +//! Hands an unhardened invocation over to the hardened copy, so that one binary serves every installation. + +const REEXEC_FROM: &str = "MAIRU_HARDENED_REEXEC_FROM"; +const REEXEC_VERSION: &str = "MAIRU_HARDENED_REEXEC_VERSION"; +const NO_REEXEC: &str = "MAIRU_NO_HARDENED_REEXEC"; +pub const README_HARDENING_URL: &str = "https://github.com/sorah/mairu#hardening"; + +/// What an unhardened copy told the hardened copy it exec'd. Only feeds a message. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Origin { + from: Option, + version: Option, +} + +/// Runs right after classification, while the process is single-threaded. Execs the hardened copy when this +/// process is unhardened and a valid one exists. Returns whether to suggest hardening, which waits for logging. +pub fn reexec_hardened_if_available() -> bool { + let origin = take_origin(); + match crate::hardening::Hardening::state() { + crate::hardening::HardeningState::Hardened { .. } => { + if let Some(message) = origin.and_then(|o| o.skew_message()) { + eprint!("{message}"); + } + return false; + } + crate::hardening::HardeningState::Misconfigured { .. } => return false, + crate::hardening::HardeningState::Unhardened { .. } => {} + } + if origin.is_some() { + eprintln!( + ":: mairu :: The hardened copy did not run hardened; continuing unhardened. Run `mairu doctor` for details." + ); + return false; + } + if std::env::args_os().nth(1).as_deref() == Some("harden".as_ref()) { + return false; + } + let home = crate::hardening::home::HardenHome::for_client(); + match find_candidate(&home) { + Some(candidate) if reexec_enabled() => { + exec(&candidate); + false + } + Some(_) => false, + None => true, + } +} + +/// The first valid re-exec candidate. +pub fn find_candidate(home: &crate::hardening::home::HardenHome) -> Option { + let marker = marker_group_gid()?; + home.candidates() + .iter() + .find_map(|c| crate::hardening::candidate::hardened_candidate(c, marker).ok()) +} + +/// The gid of the group named `mairu`. An unhardened process has no grant to read it from. +pub fn marker_group_gid() -> Option { + nix::unistd::Group::from_name(crate::hardening::MARKER_GROUP_NAME) + .ok() + .flatten() + .map(|g| g.gid.as_raw()) +} + +/// The dev profile turns re-exec off, so a contributor's debug build never hands over. Resolved without caching, +/// as logging is not set up yet. +fn reexec_enabled() -> bool { + let profile = + crate::config::Profile::resolve(false, std::env::var("MAIRU_PROFILE").ok().as_deref()); + std::env::var_os(NO_REEXEC).is_none_or(|v| v != "1") && profile != crate::config::Profile::Dev +} + +fn exec(candidate: &std::path::Path) { + use std::os::unix::process::CommandExt; + let own = own_exe().unwrap_or_default(); + let err = std::process::Command::new(candidate) + .args(std::env::args_os().skip(1)) + .env(REEXEC_FROM, own) + .env(REEXEC_VERSION, crate::version::VERSION) + .exec(); + eprintln!( + ":: mairu :: Could not run the hardened copy {} ({err}); continuing unhardened", + candidate.display() + ); +} + +fn take_origin() -> Option { + let from = std::env::var_os(REEXEC_FROM); + let version = std::env::var_os(REEXEC_VERSION); + if from.is_none() && version.is_none() { + return None; + } + // SAFETY: called from main before any other thread exists. + unsafe { + std::env::remove_var(REEXEC_FROM); + std::env::remove_var(REEXEC_VERSION); + } + Some(Origin { + from: from.map(Into::into), + version: version.and_then(|v| v.into_string().ok()), + }) +} + +impl Origin { + fn skew_message(&self) -> Option { + let own = own_exe()?; + skew_message( + &own, + crate::version::VERSION, + self.from.as_deref()?, + self.version.as_deref()?, + ) + } +} + +fn skew_message( + hardened: &std::path::Path, + hardened_version: &str, + from: &std::path::Path, + from_version: &str, +) -> Option { + if hardened_version == from_version { + return None; + } + let mut message = format!( + ":: mairu :: Using hardened mairu {hardened_version} ({}) instead of mairu {from_version} ({}).\n", + hardened.display(), + from.display() + ); + // A downgrade would be refused, so the command is only offered for a newer copy. + if !crate::version::at_least(hardened_version, from_version) { + message.push_str(&format!( + ":: mairu :: To update the hardened copy, run: sudo {} harden --from {}\n", + hardened.display(), + from.display() + )); + } + Some(message) +} + +/// How to run `mairu harden ` as root: a `sudo` command when this executable cannot be replaced by the +/// user's processes, otherwise a pointer to the README. +pub fn harden_instruction(args: &str) -> String { + let safe = own_exe().and_then(|exe| crate::hardening::candidate::root_owned_file(&exe).ok()); + match safe { + Some(exe) => format!("run: sudo {} harden{args}", exe.display()), + None => format!( + "this mairu is in a location your programs can modify, so harden from a trusted copy instead; see {README_HARDENING_URL}" + ), + } +} + +fn own_exe() -> Option { + std::env::current_exe().and_then(std::fs::canonicalize).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(s: &str) -> &std::path::Path { + std::path::Path::new(s) + } + + #[test] + fn test_skew_message_same_version() { + assert_eq!( + skew_message( + p("/usr/local/libexec/mairu/mairu"), + "0.13.0", + p("/home/u/.cargo/bin/mairu"), + "0.13.0" + ), + None + ); + } + + #[test] + fn test_skew_message_newer_user_copy() { + assert_eq!( + skew_message( + p("/usr/local/libexec/mairu/mairu"), + "0.13.0", + p("/home/u/.local/share/mise/installs/mairu/0.14.0/mairu"), + "0.14.0" + ) + .unwrap(), + ":: mairu :: Using hardened mairu 0.13.0 (/usr/local/libexec/mairu/mairu) instead of mairu 0.14.0 (/home/u/.local/share/mise/installs/mairu/0.14.0/mairu).\n\ + :: mairu :: To update the hardened copy, run: sudo /usr/local/libexec/mairu/mairu harden --from /home/u/.local/share/mise/installs/mairu/0.14.0/mairu\n" + ); + } + + #[test] + fn test_skew_message_older_user_copy() { + let message = skew_message( + p("/usr/local/libexec/mairu/mairu"), + "0.14.0", + p("/home/u/.cargo/bin/mairu"), + "0.13.0", + ) + .unwrap(); + assert!(message.starts_with(":: mairu :: Using hardened mairu 0.14.0")); + assert!(!message.contains("sudo")); + } + + #[test] + fn test_take_origin_unsets() { + // SAFETY: no other test touches these variables. + unsafe { + std::env::set_var(REEXEC_FROM, "/home/u/.cargo/bin/mairu"); + std::env::set_var(REEXEC_VERSION, "0.14.0"); + } + assert_eq!( + take_origin(), + Some(Origin { + from: Some("/home/u/.cargo/bin/mairu".into()), + version: Some("0.14.0".to_owned()), + }) + ); + assert!(std::env::var_os(REEXEC_FROM).is_none()); + assert!(std::env::var_os(REEXEC_VERSION).is_none()); + assert_eq!(take_origin(), None); + } +} diff --git a/src/hardening/suggestion.rs b/src/hardening/suggestion.rs new file mode 100644 index 0000000..7811aef --- /dev/null +++ b/src/hardening/suggestion.rs @@ -0,0 +1,255 @@ +//! Suggests `mairu harden` on hosts without a hardened copy, at most once per period. + +const DISABLE_ENV: &str = "MAIRU_NO_HARDEN_SUGGESTION"; +const STATE_FILE: &str = "harden-suggestion.json"; +const FALLBACK_INTERVAL_SECS: u64 = 3600; + +/// A terminal session: the session id of the controlling terminal and the start time of its leader. +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +pub struct TerminalSession { + pub sid: i32, + pub start_time: u64, +} + +impl TerminalSession { + pub fn current() -> Option { + let stat = crate::platform::process::linux::procfs::Stat::read(std::process::id()).ok()?; + if stat.tty_nr == 0 || stat.session <= 0 { + return None; + } + let leader = + crate::platform::process::linux::procfs::Stat::read(stat.session as u32).ok()?; + Some(Self { + sid: stat.session, + start_time: leader.start_time, + }) + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] +struct SuggestionState { + last_shown: chrono::DateTime, + session: Option, +} + +/// Prints the suggestion to a terminal when it is due. Skipped when the state file cannot be written, so that a +/// failure never turns into a suggestion on every invocation. +pub fn maybe_show() { + use std::io::IsTerminal; + if !std::io::stderr().is_terminal() { + return; + } + let config = crate::config::UserConfig::load_sync() + .map(|c| c.harden_suggestion) + .unwrap_or_default(); + if !enabled(std::env::var_os(DISABLE_ENV).as_deref(), &config) { + return; + } + let path = crate::config::state_dir().join(STATE_FILE); + let previous = std::fs::read(&path) + .ok() + .and_then(|data| serde_json::from_slice::(&data).ok()); + let session = TerminalSession::current(); + let now = chrono::Utc::now(); + if !due(&config, previous.as_ref(), now, session) { + return; + } + let state = SuggestionState { + last_shown: now, + session, + }; + if write_state(&path, &state).is_err() { + return; + } + eprint!( + "{}", + message( + &crate::hardening::reexec::harden_instruction(""), + config.interval_secs + ) + ); +} + +fn enabled( + disable_env: Option<&std::ffi::OsStr>, + config: &crate::config::HardenSuggestionConfig, +) -> bool { + config.enabled && disable_env.is_none_or(|v| v != "1") +} + +fn due( + config: &crate::config::HardenSuggestionConfig, + previous: Option<&SuggestionState>, + now: chrono::DateTime, + session: Option, +) -> bool { + let Some(previous) = previous else { + return true; + }; + let interval = match (config.interval_secs, session) { + (0, Some(session)) => return previous.session != Some(session), + (0, None) => FALLBACK_INTERVAL_SECS, + (secs, _) => secs, + }; + let elapsed = now.signed_duration_since(previous.last_shown); + // A last_shown in the future means the clock moved back. + elapsed < chrono::TimeDelta::zero() + || elapsed >= chrono::TimeDelta::seconds(i64::try_from(interval).unwrap_or(i64::MAX)) +} + +fn write_state(path: &std::path::Path, state: &SuggestionState) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(path, serde_json::to_vec(state)?) +} + +fn message(instruction: &str, interval_secs: u64) -> String { + let again = match interval_secs { + 0 => "in the next terminal session".to_owned(), + s if s % 3600 == 0 => plural(s / 3600, "hour"), + s if s % 60 == 0 => plural(s / 60, "minute"), + s => plural(s, "second"), + }; + let again = if interval_secs == 0 { + again + } else { + format!("in {again}") + }; + format!( + ":: mairu :: Harden mairu to protect your AWS credentials from other programs: {instruction}\n\ + :: mairu :: This reminder shows again {again}. To turn it off, set {DISABLE_ENV}=1 or \ + \"harden_suggestion\": {{\"enabled\": false}} in {}\n", + crate::config::UserConfig::path().display() + ) +} + +fn plural(n: u64, unit: &str) -> String { + if n == 1 { + format!("1 {unit}") + } else { + format!("{n} {unit}s") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(interval_secs: u64) -> crate::config::HardenSuggestionConfig { + crate::config::HardenSuggestionConfig { + enabled: true, + interval_secs, + } + } + + fn at(secs: i64) -> chrono::DateTime { + chrono::DateTime::from_timestamp(1_800_000_000 + secs, 0).unwrap() + } + + const SESSION: TerminalSession = TerminalSession { + sid: 4242, + start_time: 8812345, + }; + + fn shown(secs: i64, session: Option) -> SuggestionState { + SuggestionState { + last_shown: at(secs), + session, + } + } + + #[test] + fn test_enabled() { + assert!(enabled(None, &config(3600))); + assert!(enabled(Some("0".as_ref()), &config(3600))); + assert!(!enabled(Some("1".as_ref()), &config(3600))); + let disabled = crate::config::HardenSuggestionConfig { + enabled: false, + interval_secs: 3600, + }; + assert!(!enabled(None, &disabled)); + } + + #[test] + fn test_first_time() { + assert!(due(&config(3600), None, at(0), Some(SESSION))); + assert!(due(&config(0), None, at(0), None)); + } + + #[test] + fn test_interval() { + let previous = shown(0, Some(SESSION)); + assert!(!due( + &config(3600), + Some(&previous), + at(3599), + Some(SESSION) + )); + assert!(due(&config(3600), Some(&previous), at(3600), Some(SESSION))); + // Counted across terminals. + let other = TerminalSession { sid: 1, ..SESSION }; + assert!(!due(&config(3600), Some(&previous), at(60), Some(other))); + } + + #[test] + fn test_clock_moved_back() { + let previous = shown(100, Some(SESSION)); + assert!(due(&config(3600), Some(&previous), at(0), Some(SESSION))); + } + + #[test] + fn test_once_per_session() { + let previous = shown(0, Some(SESSION)); + assert!(!due(&config(0), Some(&previous), at(86400), Some(SESSION))); + let restarted = TerminalSession { + start_time: 9999999, + ..SESSION + }; + assert!(due(&config(0), Some(&previous), at(1), Some(restarted))); + let other = TerminalSession { sid: 1, ..SESSION }; + assert!(due(&config(0), Some(&previous), at(1), Some(other))); + } + + #[test] + fn test_once_per_session_without_session() { + let previous = shown(0, None); + assert!(!due(&config(0), Some(&previous), at(60), None)); + assert!(due(&config(0), Some(&previous), at(3600), None)); + } + + #[test] + fn test_unwritable_state() { + let state = shown(0, None); + assert!( + write_state( + std::path::Path::new("/proc/self/harden-suggestion.json"), + &state + ) + .is_err() + ); + } + + #[test] + fn test_state_roundtrip() { + let state = shown(0, Some(SESSION)); + let json = serde_json::to_string(&state).unwrap(); + assert!(json.contains("\"sid\":4242")); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + state + ); + } + + #[test] + fn test_message() { + let m = message("run: sudo /usr/bin/x harden", 3600); + assert!(m.starts_with( + ":: mairu :: Harden mairu to protect your AWS credentials from other programs: run: sudo /usr/bin/x harden\n" + )); + assert!(m.contains("shows again in 1 hour.")); + assert!(message("x", 0).contains("shows again in the next terminal session.")); + assert!(message("x", 7200).contains("in 2 hours.")); + assert!(message("x", 90).contains("in 90 seconds.")); + } +} diff --git a/src/helpers.rs b/src/helpers.rs index 3c33082..43986de 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -1,3 +1,8 @@ +/// Helpers that release archives ship next to `mairu` on this OS. `mairu harden` copies them next to the hardened +/// copy. +#[cfg(target_os = "linux")] +pub const SHIPPED: &[&str] = &["mairu-windows-hello.exe"]; + /// Finds a helper shipped beside the running executable, or in `../lib/mairu/` relative to it. /// /// Never searches `PATH`. Callers verify the returned helper before use. @@ -6,7 +11,8 @@ pub fn locate(file_name: &str) -> Option { crate::helpers::locate_for_executable(&exe, file_name) } -fn locate_for_executable(exe: &std::path::Path, file_name: &str) -> Option { +/// As [`locate`], for the executable at `exe`. +pub fn locate_for_executable(exe: &std::path::Path, file_name: &str) -> Option { // On macOS current_exe() returns the path mairu was started through, which may be a symlink. let exe = std::fs::canonicalize(exe).ok()?; let dir = exe.parent()?; diff --git a/src/lib.rs b/src/lib.rs index f51ed04..3558380 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,9 @@ pub mod oauth_device_code; #[cfg(feature = "cli")] pub mod oauth_refresh_token; +#[cfg(all(feature = "cli", target_os = "linux"))] +pub mod attestation; + #[cfg(feature = "cli")] pub mod api_client; #[cfg(feature = "cli")] diff --git a/src/main.rs b/src/main.rs index 81b3918..b63d025 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,10 +48,15 @@ enum Commands { /// Show how this mairu and the agent verify each other Doctor(mairu::cmd::doctor::DoctorArgs), + + /// Install a hardened copy of mairu for this host (run as root) + Harden(mairu::cmd::harden::HardenArgs), } fn main() -> Result { mairu::hardening::Hardening::detect(); + #[cfg(target_os = "linux")] + let suggest_hardening = mairu::hardening::reexec_hardened_if_available(); use clap::Parser; let cli = Cli::parse(); @@ -82,8 +87,13 @@ fn main() -> Result { Commands::CredentialProcess(_) => enable_log(LogType::Custom), Commands::KillAgent => enable_log(LogType::Custom), Commands::Doctor(_) => enable_log(LogType::Custom), + Commands::Harden(_) => enable_log(LogType::Custom), _ => enable_log(LogType::Default), } + #[cfg(target_os = "linux")] + if suggest_hardening && !matches!(cli.command, Commands::Agent(_)) { + mairu::hardening::suggestion::maybe_show(); + } let retval = match &cli.command { Commands::Agent(args) => mairu::cmd::agent::run(args), Commands::Login(args) => mairu::cmd::login::run(args), @@ -96,6 +106,7 @@ fn main() -> Result { Commands::KillAgent => mairu::cmd::kill_agent::run(), Commands::SetupSso(args) => mairu::cmd::setup_sso::run(args), Commands::Doctor(args) => mairu::cmd::doctor::run(args), + Commands::Harden(args) => mairu::cmd::harden::run(args), }; match retval { Ok(_) => Ok(std::process::ExitCode::SUCCESS), diff --git a/testdata/attestation/mise-v2026.10.24.json b/testdata/attestation/mise-v2026.10.24.json new file mode 100644 index 0000000..15146be --- /dev/null +++ b/testdata/attestation/mise-v2026.10.24.json @@ -0,0 +1,16 @@ +{ + "attestations": [ + { + "repository_id": 586920414, + "bundle_url": "https://tmaproduction.blob.core.windows.net/attestations/586920414/2026/10/30/61706750.json.sn?se=2026-10-30T22%3A56%3A58Z&sig=VUcWr7%2F3pwt877jxekp5q4ObGcFvrZLFVQjHzd1xXRg%3D&ske=2026-10-30T23%3A25%3A53Z&skoid=322a4be5-8e0b-4548-9b48-4e436a2c7c75&sks=b&skt=2026-10-30T21%3A25%3A53Z&sktid=398a6654-997b-47e9-b12b-9515b896b4de&skv=2026-12-06&sp=r&spr=https&sr=b&st=2026-10-30T21%3A56%3A58Z&sv=2026-12-06", + "initiator": "github", + "bundle": {"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"timestampVerificationData":{"rfc3161Timestamps":[{"signedTimestamp":"MIIC0DADAgEAMIICxwYJKoZIhvcNAQcCoIICuDCCArQCAQMxDTALBglghkgBZQMEAgIwgbsGCyqGSIb3DQEJEAEEoIGrBIGoMIGlAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgdeTWE79/LirgxD21zFUkYgKCpI/XW7MaoLX7/W6RjjkCFCEhP76G/4+7tnKk8Xnsmm4EMUWuGA8yMDI2MTAzMDEwMTUwM1owAwIBAaA2pDQwMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgVGltZXN0YW1waW5noAAxggHeMIIB2gIBATBKMDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIGludGVybWVkaWF0ZQIUFzPb1un+DvofqFPzAXiB5slHM1IwCwYJYIZIAWUDBAICoIIBBTAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkFMQ8XDTI2MTAzMDEwMTUwM1owPwYJKoZIhvcNAQkEMTIEMEtMgs00wKQrCTrmxRKuuRcEaxlHtUPSLOMZPDmsq3MyFPSn3myc4CR+EiLLJJa8OTCBhwYLKoZIhvcNAQkQAi8xeDB2MHQwcgQgRBbIrnMUamOhN8LlW+jecOPJrzBzvzmLqrqo2wgXztcwTjA2pDQwMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlAhQXM9vW6f4O+h+oU/MBeIHmyUczUjAKBggqhkjOPQQDAwRnMGUCMQDcuO+NqItqBG3YedJAGo99fIdOzQQIUxb/jN/PpRouqMAv6rhQ0W26dlprbbLvfCsCMFxYkgBP8iIS1Q6Z3XhPmNhwcaliUyCrDUcPlj2uUEWgfZW2co1L5V2kl+ydHVlxeA=="}]},"certificate":{"rawBytes":"MIICKzCCAbCgAwIBAgIUFEsbikF1pCGydxjUbs+jmoS800MwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI2MDYxMjAwMDAwMFoXDTI3MDYxMjAwMDAwMFowKjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMREwDwYDVQQDEwhBdHRlc3RlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABGUVEVNqcSQmA0J45gqVHzCQkSWaZn4DucVDFhO4Q5OWgVdOP4WF44OUvFXQm9lDfRrHMsIVHEbvMfL7SHOVuVWjgaUwgaIwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFDrgKNmcXl2HuRedxYt9HulXf/9IMB8GA1UdIwQYMBaAFMDhuFKkS08+3no4EQbPSY6hRZszMC0GA1UdEQQmMCSGImh0dHBzOi8vZG90Y29tLnJlbGVhc2VzLmdpdGh1Yi5jb20wCgYIKoZIzj0EAwMDaQAwZgIxAJnC2khPGWJqZiEFvgyv0rktYBinUh6AkDn43wYSgUhzm8aRUa+rF25Qk1ZeoGi3IAIxAIgIBMwOkMqDQ1DVCJydaIZu1bxtvgX9Anva7kRDZrboNxsAllbdnorYRTm9mxkZaQ=="}},"dsseEnvelope":{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCAic3ViamVjdCI6W3sidXJpIjoicGtnOmdpdGh1Yi9qZHgvbWlzZUB2MjAyNi4xMC4yNCIsICJkaWdlc3QiOnsic2hhMSI6IjAyY2RjZmI4Nzg3MWJmNzgyNDIzOTQzNmE3MzFiNTdlMTY3ZWFmZGMifX0sIHsibmFtZSI6Imluc3RhbGwucHMxIiwgImRpZ2VzdCI6eyJzaGEyNTYiOiI0N2I3NzI0NTM0YzhmOWZkMjkwN2I1YWMwNWRmZmM2ZDFmYjBiMTEzYmU5MzNlNzM3YzUxYjAyZjYyNDAwZTFhIn19LCB7Im5hbWUiOiJpbnN0YWxsLnBzMS5taW5pc2lnIiwgImRpZ2VzdCI6eyJzaGEyNTYiOiJlYjNjMzJmNDVhOWYxMTdiZDk5NDk4MTI0ZTY3OGQyYTBmNTU3ZDMyODM4ZGFlMmNhODVkZTQwOGE0MjkxMTcxIn19LCB7Im5hbWUiOiJpbnN0YWxsLnBzMS5zaWciLCAiZGlnZXN0Ijp7InNoYTI1NiI6ImRjZDhjYmIxMDYzYjI0NDdjMjE4NjY4OTliNzg4N2U4YTgzNzY2ZTJhMTE4NDY5OGM3MWIxZWYyZjE2ZjNhODcifX0sIHsibmFtZSI6Imluc3RhbGwuc2giLCAiZGlnZXN0Ijp7InNoYTI1NiI6IjlmNGZlOWZjODNkOThlYTVlMzljYmQ1YzkwMGU5YWQ0ODc1ZTM5YmRhYmY4ZTMwMWFlMTdmMGZjZTljYjU4NjcifX0sIHsibmFtZSI6Imluc3RhbGwuc2gubWluaXNpZyIsICJkaWdlc3QiOnsic2hhMjU2IjoiMzU3ZDZiNTE1YjQ5YjJjNTA1MTk0MjBjOGQ3YzEwNzdlZTA2NTJhZmM2MmY5OTMzMTMxMWM0Y2IzOGE0YWM4MiJ9fSwgeyJuYW1lIjoiaW5zdGFsbC5zaC5zaWciLCAiZGlnZXN0Ijp7InNoYTI1NiI6IjNhNGYxZGU2YTA2YjhlMmFmYjczZTMxMzQ0ODRjMGQ5OGRlM2NhMDBhNWFhZGU2YjcyZWMyNzhiZGQwZjc3YjMifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbGludXgtYXJtNjQiLCAiZGlnZXN0Ijp7InNoYTI1NiI6ImFlMTE1MWE4N2Q3MDE0NjExOWY2OGY3Mzc4MDAxYTEyZjA5ZDJhMzg4NTMwODdlZDYxMzliNzUyN2E1NWYxNDgifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbGludXgtYXJtNjQtbXVzbCIsICJkaWdlc3QiOnsic2hhMjU2IjoiYzA4MDE0NGE2YTBhZjRiYzZmNTgxZmVmMmVmMDMzMzVjMmVmOGNhNTM4NWRlNDRlZjE0YWJmOWRiYThhYTE0MiJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC1saW51eC1hcm02NC1tdXNsLnRhci5neiIsICJkaWdlc3QiOnsic2hhMjU2IjoiYzEzOTFmMTkxY2NlNjAxOWVhZDUwMGI2YjIyZWI1MGY3M2JiMGJhNDhmNjQ2MTljYzY2YmIyN2ZmNDI4NDBkMCJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC1saW51eC1hcm02NC1tdXNsLnRhci54eiIsICJkaWdlc3QiOnsic2hhMjU2IjoiYWNlOWIwMjc3NjE1NTA4ZTE3ZGQzNTllYzkxZGFjYzAyYTE3ODIxNGQ3MzM1MmJlOWZhZmE2NzYyMWFhNjQ4NSJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC1saW51eC1hcm02NC1tdXNsLnRhci56c3QiLCAiZGlnZXN0Ijp7InNoYTI1NiI6IjMwYWFmMmYwNzc2ZDhkZTYzYjBhZjAwZTdlODM3NmE2ZTQzN2RhZjg5MGQ4ZDhjMzQwYTJhZjMwNmI3NzY4MmIifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbGludXgtYXJtNjQudGFyLmd6IiwgImRpZ2VzdCI6eyJzaGEyNTYiOiI4YzhmNWQ3Zjc0OGJmM2FlZjQzMWRjNTBmMGFkNzc2MjcwZjM2MDI1Yzc4OGFhMjFjNzRlMmZhYmRiYmQ2ZDNlIn19LCB7Im5hbWUiOiJtaXNlLXYyMDI2LjEwLjI0LWxpbnV4LWFybTY0LnRhci54eiIsICJkaWdlc3QiOnsic2hhMjU2IjoiZDQ0OGY0MTAwYjUzYzg5OGQ5OWIwNDFjZTNhNzI1NDVjNTMyMzlmMWE2MDI4MTI4ZmEwZjA4MjQ0MTgyNmE5MCJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC1saW51eC1hcm02NC50YXIuenN0IiwgImRpZ2VzdCI6eyJzaGEyNTYiOiI3MTEwMjM5YWI1NjJlZDRiOGFiY2MzMmUwMmFlOGM1MzVmOThkMWI1MTZhMzI1ZWYwODVhYTRkOWZlZDgxZTY2In19LCB7Im5hbWUiOiJtaXNlLXYyMDI2LjEwLjI0LWxpbnV4LWFybXY3IiwgImRpZ2VzdCI6eyJzaGEyNTYiOiIxMGYwYjY4M2Y3MjZkNDYwYTQ1ZmI3NThjMjVhN2Q5MTRjNzkyNjVkMmY1ZmMxMzBjZmUxNGM5NjM0NGU3M2JmIn19LCB7Im5hbWUiOiJtaXNlLXYyMDI2LjEwLjI0LWxpbnV4LWFybXY3LW11c2wiLCAiZGlnZXN0Ijp7InNoYTI1NiI6ImMxOGQ1NmNkZmMyYTZmMmM4NWY5MDJkYjc5MTUxN2MzODkzNjU5MWM1MTVkMzk1MWQ2NWVjY2U4MWVmNTViZDgifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbGludXgtYXJtdjctbXVzbC50YXIuZ3oiLCAiZGlnZXN0Ijp7InNoYTI1NiI6ImFjYWJmMjAxOWZkYmZkMTIzNWI5NjYzMGNiN2QzMjIxY2RhYWY0MjUxNTQ1NmI5MWRlNjNlYmI2ZTYwMDU3NTAifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbGludXgtYXJtdjctbXVzbC50YXIueHoiLCAiZGlnZXN0Ijp7InNoYTI1NiI6IjQyNGFiODVlZWYzZWUzYzFkNDExZTNlODMzZDRjMTI1MjQ0NWVmZmIwNWYxOTMyYjU4Yzc4MGI1Nzc2MDNiYzcifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbGludXgtYXJtdjctbXVzbC50YXIuenN0IiwgImRpZ2VzdCI6eyJzaGEyNTYiOiJkNGY2NjliMWNhNmQxMzE5YzU0ZTAzNjNmMGE2OGU5NWRjNDI2ODcyZGJkMGNhM2ZhZWE5Nzc2ZDIyZWY2ZDA1In19LCB7Im5hbWUiOiJtaXNlLXYyMDI2LjEwLjI0LWxpbnV4LWFybXY3LnRhci5neiIsICJkaWdlc3QiOnsic2hhMjU2IjoiMDAyN2UxOTUxNDQyYTM4OGIxMDdjYmQ0MGMzYjU1ZGYxMTE3ZWY2ZDA5MGZkYjdlODFhZmYyY2JjNTVhNjNkNCJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC1saW51eC1hcm12Ny50YXIueHoiLCAiZGlnZXN0Ijp7InNoYTI1NiI6IjA5YWZhODUzOTkyMWQ0YWIyYjMwNDViYWI2YWVkNDFkNDY2MWNlMjBjN2YzNzNmMTI5ODIzNzA3NmRjMDU4OGQifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbGludXgtYXJtdjcudGFyLnpzdCIsICJkaWdlc3QiOnsic2hhMjU2IjoiOThkYmZkNjQzZmIyMGRmMWRkZjNlZjY3NzY5NzhmZmE5ZmUwYzQyODg5Mzk2YWI3OGNmN2EwZDg5ZDgzMTdjZCJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC1saW51eC14NjQiLCAiZGlnZXN0Ijp7InNoYTI1NiI6ImJlOTdkYmFjMWMwYjFmZTA4YmQ4YzBjZjAxNjkwZjQyNzNlYTlhYTAyYWQ3NjZiOThkNGUyMGM0M2ViOTA1OWQifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbGludXgteDY0LW11c2wiLCAiZGlnZXN0Ijp7InNoYTI1NiI6IjBjMTVmOTAxMjAyYzIzZWI3NWZiMDI2N2RmNzI3OGM4MDJmOGIwYzNmNWE5ZGE4MjNlNDdkYTU5ZTE2NDA4OGUifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbGludXgteDY0LW11c2wudGFyLmd6IiwgImRpZ2VzdCI6eyJzaGEyNTYiOiIyNTdiYzY1MjFhMjQwMTQ3MGZjMzhjZjZiZTQ1YmJmNTc4M2MwNDQzYzgwMjk4YjYxZmIxM2YzMzU0ZjdlMDZlIn19LCB7Im5hbWUiOiJtaXNlLXYyMDI2LjEwLjI0LWxpbnV4LXg2NC1tdXNsLnRhci54eiIsICJkaWdlc3QiOnsic2hhMjU2IjoiZTkzNzE3Y2U2OTc0NGMzOWUwZTdlMWIzZmY2N2MwMjFiMDYxODNlMGI5ZGVhNGE3Y2ZkOWViYjM1MWQ2M2VjNiJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC1saW51eC14NjQtbXVzbC50YXIuenN0IiwgImRpZ2VzdCI6eyJzaGEyNTYiOiI1MmU3ZDRhMzI5OGE5ZGIxYmEyZDNjMDJhZjRiYTBmYTliOWUyNzdjNzNiNWRkNDU1ZDJkMGFiODdhMDVhNmQyIn19LCB7Im5hbWUiOiJtaXNlLXYyMDI2LjEwLjI0LWxpbnV4LXg2NC50YXIuZ3oiLCAiZGlnZXN0Ijp7InNoYTI1NiI6IjlhNmM5YzU1ZjVjNjM3MmQwYWY3MGE5OTc5ZmU2ZDc0MmFlYTM3OTMyNjdhOTI5ZTE3MzgwMjIxNDNjNDY2Y2MifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbGludXgteDY0LnRhci54eiIsICJkaWdlc3QiOnsic2hhMjU2IjoiMWJlMWUyNGRkYjNhMDEwODVkZDczZjcwM2JjOTczOThjOWEzZjUzNDVlZGIxOTJhNTIwNTVhMTE0MGJjYzZmYiJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC1saW51eC14NjQudGFyLnpzdCIsICJkaWdlc3QiOnsic2hhMjU2IjoiYmQ5ZDVjNjI4ZDI2NDhiZTRmYjVhOWZiNGY0MWE1OWUzMzYwMzNlY2VkYjlhZjg1MTZlOGRhZmJiYjQ2NWVlMSJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC1tYWNvcy1hcm02NCIsICJkaWdlc3QiOnsic2hhMjU2IjoiYWMwZWUxNDVjYjE0NWFmZmUzZWE2MDYyM2E0YWJkYjI1M2U1YWQxZjkzOGRlZjI5OWU2MzNlODE2YzBiODUyMyJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC1tYWNvcy1hcm02NC50YXIuZ3oiLCAiZGlnZXN0Ijp7InNoYTI1NiI6ImFhMmZjNTkyNDJkYjEwOGY5N2VhOWZiMmQyZTJjYjQyZmYxNDY3ZmNhZmUzYzkxZTQ4ZDUxYzg0MWJhNjdhNjUifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbWFjb3MtYXJtNjQudGFyLnh6IiwgImRpZ2VzdCI6eyJzaGEyNTYiOiI2ZDQ1YTBhNDdkM2VjYzlmMTdiODc5ZDA1OGY4ZDcyMTliYjFmMDk3Y2FlZGYxODVmN2RhOTUxNzdmMWUwYzEyIn19LCB7Im5hbWUiOiJtaXNlLXYyMDI2LjEwLjI0LW1hY29zLWFybTY0LnRhci56c3QiLCAiZGlnZXN0Ijp7InNoYTI1NiI6IjJmNTY2ZjgzYjlkNTJmODJlODg4MGY1MWFlZTcwMjZkYjY0YzNmZTE4MGZlYjIxNDVkMTliMDM4NTI2MTA3YjYifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbWFjb3MteDY0IiwgImRpZ2VzdCI6eyJzaGEyNTYiOiIzOTllNDZmMDU5ZjhmZmEzZjVhNDMyMTUxMzk5NmYxMzEwMGYyYmMyNTEzZTU0NjRiYTY2NmVkNGMxMTcwMzlhIn19LCB7Im5hbWUiOiJtaXNlLXYyMDI2LjEwLjI0LW1hY29zLXg2NC50YXIuZ3oiLCAiZGlnZXN0Ijp7InNoYTI1NiI6IjgzYTM2MzU1YmY2ZWU3YzE2NjAzMDBlMTJkZDk0NDY0ZGU1YzY3MmRhYjFiOGNjMzNlYzRkMTkzNTZlN2RkYWMifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtbWFjb3MteDY0LnRhci54eiIsICJkaWdlc3QiOnsic2hhMjU2IjoiMmNmZmUwMDJjNThhMzE1N2M1OThhOTUwNjNiZGQ2NGQxZGMyOWQ5M2E5YTEyOTQxMGExYTgwYTBmZTdmMzNlZSJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC1tYWNvcy14NjQudGFyLnpzdCIsICJkaWdlc3QiOnsic2hhMjU2IjoiYjgyYzE2NGQ1YTkxOGNkNjEyNzAyZmM3YTM5ODUyNjkzMjRmMTAyZTJiYTNmZDgyMzZlZjAxZjc2NGE3MDg1MSJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC13aW5kb3dzLWFybTY0LmV4ZSIsICJkaWdlc3QiOnsic2hhMjU2IjoiZTM2ZDI3MGRjMDAwZTZjNzNlYzNhZjdkMWM0YmU3NDI5YjAwODQyMjY1MThlYzNmMzU5NzE5NzJjNjQ4Y2Q5ZSJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC13aW5kb3dzLWFybTY0LnppcCIsICJkaWdlc3QiOnsic2hhMjU2IjoiYzUzNDcxOWE5ZjAwNTc3ZWQ5MjgzMjc0MjRlYzUyNGQ2MmY1N2MxYTE5YzRjYmJiZTM0MmZlNjkxMzYxY2RmNCJ9fSwgeyJuYW1lIjoibWlzZS12MjAyNi4xMC4yNC13aW5kb3dzLXg2NC5leGUiLCAiZGlnZXN0Ijp7InNoYTI1NiI6Ijg0N2Q4NWQ5NDgwOGE3N2U5OTgzODhkMjVmMzM1MzdmZjBmMTk3YWU3NGYxY2Q1ZDVjZjY1ZTRiYTBlN2FmNGUifX0sIHsibmFtZSI6Im1pc2UtdjIwMjYuMTAuMjQtd2luZG93cy14NjQuemlwIiwgImRpZ2VzdCI6eyJzaGEyNTYiOiJjZmZmZWM0MGRlZDY3ZjFkMDQwMzllYjM4ODgyN2FkOGRlMGEwMDg5ZDVjOWQ5YWJjNTdjNmM4NWMwYWVkYzcxIn19LCB7Im5hbWUiOiJtaXNlLmJhc2giLCAiZGlnZXN0Ijp7InNoYTI1NiI6ImIyZWYzM2RlZWJlOGZmZDllMDljMzk1NDc0ZjJhYjBiZTZhZTdkMDQ1NzM5OWQzZjc5MDdhYjEyZDA3MGMzM2QifX0sIHsibmFtZSI6Im1pc2UuZmlzaCIsICJkaWdlc3QiOnsic2hhMjU2IjoiNmNlNWEzN2VmMjA5OTdiNDU1NjZhYmUxZmVkZDk4ZjA2YjM3MGUzY2Q4YjNlYzVlMDM5YTUxY2VkMjJlOWMwOCJ9fSwgeyJuYW1lIjoibWlzZS5wb3dlcnNoZWxsIiwgImRpZ2VzdCI6eyJzaGEyNTYiOiI4OTAxYTBjNGYyMDVhOTU4ZjYxNDgzZDhlM2YwODc3NjQzNjMyYzNjNTkyOGZjNDA0YWJiNzMyMzAyOGFlYTkzIn19LCB7Im5hbWUiOiJtaXNlLnVzYWdlLmtkbCIsICJkaWdlc3QiOnsic2hhMjU2IjoiYWE4NjliMmNiMjI0ZDdlNDQ2YTg0ZTBkOTc2OWQ1MzZkNWZjZjVmZTk1YmNjM2I5NTA5Y2E4NjZmZjc1MTU5MyJ9fSwgeyJuYW1lIjoibWlzZS56c2giLCAiZGlnZXN0Ijp7InNoYTI1NiI6IjQ3MDFiZmZkM2U0ZGIwMTg4ZDAzYWI5YmJjZGY0MjlkMTNiOTVmMTgyMThkYmMwOGYyM2MzODgyMThkMzU2NDAifX0sIHsibmFtZSI6InBhY2tzbGlwLnNpZ3N0b3JlLmpzb24iLCAiZGlnZXN0Ijp7InNoYTI1NiI6IjY4N2UwZWE4NDg2OTM5ZTYwZDFiNTdiNmI0NmNkOGRiZWVhYTU4N2NjMjNiNWI2ZTU2YTJjNzNiMGExMDhjZjcifX0sIHsibmFtZSI6IlNIQVNVTVMyNTYuYXNjIiwgImRpZ2VzdCI6eyJzaGEyNTYiOiIxYzBkODA4OGIxN2RhMGRjOTExYTYxM2Q4MmFjMzdiNTA3ZTQwNTM0MDQ5ZmRkMzRiNzZkN2QxOTk0ZDgzZTJhIn19LCB7Im5hbWUiOiJTSEFTVU1TMjU2LnR4dCIsICJkaWdlc3QiOnsic2hhMjU2IjoiNTg3NTQ5YWMzZWNkZTkyMjk0Zjc2MGJiNzEzZGMwZDBhM2QwMDFiOGY3NGMzNjYwNjc5Njg2YmRiNDdlN2E0MCJ9fSwgeyJuYW1lIjoiU0hBU1VNUzI1Ni50eHQubWluaXNpZyIsICJkaWdlc3QiOnsic2hhMjU2IjoiNTc4Mzc4YWFhNDE4MWEwNTcxYzA4M2JiNWFkMTEyNmQzMzZmZTgwOGVlMDRiM2RmNjQ1MTRlYzNkMjUyMmUwYyJ9fSwgeyJuYW1lIjoiU0hBU1VNUzUxMi5hc2MiLCAiZGlnZXN0Ijp7InNoYTI1NiI6ImM1MmNlYmQxZWZiMjBjYWE0MmQyY2E1MmYyOWNmYzBhM2Y1MzkyN2ZkODA3OTk1YjU5ZmM2MzQxNDFmODBhMzgifX0sIHsibmFtZSI6IlNIQVNVTVM1MTIudHh0IiwgImRpZ2VzdCI6eyJzaGEyNTYiOiJjYTlhOGRmMDgyM2ZmYzRhYWRhMjAyODk5MjBlMzNkYzI5Yjk1ODgxYzdjNjcwMGYzNzM5NzQ2MzBiYjEzN2YzIn19LCB7Im5hbWUiOiJTSEFTVU1TNTEyLnR4dC5taW5pc2lnIiwgImRpZ2VzdCI6eyJzaGEyNTYiOiJkMWE0ZDAxZGFmYmQ5NWM0ZWRiMzlhOGE4OWZkNGIxM2E0NWJhZDNkYTUyMmM1M2JkODliMWI5NDllYTgwZTE0In19LCB7Im5hbWUiOiJ2MjAyNi4xMC4yNC50YXIuZ3ouc2lnIiwgImRpZ2VzdCI6eyJzaGEyNTYiOiI1ZDQzODk3YWEwMzI0ZmJhOGYzMmIzZTViMzkxMWYxZTExYjU1M2M0NGFiY2Y3ZmYyYzY5YTIwNjUyZTU4ZDQyIn19XSwgInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2luLXRvdG8uaW8vYXR0ZXN0YXRpb24vcmVsZWFzZS92MC4yIiwgInByZWRpY2F0ZSI6eyJkYXRhYmFzZUlkIjoiNDI4ODgyNzYxIiwgIm93bmVySWQiOiIyMTYxODgiLCAicGFja2FnZUlkIjoiNTg2OTIwNDE0IiwgInB1cmwiOiJwa2c6Z2l0aHViL2pkeC9taXNlQHYyMDI2LjEwLjI0IiwgInJlcG9zaXRvcnkiOiJqZHgvbWlzZSIsICJyZXBvc2l0b3J5SWQiOiI1ODY5MjA0MTQiLCAidGFnIjoidjIwMjYuMTAuMjQifX0=","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEUCIQDe6fBEe+yBcH72mW3ynrw4ob0QQnLVzbzm8gccWgk5OQIgR5NDdsrbNeps9KrAIPlrUMl4dzJUnJZmnjKr4kYTS8U="}]}} + }, + { + "repository_id": 586920414, + "bundle_url": "https://tmaproduction.blob.core.windows.net/attestations/586920414/2026/10/30/61705688.json.sn?se=2026-10-30T22%3A56%3A58Z&sig=QB8jAMm8QqundWWJVuU%2Fiml2jID4eeaBV2k%2B9y0hW1Q%3D&ske=2026-10-30T23%3A25%3A53Z&skoid=322a4be5-8e0b-4548-9b48-4e436a2c7c75&sks=b&skt=2026-10-30T21%3A25%3A53Z&sktid=398a6654-997b-47e9-b12b-9515b896b4de&skv=2026-12-06&sp=r&spr=https&sr=b&st=2026-10-30T21%3A56%3A58Z&sv=2026-12-06", + "initiator": "user", + "bundle": {"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"tlogEntries":[{"logIndex":"3590945357","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1793355108","inclusionPromise":{"signedEntryTimestamp":"MEQCID8nRmcxdpccs71i3vmyOMda1Z6OGpabgizmahVK//9DAiBlYOtHLEOEVet9MVNQ6klTz7dtGwzth2bOWiPrFPIUvA=="},"inclusionProof":{"logIndex":"3469041095","rootHash":"F9f/puhjjZkS6b57G0y8NyWjX2PchpouufvDzYcXmzg=","treeSize":"3469041133","hashes":["173JFY+8Ky/OMhaPPmUxkTsVSR98kN3ne1mnOvDQXaU=","T1f4yeh1Lyk9Y3owiSmbP0/CTS9OJ+eSsb8IWAA3jmc=","rhJYdSdayUMxIfKgGJWJmKgf6cgAq5/JeFQJJL91fFM=","rf/XzjDZYoziMweRCRrc9Lr2ea97LwJndHfnWAA12xo=","6FmYOyagPUnyHB5eqx1H+palDPLkJXI/EFjl0RpMTkI=","H1VHhR/rtpWiIkl1Q6rKzHu2Q4n0hpi1ZDJSHD2U2Sc=","4mYPgjjacULfGd13AwDZ7pP4qwGwXgaroa+zEd5xC/E=","KFt+bX8afgEGq0Lt1TMnSFes0K1fa08N/45SA9BjyZs=","N4qH5gZ1ll1uCxIk3C/C+ftiQhHl0NQe4MormbYQBpc=","nbvzh6GnfENnZQe5SvTMAt12hhajzV84U1Kk47pUK1M=","oF3GcQcgD1HUNiCty+VtWPwY8XlOmpl8p+ZqB9ESdTU=","+kcUQRyhRaKE7jD6PfB75G1rSCC8cnRlgaS6QPNAA4Y=","ZcZE8mGslDMOJgLHhV2NIoiBrScAL9YsqE36JqWpYB0=","HD5ZIW38l97DTkiWu7gd1q8aPNoJXN77O7EopEUw2Go=","s/j2XeRoqS1fykiGhWgbA1EOJv2HMTDtGL4CuMqt40w=","MU+8Ne0xNNzh4wf1yMLJ0/6dV0nJVv1lJRXcilQkMCQ=","AcZgpjfFssWEQpKK3QQIrY5d96MW2Eb07+rfWtmhAGg=","b+938HIdfjdD/+LgIs3AVRE3Wc65/1NSSAL4OJ5FUXE=","kQz/4ND7LdhfAQn+idNsO+ZFC32sfkYRlhjWF5W+sPc=","zGfeoWKmrZlXMOvyi3R2GDbGpmI1skJW/wLTXCCgF0o=","1DaDKzrCNNBhmSdY5vFjz+NpAmxmYfYEgBCdPLrMP1A=","xH/DCseLHr9eKoYT8qsORZK7zVdEGYWHuVtsVrD95wY="],"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n3469041133\nF9f/puhjjZkS6b57G0y8NyWjX2PchpouufvDzYcXmzg=\n\n— rekor.sigstore.dev wNI9ajBFAiANise1g5nVNbSfZUpOjQQuQ5nOJw/ne/ILsHrLUEvwKAIhAO3e7pP22RR9ohSY/ubXZKV1x/dk4QsL7LhvEuyopI0u\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZDJiY2ZkODQ5MDEyZDQ5YzVmNGZlYWVmZDhhODZiMmJmYjc0NGQ4NzQzZDRhNGI3MTU2ZGYyODEyMjkwOTE2YyJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjBhNTg2ZjNjNjk2MDQxMDc5OWNhOTk0NjQ5MTlmZjU4MWIxOWU1ZjBlMmViN2M0NDk0YWQzYjg4MTk4MWM0ZTgifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lRQ3YrMm5sa3RtdmNOK0FjNEJZZkhWQytQNWRnVTZzUFdnTkIvM0pHSXBLbEFJZ0pxWmROa0tzN29jVTZhSVNqaGNTT0hHRU9LZ0xpekwybHRRZEdBYmNsaHM9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VkdWVrTkRRbWxYWjBGM1NVSkJaMGxWVVVwd1UyOVlXazVhTW1wdFVUaHVVWFU1TWtkTGNEQlhVbmgzZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmVFMUVUWGROVkVGNFRWUlJNbGRvWTA1TmFsbDRUVVJOZDAxVVFYbE5WRkV5VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVlRUVEJtWjI5Q01HOW5aV0o2VUVsS1pEVllaV3N4ZUZOcmNsVjZNMEV6VkRoUGJFOEtObnBuWW5FeFFsZHFWV3cyU1U5V1EzWXlTRWRhTXpONVRrUlhlSGRSV1VWeFdGSklNMVJuY0VaV1RrWjVXR1JXUkV0UFEwSlZVWGRuWjFaQlRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdaQ1owNVdTRk5OUlVkRVFWZG5RbFJtQ2pBcmJsQldhVkZTYkhadGJ6SlBhMjlXWVV4SFRHaG9hMUI2UW1SQ1owNVdTRkpGUWtGbU9FVlZla0pTYUdzNWIyUklVbmRqZW05MlRESmtjR1JIYURFS1dXazFhbUl5TUhaaGJWSTBUREl4Y0dNeVZYWk1iV1J3WkVkb01WbHBPVE5pTTBweVdtMTRkbVF6VFhaamJWWnpXbGRHZWxwVE5UVmlWM2hCWTIxV2JRcGplVGt3V1Zka2Vrd3pXWGxOUkVreVRHcEZkMHhxU1RCTlJHdEhRMmx6UjBGUlVVSm5OemgzUVZGRlJVc3lhREJrU0VKNlQyazRkbVJIT1hKYVZ6UjFDbGxYVGpCaFZ6bDFZM2sxYm1GWVVtOWtWMG94WXpKV2VWa3lPWFZrUjFaMVpFTTFhbUl5TUhkRloxbExTM2RaUWtKQlIwUjJla0ZDUVdkUlJXTklWbm9LWVVSQk1rSm5iM0pDWjBWRlFWbFBMMDFCUlVSQ1EyaG9XVlJKTWsxRWEzZGFiVTE0VFVSRk5FNUhTbTFPYWtVMVQxUmtiRTVVWXpST1Ixa3pUVzFOZWdwT2FsWnFUMGROTkZwWFRteE5RbFZIUTJselIwRlJVVUpuTnpoM1FWRlJSVUl6U214aVIxWm9ZekpWZDBabldVdExkMWxDUWtGSFJIWjZRVUpDVVZGSkNtRnRValJNTWpGd1l6SlZkMGwzV1V0TGQxbENRa0ZIUkhaNlFVSkNaMUZXWTIxV2JXTjVPVEJaVjJSNlRETlplVTFFU1RKTWFrVjNUR3BKTUUxRWMwY0tRMmx6UjBGUlVVSm5OemgzUVZGblJVeFJkM0poU0ZJd1kwaE5Oa3g1T1RCaU1uUnNZbWsxYUZrelVuQmlNalY2VEcxa2NHUkhhREZaYmxaNldsaEthZ3BpTWpVd1dsYzFNRXh0VG5aaVZFSm1RbWR2Y2tKblJVVkJXVTh2VFVGRlNrSkdSVTFVTW1nd1pFaENlazlwT0haYU1td3dZVWhXYVV4dFRuWmlVemx4Q2xwSVozWmlWMng2V2xNNGRWb3liREJoU0ZacFRETmtkbU50ZEcxaVJ6a3pZM2s1ZVZwWGVHeFpXRTVzVEc1c2RHSkZRbmxhVjFwNlRETlNhRm96VFhZS1pHcEpkMDFxV1hWTlZFRjFUV3BSZDA5QldVdExkMWxDUWtGSFJIWjZRVUpEWjFGeFJFTm9hRmxVU1RKTlJHdDNXbTFOZUUxRVJUUk9SMHB0VG1wRk5RcFBWR1JzVGxSak5FNUhXVE5OYlUxNlRtcFdhazlIVFRSYVYwNXNUVUl3UjBOcGMwZEJVVkZDWnpjNGQwRlJjMFZFZDNkT1dqSnNNR0ZJVm1sTVYyaDJDbU16VW14YVJFRnlRbWR2Y2tKblJVVkJXVTh2VFVGRlRVSkNNRTFITW1nd1pFaENlazlwT0haYU1td3dZVWhXYVV4dFRuWmlVemx4V2tobmRtSlhiSG9LV2xSQk5FSm5iM0pDWjBWRlFWbFBMMDFCUlU1Q1EyOU5TMGRHYUUxcVdYZFBWRUp0V1hwRmQwMVVaekJaYlZreVRWUnJOVTR5VlRGT2VtY3dXbXBqZVFwWmVrMHlUbGROTkZsNmFHeFpNbFYzU2xGWlMwdDNXVUpDUVVkRWRucEJRa1JuVVZoRVFsWjVXbGRhZWt3elVtaGFNMDEyWkdwSmQwMXFXWFZOVkVGMUNrMXFVWGRIVVZsTFMzZFpRa0pCUjBSMmVrRkNSSGRSVEVSQmF6RlBSRmsxVFdwQk1FMVVVWGRLWjFsTFMzZFpRa0pCUjBSMmVrRkNSVUZSV1VSQ1dtOEtaRWhTZDJONmIzWk1NbVJ3WkVkb01WbHBOV3BpTWpCMllXMVNORTFDV1VkRGFYTkhRVkZSUW1jM09IZEJVa1ZGUTBGM1IwMXFSVEpOVkdjMFRVWTRSd3BEYVhOSFFWRlJRbWMzT0hkQlVrbEZWVkY0VUdGSVVqQmpTRTAyVEhrNWJtRllVbTlrVjBsMVdUSTVkRXd5Y0d0bFF6bDBZVmhPYkV4NU5XNWhXRkp2Q21SWFNYWmtNamw1WVRKYWMySXpaSHBNTTBwc1lrZFdhR015VlhWbFZ6RnpVVWhLYkZwdVRYWmtSMFp1WTNrNU1rMXFRWGxPYVRSNFRVTTBlVTVFUVRRS1FtZHZja0puUlVWQldVOHZUVUZGVkVKRGIwMUxSMFpvVFdwWmQwOVVRbTFaZWtWM1RWUm5NRmx0V1RKTlZHczFUakpWTVU1Nlp6QmFhbU41V1hwTk1ncE9WMDAwV1hwb2JGa3lWWGRHUVZsTFMzZFpRa0pCUjBSMmVrRkNSa0ZSUjBSQlVuZGtXRTV2VFVVNFIwTnBjMGRCVVZGQ1p6YzRkMEZTVlVWUlVYY3ZDbUZJVWpCalNFMDJUSGs1Ym1GWVVtOWtWMGwxV1RJNWRFd3ljR3RsUXpsMFlWaE9iRXd5Um1wa1IyeDJZbTVOZG1OdVZuVmplVGd3VFZSQmVVNTZTWGdLVG5wTk1VMXBPV2hrU0ZKc1lsaENNR041T0hoTlFsbEhRMmx6UjBGUlVVSm5OemgzUVZKWlJVTkJkMGRqU0ZacFlrZHNhazFFWTBkRGFYTkhRVkZSUWdwbk56aDNRVkpuUlV0UmQyNWpiVlozWW5wd2NWcElaM1ppVjJ4NldsUndlVnBYV1RaamJWWnRZM2s1TUZsWFpIcE1NMWw1VFVSSk1reHFSWGRNYWtrd0NrMUpSMHRDWjI5eVFtZEZSVUZrV2pWQloxRkRRa2gzUldWblFqUkJTRmxCTTFRd2QyRnpZa2hGVkVwcVIxSTBZMjFYWXpOQmNVcExXSEpxWlZCTE15OEthRFJ3ZVdkRE9IQTNielJCUVVGSGFHcEZkRWh3ZDBGQlFrRk5RVko2UWtaQmFVVkJhazFWY0ZwaGFEZDJka05uWVV4RkwyWXJXblJtVTNkSVpXbFFTZ3BRVkZwa05EVjJkM1J3TTJ0M2IwVkRTVUpsTm14eWRFZGpialpETURBNFlUaG9RamRIVEZORWNrTnlNMFo0UkhoSWFqVk1jWEJRT0d0eWJXSk5RVzlIQ2tORGNVZFRUVFE1UWtGTlJFRXlaMEZOUjFWRFRVVnBkWEpPVlhGWFZWYzJXRXB0T1d4eGJqRkNNVFl6T1dFNU1GUndXbFZpVlRCWEsxbE9iVVpyVUZNS2FrWkNPVFZCUXpKa0t6SklRMFp6ZUd4V2JVRlBVVWw0UVU1d1NWTndaMmR4VmtwaWVrODRZMmhvWkUxMVRtaFZNbWRsYTNoT1pEZFlhV2xwUVRaVmNRcHViRkJFYWpKME4zQTJOSFpRYTAxaVVHMHZhM1YyVTBjclVUMDlDaTB0TFMwdFJVNUVJRU5GVWxSSlJrbERRVlJGTFMwdExTMEsifV19fQ=="}],"timestampVerificationData":{},"certificate":{"rawBytes":"MIIGnzCCBiWgAwIBAgIUQJpSoXZNZ2jmQ8nQu92GKp0WRxwwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYxMDMwMTAxMTQ2WhcNMjYxMDMwMTAyMTQ2WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESM0fgoB0ogebzPIJd5Xek1xSkrUz3A3T8OlO6zgbq1BWjUl6IOVCv2HGZ33yNDWxwQYEqXRH3TgpFVNFyXdVDKOCBUQwggVAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSMEGDAWgBTf0+nPViQRlvmo2OkoVaLGLhhkPzBdBgNVHREBAf8EUzBRhk9odHRwczovL2dpdGh1Yi5jb20vamR4L21pc2UvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy90YWdzL3YyMDI2LjEwLjI0MDkGCisGAQQBg78wAQEEK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wEgYKKwYBBAGDvzABAgQEcHVzaDA2BgorBgEEAYO/MAEDBChhYTI2MDkwZmMxMDE4NGJmNjE5OTdlNTc4NGY3MmMzNjVjOGM4ZWNlMBUGCisGAQQBg78wAQQEB3JlbGVhc2UwFgYKKwYBBAGDvzABBQQIamR4L21pc2UwIwYKKwYBBAGDvzABBgQVcmVmcy90YWdzL3YyMDI2LjEwLjI0MDsGCisGAQQBg78wAQgELQwraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTBfBgorBgEEAYO/MAEJBFEMT2h0dHBzOi8vZ2l0aHViLmNvbS9qZHgvbWlzZS8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbEByZWZzL3RhZ3MvdjIwMjYuMTAuMjQwOAYKKwYBBAGDvzABCgQqDChhYTI2MDkwZmMxMDE4NGJmNjE5OTdlNTc4NGY3MmMzNjVjOGM4ZWNlMB0GCisGAQQBg78wAQsEDwwNZ2l0aHViLWhvc3RlZDArBgorBgEEAYO/MAEMBB0MG2h0dHBzOi8vZ2l0aHViLmNvbS9qZHgvbWlzZTA4BgorBgEEAYO/MAENBCoMKGFhMjYwOTBmYzEwMTg0YmY2MTk5N2U1Nzg0ZjcyYzM2NWM4YzhlY2UwJQYKKwYBBAGDvzABDgQXDBVyZWZzL3RhZ3MvdjIwMjYuMTAuMjQwGQYKKwYBBAGDvzABDwQLDAk1ODY5MjA0MTQwJgYKKwYBBAGDvzABEAQYDBZodHRwczovL2dpdGh1Yi5jb20vamR4MBYGCisGAQQBg78wAREECAwGMjE2MTg4MF8GCisGAQQBg78wARIEUQxPaHR0cHM6Ly9naXRodWIuY29tL2pkeC9taXNlLy5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvdGFncy92MjAyNi4xMC4yNDA4BgorBgEEAYO/MAETBCoMKGFhMjYwOTBmYzEwMTg0YmY2MTk5N2U1Nzg0ZjcyYzM2NWM4YzhlY2UwFAYKKwYBBAGDvzABFAQGDARwdXNoME8GCisGAQQBg78wARUEQQw/aHR0cHM6Ly9naXRodWIuY29tL2pkeC9taXNlL2FjdGlvbnMvcnVucy80MTAyNzIxNzM1Mi9hdHRlbXB0cy8xMBYGCisGAQQBg78wARYECAwGcHVibGljMDcGCisGAQQBg78wARgEKQwncmVwbzpqZHgvbWlzZTpyZWY6cmVmcy90YWdzL3YyMDI2LjEwLjI0MIGKBgorBgEEAdZ5AgQCBHwEegB4AHYA3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4AAAGhjEtHpwAABAMARzBFAiEAjMUpZah7vvCgaLE/f+ZtfSwHeiPJPTZd45vwtp3kwoECIBe6lrtGcn6C008a8hB7GLSDrCr3FxDxHj5LqpP8krmbMAoGCCqGSM49BAMDA2gAMGUCMEiurNUqWUW6XJm9lqn1B1639a90TpZUbU0W+YNmFkPSjFB95AC2d+2HCFsxlVmAOQIxANpISpggqVJbzO8chhdMuNhU2gekxNd7XiiiA6UqnlPDj2t7p64vPkMbPm/kuvSG+Q=="}},"dsseEnvelope":{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LWxpbnV4LWFybTY0IiwiZGlnZXN0Ijp7InNoYTI1NiI6ImFlMTE1MWE4N2Q3MDE0NjExOWY2OGY3Mzc4MDAxYTEyZjA5ZDJhMzg4NTMwODdlZDYxMzliNzUyN2E1NWYxNDgifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LWxpbnV4LWFybTY0LW11c2wiLCJkaWdlc3QiOnsic2hhMjU2IjoiYzA4MDE0NGE2YTBhZjRiYzZmNTgxZmVmMmVmMDMzMzVjMmVmOGNhNTM4NWRlNDRlZjE0YWJmOWRiYThhYTE0MiJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtbGludXgtYXJtNjQtbXVzbC50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiYzEzOTFmMTkxY2NlNjAxOWVhZDUwMGI2YjIyZWI1MGY3M2JiMGJhNDhmNjQ2MTljYzY2YmIyN2ZmNDI4NDBkMCJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtbGludXgtYXJtNjQtbXVzbC50YXIueHoiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWNlOWIwMjc3NjE1NTA4ZTE3ZGQzNTllYzkxZGFjYzAyYTE3ODIxNGQ3MzM1MmJlOWZhZmE2NzYyMWFhNjQ4NSJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtbGludXgtYXJtNjQtbXVzbC50YXIuenN0IiwiZGlnZXN0Ijp7InNoYTI1NiI6IjMwYWFmMmYwNzc2ZDhkZTYzYjBhZjAwZTdlODM3NmE2ZTQzN2RhZjg5MGQ4ZDhjMzQwYTJhZjMwNmI3NzY4MmIifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LWxpbnV4LWFybTY0LnRhci5neiIsImRpZ2VzdCI6eyJzaGEyNTYiOiI4YzhmNWQ3Zjc0OGJmM2FlZjQzMWRjNTBmMGFkNzc2MjcwZjM2MDI1Yzc4OGFhMjFjNzRlMmZhYmRiYmQ2ZDNlIn19LHsibmFtZSI6Ii4vbWlzZS12MjAyNi4xMC4yNC1saW51eC1hcm02NC50YXIueHoiLCJkaWdlc3QiOnsic2hhMjU2IjoiZDQ0OGY0MTAwYjUzYzg5OGQ5OWIwNDFjZTNhNzI1NDVjNTMyMzlmMWE2MDI4MTI4ZmEwZjA4MjQ0MTgyNmE5MCJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtbGludXgtYXJtNjQudGFyLnpzdCIsImRpZ2VzdCI6eyJzaGEyNTYiOiI3MTEwMjM5YWI1NjJlZDRiOGFiY2MzMmUwMmFlOGM1MzVmOThkMWI1MTZhMzI1ZWYwODVhYTRkOWZlZDgxZTY2In19LHsibmFtZSI6Ii4vbWlzZS12MjAyNi4xMC4yNC1saW51eC1hcm12NyIsImRpZ2VzdCI6eyJzaGEyNTYiOiIxMGYwYjY4M2Y3MjZkNDYwYTQ1ZmI3NThjMjVhN2Q5MTRjNzkyNjVkMmY1ZmMxMzBjZmUxNGM5NjM0NGU3M2JmIn19LHsibmFtZSI6Ii4vbWlzZS12MjAyNi4xMC4yNC1saW51eC1hcm12Ny1tdXNsIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImMxOGQ1NmNkZmMyYTZmMmM4NWY5MDJkYjc5MTUxN2MzODkzNjU5MWM1MTVkMzk1MWQ2NWVjY2U4MWVmNTViZDgifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LWxpbnV4LWFybXY3LW11c2wudGFyLmd6IiwiZGlnZXN0Ijp7InNoYTI1NiI6ImFjYWJmMjAxOWZkYmZkMTIzNWI5NjYzMGNiN2QzMjIxY2RhYWY0MjUxNTQ1NmI5MWRlNjNlYmI2ZTYwMDU3NTAifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LWxpbnV4LWFybXY3LW11c2wudGFyLnh6IiwiZGlnZXN0Ijp7InNoYTI1NiI6IjQyNGFiODVlZWYzZWUzYzFkNDExZTNlODMzZDRjMTI1MjQ0NWVmZmIwNWYxOTMyYjU4Yzc4MGI1Nzc2MDNiYzcifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LWxpbnV4LWFybXY3LW11c2wudGFyLnpzdCIsImRpZ2VzdCI6eyJzaGEyNTYiOiJkNGY2NjliMWNhNmQxMzE5YzU0ZTAzNjNmMGE2OGU5NWRjNDI2ODcyZGJkMGNhM2ZhZWE5Nzc2ZDIyZWY2ZDA1In19LHsibmFtZSI6Ii4vbWlzZS12MjAyNi4xMC4yNC1saW51eC1hcm12Ny50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiMDAyN2UxOTUxNDQyYTM4OGIxMDdjYmQ0MGMzYjU1ZGYxMTE3ZWY2ZDA5MGZkYjdlODFhZmYyY2JjNTVhNjNkNCJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtbGludXgtYXJtdjcudGFyLnh6IiwiZGlnZXN0Ijp7InNoYTI1NiI6IjA5YWZhODUzOTkyMWQ0YWIyYjMwNDViYWI2YWVkNDFkNDY2MWNlMjBjN2YzNzNmMTI5ODIzNzA3NmRjMDU4OGQifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LWxpbnV4LWFybXY3LnRhci56c3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiOThkYmZkNjQzZmIyMGRmMWRkZjNlZjY3NzY5NzhmZmE5ZmUwYzQyODg5Mzk2YWI3OGNmN2EwZDg5ZDgzMTdjZCJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtbGludXgteDY0IiwiZGlnZXN0Ijp7InNoYTI1NiI6ImJlOTdkYmFjMWMwYjFmZTA4YmQ4YzBjZjAxNjkwZjQyNzNlYTlhYTAyYWQ3NjZiOThkNGUyMGM0M2ViOTA1OWQifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LWxpbnV4LXg2NC1tdXNsIiwiZGlnZXN0Ijp7InNoYTI1NiI6IjBjMTVmOTAxMjAyYzIzZWI3NWZiMDI2N2RmNzI3OGM4MDJmOGIwYzNmNWE5ZGE4MjNlNDdkYTU5ZTE2NDA4OGUifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LWxpbnV4LXg2NC1tdXNsLnRhci5neiIsImRpZ2VzdCI6eyJzaGEyNTYiOiIyNTdiYzY1MjFhMjQwMTQ3MGZjMzhjZjZiZTQ1YmJmNTc4M2MwNDQzYzgwMjk4YjYxZmIxM2YzMzU0ZjdlMDZlIn19LHsibmFtZSI6Ii4vbWlzZS12MjAyNi4xMC4yNC1saW51eC14NjQtbXVzbC50YXIueHoiLCJkaWdlc3QiOnsic2hhMjU2IjoiZTkzNzE3Y2U2OTc0NGMzOWUwZTdlMWIzZmY2N2MwMjFiMDYxODNlMGI5ZGVhNGE3Y2ZkOWViYjM1MWQ2M2VjNiJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtbGludXgteDY0LW11c2wudGFyLnpzdCIsImRpZ2VzdCI6eyJzaGEyNTYiOiI1MmU3ZDRhMzI5OGE5ZGIxYmEyZDNjMDJhZjRiYTBmYTliOWUyNzdjNzNiNWRkNDU1ZDJkMGFiODdhMDVhNmQyIn19LHsibmFtZSI6Ii4vbWlzZS12MjAyNi4xMC4yNC1saW51eC14NjQudGFyLmd6IiwiZGlnZXN0Ijp7InNoYTI1NiI6IjlhNmM5YzU1ZjVjNjM3MmQwYWY3MGE5OTc5ZmU2ZDc0MmFlYTM3OTMyNjdhOTI5ZTE3MzgwMjIxNDNjNDY2Y2MifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LWxpbnV4LXg2NC50YXIueHoiLCJkaWdlc3QiOnsic2hhMjU2IjoiMWJlMWUyNGRkYjNhMDEwODVkZDczZjcwM2JjOTczOThjOWEzZjUzNDVlZGIxOTJhNTIwNTVhMTE0MGJjYzZmYiJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtbGludXgteDY0LnRhci56c3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYmQ5ZDVjNjI4ZDI2NDhiZTRmYjVhOWZiNGY0MWE1OWUzMzYwMzNlY2VkYjlhZjg1MTZlOGRhZmJiYjQ2NWVlMSJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtbWFjb3MtYXJtNjQiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWMwZWUxNDVjYjE0NWFmZmUzZWE2MDYyM2E0YWJkYjI1M2U1YWQxZjkzOGRlZjI5OWU2MzNlODE2YzBiODUyMyJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtbWFjb3MtYXJtNjQudGFyLmd6IiwiZGlnZXN0Ijp7InNoYTI1NiI6ImFhMmZjNTkyNDJkYjEwOGY5N2VhOWZiMmQyZTJjYjQyZmYxNDY3ZmNhZmUzYzkxZTQ4ZDUxYzg0MWJhNjdhNjUifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LW1hY29zLWFybTY0LnRhci54eiIsImRpZ2VzdCI6eyJzaGEyNTYiOiI2ZDQ1YTBhNDdkM2VjYzlmMTdiODc5ZDA1OGY4ZDcyMTliYjFmMDk3Y2FlZGYxODVmN2RhOTUxNzdmMWUwYzEyIn19LHsibmFtZSI6Ii4vbWlzZS12MjAyNi4xMC4yNC1tYWNvcy1hcm02NC50YXIuenN0IiwiZGlnZXN0Ijp7InNoYTI1NiI6IjJmNTY2ZjgzYjlkNTJmODJlODg4MGY1MWFlZTcwMjZkYjY0YzNmZTE4MGZlYjIxNDVkMTliMDM4NTI2MTA3YjYifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LW1hY29zLXg2NCIsImRpZ2VzdCI6eyJzaGEyNTYiOiIzOTllNDZmMDU5ZjhmZmEzZjVhNDMyMTUxMzk5NmYxMzEwMGYyYmMyNTEzZTU0NjRiYTY2NmVkNGMxMTcwMzlhIn19LHsibmFtZSI6Ii4vbWlzZS12MjAyNi4xMC4yNC1tYWNvcy14NjQudGFyLmd6IiwiZGlnZXN0Ijp7InNoYTI1NiI6IjgzYTM2MzU1YmY2ZWU3YzE2NjAzMDBlMTJkZDk0NDY0ZGU1YzY3MmRhYjFiOGNjMzNlYzRkMTkzNTZlN2RkYWMifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LW1hY29zLXg2NC50YXIueHoiLCJkaWdlc3QiOnsic2hhMjU2IjoiMmNmZmUwMDJjNThhMzE1N2M1OThhOTUwNjNiZGQ2NGQxZGMyOWQ5M2E5YTEyOTQxMGExYTgwYTBmZTdmMzNlZSJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtbWFjb3MteDY0LnRhci56c3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYjgyYzE2NGQ1YTkxOGNkNjEyNzAyZmM3YTM5ODUyNjkzMjRmMTAyZTJiYTNmZDgyMzZlZjAxZjc2NGE3MDg1MSJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtd2luZG93cy1hcm02NC5leGUiLCJkaWdlc3QiOnsic2hhMjU2IjoiZTM2ZDI3MGRjMDAwZTZjNzNlYzNhZjdkMWM0YmU3NDI5YjAwODQyMjY1MThlYzNmMzU5NzE5NzJjNjQ4Y2Q5ZSJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtd2luZG93cy1hcm02NC56aXAiLCJkaWdlc3QiOnsic2hhMjU2IjoiYzUzNDcxOWE5ZjAwNTc3ZWQ5MjgzMjc0MjRlYzUyNGQ2MmY1N2MxYTE5YzRjYmJiZTM0MmZlNjkxMzYxY2RmNCJ9fSx7Im5hbWUiOiIuL21pc2UtdjIwMjYuMTAuMjQtd2luZG93cy14NjQuZXhlIiwiZGlnZXN0Ijp7InNoYTI1NiI6Ijg0N2Q4NWQ5NDgwOGE3N2U5OTgzODhkMjVmMzM1MzdmZjBmMTk3YWU3NGYxY2Q1ZDVjZjY1ZTRiYTBlN2FmNGUifX0seyJuYW1lIjoiLi9taXNlLXYyMDI2LjEwLjI0LXdpbmRvd3MteDY0LnppcCIsImRpZ2VzdCI6eyJzaGEyNTYiOiJjZmZmZWM0MGRlZDY3ZjFkMDQwMzllYjM4ODgyN2FkOGRlMGEwMDg5ZDVjOWQ5YWJjNTdjNmM4NWMwYWVkYzcxIn19XSwicHJlZGljYXRlVHlwZSI6Imh0dHBzOi8vc2xzYS5kZXYvcHJvdmVuYW5jZS92MSIsInByZWRpY2F0ZSI6eyJidWlsZERlZmluaXRpb24iOnsiYnVpbGRUeXBlIjoiaHR0cHM6Ly9hY3Rpb25zLmdpdGh1Yi5pby9idWlsZHR5cGVzL3dvcmtmbG93L3YxIiwiZXh0ZXJuYWxQYXJhbWV0ZXJzIjp7IndvcmtmbG93Ijp7InJlZiI6InJlZnMvdGFncy92MjAyNi4xMC4yNCIsInJlcG9zaXRvcnkiOiJodHRwczovL2dpdGh1Yi5jb20vamR4L21pc2UiLCJwYXRoIjoiLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWwifX0sImludGVybmFsUGFyYW1ldGVycyI6eyJnaXRodWIiOnsiZXZlbnRfbmFtZSI6InB1c2giLCJyZXBvc2l0b3J5X2lkIjoiNTg2OTIwNDE0IiwicmVwb3NpdG9yeV9vd25lcl9pZCI6IjIxNjE4OCIsInJ1bm5lcl9lbnZpcm9ubWVudCI6ImdpdGh1Yi1ob3N0ZWQifX0sInJlc29sdmVkRGVwZW5kZW5jaWVzIjpbeyJ1cmkiOiJnaXQraHR0cHM6Ly9naXRodWIuY29tL2pkeC9taXNlQHJlZnMvdGFncy92MjAyNi4xMC4yNCIsImRpZ2VzdCI6eyJnaXRDb21taXQiOiJhYTI2MDkwZmMxMDE4NGJmNjE5OTdlNTc4NGY3MmMzNjVjOGM4ZWNlIn19XX0sInJ1bkRldGFpbHMiOnsiYnVpbGRlciI6eyJpZCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9qZHgvbWlzZS8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbEByZWZzL3RhZ3MvdjIwMjYuMTAuMjQifSwibWV0YWRhdGEiOnsiaW52b2NhdGlvbklkIjoiaHR0cHM6Ly9naXRodWIuY29tL2pkeC9taXNlL2FjdGlvbnMvcnVucy80MTAyNzIxNzM1Mi9hdHRlbXB0cy8xIn19fX0=","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEUCIQCv+2nlktmvcN+Ac4BYfHVC+P5dgU6sPWgNB/3JGIpKlAIgJqZdNkKs7ocU6aISjhcSOHGEOKgLizL2ltQdGAbclhs="}]}} + } + ] +} From 5098f29cdd1568f525d88951f20097e8114b38d8 Mon Sep 17 00:00:00 2001 From: Sorah Fukumori Date: Sat, 31 Oct 2026 07:38:01 +0900 Subject: [PATCH 2/3] docs/specs: mark 005 install implemented Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM --- docs/specs/005-install.md | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/docs/specs/005-install.md b/docs/specs/005-install.md index 5a9e26a..4e32225 100644 --- a/docs/specs/005-install.md +++ b/docs/specs/005-install.md @@ -345,22 +345,37 @@ linux-hardened job sudo harden in the runner; marker, re-exec, peer verifi ## Current Status -Spec drafted; awaiting review before implementation. Implementors MUST keep this section updated as they work. +Implemented on `hardening/install`. Root-requiring scenarios verified in Docker (see Updates). The AUR packages are a follow-up in their own repositories. ### Checklist -- [ ] `mairu harden`, `--uninstall`, `--purge`; `--uninstall` reads only a root-owned manifest -- [ ] Upgrades: `harden --from FILE` with sigstore attestation verification, `--unattested`, `--allow-downgrade`; no `sudo` command printed for user-writable binaries -- [ ] `/var/lib/mairu/installs`, and `doctor` listing hardened copies -- [ ] Harden home: `--home`, `MAIRU_HARDEN_HOME`, `harden_home` -- [ ] Re-exec with candidate validation and the version-skew warning; dev profile disables it -- [ ] Periodic harden suggestion (`harden_suggestion`, `MAIRU_NO_HARDEN_SUGGESTION`) -- [ ] Debian postinst; sysusers/tmpfiles snippets -- [ ] Root-reachability warnings in `doctor` -- [ ] linux-hardened CI job -- [ ] README and CHANGELOG +- [x] `mairu harden`, `--uninstall`, `--purge`; `--uninstall` reads only a root-owned manifest +- [x] Upgrades: `harden --from FILE` with sigstore attestation verification, `--unattested`, `--allow-downgrade`; no `sudo` command printed for user-writable binaries +- [x] `/var/lib/mairu/installs`, and `doctor` listing hardened copies +- [x] Harden home: `--home`, `MAIRU_HARDEN_HOME`, `harden_home` +- [x] Re-exec with candidate validation and the version-skew warning; dev profile disables it +- [x] Periodic harden suggestion (`harden_suggestion`, `MAIRU_NO_HARDEN_SUGGESTION`) +- [x] Debian postinst; sysusers/tmpfiles snippets +- [x] Root-reachability warnings in `doctor` +- [x] linux-hardened CI job (run in Docker as a passwordless-sudo user; not yet on a GitHub runner) +- [x] README and CHANGELOG - [ ] Follow-up: AUR packages (`mairu`, `mairu-bin`, separate repositories) ### Updates - 2026-10-29: Split out of hardening.md, policy.md and platform-helper.md. +- 2026-10-31: Implemented. Decisions and deviations: + - Attestations are verified with `sigstore-verify` and `sigstore-trust-root` 0.15 (the sigstore organization's sigstore-rust) rather than the `sigstore` crate. They verify bundles only, embed the public-good TUF root and fetch the trusted root through TUF, and use the system trust store (reqwest 0.13 with `rustls-platform-verifier`, or native-tls); no `webpki-roots`. reqwest 0.13 sits next to the 0.12 the rest of mairu uses. The x86_64 glibc rustls release binary grows from 18.6 MB to 21.3 MB. The dependencies are Linux-only. + - The trusted root is fetched fresh through TUF on every `harden --from`, without a cache. Bundles from GitHub's attestation API are tried in turn: inline bundles, else the snappy-compressed `bundle_url`. The first bundle whose certificate chain and transparency log entry verify, with issuer `https://token.actions.githubusercontent.com` and identity `https://github.com/sorah/mairu/.github/workflows/release.yml@refs/tags/v`, gives the version. Verification runs over the bytes read once, which are the bytes installed. + - "The installed copy's" version is the version of the binary running `harden`, which is the hardened copy in the documented upgrade command. `--unattested` skips the version check, as an unattested file has no version that mairu would trust without running it. + - The version-skew message prints the update command only when the user-installed copy is newer, since an older one would be refused as a downgrade. + - Candidate validation also rejects a candidate on a `nosuid` mount, where the setgid bit is ignored. A process that was re-exec'd (`MAIRU_HARDENED_REEXEC_*` set) but did not come up hardened never re-execs again; it warns and continues unhardened. + - `harden` verifies the `--from` attestation before creating the group or any file. The busybox fallback is `addgroup -S mairu`, the form busybox accepts. Helpers are copied when they are regular files; their consumer-specific checks (016's SHA-256 pin) do not exist yet. The helper list is `crate::helpers::SHIPPED` (`mairu-windows-hello.exe` on Linux). + - `/var/lib/mairu/installs` is `root:mairu 0640`, readable by `doctor` with the marker raised. `harden` trusts it when it is a root-owned regular file not writable by group or others; its directory is writable by the marker gid by design. + - `harden` reads `harden_home` from `~/.config/mairu/config.json` under the `SUDO_UID` user's passwd home, as `sudo` does not pass `XDG_CONFIG_HOME`; non-regular files are ignored. A relative `--home` is rejected by argument parsing; relative values from the environment or config are ignored with a warning. + - `doctor` shows the harden home and its source, and each known copy (the resolved home, `/var/lib/mairu/installs`, `/usr/bin/mairu`) with whether it passes candidate validation and its version, read by running it with `--version` and `MAIRU_NO_HARDENED_REEXEC=1`. + - The harden suggestion is printed after argument parsing and logging setup, so `--help` and `--version` do not show it, and never for `agent` or `harden`. A debug build in the dev profile still suggests hardening when no valid candidate exists. With `interval_secs: 0` and no controlling terminal session, it falls back to once an hour. + - The Debian postinst runs with `set -e`. The linux-hardened CI job runs `.github/scripts/linux-hardened.sh`. + - Executing a binary with `cap_ipc_lock=+ep` fails with `EPERM` where `CAP_IPC_LOCK` is outside the capability bounding set, such as Docker's default; re-exec then warns and continues unhardened. The Docker scenarios add `--cap-add IPC_LOCK`. + - Verified in Docker (debian:trixie, scenario `005-install`, 120 checks, plus `005-ci-script` running the CI script, 16 checks): the non-root refusal with and without a `sudo` command; the suggestion on a pty (shown, not repeated within the interval, repeated after it, disabled by the variable and the config, once per terminal session with 0, skipped with an unwritable state file, absent without a terminal or with a hardened copy); `harden` from a user-installed copy creating the system group, `root:mairu 2755` with `cap_ipc_lock`, `root:root 0755` directory, `/var/lib/mairu{,/users}` `root:mairu 2770`, the helper `root:root 0755`, manifest and installs; idempotent re-run replacing the binary; re-exec from a user copy, dev profile and `MAIRU_NO_HARDENED_REEXEC=1` opt-outs, the hardened agent refusing the opted-out client, unhardened-agent replacement, re-exec variables unset; skew warnings both ways; `--from --unattested` installing the file's exact bytes and warning; `--from` refused offline and with a malformed bundle, leaving the copy unchanged; the group-member, primary-group and group-password checks; `--home`, `MAIRU_HARDEN_HOME` and the invoking user's `harden_home`, for `harden` and for clients; a user-owned harden home refused; candidates rejected for a user-owned ancestor, no setgid, another group, other-writable; `doctor` root-reachability warnings for `docker` and `NOPASSWD`; a minimum-version warning for an old copy; `--uninstall` refusing a user-owned manifest, removing only listed files, `--purge`, the group left; the Debian package built with cargo-deb installing `/usr/bin/mairu` `root:mairu 2755` with `cap_ipc_lock` and the state directories, and becoming the re-exec candidate. Scenarios 003 and 004 still pass unchanged. + - Not verified against the network: attestation verification of a real mairu release, as none is attested yet. Unit tests verify a real GitHub build-provenance bundle (jdx/mise, `testdata/attestation/`) offline against the embedded trust root, including the identity, digest and signer checks, and an ignored test fetches the trust root through TUF and a bundle from GitHub's API (run by hand: both TLS backends pass). The downgrade refusal is unit-tested. From 479fde759260b12ef1d6e002265b3c6b32c98abb Mon Sep 17 00:00:00 2001 From: Sorah Fukumori Date: Sat, 31 Oct 2026 08:01:53 +0900 Subject: [PATCH 3/3] ci: harden into a root-owned scratch home Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM --- .github/scripts/linux-hardened.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/linux-hardened.sh b/.github/scripts/linux-hardened.sh index 6745216..c1e559b 100755 --- a/.github/scripts/linux-hardened.sh +++ b/.github/scripts/linux-hardened.sh @@ -12,7 +12,8 @@ capture() { local f; f="$(mktemp)"; "$@" >"$f" 2>&1; rc=$?; out="$(cat "$f")"; r dev=./target/debug/mairu hardened=/usr/local/libexec/mairu/mairu -scratch=/opt/mairu-ci +# /opt is world-writable on GitHub runners, which harden refuses. +scratch=/usr/local/mairu-ci echo "--- harden" capture sudo "$dev" harden