fix(distribution): harden the artifact apply pipeline (adversarial review)
A 4-lens adversarial security review of the Phase 2 applier raised 19 issues and confirmed 17 after per-finding verification. All are trust-boundary (they require the signing key), but several break the explicit "no code-exec, always reversible, nothing-silent" contract, so all 17 are fixed: Trust path — fail CLOSED, never misreport: - lpFetchIndex now surfaces the real signature state (LP_INDEX_SIGSTATE); artifactApply REFUSES to mutate unless the index is actually verified, and _artifactFetchPayload refuses an unsigned payload. The read path still tolerates dev/unsigned but now says "UNSIGNED" instead of "Signed + verified". - valid_until and index_serial are now MANDATORY + numeric in lpFetchIndex (missing = refuse) — closes the anti-withholding / anti-rollback fail-opens. Injection / code-exec (defense in depth even for a signed payload): - runFileWrite rootless branch no longer builds a `bash -c` shell string with the destination interpolated — it uses the argv form (like runFileOp), so a path with a quote can't inject a command as the install user. (shared-helper fix) - op paths must match a safe-filename charset (no quotes/$/backtick/;/newline); set-config-key values and set-compose-image refs are charset-guarded too. - content_b64 is validated as real base64 at precheck. Reversibility / honest failure: - dockerComposeUp now returns the real compose exit status (it always returned 0, so the updater's rollback gate AND the apply's start-failure detection were fail-open). (shared-helper fix) - set-config-key undo captures the WHOLE config file (lossless) instead of a lossy re-parsed scalar; edit-only (rejects an absent key). - _artifactReplayUndoFile returns non-zero if any inverse op fails; auto-rollback and revert now record "rollback-incomplete"/"revert-incomplete" + isError instead of falsely claiming success, and revert keeps the record for retry. - applied-record write failure is checked — apply rolls back rather than leave an un-revertable change. System-scope regen failure is no longer swallowed. - Writes are path-aware (configs/ -> runInstallWrite, container tree -> runFileWrite) so system-scope hotfixes write/restore correctly. - Checked lazy-sourcing surfaces a clear error instead of a bare exit 127. Unit-tested 35/35 (adds: command-sub value rejection, bad image-ref, invalid base64, quote/metachar path-injection rejection, replay-failure reporting). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
778b640f91
commit
a27304a191
@ -1,28 +1,30 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
#
|
||||||
# Artifact APPLY pipeline — Phase 2 of the unified distribution primitive
|
# Artifact APPLY pipeline -- Phase 2 of the unified distribution primitive
|
||||||
# (docs/roadmap/updates-and-distribution.md §8.3). The MUTATING side: it takes a
|
# (docs/roadmap/updates-and-distribution.md section 8.3). The MUTATING side: it
|
||||||
# verified artifact from the signed index and applies it reversibly, then can
|
# takes a verified artifact from the signed index and applies it reversibly, and
|
||||||
# revert it. Runs ONLY under the task system (cli_artifact_commands.sh enqueues;
|
# can revert it. Runs ONLY under the task system (cli_artifact_commands.sh
|
||||||
# the processor re-invokes with LIBREPORTAL_TASK_EXEC=1, which is when these run).
|
# enqueues; the processor re-invokes with LIBREPORTAL_TASK_EXEC=1).
|
||||||
#
|
#
|
||||||
# Design contracts (all enforced below, fail-closed):
|
# Design contracts (all enforced below, fail-closed):
|
||||||
# * The trust core is the same anchor as releases — the index is minisign-
|
# * Trust core == the release anchor: the index is minisign-verified against the
|
||||||
# verified against the root-owned footprint key by lpFetchIndex (source/
|
# root-owned footprint key by lpFetchIndex; APPLY additionally REFUSES unless
|
||||||
# artifacts.sh); the payload is sha256-pinned by that signed index and itself
|
# that verification actually happened (LP_INDEX_SIGSTATE==verified) and the
|
||||||
# minisign-verified. The publishers map + role gate makes a community key
|
# payload itself is sha256-pinned by the signed index AND minisig-verified.
|
||||||
# unable to masquerade as official (registry-ready; first-party-only today).
|
# The publishers map + role gate stops a community key claiming official.
|
||||||
# * The op vocabulary is a CLOSED allowlist (no run-script/exec/shell, ever).
|
# * The op vocabulary is a CLOSED allowlist (no run-script/exec/shell, ever).
|
||||||
# An unsupported op rejects the WHOLE artifact at validation, before any write.
|
# An unsupported op rejects the WHOLE artifact at validation, before any write.
|
||||||
|
# * No op VALUE/PATH may carry a shell/quote/sed metacharacter (defense in depth
|
||||||
|
# even against a compromised-but-signed payload): _artifactSafeScalar + the
|
||||||
|
# _artifactPathAllowed charset gate enforce the "no code-exec" contract.
|
||||||
# * ALL-OR-NOTHING: every op is dry-prechecked first; one failed precondition
|
# * ALL-OR-NOTHING: every op is dry-prechecked first; one failed precondition
|
||||||
# skips the whole artifact untouched (recorded as a skip, so coverage gaps
|
# skips the whole artifact untouched (recorded, so coverage gaps are visible).
|
||||||
# are visible — a customised box may legitimately miss a fix).
|
# * Two-tier reversibility: every op records a precise inverse (the revert path);
|
||||||
# * Two-tier reversibility: every op records a precise inverse into a per-id
|
# a best-effort snapshot is also taken. Rollback/revert NEVER report success
|
||||||
# applied-record (the revert path); a best-effort recovery snapshot is also
|
# when an inverse op failed -- they record an explicit "*-incomplete" state.
|
||||||
# taken when a backup location exists (the dirty-op fallback).
|
# * Mutations write only through the de-sudo funnels, path-aware (container tree
|
||||||
# * Mutations write only through the de-sudo funnels (runFileOp / runInstallOp /
|
# -> runFileOp/runFileWrite; manager-owned configs/ -> runInstallOp/Write),
|
||||||
# updateConfigOption), never raw sudo. The install tree (our own code) is
|
# never raw sudo. The install tree (our own code) is off-limits to hotfixes.
|
||||||
# off-limits to hotfixes — code fixes ride signed releases (fork 1).
|
|
||||||
|
|
||||||
# --- paths -------------------------------------------------------------------
|
# --- paths -------------------------------------------------------------------
|
||||||
_artifactGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updater/generated"; }
|
_artifactGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updater/generated"; }
|
||||||
@ -34,14 +36,42 @@ _artifactRecordFile() { echo "$(_artifactAppliedDir)/$1.json"; } # $1=id
|
|||||||
# parsing, and apply is a heavy, rare, privileged path where requiring jq is fine.
|
# parsing, and apply is a heavy, rare, privileged path where requiring jq is fine.
|
||||||
_artifactNeedJq() {
|
_artifactNeedJq() {
|
||||||
command -v jq >/dev/null 2>&1 && return 0
|
command -v jq >/dev/null 2>&1 && return 0
|
||||||
isError "artifact: jq is required to apply/revert artifacts (the op interpreter needs it) — refusing."
|
isError "artifact: jq is required to apply/revert artifacts (the op interpreter needs it) -- refusing."
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Reject a scalar that could inject when it flows into a shell/sed/sourced-config
|
||||||
|
# context. Op values come from a signed payload, but the "no code-exec" contract
|
||||||
|
# must hold even for a compromised-but-signed payload (defense in depth).
|
||||||
|
# Bans: double-quote, single-quote, backslash, $, backtick, and any whitespace
|
||||||
|
# control char (newline/CR/tab).
|
||||||
|
_artifactSafeScalar() {
|
||||||
|
case "$1" in
|
||||||
|
*'"'*|*"'"*|*'\'*|*'$'*|*'`'*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[[ "$1" == *$'\n'* || "$1" == *$'\r'* || "$1" == *$'\t'* ]] && return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Path-aware writer/remover: container tree -> runFileWrite/runFileOp (install user
|
||||||
|
# in rootless); manager-owned (configs/) -> runInstallWrite/runInstallOp. A
|
||||||
|
# system-scope hotfix patches configs/, which the container funnel can't write.
|
||||||
|
_artifactWriteFile() { # stdin -> $1
|
||||||
|
local path="$1"
|
||||||
|
if [[ -n "${containers_dir:-}" && "$path" == "${containers_dir%/}/"* ]]; then runFileWrite "$path"
|
||||||
|
else runInstallWrite "$path"; fi
|
||||||
|
}
|
||||||
|
_artifactRmFile() {
|
||||||
|
local path="$1"
|
||||||
|
if [[ -n "${containers_dir:-}" && "$path" == "${containers_dir%/}/"* ]]; then runFileOp rm -f "$path"
|
||||||
|
else runInstallOp rm -f "$path"; fi
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# RESOLVE — fetch+verify the index, pull out one artifact, check the gates.
|
# RESOLVE -- fetch+verify the index, pull out one artifact, check the gates.
|
||||||
# Echoes the artifact JSON on success; non-zero (nothing usable) otherwise.
|
# Echoes the artifact JSON on success; non-zero (nothing usable) otherwise.
|
||||||
# Sets globals: _ART_INDEX (verified index json), _ART_SCOPE, _ART_APP.
|
# Sets globals: _ART_INDEX (verified index json), _ART_SCOPE, _ART_APP.
|
||||||
|
# (LP_INDEX_SIGSTATE is set by lpFetchIndex; artifactApply enforces it.)
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
_artifactResolve() {
|
_artifactResolve() {
|
||||||
local id="$1"
|
local id="$1"
|
||||||
@ -61,15 +91,15 @@ _artifactResolve() {
|
|||||||
trust="$(jq -r '.trust // "official"' <<<"$art")"
|
trust="$(jq -r '.trust // "official"' <<<"$art")"
|
||||||
role="$(printf '%s' "$_ART_INDEX" | jq -r --arg p "$pub" '.publishers[$p].role // empty' 2>/dev/null)"
|
role="$(printf '%s' "$_ART_INDEX" | jq -r --arg p "$pub" '.publishers[$p].role // empty' 2>/dev/null)"
|
||||||
if [[ -z "$pub" || -z "$role" ]]; then
|
if [[ -z "$pub" || -z "$role" ]]; then
|
||||||
isError "artifact: publisher '$pub' is not in the index publishers map — refusing."; return 1
|
isError "artifact: publisher '$pub' is not in the index publishers map -- refusing."; return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$trust" == "official" && "$role" != "official" ]]; then
|
if [[ "$trust" == "official" && "$role" != "official" ]]; then
|
||||||
isError "artifact: '$id' claims trust=official but publisher '$pub' has role '$role' — refusing."; return 1
|
isError "artifact: '$id' claims trust=official but publisher '$pub' has role '$role' -- refusing."; return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$role" != "official" ]]; then
|
if [[ "$role" != "official" ]]; then
|
||||||
# Community/custom would require the artifact's own canonical-bytes signature
|
# Community/custom would require the artifact's own canonical-bytes signature
|
||||||
# against the publisher's key. Not enabled in this first-party-only build.
|
# against the publisher's key. Not enabled in this first-party-only build.
|
||||||
isError "artifact: publisher role '$role' (non-official) is not enabled yet — refusing."; return 1
|
isError "artifact: publisher role '$role' (non-official) is not enabled yet -- refusing."; return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- gates (applies_when) ---
|
# --- gates (applies_when) ---
|
||||||
@ -77,7 +107,7 @@ _artifactResolve() {
|
|||||||
_ART_SCOPE="system"; [[ -n "$_ART_APP" ]] && _ART_SCOPE="app"
|
_ART_SCOPE="system"; [[ -n "$_ART_APP" ]] && _ART_SCOPE="app"
|
||||||
|
|
||||||
if [[ "$_ART_SCOPE" == "app" && ! -d "${containers_dir%/}/$_ART_APP" ]]; then
|
if [[ "$_ART_SCOPE" == "app" && ! -d "${containers_dir%/}/$_ART_APP" ]]; then
|
||||||
isNotice "artifact: '$id' targets '$_ART_APP' which is not installed — not applicable."; return 2
|
isNotice "artifact: '$id' targets '$_ART_APP' which is not installed -- not applicable."; return 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local minlp maxlp maxfp curlp curfp
|
local minlp maxlp maxfp curlp curfp
|
||||||
@ -86,15 +116,15 @@ _artifactResolve() {
|
|||||||
maxfp="$(jq -r '.applies_when.max_footprint // empty' <<<"$art")"
|
maxfp="$(jq -r '.applies_when.max_footprint // empty' <<<"$art")"
|
||||||
curlp="${CFG_LIBREPORTAL_VERSION:-$(cat "$script_dir/version" 2>/dev/null | tr -dc '0-9.')}"
|
curlp="${CFG_LIBREPORTAL_VERSION:-$(cat "$script_dir/version" 2>/dev/null | tr -dc '0-9.')}"
|
||||||
if [[ -n "$minlp" && -n "$curlp" ]] && lpVersionGt "$minlp" "$curlp"; then
|
if [[ -n "$minlp" && -n "$curlp" ]] && lpVersionGt "$minlp" "$curlp"; then
|
||||||
isNotice "artifact: '$id' needs LibrePortal >= $minlp (have ${curlp:-?}) — not applicable."; return 2
|
isNotice "artifact: '$id' needs LibrePortal >= $minlp (have ${curlp:-?}) -- not applicable."; return 2
|
||||||
fi
|
fi
|
||||||
if [[ -n "$maxlp" && -n "$curlp" ]] && lpVersionGt "$curlp" "$maxlp"; then
|
if [[ -n "$maxlp" && -n "$curlp" ]] && lpVersionGt "$curlp" "$maxlp"; then
|
||||||
isNotice "artifact: '$id' applies only up to LibrePortal $maxlp (have $curlp) — not applicable."; return 2
|
isNotice "artifact: '$id' applies only up to LibrePortal $maxlp (have $curlp) -- not applicable."; return 2
|
||||||
fi
|
fi
|
||||||
if [[ -n "$maxfp" ]]; then
|
if [[ -n "$maxfp" ]]; then
|
||||||
curfp="$(lpInstalledFootprintVersion)"
|
curfp="$(lpInstalledFootprintVersion)"
|
||||||
if (( curfp > maxfp )); then
|
if (( curfp > maxfp )); then
|
||||||
isNotice "artifact: '$id' applies only up to footprint $maxfp (have $curfp) — not applicable."; return 2
|
isNotice "artifact: '$id' applies only up to footprint $maxfp (have $curfp) -- not applicable."; return 2
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -102,11 +132,12 @@ _artifactResolve() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# PAYLOAD — download, sha256-pin (from the signed index), minisig-verify.
|
# PAYLOAD -- download, sha256-pin (from the signed index), minisig-verify.
|
||||||
|
# REFUSES an unsigned payload (signing-not-activated) on the apply path.
|
||||||
# Echoes the verified payload JSON; non-zero on any failure.
|
# Echoes the verified payload JSON; non-zero on any failure.
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
_artifactFetchPayload() {
|
_artifactFetchPayload() {
|
||||||
local art="$1" base channel url want_sha sig_url tmp pf sf got kind
|
local art="$1" base channel url want_sha sig_url tmp pf sf got kind pstate
|
||||||
base="$(lpReleaseBaseUrl)"; channel="$(lpReleaseChannel)"
|
base="$(lpReleaseBaseUrl)"; channel="$(lpReleaseChannel)"
|
||||||
kind="$(jq -r '.payload.kind // empty' <<<"$art")"
|
kind="$(jq -r '.payload.kind // empty' <<<"$art")"
|
||||||
if [[ "$kind" != "ops" ]]; then
|
if [[ "$kind" != "ops" ]]; then
|
||||||
@ -115,19 +146,21 @@ _artifactFetchPayload() {
|
|||||||
url="$(jq -r '.payload.url // empty' <<<"$art")"
|
url="$(jq -r '.payload.url // empty' <<<"$art")"
|
||||||
want_sha="$(jq -r '.payload.sha256 // empty' <<<"$art")"
|
want_sha="$(jq -r '.payload.sha256 // empty' <<<"$art")"
|
||||||
sig_url="$(jq -r '.payload.sig // empty' <<<"$art")"
|
sig_url="$(jq -r '.payload.sig // empty' <<<"$art")"
|
||||||
[[ -n "$url" && -n "$want_sha" ]] || { isError "artifact: payload missing url/sha256 — refusing."; return 1; }
|
[[ -n "$url" && -n "$want_sha" ]] || { isError "artifact: payload missing url/sha256 -- refusing."; return 1; }
|
||||||
|
|
||||||
tmp="$(mktemp -d)"; pf="$tmp/payload.json"; sf="$tmp/payload.sig"
|
tmp="$(mktemp -d)"; pf="$tmp/payload.json"; sf="$tmp/payload.sig"
|
||||||
# url/sig may be channel-relative ("stable/payloads/..") or absolute.
|
|
||||||
case "$url" in http*://*) : ;; *) url="$base/$url" ;; esac
|
case "$url" in http*://*) : ;; *) url="$base/$url" ;; esac
|
||||||
if ! _lpDownload "$url" "$pf" 2>/dev/null; then isError "artifact: payload download failed."; rm -rf "$tmp"; return 1; fi
|
if ! _lpDownload "$url" "$pf" 2>/dev/null; then isError "artifact: payload download failed."; rm -rf "$tmp"; return 1; fi
|
||||||
got="$(_lpSha256 "$pf")"
|
got="$(_lpSha256 "$pf")"
|
||||||
if [[ "$got" != "$want_sha" ]]; then isError "artifact: payload CHECKSUM MISMATCH — refusing."; rm -rf "$tmp"; return 1; fi
|
if [[ "$got" != "$want_sha" ]]; then isError "artifact: payload CHECKSUM MISMATCH -- refusing."; rm -rf "$tmp"; return 1; fi
|
||||||
if [[ -n "$sig_url" ]]; then
|
if [[ -n "$sig_url" ]]; then
|
||||||
case "$sig_url" in http*://*) : ;; *) sig_url="$base/$sig_url" ;; esac
|
case "$sig_url" in http*://*) : ;; *) sig_url="$base/$sig_url" ;; esac
|
||||||
_lpDownload "$sig_url" "$sf" 2>/dev/null || true
|
_lpDownload "$sig_url" "$sf" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
if ! lpVerifyMinisig "$pf" "$sf" >/dev/null; then rm -rf "$tmp"; return 1; fi
|
pstate="$(lpVerifyMinisig "$pf" "$sf")" || { rm -rf "$tmp"; return 1; }
|
||||||
|
if [[ "$pstate" != "verified" ]]; then
|
||||||
|
isError "artifact: refusing to APPLY an unsigned payload (signing not activated / footprint key missing)."; rm -rf "$tmp"; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
cat "$pf"; rm -rf "$tmp"
|
cat "$pf"; rm -rf "$tmp"
|
||||||
}
|
}
|
||||||
@ -135,10 +168,13 @@ _artifactFetchPayload() {
|
|||||||
# --- path allowlist (the write-target firewall) -----------------------------
|
# --- path allowlist (the write-target firewall) -----------------------------
|
||||||
# scope:app -> only under $containers_dir/<app>/
|
# scope:app -> only under $containers_dir/<app>/
|
||||||
# scope:system -> only under $configs_dir/ (the install/code tree is OFF-LIMITS
|
# scope:system -> only under $configs_dir/ (the install/code tree is OFF-LIMITS
|
||||||
# to hotfixes — code rides signed releases; fork 1)
|
# to hotfixes -- code rides signed releases; fork 1)
|
||||||
|
# Also enforces a safe-filename charset so a path can never carry a shell/quote
|
||||||
|
# metacharacter into a funnel (belt-and-braces with the runFileWrite argv fix).
|
||||||
_artifactPathAllowed() {
|
_artifactPathAllowed() {
|
||||||
local path="$1" scope="$2" app="$3" real root
|
local path="$1" scope="$2" app="$3" real root
|
||||||
[[ "$path" == *".."* ]] && return 1
|
[[ "$path" == *".."* ]] && return 1
|
||||||
|
[[ "$path" =~ ^[A-Za-z0-9._/@:+-]+$ ]] || return 1
|
||||||
real="$(realpath -m -- "$path" 2>/dev/null)"; [[ -n "$real" ]] || return 1
|
real="$(realpath -m -- "$path" 2>/dev/null)"; [[ -n "$real" ]] || return 1
|
||||||
if [[ "$scope" == "app" ]]; then
|
if [[ "$scope" == "app" ]]; then
|
||||||
root="$(realpath -m -- "${containers_dir%/}/$app" 2>/dev/null)"
|
root="$(realpath -m -- "${containers_dir%/}/$app" 2>/dev/null)"
|
||||||
@ -150,7 +186,7 @@ _artifactPathAllowed() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# current image of an app's compose (first image: line), quotes stripped.
|
# current image of an app's compose (first image: line), quotes/comment stripped.
|
||||||
_artifactComposeImage() {
|
_artifactComposeImage() {
|
||||||
local app="$1" f="${containers_dir%/}/$1/docker-compose.yml"
|
local app="$1" f="${containers_dir%/}/$1/docker-compose.yml"
|
||||||
[[ -f "$f" ]] || return 1
|
[[ -f "$f" ]] || return 1
|
||||||
@ -158,7 +194,7 @@ _artifactComposeImage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# OP PRECHECK — pure read; returns 0 if the op can be applied as-is, else 1 with
|
# OP PRECHECK -- pure read; returns 0 if the op can be applied as-is, else 1 with
|
||||||
# a reason on stderr. This is the dry-run that enforces all-or-nothing.
|
# a reason on stderr. This is the dry-run that enforces all-or-nothing.
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
_artifactOpPrecheck() {
|
_artifactOpPrecheck() {
|
||||||
@ -166,38 +202,49 @@ _artifactOpPrecheck() {
|
|||||||
op="$(jq -r '.op // empty' <<<"$op_json")"
|
op="$(jq -r '.op // empty' <<<"$op_json")"
|
||||||
case "$op" in
|
case "$op" in
|
||||||
set-config-key)
|
set-config-key)
|
||||||
local key expect cur
|
local key value expect cur f
|
||||||
key="$(jq -r '.key // empty' <<<"$op_json")"
|
key="$(jq -r '.key // empty' <<<"$op_json")"
|
||||||
|
value="$(jq -r '.value // empty' <<<"$op_json")"
|
||||||
[[ "$key" =~ ^CFG_[A-Z0-9_]+$ ]] || { isError "op set-config-key: bad key '$key'"; return 1; }
|
[[ "$key" =~ ^CFG_[A-Z0-9_]+$ ]] || { isError "op set-config-key: bad key '$key'"; return 1; }
|
||||||
expect="$(jq -r 'if has("expect_current") then .expect_current else " | |||||||