fix(security): harden installer checksum, filter-trust, meta-command fallthrough

This commit is contained in:
aesoft
2026-06-12 14:01:04 +02:00
parent 6785a6c769
commit 9cc4937dac
6 changed files with 107 additions and 29 deletions
+15 -6
View File
@@ -182,10 +182,13 @@ jobs:
- name: Get version
id: version
env:
INPUT_TAG: ${{ inputs.tag }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
TAG="${{ inputs.tag }}"
TAG="$INPUT_TAG"
if [ -z "$TAG" ]; then
TAG="${{ github.event.release.tag_name }}"
TAG="$RELEASE_TAG"
fi
echo "version=$TAG" >> $GITHUB_OUTPUT
@@ -225,10 +228,13 @@ jobs:
steps:
- name: Get version
id: version
env:
INPUT_TAG: ${{ inputs.tag }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
TAG="${{ inputs.tag }}"
TAG="$INPUT_TAG"
if [ -z "$TAG" ]; then
TAG="${{ github.event.release.tag_name }}"
TAG="$RELEASE_TAG"
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
@@ -259,10 +265,13 @@ jobs:
steps:
- name: Get version
id: version
env:
INPUT_TAG: ${{ inputs.tag }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
TAG="${{ inputs.tag }}"
TAG="$INPUT_TAG"
if [ -z "$TAG" ]; then
TAG="${{ github.event.release.tag_name }}"
TAG="$RELEASE_TAG"
fi
VERSION="${TAG#v}"
echo "tag=$TAG" >> $GITHUB_OUTPUT
+37 -3
View File
@@ -90,17 +90,47 @@ install() {
info "Version: $VERSION"
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${BINARY_NAME}-${TARGET}.tar.gz"
CHECKSUMS_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt"
TEMP_DIR=$(mktemp -d)
ARCHIVE="${TEMP_DIR}/${BINARY_NAME}.tar.gz"
CHECKSUMS="${TEMP_DIR}/checksums.txt"
ASSET_NAME="${BINARY_NAME}-${TARGET}.tar.gz"
info "Downloading from: $DOWNLOAD_URL"
if ! curl -fsSL "$DOWNLOAD_URL" -o "$ARCHIVE"; then
error "Failed to download binary"
fi
info "Downloading checksums..."
if ! curl -fsSL "$CHECKSUMS_URL" -o "$CHECKSUMS"; then
error "Failed to download checksums.txt — refusing to install unverified binary (set RTK_SKIP_CHECKSUM=1 to bypass at your own risk)"
fi
if [ "${RTK_SKIP_CHECKSUM:-0}" = "1" ]; then
warn "RTK_SKIP_CHECKSUM=1 set — SKIPPING checksum verification (NOT RECOMMENDED)"
else
info "Verifying SHA-256 checksum..."
EXPECTED=$(grep "[[:space:]]${ASSET_NAME}\$" "$CHECKSUMS" | awk '{print $1}')
if [ -z "$EXPECTED" ]; then
error "checksum for ${ASSET_NAME} not found in checksums.txt — refusing to install"
fi
# sha256sum (Linux GNU) vs shasum -a 256 (macOS) — prefer whichever is available.
if command -v sha256sum >/dev/null 2>&1; then
ACTUAL=$(sha256sum "$ARCHIVE" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
ACTUAL=$(shasum -a 256 "$ARCHIVE" | awk '{print $1}')
else
error "Neither sha256sum nor shasum available — cannot verify checksum"
fi
if [ "$EXPECTED" != "$ACTUAL" ]; then
error "checksum mismatch! expected=${EXPECTED} actual=${ACTUAL} — refusing to install"
fi
info "Checksum verified."
fi
# Verify archive contents before extraction (CWE-22 path traversal).
# Reject any entry with an absolute path or a ".." component.
info "Verifying archive..."
info "Verifying archive contents..."
if tar -tzf "$ARCHIVE" | grep -qE '^/|(^|/)\.\.(/|$)'; then
error "Archive contains unsafe paths (absolute or directory traversal) — refusing to extract"
fi
@@ -121,9 +151,13 @@ install() {
# Verify installation
verify() {
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
info "Verification: $($BINARY_NAME --version)"
INSTALLED_BIN="${INSTALL_DIR}/${BINARY_NAME}"
if [ -x "$INSTALLED_BIN" ]; then
info "Verification: $("$INSTALLED_BIN" --version)"
else
error "Binary not found at expected location: $INSTALLED_BIN"
fi
if ! command -v "$BINARY_NAME" >/dev/null 2>&1; then
warn "Binary installed but not in PATH. Add to your shell profile:"
warn " export PATH=\"\$HOME/.local/bin:\$PATH\""
fi
+8 -6
View File
@@ -191,13 +191,14 @@ impl TomlFilterRegistry {
// Priority 1: project-local .rtk/filters.toml (trust-gated)
let project_filter_path = std::path::Path::new(".rtk/filters.toml");
if project_filter_path.exists() {
let trust_status = crate::hooks::trust::check_trust(project_filter_path)
.unwrap_or(crate::hooks::trust::TrustStatus::Untrusted);
let (trust_status, verified_content) =
crate::hooks::trust::check_trust_with_content(project_filter_path)
.unwrap_or((crate::hooks::trust::TrustStatus::Untrusted, None));
match trust_status {
crate::hooks::trust::TrustStatus::Trusted
| crate::hooks::trust::TrustStatus::EnvOverride => {
if let Ok(content) = std::fs::read_to_string(project_filter_path) {
if let Some(content) = verified_content {
match Self::parse_and_compile(&content, "project") {
Ok(f) => filters.extend(f),
Err(e) => eprintln!("[rtk] warning: .rtk/filters.toml: {}", e),
@@ -559,12 +560,13 @@ pub fn run_filter_tests(filter_name_opt: Option<&str>) -> VerifyResults {
// Trust-gated: only verify project-local filters if trusted (SA-2025-RTK-002)
let project_path = std::path::Path::new(".rtk/filters.toml");
if project_path.exists() {
let trust_status = crate::hooks::trust::check_trust(project_path)
.unwrap_or(crate::hooks::trust::TrustStatus::Untrusted);
let (trust_status, verified_content) =
crate::hooks::trust::check_trust_with_content(project_path)
.unwrap_or((crate::hooks::trust::TrustStatus::Untrusted, None));
match trust_status {
crate::hooks::trust::TrustStatus::Trusted
| crate::hooks::trust::TrustStatus::EnvOverride => {
if let Ok(content) = std::fs::read_to_string(project_path) {
if let Some(content) = verified_content {
collect_test_outcomes(
&content,
filter_name_opt,
+8 -3
View File
@@ -37,13 +37,18 @@ pub enum IntegrityStatus {
OrphanedHash,
}
/// Compute SHA-256 hash of a file, returned as lowercase hex
/// Compute SHA-256 hash of a file, returned as lowercase hex.
pub fn compute_hash(path: &Path) -> Result<String> {
let content =
fs::read(path).with_context(|| format!("Failed to read file: {}", path.display()))?;
Ok(compute_hash_bytes(&content))
}
/// Compute SHA-256 of an in-memory byte buffer, returned as lowercase hex.
pub fn compute_hash_bytes(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(&content);
Ok(format!("{:x}", hasher.finalize()))
hasher.update(content);
format!("{:x}", hasher.finalize())
}
/// Derive the hash file path from the hook path
+34 -11
View File
@@ -93,9 +93,14 @@ fn canonical_key(filter_path: &Path) -> Result<String> {
///
/// Priority: env var > hash match > untrusted.
/// All errors are soft — if anything fails, returns Untrusted (fail-secure).
#[cfg_attr(not(test), allow(dead_code))]
pub fn check_trust(filter_path: &Path) -> Result<TrustStatus> {
// Fast path: env var override for CI pipelines only.
// Requires a known CI env var to be set to prevent .envrc injection attacks.
check_trust_with_content(filter_path).map(|(status, _)| status)
}
/// Reads the file once, hashes those bytes, and returns the trust status with
/// the verified content. Content is `Some` only for `Trusted` / `EnvOverride`.
pub fn check_trust_with_content(filter_path: &Path) -> Result<(TrustStatus, Option<String>)> {
if std::env::var("RTK_TRUST_PROJECT_FILTERS").as_deref() == Ok("1") {
let in_ci = std::env::var("CI").is_ok()
|| std::env::var("GITHUB_ACTIONS").is_ok()
@@ -103,13 +108,20 @@ pub fn check_trust(filter_path: &Path) -> Result<TrustStatus> {
|| std::env::var("JENKINS_URL").is_ok()
|| std::env::var("BUILDKITE").is_ok();
if in_ci {
return Ok(TrustStatus::EnvOverride);
let content = std::fs::read_to_string(filter_path)
.with_context(|| format!("Failed to read filter: {}", filter_path.display()))?;
return Ok((TrustStatus::EnvOverride, Some(content)));
}
eprintln!(
"[rtk] WARNING: RTK_TRUST_PROJECT_FILTERS=1 ignored (CI environment not detected)"
);
}
let bytes = match std::fs::read(filter_path) {
Ok(b) => b,
Err(_) => return Ok((TrustStatus::Untrusted, None)),
};
let key = canonical_key(filter_path)?;
let store = match read_store() {
Ok(s) => s,
@@ -124,19 +136,30 @@ pub fn check_trust(filter_path: &Path) -> Result<TrustStatus> {
let entry = match store.trusted.get(&key) {
Some(e) => e,
None => return Ok(TrustStatus::Untrusted),
None => return Ok((TrustStatus::Untrusted, None)),
};
let actual_hash = integrity::compute_hash(filter_path)
.with_context(|| format!("Failed to hash: {}", filter_path.display()))?;
let actual_hash = integrity::compute_hash_bytes(&bytes);
if actual_hash == entry.sha256 {
Ok(TrustStatus::Trusted)
match String::from_utf8(bytes) {
Ok(content) => Ok((TrustStatus::Trusted, Some(content))),
Err(_) => {
eprintln!(
"[rtk] WARNING: trusted filter {} is not valid UTF-8 — treating as untrusted",
filter_path.display()
);
Ok((TrustStatus::Untrusted, None))
}
}
} else {
Ok(TrustStatus::ContentChanged {
expected: entry.sha256.clone(),
actual: actual_hash,
})
Ok((
TrustStatus::ContentChanged {
expected: entry.sha256.clone(),
actual: actual_hash,
},
None,
))
}
}
+5
View File
@@ -1133,6 +1133,7 @@ enum GoCommands {
/// RTK-only subcommands that should never fall back to raw execution.
/// If Clap fails to parse these, show the Clap error directly.
/// When adding a new RTK-only subcommand to `Commands`, add its clap name here.
const RTK_META_COMMANDS: &[&str] = &[
"gain",
"discover",
@@ -1150,6 +1151,10 @@ const RTK_META_COMMANDS: &[&str] = &[
"untrust",
"session",
"rewrite",
"telemetry",
"smart",
"deps",
"json",
];
fn run_fallback(parse_error: clap::Error) -> Result<i32> {