Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-14 12:56:42 -04:00
commit e0765c29f4
3 changed files with 66 additions and 20 deletions

View file

@ -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

View file

@ -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 \

View file

@ -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"