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>
136 lines
5.3 KiB
Bash
136 lines
5.3 KiB
Bash
#!/bin/bash
|
|
|
|
# Artifact command handler — `libreportal artifact <sub>`
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatched automatically by cli_initialize.sh (category -> cliHandleArtifactCommands).
|
|
#
|
|
# The unified distribution primitive. The READ side (`index`/`applied`) fetches +
|
|
# verifies the team-signed artifact index (hotfixes today; apps/themes/components
|
|
# later — all one envelope) and lists what's available/applied; it changes nothing,
|
|
# so — like `updater check` — it runs directly. The state-changing `apply`/`revert`
|
|
# verbs route through the TASK system (snapshot → bounded declarative ops →
|
|
# auto-rollback → History), never a mutating API. See docs/roadmap/updates-and-
|
|
# distribution.md and cli_artifact_apply.sh.
|
|
|
|
cliHandleArtifactCommands()
|
|
{
|
|
local sub="$initial_command2"
|
|
local id="$initial_command3"
|
|
|
|
# Lazy-loader gap: ensure the read primitives + apply pipeline are defined.
|
|
# These are new files; the array/manifest regen self-heals them on deploy,
|
|
# but this covers the window before that. Source CHECKED — a missing/corrupt
|
|
# file must surface a clear error, not degrade to a bare "command not found".
|
|
if ! declare -F lpFetchIndex >/dev/null 2>&1; then
|
|
local _f
|
|
for _f in source/fetch.sh source/artifacts.sh; do
|
|
if [[ ! -f "$install_scripts_dir/$_f" ]] || ! source "$install_scripts_dir/$_f"; then
|
|
isError "artifact: failed to load the read pipeline ($_f) — try: libreportal regen"; return 1
|
|
fi
|
|
done
|
|
fi
|
|
if ! declare -F artifactApply >/dev/null 2>&1; then
|
|
local _af="cli/commands/artifact/cli_artifact_apply.sh"
|
|
if [[ ! -f "$install_scripts_dir/$_af" ]] || ! source "$install_scripts_dir/$_af"; then
|
|
isError "artifact: failed to load the apply pipeline ($_af) — try: libreportal regen"; return 1
|
|
fi
|
|
fi
|
|
|
|
case "$sub" in
|
|
""|"index"|"list")
|
|
artifactListIndex
|
|
;;
|
|
"applied")
|
|
artifactListApplied
|
|
;;
|
|
"apply")
|
|
if [[ -z "$id" ]]; then isError "Usage: libreportal artifact apply <id>"; return 1; fi
|
|
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
|
|
artifactApply "$id"
|
|
else
|
|
cliTaskRun "libreportal artifact apply $id" "artifact_apply" "$id" ""
|
|
fi
|
|
;;
|
|
"revert"|"rollback")
|
|
if [[ -z "$id" ]]; then isError "Usage: libreportal artifact revert <id>"; return 1; fi
|
|
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
|
|
artifactRevert "$id"
|
|
else
|
|
cliTaskRun "libreportal artifact revert $id" "artifact_revert" "$id" ""
|
|
fi
|
|
;;
|
|
*)
|
|
cliShowArtifactHelp
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# List the applied hotfixes (read-only) from the per-id applied records.
|
|
artifactListApplied()
|
|
{
|
|
isHeader "Applied hotfixes"
|
|
local dir; dir="${containers_dir%/}/libreportal/frontend/data/updater/generated/applied"
|
|
if ! compgen -G "$dir/*.json" >/dev/null 2>&1; then
|
|
isSuccessful "0 hotfixes applied."
|
|
return 0
|
|
fi
|
|
local n=0 f id title app
|
|
for f in "$dir"/*.json; do
|
|
n=$((n+1))
|
|
if command -v jq >/dev/null 2>&1; then
|
|
id="$(jq -r '.id' "$f" 2>/dev/null)"; title="$(jq -r '.title // ""' "$f" 2>/dev/null)"; app="$(jq -r '.app // ""' "$f" 2>/dev/null)"
|
|
echo " • $id${app:+ [$app]} — $title"
|
|
else
|
|
echo " • $(basename "$f" .json)"
|
|
fi
|
|
done
|
|
isSuccessful "$n hotfix(es) applied. Revert with: libreportal artifact revert <id>"
|
|
}
|
|
|
|
# Fetch + verify the signed index and print a human summary. Read-only.
|
|
artifactListIndex()
|
|
{
|
|
isHeader "Artifact index ($(lpReleaseChannel))"
|
|
|
|
local json
|
|
if ! json="$(lpFetchIndex)"; then
|
|
isError "Could not fetch or verify the artifact index from $(lpArtifactIndexUrl)."
|
|
isNotice "Nothing is published yet, or the channel is unreachable. (This is expected before the first index ships.)"
|
|
return 1
|
|
fi
|
|
|
|
local serial generated_at
|
|
serial="$(_lpJsonNum "$json" index_serial)"
|
|
generated_at="$(lpIndexTop generated_at "$json")"
|
|
# Report the ACTUAL signature state — never claim "verified" when the feed was
|
|
# only accepted because signing isn't activated (dev/placeholder key).
|
|
if [[ "$LP_INDEX_SIGSTATE" == "verified" ]]; then
|
|
isNotice "Signed + verified. serial=${serial:-?} generated=${generated_at:-?}"
|
|
else
|
|
isNotice "UNSIGNED (signing not activated — dev/placeholder key). serial=${serial:-?} generated=${generated_at:-?}"
|
|
fi
|
|
|
|
local ids; ids="$(lpIndexArtifactIds "$json")"
|
|
if [[ -z "$ids" ]]; then
|
|
isSuccessful "0 artifacts available — the index is empty (nothing to apply)."
|
|
return 0
|
|
fi
|
|
|
|
local n=0 id obj title type sev
|
|
while IFS= read -r id; do
|
|
[[ -z "$id" ]] && continue
|
|
n=$((n + 1))
|
|
obj="$(lpArtifactById "$json" "$id")"
|
|
if [[ -n "$obj" ]]; then
|
|
title="$(_lpJsonStr "$obj" title)"
|
|
type="$(_lpJsonStr "$obj" type)"
|
|
sev="$(_lpJsonStr "$obj" severity)"
|
|
echo " • [${type:-?}/${sev:-info}] $id — ${title:-}"
|
|
else
|
|
echo " • $id"
|
|
fi
|
|
done <<< "$ids"
|
|
|
|
isSuccessful "$n artifact(s) available."
|
|
}
|