-
Notifications
You must be signed in to change notification settings - Fork 16
Add package integrity verification for APT artifacts #68
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,218 @@ | ||||||||||
| #!/bin/bash | ||||||||||
| # verify-integrity.sh - Verify CX Linux APT repository package integrity | ||||||||||
| # | ||||||||||
| # Checks repository metadata signatures and validates package checksums from | ||||||||||
| # Packages indexes without installing or executing package contents. | ||||||||||
| # | ||||||||||
| # Usage: | ||||||||||
| # ./apt/scripts/verify-integrity.sh [repo-root] | ||||||||||
| # ./apt/scripts/verify-integrity.sh --keyring /path/to/pub.gpg [repo-root] | ||||||||||
| # | ||||||||||
| # SPDX-License-Identifier: Apache-2.0 | ||||||||||
|
|
||||||||||
| set -euo pipefail | ||||||||||
|
|
||||||||||
| KEYRING="" | ||||||||||
| REPO_ROOT="" | ||||||||||
|
|
||||||||||
| usage() { | ||||||||||
| cat <<'EOF' | ||||||||||
| Usage: verify-integrity.sh [--keyring KEYRING] [REPO_ROOT] | ||||||||||
|
|
||||||||||
| Verify a CX Linux APT repository: | ||||||||||
| - Release.gpg detached signatures when a keyring is supplied | ||||||||||
| - InRelease clearsigned metadata when a keyring is supplied | ||||||||||
| - SHA256 checksums listed in Packages and Packages.gz indexes | ||||||||||
| - Missing or tampered .deb files referenced by package indexes | ||||||||||
|
|
||||||||||
| Arguments: | ||||||||||
| REPO_ROOT Repository root containing dists/ and pool/. | ||||||||||
| Defaults to the parent of this script's directory. | ||||||||||
|
|
||||||||||
| Options: | ||||||||||
| --keyring PATH Public GPG keyring or armored key used for signature checks. | ||||||||||
| -h, --help Show this help text. | ||||||||||
| EOF | ||||||||||
| } | ||||||||||
|
|
||||||||||
| while [[ $# -gt 0 ]]; do | ||||||||||
| case "$1" in | ||||||||||
| --keyring) | ||||||||||
| if [[ $# -lt 2 ]]; then | ||||||||||
| echo "Error: --keyring requires a path" >&2 | ||||||||||
| exit 2 | ||||||||||
| fi | ||||||||||
| KEYRING="$2" | ||||||||||
| shift 2 | ||||||||||
| ;; | ||||||||||
| -h|--help) | ||||||||||
| usage | ||||||||||
| exit 0 | ||||||||||
| ;; | ||||||||||
| -*) | ||||||||||
| echo "Error: unknown option: $1" >&2 | ||||||||||
| usage >&2 | ||||||||||
| exit 2 | ||||||||||
| ;; | ||||||||||
| *) | ||||||||||
| if [[ -n "$REPO_ROOT" ]]; then | ||||||||||
| echo "Error: multiple repository roots provided" >&2 | ||||||||||
| exit 2 | ||||||||||
| fi | ||||||||||
| REPO_ROOT="$1" | ||||||||||
| shift | ||||||||||
| ;; | ||||||||||
| esac | ||||||||||
| done | ||||||||||
|
|
||||||||||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||||||||||
| REPO_ROOT="${REPO_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" | ||||||||||
|
|
||||||||||
| if [[ ! -d "$REPO_ROOT" ]]; then | ||||||||||
| echo "Error: repository root does not exist: $REPO_ROOT" >&2 | ||||||||||
| exit 2 | ||||||||||
| fi | ||||||||||
|
|
||||||||||
| DISTS_DIR="$REPO_ROOT/dists" | ||||||||||
| POOL_DIR="$REPO_ROOT/pool" | ||||||||||
|
|
||||||||||
| if [[ ! -d "$DISTS_DIR" ]]; then | ||||||||||
| echo "Error: missing dists directory: $DISTS_DIR" >&2 | ||||||||||
| exit 2 | ||||||||||
| fi | ||||||||||
|
|
||||||||||
| PASS=0 | ||||||||||
| FAIL=0 | ||||||||||
| WARN=0 | ||||||||||
|
|
||||||||||
| pass() { | ||||||||||
| printf 'PASS %s\n' "$1" | ||||||||||
| PASS=$((PASS + 1)) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| fail() { | ||||||||||
| printf 'FAIL %s\n' "$1" >&2 | ||||||||||
| FAIL=$((FAIL + 1)) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| warn() { | ||||||||||
| printf 'WARN %s\n' "$1" >&2 | ||||||||||
| WARN=$((WARN + 1)) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| verify_signature() { | ||||||||||
| local release_file="$1" | ||||||||||
| local dist_dir | ||||||||||
| dist_dir="$(dirname "$release_file")" | ||||||||||
|
|
||||||||||
| if [[ -z "$KEYRING" ]]; then | ||||||||||
| warn "signature checks skipped for ${release_file#$REPO_ROOT/}; no --keyring supplied" | ||||||||||
| return | ||||||||||
| fi | ||||||||||
|
|
||||||||||
| if [[ ! -f "$KEYRING" ]]; then | ||||||||||
| fail "keyring not found: $KEYRING" | ||||||||||
| return | ||||||||||
| fi | ||||||||||
|
|
||||||||||
| if [[ -f "$dist_dir/Release.gpg" ]]; then | ||||||||||
| if gpgv --keyring "$KEYRING" "$dist_dir/Release.gpg" "$release_file" >/dev/null 2>&1; then | ||||||||||
| pass "detached signature valid: ${dist_dir#$REPO_ROOT/}/Release.gpg" | ||||||||||
| else | ||||||||||
| fail "detached signature invalid: ${dist_dir#$REPO_ROOT/}/Release.gpg" | ||||||||||
| fi | ||||||||||
| else | ||||||||||
| warn "missing detached signature: ${dist_dir#$REPO_ROOT/}/Release.gpg" | ||||||||||
| fi | ||||||||||
|
|
||||||||||
| if [[ -f "$dist_dir/InRelease" ]]; then | ||||||||||
| if gpgv --keyring "$KEYRING" "$dist_dir/InRelease" >/dev/null 2>&1; then | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Consider using |
||||||||||
| pass "clearsigned metadata valid: ${dist_dir#$REPO_ROOT/}/InRelease" | ||||||||||
| else | ||||||||||
| fail "clearsigned metadata invalid: ${dist_dir#$REPO_ROOT/}/InRelease" | ||||||||||
| fi | ||||||||||
| else | ||||||||||
| warn "missing clearsigned metadata: ${dist_dir#$REPO_ROOT/}/InRelease" | ||||||||||
| fi | ||||||||||
| } | ||||||||||
|
|
||||||||||
| packages_stream() { | ||||||||||
| local package_index="$1" | ||||||||||
|
|
||||||||||
| case "$package_index" in | ||||||||||
| *.gz) gzip -cd "$package_index" ;; | ||||||||||
| *) cat "$package_index" ;; | ||||||||||
| esac | ||||||||||
| } | ||||||||||
|
|
||||||||||
| verify_packages_index() { | ||||||||||
| local package_index="$1" | ||||||||||
| local index_dir filename sha256 package_path actual | ||||||||||
|
|
||||||||||
| index_dir="$(dirname "$package_index")" | ||||||||||
|
|
||||||||||
| while IFS=$'\t' read -r filename sha256; do | ||||||||||
| if [[ -z "$filename" || -z "$sha256" ]]; then | ||||||||||
| continue | ||||||||||
| fi | ||||||||||
|
|
||||||||||
| if [[ "$filename" = /* || "$filename" == *".."* ]]; then | ||||||||||
| fail "unsafe package path in ${package_index#$REPO_ROOT/}: $filename" | ||||||||||
| continue | ||||||||||
| fi | ||||||||||
|
|
||||||||||
| package_path="$REPO_ROOT/$filename" | ||||||||||
| if [[ ! -f "$package_path" ]]; then | ||||||||||
| fail "missing package referenced by ${package_index#$REPO_ROOT/}: $filename" | ||||||||||
| continue | ||||||||||
| fi | ||||||||||
|
|
||||||||||
| actual="$(sha256sum "$package_path" | awk '{print $1}')" | ||||||||||
| if [[ "$actual" == "$sha256" ]]; then | ||||||||||
| pass "checksum valid: $filename" | ||||||||||
| else | ||||||||||
| fail "checksum mismatch: $filename expected $sha256 got $actual" | ||||||||||
| fi | ||||||||||
| done < <(packages_stream "$package_index" | awk ' | ||||||||||
| /^Filename: / { filename=$2 } | ||||||||||
| /^SHA256: / { sha256=$2 } | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using
Suggested change
|
||||||||||
| /^$/ { | ||||||||||
| if (filename != "" && sha256 != "") { | ||||||||||
| print filename "\t" sha256 | ||||||||||
| } | ||||||||||
| filename="" | ||||||||||
| sha256="" | ||||||||||
| } | ||||||||||
| END { | ||||||||||
| if (filename != "" && sha256 != "") { | ||||||||||
| print filename "\t" sha256 | ||||||||||
| } | ||||||||||
| } | ||||||||||
| ') | ||||||||||
|
|
||||||||||
| pass "parsed package index: ${package_index#$REPO_ROOT/}" | ||||||||||
|
|
||||||||||
| if [[ ! -d "$POOL_DIR" ]]; then | ||||||||||
| warn "pool directory missing while package index exists: ${index_dir#$REPO_ROOT/}" | ||||||||||
| fi | ||||||||||
| } | ||||||||||
|
|
||||||||||
| while IFS= read -r release_file; do | ||||||||||
| verify_signature "$release_file" | ||||||||||
| done < <(find "$DISTS_DIR" -type f -name Release | sort) | ||||||||||
|
|
||||||||||
| package_indexes_found=0 | ||||||||||
| while IFS= read -r package_index; do | ||||||||||
| package_indexes_found=$((package_indexes_found + 1)) | ||||||||||
| verify_packages_index "$package_index" | ||||||||||
| done < <(find "$DISTS_DIR" -type f \( -name Packages -o -name Packages.gz \) | sort) | ||||||||||
|
|
||||||||||
| if [[ "$package_indexes_found" -eq 0 ]]; then | ||||||||||
| warn "no package indexes found under ${DISTS_DIR#$REPO_ROOT/}" | ||||||||||
| fi | ||||||||||
|
|
||||||||||
| printf '\nIntegrity report: %d passed, %d warnings, %d failed\n' "$PASS" "$WARN" "$FAIL" | ||||||||||
|
|
||||||||||
| if [[ "$FAIL" -gt 0 ]]; then | ||||||||||
| exit 1 | ||||||||||
| fi | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| #!/bin/bash | ||
| # Tests for apt/scripts/verify-integrity.sh. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| set -euo pipefail | ||
|
|
||
| REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | ||
| SCRIPT="$REPO_ROOT/apt/scripts/verify-integrity.sh" | ||
| TMP_DIR="$(mktemp -d)" | ||
|
|
||
| cleanup() { | ||
| rm -rf "$TMP_DIR" | ||
| } | ||
| trap cleanup EXIT | ||
|
|
||
| assert_success() { | ||
| local name="$1" | ||
| shift | ||
|
|
||
| if "$@" >"$TMP_DIR/${name}.out" 2>"$TMP_DIR/${name}.err"; then | ||
| printf 'PASS %s\n' "$name" | ||
| else | ||
| printf 'FAIL %s\n' "$name" >&2 | ||
| cat "$TMP_DIR/${name}.out" >&2 || true | ||
| cat "$TMP_DIR/${name}.err" >&2 || true | ||
| exit 1 | ||
| fi | ||
| } | ||
|
|
||
| assert_failure() { | ||
| local name="$1" | ||
| shift | ||
|
|
||
| if "$@" >"$TMP_DIR/${name}.out" 2>"$TMP_DIR/${name}.err"; then | ||
| printf 'FAIL %s unexpectedly succeeded\n' "$name" >&2 | ||
| cat "$TMP_DIR/${name}.out" >&2 || true | ||
| exit 1 | ||
| else | ||
| printf 'PASS %s\n' "$name" | ||
| fi | ||
| } | ||
|
|
||
| build_fixture() { | ||
| local fixture="$1" | ||
|
|
||
| mkdir -p "$fixture/dists/stable/main/binary-amd64" | ||
| mkdir -p "$fixture/pool/main/c/cx" | ||
|
|
||
| printf 'package payload\n' > "$fixture/pool/main/c/cx/cx-test_1.0.0_all.deb" | ||
| local checksum | ||
| checksum="$(sha256sum "$fixture/pool/main/c/cx/cx-test_1.0.0_all.deb" | awk '{print $1}')" | ||
|
|
||
| cat > "$fixture/dists/stable/main/binary-amd64/Packages" <<EOF | ||
| Package: cx-test | ||
| Version: 1.0.0 | ||
| Architecture: all | ||
| Filename: pool/main/c/cx/cx-test_1.0.0_all.deb | ||
| SHA256: $checksum | ||
|
|
||
| EOF | ||
|
|
||
| cat > "$fixture/dists/stable/Release" <<'EOF' | ||
| Origin: repo.cxlinux.com | ||
| Label: CX Linux | ||
| Suite: stable | ||
| Codename: stable | ||
| Architectures: amd64 | ||
| Components: main | ||
| EOF | ||
| } | ||
|
|
||
| GOOD_REPO="$TMP_DIR/good" | ||
| BAD_REPO="$TMP_DIR/bad" | ||
| MISSING_REPO="$TMP_DIR/missing" | ||
|
|
||
| build_fixture "$GOOD_REPO" | ||
| cp -R "$GOOD_REPO" "$BAD_REPO" | ||
| cp -R "$GOOD_REPO" "$MISSING_REPO" | ||
|
|
||
| printf 'tampered payload\n' > "$BAD_REPO/pool/main/c/cx/cx-test_1.0.0_all.deb" | ||
| rm "$MISSING_REPO/pool/main/c/cx/cx-test_1.0.0_all.deb" | ||
|
|
||
| assert_success "valid-checksum" "$SCRIPT" "$GOOD_REPO" | ||
| assert_failure "tampered-package" "$SCRIPT" "$BAD_REPO" | ||
| assert_failure "missing-package" "$SCRIPT" "$MISSING_REPO" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For consistency with other test invocations in this Makefile (lines 161-163), the test script should be called directly rather than via
bash, assuming it has the appropriate shebang and execution permissions.