supply-chain: honest self-attestation posture (dep-scan, build provenance, install guard)
dep-scan.sh: capture govulncheck's real exit code and feed the reachability gate only on exit 0 (clean) or 3 (vulns found). Any other exit (crash, timeout, 137) is now INFRA, so a truncated-but-parseable JSON stream can no longer parse clean and yield a falsely attested posture. npm ci / npm audit / cargo audit stderr is captured and surfaced on failure (no more 2>/dev/null). Dockerfile: the posture-builder stage no longer curl|sh's rustup.rs. The Rust toolchain is COPY --from=rust:1-bookworm (canonical official image, same base as desktop-builder) with RUSTUP_HOME/CARGO_HOME set -- stronger provenance than TLS-TOFU rustup, no pipe-to-shell inside the stage that produces the attestation. docker compose build will validate the new layer. linux.sh.tmpl: guard the posture check under set -e so an un-attested (exit 3) or blocked (exit 4) verdict degrades honestly instead of aborting the install before POSTURE_RC is captured. ETHOS #1 (errors are history), #3 (assume failure). CI-002 + CI-003.
This commit is contained in:
parent
8ee6d001f3
commit
e0765c29f4
3 changed files with 66 additions and 20 deletions
|
|
@ -43,26 +43,52 @@ INFRA=0
|
|||
|
||||
# --- Go: reachability-gated via the documented allowlist ---------------------
|
||||
for mod in server agent; do
|
||||
( cd "$mod" && govulncheck -format=json ./... ) > "$TMP/gv-$mod.json" 2>"$TMP/gv-$mod.err" || true
|
||||
( cd "$mod" && govulncheck -format=json ./... ) > "$TMP/gv-$mod.json" 2>"$TMP/gv-$mod.err"
|
||||
gv_rc=$?
|
||||
# govulncheck exit contract: 0 = completed clean, 3 = completed with vulns.
|
||||
# Anything else (1/2/137/timeout) means the scan did not finish. govulncheck
|
||||
# streams JSON findings as it scans, so a crash mid-scan leaves a stream of
|
||||
# complete-but-truncated objects that the gate's lenient raw_decode loop parses
|
||||
# without raising -- yielding a falsely clean verdict (real fail-open:
|
||||
# attested:true with an undercount). Do NOT feed an incomplete stream to the
|
||||
# gate; treat a non-{0,3} exit as INFRA so the posture degrades honestly to
|
||||
# unattested instead. (CI-003; ETHOS #1 errors-are-history, #3 assume-failure)
|
||||
if [ "$gv_rc" != "0" ] && [ "$gv_rc" != "3" ]; then
|
||||
echo "[ERROR] [dep-scan] [$mod] govulncheck exited $gv_rc (scan did not complete) -- stderr:"
|
||||
cat "$TMP/gv-$mod.err" >&2
|
||||
INFRA=1
|
||||
continue
|
||||
fi
|
||||
python3 scripts/govulncheck-gate.py --allow .govulncheck-allow --label "$mod" \
|
||||
--summary-out "$TMP/go-$mod.json" < "$TMP/gv-$mod.json"
|
||||
rc=$?
|
||||
if [ "$rc" = "1" ]; then BLOCK=1
|
||||
elif [ "$rc" != "0" ]; then
|
||||
echo "[ERROR] [dep-scan] [$mod] govulncheck could not run (offline advisory DB?)"; INFRA=1
|
||||
echo "[ERROR] [dep-scan] [$mod] govulncheck-gate could not parse the stream"; INFRA=1
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Web: production tree gates; dev tree is advisory (never shipped) ---------
|
||||
[ -d web/node_modules ] || ( cd web && npm ci --ignore-scripts )
|
||||
( cd web && npm audit --omit=dev --json ) > "$TMP/npm-prod.json" 2>/dev/null || true
|
||||
if [ ! -d web/node_modules ]; then
|
||||
# npm ci must succeed before npm audit's verdict can be trusted: a half-installed
|
||||
# tree can report 0 vulnerabilities and yield a falsely clean posture. Capture
|
||||
# stderr (ETHOS: errors are history, never 2>/dev/null) so the real cause shows.
|
||||
if ! ( cd web && npm ci --ignore-scripts ) >"$TMP/npm-ci.log" 2>&1; then
|
||||
echo "[ERROR] [dep-scan] [web] npm ci failed — production-tree audit is not trustworthy:"
|
||||
cat "$TMP/npm-ci.log" >&2
|
||||
INFRA=1
|
||||
fi
|
||||
fi
|
||||
( cd web && npm audit --omit=dev --json ) > "$TMP/npm-prod.json" 2>"$TMP/npm-prod.err" || true
|
||||
NPM_BLOCK=$(python3 -c "import json,sys
|
||||
try:
|
||||
m=json.load(open('$TMP/npm-prod.json'))['metadata']['vulnerabilities']
|
||||
print(m.get('high',0)+m.get('critical',0))
|
||||
except Exception: print(-1)")
|
||||
if [ "$NPM_BLOCK" -lt 0 ]; then
|
||||
echo "[ERROR] [dep-scan] [web] npm audit (production) did not run (offline?)"; INFRA=1
|
||||
echo "[ERROR] [dep-scan] [web] npm audit (production) did not run (offline?) — stderr:"
|
||||
cat "$TMP/npm-prod.err" >&2
|
||||
INFRA=1
|
||||
elif [ "$NPM_BLOCK" -gt 0 ]; then
|
||||
echo "[ERROR] [dep-scan] [web] $NPM_BLOCK high/critical vulnerability(ies) in the PRODUCTION tree -- build blocked"
|
||||
( cd web && npm audit --omit=dev --audit-level=high ) || true
|
||||
|
|
@ -70,18 +96,23 @@ elif [ "$NPM_BLOCK" -gt 0 ]; then
|
|||
else
|
||||
echo "[INFO] [dep-scan] [web] production tree clean (0 high/critical)"
|
||||
fi
|
||||
# dev tree advisory — print, never block
|
||||
( cd web && npm audit --audit-level=high ) >/dev/null 2>&1 \
|
||||
|| echo "[WARN] [dep-scan] [web] dev-tree advisories exist (build-time only, not shipped)"
|
||||
# dev tree advisory — print a bounded summary, never block. Surface the advisory
|
||||
# list (first lines) instead of silencing it outright (ETHOS).
|
||||
if ! ( cd web && npm audit --audit-level=high ) >"$TMP/npm-dev.out" 2>"$TMP/npm-dev.err"; then
|
||||
echo "[WARN] [dep-scan] [web] dev-tree advisories exist (build-time only, not shipped):"
|
||||
head -n 8 "$TMP/npm-dev.out" >&2
|
||||
fi
|
||||
|
||||
# --- Rust helper: gate outright ----------------------------------------------
|
||||
( cd helper && cargo audit --json ) > "$TMP/cargo.json" 2>/dev/null || true
|
||||
( cd helper && cargo audit --json ) > "$TMP/cargo.json" 2>"$TMP/cargo.err" || true
|
||||
CARGO_BLOCK=$(python3 -c "import json
|
||||
try:
|
||||
d=json.load(open('$TMP/cargo.json')); print(d['vulnerabilities']['count'])
|
||||
except Exception: print(-1)")
|
||||
if [ "$CARGO_BLOCK" -lt 0 ]; then
|
||||
echo "[ERROR] [dep-scan] [helper] cargo audit did not run (offline?)"; INFRA=1
|
||||
echo "[ERROR] [dep-scan] [helper] cargo audit did not run (offline?) — stderr:"
|
||||
cat "$TMP/cargo.err" >&2
|
||||
INFRA=1
|
||||
elif [ "$CARGO_BLOCK" -gt 0 ]; then
|
||||
echo "[ERROR] [dep-scan] [helper] $CARGO_BLOCK vulnerability(ies) -- build blocked"; BLOCK=1
|
||||
else
|
||||
|
|
|
|||
|
|
@ -23,18 +23,26 @@ FROM golang:1.25-bookworm AS posture-builder
|
|||
|
||||
WORKDIR /src
|
||||
|
||||
# Toolchains for the three ecosystems + python3 for the gate scripts. Debian's
|
||||
# packaged node is enough for `npm audit` (no front-end build happens here).
|
||||
# Rust comes from rustup, NOT apt: bookworm's cargo (~1.65) is too old to build
|
||||
# a current cargo-audit (it needs the 2024 edition), and an old scanner misses
|
||||
# new advisories. This mirrors CI's rust-toolchain@stable so the docker
|
||||
# self-attest path and the pipeline can't drift on the toolchain.
|
||||
# Rust toolchain from the canonical official Rust image, not apt and not a
|
||||
# curl|sh rustup.rs installer: bookworm's packaged cargo (~1.65) is too old to
|
||||
# build a current cargo-audit (needs the 2024 edition), and an old scanner
|
||||
# misses new advisories. Copying the toolchain out of rust:1-bookworm -- the same
|
||||
# base the desktop-builder stage below uses -- gives a pinned, reproducibly
|
||||
# distributed rustc with no pipe-to-shell install inside the stage that produces
|
||||
# the attestation. Stronger provenance than a TLS-TOFU rustup.rs fetch, and
|
||||
# consistent with the other Rust stages. (CI-003)
|
||||
COPY --from=rust:1-bookworm /usr/local/rustup /usr/local/rustup
|
||||
COPY --from=rust:1-bookworm /usr/local/cargo /usr/local/cargo
|
||||
ENV RUSTUP_HOME=/usr/local/rustup
|
||||
ENV CARGO_HOME=/usr/local/cargo
|
||||
ENV PATH="/go/bin:/usr/local/cargo/bin:${PATH}"
|
||||
|
||||
# Node + python3 for the gate scripts (Debian's node is enough for npm audit; no
|
||||
# front-end build happens in this stage). curl is gone -- only the removed
|
||||
# rustup installer used it.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
nodejs npm python3 pkg-config libssl-dev curl \
|
||||
nodejs npm python3 pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal --default-toolchain stable
|
||||
ENV PATH="/go/bin:/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Scanners installed in their own layer so they cache until this line changes.
|
||||
RUN go install golang.org/x/vuln/cmd/govulncheck@latest \
|
||||
|
|
|
|||
|
|
@ -517,8 +517,15 @@ if blocked:
|
|||
if not p.get("attested"):
|
||||
sys.exit(3)
|
||||
POSTURE_EOF
|
||||
# set -e is active (line 8); capture the posture rc the same way the manifest
|
||||
# block above does (479-482). Without this guard, the python's exit 3 (un-attested)
|
||||
# or 4 (blocked) aborts the script before POSTURE_RC is ever assigned — turning the
|
||||
# designed "warn on un-attested" into a hard install refusal and negating the
|
||||
# offline-degrades-honestly posture the build emits.
|
||||
set +e
|
||||
python3 "$POSTURE_CHECK" "$TMP_MANIFEST"
|
||||
POSTURE_RC=$?
|
||||
set -e
|
||||
rm -f "$POSTURE_CHECK"
|
||||
if [ "$POSTURE_RC" = "4" ]; then
|
||||
rm -f "$TMP_MANIFEST"
|
||||
|
|
|
|||
Loading…
Reference in a new issue