#!/usr/bin/env bash # ----------------------------------------------------------------------------- # install-secrets-bundle.sh # Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration) # Task: OKS-005 - Create bundle installation script # Description: Install signed secrets rule bundle for offline environments # ----------------------------------------------------------------------------- # Usage: ./install-secrets-bundle.sh [install-path] [attestor-mirror] # Example: ./install-secrets-bundle.sh /mnt/offline-kit/rules/secrets/2026.01 set -euo pipefail # Configuration BUNDLE_PATH="${1:?Bundle path required (e.g., /mnt/offline-kit/rules/secrets/2026.01)}" INSTALL_PATH="${2:-/opt/stellaops/plugins/scanner/analyzers/secrets}" ATTESTOR_MIRROR="${3:-}" BUNDLE_ID="${BUNDLE_ID:-secrets.ruleset}" REQUIRE_SIGNATURE="${REQUIRE_SIGNATURE:-true}" STELLAOPS_USER="${STELLAOPS_USER:-stellaops}" STELLAOPS_GROUP="${STELLAOPS_GROUP:-stellaops}" # Color output helpers (disabled if not a terminal) if [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' NC='\033[0m' # No Color else RED='' GREEN='' YELLOW='' NC='' fi log_info() { echo -e "${GREEN}==>${NC} $*"; } log_warn() { echo -e "${YELLOW}WARN:${NC} $*" >&2; } log_error() { echo -e "${RED}ERROR:${NC} $*" >&2; } # Validate bundle path log_info "Validating secrets bundle at ${BUNDLE_PATH}" if [[ ! -d "${BUNDLE_PATH}" ]]; then log_error "Bundle directory not found: ${BUNDLE_PATH}" exit 1 fi MANIFEST_FILE="${BUNDLE_PATH}/${BUNDLE_ID}.manifest.json" RULES_FILE="${BUNDLE_PATH}/${BUNDLE_ID}.rules.jsonl" SIGNATURE_FILE="${BUNDLE_PATH}/${BUNDLE_ID}.dsse.json" if [[ ! -f "${MANIFEST_FILE}" ]]; then log_error "Manifest not found: ${MANIFEST_FILE}" exit 1 fi if [[ ! -f "${RULES_FILE}" ]]; then log_error "Rules file not found: ${RULES_FILE}" exit 1 fi # Extract bundle version BUNDLE_VERSION=$(jq -r '.version // "unknown"' "${MANIFEST_FILE}" 2>/dev/null || echo "unknown") RULE_COUNT=$(jq -r '.ruleCount // 0' "${MANIFEST_FILE}" 2>/dev/null || echo "0") SIGNER_KEY_ID=$(jq -r '.signerKeyId // "unknown"' "${MANIFEST_FILE}" 2>/dev/null || echo "unknown") log_info "Bundle version: ${BUNDLE_VERSION}" log_info "Rule count: ${RULE_COUNT}" log_info "Signer key ID: ${SIGNER_KEY_ID}" # Verify signature if required if [[ "${REQUIRE_SIGNATURE}" == "true" ]]; then log_info "Verifying bundle signature..." if [[ ! -f "${SIGNATURE_FILE}" ]]; then log_error "Signature file not found: ${SIGNATURE_FILE}" log_error "Set REQUIRE_SIGNATURE=false to skip signature verification (not recommended)" exit 1 fi # Set attestor mirror URL if provided if [[ -n "${ATTESTOR_MIRROR}" ]]; then export STELLA_ATTESTOR_URL="file://${ATTESTOR_MIRROR}" log_info "Using attestor mirror: ${STELLA_ATTESTOR_URL}" fi # Verify using stella CLI if available if command -v stella &>/dev/null; then if ! stella secrets bundle verify --bundle "${BUNDLE_PATH}" --bundle-id "${BUNDLE_ID}"; then log_error "Bundle signature verification failed" exit 1 fi log_info "Signature verification passed" else log_warn "stella CLI not found, performing basic signature file check only" # Basic check: verify signature file is valid JSON with expected structure if ! jq -e '.payloadType and .payload and .signatures' "${SIGNATURE_FILE}" >/dev/null 2>&1; then log_error "Invalid DSSE envelope structure in ${SIGNATURE_FILE}" exit 1 fi # Verify payload digest matches EXPECTED_DIGEST=$(jq -r '.payload' "${SIGNATURE_FILE}" | base64 -d | sha256sum | cut -d' ' -f1) ACTUAL_DIGEST=$(sha256sum "${MANIFEST_FILE}" | cut -d' ' -f1) if [[ "${EXPECTED_DIGEST}" != "${ACTUAL_DIGEST}" ]]; then log_error "Payload digest mismatch" log_error "Expected: ${EXPECTED_DIGEST}" log_error "Actual: ${ACTUAL_DIGEST}" exit 1 fi log_warn "Basic signature structure verified (full cryptographic verification requires stella CLI)" fi else log_warn "Signature verification skipped (REQUIRE_SIGNATURE=false)" fi # Verify file digests listed in manifest log_info "Verifying file digests..." DIGEST_ERRORS=() while IFS= read -r file_entry; do FILE_NAME=$(echo "${file_entry}" | jq -r '.name') EXPECTED_DIGEST=$(echo "${file_entry}" | jq -r '.digest' | sed 's/sha256://') FILE_PATH="${BUNDLE_PATH}/${FILE_NAME}" if [[ ! -f "${FILE_PATH}" ]]; then DIGEST_ERRORS+=("File missing: ${FILE_NAME}") continue fi ACTUAL_DIGEST=$(sha256sum "${FILE_PATH}" | cut -d' ' -f1) if [[ "${EXPECTED_DIGEST}" != "${ACTUAL_DIGEST}" ]]; then DIGEST_ERRORS+=("Digest mismatch: ${FILE_NAME}") fi done < <(jq -c '.files[]' "${MANIFEST_FILE}" 2>/dev/null) if [[ ${#DIGEST_ERRORS[@]} -gt 0 ]]; then log_error "File digest verification failed:" for err in "${DIGEST_ERRORS[@]}"; do log_error " - ${err}" done exit 1 fi log_info "File digests verified" # Check existing installation if [[ -d "${INSTALL_PATH}" ]]; then EXISTING_MANIFEST="${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" if [[ -f "${EXISTING_MANIFEST}" ]]; then EXISTING_VERSION=$(jq -r '.version // "unknown"' "${EXISTING_MANIFEST}" 2>/dev/null || echo "unknown") log_info "Existing installation found: version ${EXISTING_VERSION}" # Version comparison (CalVer: YYYY.MM) if [[ "${EXISTING_VERSION}" > "${BUNDLE_VERSION}" ]]; then log_warn "Existing version (${EXISTING_VERSION}) is newer than bundle (${BUNDLE_VERSION})" log_warn "Use FORCE_INSTALL=true to override" if [[ "${FORCE_INSTALL:-false}" != "true" ]]; then exit 1 fi fi fi fi # Create installation directory log_info "Creating installation directory: ${INSTALL_PATH}" mkdir -p "${INSTALL_PATH}" # Install bundle files log_info "Installing bundle files..." for file in "${BUNDLE_PATH}"/${BUNDLE_ID}.*; do if [[ -f "${file}" ]]; then FILE_NAME=$(basename "${file}") echo " ${FILE_NAME}" cp -f "${file}" "${INSTALL_PATH}/" fi done # Set permissions log_info "Setting file permissions..." chmod 640 "${INSTALL_PATH}"/${BUNDLE_ID}.* 2>/dev/null || true # Set ownership if running as root if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then if id "${STELLAOPS_USER}" &>/dev/null; then chown "${STELLAOPS_USER}:${STELLAOPS_GROUP}" "${INSTALL_PATH}"/${BUNDLE_ID}.* 2>/dev/null || true log_info "Set ownership to ${STELLAOPS_USER}:${STELLAOPS_GROUP}" else log_warn "User ${STELLAOPS_USER} does not exist, skipping ownership change" fi else log_info "Not running as root, skipping ownership change" fi # Create installation receipt RECEIPT_FILE="${INSTALL_PATH}/.install-receipt.json" cat > "${RECEIPT_FILE}" </dev/null || hostname)" } EOF # Verify installation INSTALLED_VERSION=$(jq -r '.version' "${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" 2>/dev/null || echo "unknown") log_info "Successfully installed secrets bundle version ${INSTALLED_VERSION}" echo "" echo "Installation summary:" echo " Bundle ID: ${BUNDLE_ID}" echo " Version: ${INSTALLED_VERSION}" echo " Rule count: ${RULE_COUNT}" echo " Install path: ${INSTALL_PATH}" echo " Receipt: ${RECEIPT_FILE}" echo "" echo "Next steps:" echo " 1. Restart Scanner Worker to load the new bundle:" echo " systemctl restart stellaops-scanner-worker" echo "" echo " Or with Kubernetes:" echo " kubectl rollout restart deployment/scanner-worker -n stellaops" echo "" echo " 2. Verify bundle is loaded:" echo " kubectl logs -l app=scanner-worker --tail=100 | grep SecretsAnalyzerHost"