Watch
1
0
Fork
You've already forked RedFlag
0

supply-chain: gate our own deps, ship the verdict signed

dep-scan.sh gates go/npm/cargo on push and bakes an attested posture into the
release — embedded in the server, signed into the manifest. Reasoning and the
two Moby exceptions are in SECURITY.md.

(posture-builder runs rustup; bookworm's cargo is too old for cargo-audit.)
This commit is contained in:
Fimeg 2026-06-14 11:29:08 -04:00
commit e2dab2845a
18 changed files with 5563 additions and 27 deletions

View file

@ -139,6 +139,56 @@ jobs:
- name: Install template integrity
run: cd server && go test -run 'TestInstallTemplateRenders|TestFreshInstallConfigKeys|TestInstallTemplateScriptletSyntax' -v -count=1 ./internal/services/
# Dependency vulnerability scanning — RedFlag held to the supply-chain standard
# it enforces on the fleet. Tools installed directly (no third-party actions) so
# the socket-mounted runner's surface stays small. Go is reachability-gated via a
# documented allowlist (.govulncheck-allow, mirrored in SECURITY.md); npm gates the
# production tree and treats dev-only advisories as warnings; cargo gates outright.
dep-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
# Provenance first: record the substrate even if a later scan fails the job.
# "Are we using hacked programs to build it?" — this is how we SEE the answer.
# Floor enforcement (fail on an out-of-date engine/toolchain) is the next layer.
- name: Record build substrate
run: |
{
echo "## Build substrate"
echo '```'
echo "go: $(go version)"
echo "rustc: $(rustc --version)"
echo "cargo: $(cargo --version)"
echo "node: $(node --version)"
echo "npm: $(npm --version)"
echo "docker: $(docker version --format '{{.Server.Version}}' 2>&1 || echo 'no engine reachable')"
echo "runner: ${RUNNER_NAME:-unknown} / $(uname -srm)"
echo '```'
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
# Scanners run latest on purpose — an old scanner misses new advisories.
- name: Install scanners
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
cargo install cargo-audit --locked
# One audited script gates Go (reachability + allowlist), web (prod tree),
# and the Rust helper. release.yml runs the same script with --posture-out
# to emit the attested posture — CI and release can't drift on the verdict.
- name: Dependency gate
run: scripts/dep-scan.sh
no-ai-attribution:
runs-on: ubuntu-latest
steps:
@ -192,7 +242,7 @@ jobs:
# Requires CODEBERG_TOKEN secret in the repo settings.
sync-codeberg:
runs-on: ubuntu-latest
needs: [go-vet, go-test, rust-test, cross-compile, web-build, installer-integrity, no-ai-attribution, action-pins]
needs: [go-vet, go-test, rust-test, cross-compile, web-build, installer-integrity, dep-scan, no-ai-attribution, action-pins]
if: github.ref == 'refs/heads/public'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

View file

@ -180,11 +180,42 @@ jobs:
path: server/internal/webui/dist
retention-days: 1
# Build binaries for every target platform. The webui-dist artifact is
# downloaded into the embed path before the server compile.
# Supply-chain gate with teeth: a release cannot ship with an un-accepted
# reachable dependency vulnerability. Runs the SAME scripts/dep-scan.sh as CI,
# plus --posture-out to emit the attested posture embedded into the server
# binary and signed into the release manifest. If this fails, `release` never
# builds (it is in `needs`).
dep-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Install scanners
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
cargo install cargo-audit --locked
- name: Dependency gate + posture
run: scripts/dep-scan.sh --posture-out server/internal/services/posture-build.json
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: supply-chain-posture
path: server/internal/services/posture-build.json
retention-days: 1
# Build binaries for every target platform. The webui-dist and supply-chain
# posture artifacts are downloaded into the embed paths before the server compile.
release:
runs-on: ubuntu-latest
needs: [gate, web]
needs: [gate, web, dep-scan]
strategy:
fail-fast: false
matrix:
@ -245,6 +276,14 @@ jobs:
name: webui-dist
path: server/internal/webui/dist
# Embed the attested supply-chain posture (replaces the committed
# attested:false stub) so the running server signs an honest posture.
- name: Download supply-chain posture
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: supply-chain-posture
path: server/internal/services
- name: Build server
env:
GOOS: ${{ matrix.goos }}

4
.gitignore vendored
View file

@ -369,6 +369,10 @@ secrets/
# Lock files (keep some, ignore others)
*.lock
# Rust application crates: commit the lockfile — reproducible builds and the
# cargo-audit dependency gate (CI + docker self-attestation) both need it.
!helper/Cargo.lock
!desktop/Cargo.lock
# Keep package-lock.json and yarn.lock for dependency management
# yarn.lock
# package-lock.json

15
.govulncheck-allow Normal file
View file

@ -0,0 +1,15 @@
# RedFlag dependency vulnerability exceptions (govulncheck)
#
# Each line is a KNOWN, ACCEPTED *reachable* finding with a documented reason.
# This file is the machine-readable register; the same exceptions are PUBLISHED
# in SECURITY.md ("Accepted dependency exceptions"). This is honest disclosure,
# not a silent bypass: any vuln govulncheck reports as reachable and NOT listed
# here fails CI. Keep this list as short as the world allows.
#
# Format: <GO-id> <reason>
#
# Review trigger: when a listed module gets a fixed version, bump the dep and
# DELETE the line. The gate warns on stale entries that no longer fire.
GO-2026-4887 Moby AuthZ plugin bypass via oversized request bodies. Daemon-side. RedFlag links github.com/docker/docker only as a CLIENT (Ping / SecretList / container scan) and never runs the engine's AuthZ path. No fixed version published (Fixed: N/A).
GO-2026-4883 Moby off-by-one in plugin privilege validation. Daemon-side. Same client-only rationale as GO-2026-4887. No fixed version published (Fixed: N/A).

View file

@ -54,6 +54,29 @@ Before any package install: the agent fetches the expected SHA-256 from the serv
---
## Our Own Dependencies
RedFlag holds itself to the standard it enforces on the fleet. Every push runs
dependency vulnerability scanning in CI: `govulncheck` (reachability-based) on the Go
server and agent, `npm audit` on the web tree, `cargo audit` on the Rust helper. A
reachable vulnerability that isn't explicitly accepted fails the build — the same
fail-closed posture the binary takes at runtime.
Some findings have no fix to take. RedFlag links the Docker engine library as a
*client* (for container scanning) and inherits daemon-side Moby advisories that carry
no patched version. We don't bury those: each one is a documented entry in a
machine-readable exception register, every entry naming the advisory and the reason
it's accepted. That register is the source of truth — it's surfaced **in the app**, so
RedFlag's own residual exposure is visible the same way fleet exposure is, and can't
quietly rot in a doc nobody re-reads. When an upstream fix ships, the dependency is
bumped and the entry is removed; CI warns on an exception that no longer applies.
The build substrate itself is recorded on every run (toolchain and engine versions) so
"what built this" is never a mystery. Enforcing a minimum-patched floor on that
substrate is the next layer.
---
## Visibility
**Security Health** is surfaced as a dashboard panel on each agent — signing status, nonce protection, machine binding violations, command validation — so the posture is visible without digging through logs. All operations are logged with full context, sanitized against log injection (ANSI stripping, control character replacement, field truncation) with the content preserved.

View file

@ -2,7 +2,10 @@ module github.com/Fimeg/RedFlag/agent
go 1.25.0
toolchain go1.25.11
require (
github.com/Microsoft/go-winio v0.4.21
github.com/cilium/ebpf v0.21.0
github.com/denisbrodbeck/machineid v1.0.1
github.com/docker/docker v27.4.1+incompatible
@ -13,7 +16,6 @@ require (
)
require (
github.com/Microsoft/go-winio v0.4.21 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect

4568
desktop/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

360
helper/Cargo.lock generated Normal file
View file

@ -0,0 +1,360 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "redflag-helper"
version = "0.2.8"
dependencies = [
"ed25519-dalek",
"hex",
"serde",
"serde_json",
"sha2",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

169
scripts/dep-scan.sh Executable file
View file

@ -0,0 +1,169 @@
#!/usr/bin/env bash
#
# RedFlag dependency vulnerability gate + supply-chain posture generator.
#
# One audited script, two callers: ci.yml runs it as a gate on every push;
# release.yml runs it with --posture-out to ALSO emit the attested posture that
# gets embedded into the server binary and signed into the release manifest.
#
# scripts/dep-scan.sh # gate only (CI / local)
# scripts/dep-scan.sh --posture-out PATH # gate + write attested posture JSON
#
# Gating:
# Go govulncheck, reachability-gated via .govulncheck-allow (server + agent)
# Web npm audit --omit=dev at high (production tree; dev tree advisory only)
# Rust cargo audit (helper)
# Any un-accepted finding -> exit 1.
#
# Assumes govulncheck, cargo-audit, npm, go on PATH. The workflow installs them;
# locally, `go install golang.org/x/vuln/cmd/govulncheck@latest` and
# `cargo install cargo-audit` once.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export PATH="$PATH:$(go env GOPATH 2>/dev/null)/bin:$HOME/.cargo/bin"
POSTURE_OUT=""
while [ $# -gt 0 ]; do
case "$1" in
--posture-out) POSTURE_OUT="$2"; shift 2 ;;
*) echo "[ERROR] [dep-scan] unknown arg: $1" >&2; exit 2 ;;
esac
done
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# BLOCK = a real un-accepted vulnerability was found (the gate did its job).
# INFRA = a scanner could not run/parse (offline advisory DB, missing tool).
# These exit differently (1 vs 2) so the Dockerfile can fail the build on a real
# block but degrade to an honest unattested posture when the operator is offline.
BLOCK=0
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
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
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
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
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
BLOCK=1
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)"
# --- Rust helper: gate outright ----------------------------------------------
( cd helper && cargo audit --json ) > "$TMP/cargo.json" 2>/dev/null || 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
elif [ "$CARGO_BLOCK" -gt 0 ]; then
echo "[ERROR] [dep-scan] [helper] $CARGO_BLOCK vulnerability(ies) -- build blocked"; BLOCK=1
else
echo "[INFO] [dep-scan] [helper] clean (0 advisories)"
fi
# --- Posture assembly (release path) -----------------------------------------
if [ -n "$POSTURE_OUT" ]; then
GO_VER="$(go version 2>/dev/null | awk '{print $3}')"
RUSTC_VER="$(rustc --version 2>/dev/null | awk '{print $2}')"
CARGO_VER="$(cargo --version 2>/dev/null | awk '{print $2}')"
NODE_VER="$(node --version 2>/dev/null)"
NPM_VER="$(npm --version 2>/dev/null)"
DOCKER_VER="$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo unknown)"
# A posture is only attested when every scanner actually ran. If any scan
# could not complete, we emit an honest unattested posture rather than claim
# a clean bill we did not earn.
ATTESTED=$([ "$INFRA" -eq 0 ] && echo true || echo false)
export GO_VER RUSTC_VER CARGO_VER NODE_VER NPM_VER DOCKER_VER NPM_BLOCK CARGO_BLOCK ATTESTED
python3 - "$TMP" "$POSTURE_OUT" <<'PY'
import json, os, sys, time
tmp, out = sys.argv[1], sys.argv[2]
def load(name):
try:
return json.load(open(os.path.join(tmp, name)))
except Exception:
return {"accepted": 0, "blocked": 0, "exceptions": []}
gs, ga = load("go-server.json"), load("go-agent.json")
# Exceptions are the same advisory set across modules — dedupe by id.
exc = {}
for s in (gs, ga):
for e in s.get("exceptions", []):
exc[e["id"]] = e["reason"]
go_blocked = gs.get("blocked", 0) + ga.get("blocked", 0)
npm_block = int(os.environ.get("NPM_BLOCK", "0") or 0)
cargo_block = int(os.environ.get("CARGO_BLOCK", "0") or 0)
def status(accepted, blocked):
return "blocked" if blocked > 0 else ("accepted" if accepted > 0 else "clean")
posture = {
"attested": os.environ.get("ATTESTED", "false") == "true",
"generated_at": int(time.time()),
"substrate": {
"go": os.environ.get("GO_VER", ""),
"rustc": os.environ.get("RUSTC_VER", ""),
"cargo": os.environ.get("CARGO_VER", ""),
"node": os.environ.get("NODE_VER", ""),
"npm": os.environ.get("NPM_VER", ""),
"docker": os.environ.get("DOCKER_VER", ""),
},
"scans": [
{"ecosystem": "go", "tool": "govulncheck", "status": status(len(exc), go_blocked),
"accepted": len(exc), "blocked": go_blocked},
{"ecosystem": "npm", "tool": "npm-audit", "status": status(0, max(npm_block, 0)),
"accepted": 0, "blocked": max(npm_block, 0)},
{"ecosystem": "cargo", "tool": "cargo-audit", "status": status(0, max(cargo_block, 0)),
"accepted": 0, "blocked": max(cargo_block, 0)},
],
"exceptions": [{"id": k, "reason": v} for k, v in sorted(exc.items())],
}
with open(out, "w") as fh:
json.dump(posture, fh, indent=2)
fh.write("\n")
print(f"[INFO] [dep-scan] [posture] wrote {out} "
f"(attested={posture['attested']}, "
f"{len(posture['exceptions'])} accepted exception(s))")
PY
fi
# Exit semantics: a real block is a hard failure everywhere (exit 1). An
# infrastructure-only failure exits 2 so the Dockerfile can degrade to an
# unattested posture offline, while CI (which treats any non-zero as failure)
# still fails loudly.
if [ "$BLOCK" -ne 0 ]; then
echo "[ERROR] [dep-scan] gate failed — un-accepted dependency vulnerability(ies) present"
exit 1
fi
if [ "$INFRA" -ne 0 ]; then
echo "[ERROR] [dep-scan] could not complete — a scanner did not run (offline advisory DB?)"
exit 2
fi
echo "[INFO] [dep-scan] gate passed"

136
scripts/govulncheck-gate.py Executable file
View file

@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
govulncheck reachability gate.
govulncheck has no native ignore mechanism. RedFlag links github.com/docker/docker
as a client and inherits two daemon-side Moby CVEs that have no fixed version, so a
bare `govulncheck ./...` can never go green. This gate reads govulncheck JSON, keeps
only the *reachable* (called) vulns, subtracts a documented allowlist, and fails the
build on anything left over.
govulncheck -format=json ./... | govulncheck-gate.py --allow ../.govulncheck-allow
Exit codes:
0 no un-allowlisted reachable vulns
1 one or more reachable vulns not in the allowlist -> CI fails
2 usage / parse error
A vuln is "reachable" when govulncheck emits a finding whose trace has a frame with
a populated `function` (symbol-level), per govulncheck's own semantics. Findings that
stop at module/package level (imported but not called) are reported as advisory only.
"""
import argparse
import json
import re
import sys
GO_ID = re.compile(r"^GO-\d{4}-\d+$")
def load_allow(path):
"""Return {id: reason} from the allowlist file (blank/`#` lines ignored)."""
allow = {}
if not path:
return allow
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
ident = parts[0]
if not GO_ID.match(ident):
print(f"[WARN] [dep-scan] [govulncheck-gate] allowlist line ignored "
f"(not a GO-id): {line}", file=sys.stderr)
continue
allow[ident] = parts[1].strip() if len(parts) > 1 else "(no reason given)"
return allow
def parse_findings(text):
"""
govulncheck -format=json emits a stream of concatenated JSON objects.
Return {osv_id: reachable_bool} merged across all findings for that id.
"""
reachable = {}
osv_titles = {}
decoder = json.JSONDecoder()
idx, n = 0, len(text)
while idx < n:
while idx < n and text[idx] in " \t\r\n":
idx += 1
if idx >= n:
break
obj, end = decoder.raw_decode(text, idx)
idx = end
if "osv" in obj:
osv = obj["osv"]
osv_titles[osv.get("id", "")] = (osv.get("summary") or "").strip()
if "finding" in obj:
f = obj["finding"]
osv_id = f.get("osv")
if not osv_id:
continue
frames = f.get("trace") or []
called = any(fr.get("function") for fr in frames)
reachable[osv_id] = reachable.get(osv_id, False) or called
return reachable, osv_titles
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--allow", default=None, help="path to .govulncheck-allow")
ap.add_argument("--label", default="", help="module label for log lines")
ap.add_argument("--summary-out", default=None,
help="write a JSON verdict {accepted,blocked,exceptions} here "
"for posture assembly")
args = ap.parse_args()
raw = sys.stdin.read()
if not raw.strip():
print("[ERROR] [dep-scan] [govulncheck-gate] no JSON on stdin", file=sys.stderr)
return 2
try:
reachable, titles = parse_findings(raw)
except (json.JSONDecodeError, ValueError) as exc:
print(f"[ERROR] [dep-scan] [govulncheck-gate] bad govulncheck JSON: {exc}",
file=sys.stderr)
return 2
allow = load_allow(args.allow)
label = f"[{args.label}] " if args.label else ""
called = sorted(i for i, r in reachable.items() if r)
blocked = [i for i in called if i not in allow]
accepted = [i for i in called if i in allow]
stale = [i for i in allow if i not in called]
for i in accepted:
print(f"[INFO] [dep-scan] [govulncheck-gate] {label}accepted {i}: "
f"{titles.get(i, '')} -- {allow[i]}")
for i in stale:
print(f"[WARN] [dep-scan] [govulncheck-gate] {label}stale exception {i} "
f"no longer reachable -- remove it from .govulncheck-allow")
for i in blocked:
print(f"[ERROR] [dep-scan] [govulncheck-gate] {label}reachable & NOT "
f"allowlisted: {i}: {titles.get(i, '')}")
if args.summary_out:
with open(args.summary_out, "w", encoding="utf-8") as fh:
json.dump({
"accepted": len(accepted),
"blocked": len(blocked),
"exceptions": [{"id": i, "reason": allow[i]} for i in accepted],
}, fh)
if blocked:
print(f"[ERROR] [dep-scan] [govulncheck-gate] {label}{len(blocked)} "
f"un-accepted reachable vulnerability(ies) -- build blocked")
return 1
print(f"[INFO] [dep-scan] [govulncheck-gate] {label}clean "
f"({len(accepted)} accepted, 0 blocked)")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -13,6 +13,47 @@ RUN npx vite build
# Node 20; bookworm's packaged Node 18 is below Vite's floor.
RUN npx tsc && npx vite build --config vite.desktop.config.ts
# Stage 0b: Supply-chain self-attestation. Runs the SAME scripts/dep-scan.sh the
# CI/release pipeline runs, so an operator's own docker-compose build gates its
# dependency supply chain and produces the attested posture embedded into the
# server below. A real un-accepted vulnerability fails the build here; an offline
# build (advisory DBs unreachable) degrades to an honest unattested posture
# rather than blocking an air-gapped operator.
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.
RUN apt-get update && apt-get install -y --no-install-recommends \
nodejs npm python3 pkg-config libssl-dev curl \
&& 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 \
&& cargo install cargo-audit --locked
COPY server/ ./server/
COPY agent/ ./agent/
COPY web/ ./web/
COPY helper/ ./helper/
COPY scripts/ ./scripts/
COPY .govulncheck-allow ./
# exit 1 = real block (fail the build); exit 2 = infra/offline (posture written
# unattested, continue). Guarantee a posture file exists regardless.
RUN rc=0; bash scripts/dep-scan.sh --posture-out /posture-build.json || rc=$?; \
if [ "$rc" = "1" ]; then echo "[ERROR] dep-scan blocked the build"; exit 1; fi; \
test -f /posture-build.json || \
printf '%s' '{"attested":false,"generated_at":0,"substrate":{},"scans":[],"exceptions":[]}' > /posture-build.json
# Stage 1: Build server binary
FROM golang:1.25-alpine AS server-builder
@ -30,6 +71,10 @@ COPY server/ ./
# Embed the dashboard build — server/internal/webui picks this up via go:embed
COPY --from=web-builder /web/dist ./internal/webui/dist
# Embed the attested supply-chain posture over the committed attested:false stub
# (services/posture.go picks this up via go:embed).
COPY --from=posture-builder /posture-build.json ./internal/services/posture-build.json
# Build server with version injection
RUN echo "Building server version: $BUILD_VERSION" && \
CGO_ENABLED=0 go build \

View file

@ -2,15 +2,16 @@ module github.com/Fimeg/RedFlag/server
go 1.25.0
toolchain go1.25.11
require (
github.com/alexedwards/argon2id v1.0.0
github.com/docker/docker v25.0.6+incompatible
github.com/docker/docker v25.0.13+incompatible
github.com/doug-martin/goqu/v9 v9.19.0
github.com/gin-gonic/gin v1.11.0
github.com/go-git/go-git/v5 v5.19.1
github.com/gofrs/uuid/v5 v5.4.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.10.9
gopkg.in/natefinch/lumberjack.v2 v2.2.1
@ -60,8 +61,8 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@ -73,16 +74,12 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gotest.tools/v3 v3.5.2 // indirect

View file

@ -39,8 +39,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v25.0.6+incompatible h1:5cPwbwriIcsua2REJe8HqQV+6WlWc1byg2QSXzBxBGg=
github.com/docker/docker v25.0.6+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v25.0.13+incompatible h1:YeBrkUd3q0ZoRDNoEzuopwCLU+uD8GZahDHwBdsTnkU=
github.com/docker/docker v25.0.13+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@ -156,10 +156,10 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
@ -211,8 +211,8 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -231,8 +231,6 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -249,8 +247,6 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -292,8 +288,6 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -469,6 +469,7 @@ func (h *DownloadHandler) buildReleaseManifest(version string) services.ReleaseM
GeneratedAt: time.Now().UTC().Unix(),
KeyID: h.signingService.GetCurrentKeyID(),
Components: services.ComponentCatalog(),
SupplyChain: services.EmbeddedPosture(),
}
platforms := []struct{ platform, arch string }{

View file

@ -0,0 +1,7 @@
{
"attested": false,
"generated_at": 0,
"substrate": {},
"scans": [],
"exceptions": []
}

View file

@ -0,0 +1,78 @@
package services
import (
_ "embed"
"encoding/json"
"log"
)
// Supply-chain posture — RedFlag's attestation of its OWN dependency hygiene,
// folded into the signed release manifest so it rides the same Ed25519 trust
// root as the binaries. The installer verifies the manifest signature before
// trusting any of this, so the posture cannot be forged by editing a file next
// to the binary: tamper breaks the signature and the install refuses.
//
// The content is generated at build time by scripts/dep-scan.sh (the same gate
// that fails the release on an un-accepted reachable vulnerability) and embedded
// here. A bare `go build` outside that pipeline embeds the committed stub with
// Attested=false — an honest "this build was not gated", which the installer
// treats as a refusal-worthy posture.
//go:embed posture-build.json
var postureBuildJSON []byte
// DependencyException is a known, accepted reachable vulnerability with the
// documented reason it is tolerated. Mirrors a line in .govulncheck-allow.
type DependencyException struct {
ID string `json:"id"`
Reason string `json:"reason"`
}
// EcosystemScan is one scanner's verdict over one ecosystem.
type EcosystemScan struct {
Ecosystem string `json:"ecosystem"` // go, npm, cargo
Tool string `json:"tool"` // govulncheck, npm-audit, cargo-audit
Status string `json:"status"` // clean, accepted, blocked
Accepted int `json:"accepted"`
Blocked int `json:"blocked"`
}
// SupplyChainPosture is RedFlag's own dependency attestation for this build.
type SupplyChainPosture struct {
// Attested is true only when this posture was produced by the gated build
// pipeline. A stub/dev build is Attested=false.
Attested bool `json:"attested"`
GeneratedAt int64 `json:"generated_at"`
Substrate map[string]string `json:"substrate"` // go, rustc, node, npm, docker versions
Scans []EcosystemScan `json:"scans"`
Exceptions []DependencyException `json:"exceptions"`
}
// Blocked reports whether any ecosystem carries an un-accepted reachable vuln.
// A clean build never embeds a blocked scan (the gate fails the release first),
// so this is defense-in-depth for a tampered or hand-built artifact.
func (p SupplyChainPosture) Blocked() bool {
for _, s := range p.Scans {
if s.Blocked > 0 {
return true
}
}
return false
}
// loadEmbeddedPosture parses the build-time posture. A parse failure is logged
// and degraded to an explicit un-attested posture rather than a crash — the
// manifest still signs, and the installer refuses on Attested=false.
func loadEmbeddedPosture() SupplyChainPosture {
var p SupplyChainPosture
if err := json.Unmarshal(postureBuildJSON, &p); err != nil {
log.Printf("[ERROR] [server] [posture] embedded posture-build.json unparseable: %v", err)
return SupplyChainPosture{Attested: false}
}
return p
}
// EmbeddedPosture returns the build-time supply-chain posture for this server.
func EmbeddedPosture() SupplyChainPosture {
return loadEmbeddedPosture()
}

View file

@ -40,12 +40,18 @@ type ManifestComponent struct {
}
// ReleaseManifest is the signed component+artifact catalog for one version.
//
// SupplyChain is the running server's own dependency attestation (built-time
// scan verdict + accepted exceptions + build substrate). It rides the manifest
// signature so the installer can show "safe checks" without a second trust root,
// and refuse on an un-attested or blocked posture.
type ReleaseManifest struct {
Version string `json:"version"`
GeneratedAt int64 `json:"generated_at"`
KeyID string `json:"key_id"`
Components []ManifestComponent `json:"components"`
Artifacts []ManifestArtifact `json:"artifacts"`
SupplyChain SupplyChainPosture `json:"supply_chain"`
}
// ComponentCatalog returns the fixed component set for a release.

View file

@ -488,6 +488,48 @@ if [ $MANIFEST_RC -ne 0 ]; then
fi
echo "✓ Manifest signature verified (key ${MANIFEST_KEY_ID})"
# --- Supply-chain posture: RedFlag's own dependency attestation ---------------
# The server folds its build-time scan verdict + accepted exceptions into the
# signed manifest. We just verified that signature, so this posture cannot be
# forged by editing a file next to the binary. Surface it, and refuse on a
# BLOCKED posture (a build carrying an un-accepted vulnerability) — the same
# fail-closed stance the binary takes at runtime. An UN-ATTESTED posture (a
# server built from source outside RedFlag's release pipeline) is a loud warning,
# not a refusal: the operator built it and owns that trust.
POSTURE_CHECK=$(mktemp)
cat <<'POSTURE_EOF' > "$POSTURE_CHECK"
import sys, json
p = (json.load(open(sys.argv[1])).get("supply_chain") or {})
scans = p.get("scans", [])
sub = p.get("substrate", {})
blocked = [s for s in scans if s.get("blocked", 0) > 0]
print("Supply-chain posture:")
if sub:
print(" built on: " + ", ".join("%s %s" % (k, v) for k, v in sub.items() if v))
for s in scans:
print(" %-6s %-12s %s (accepted=%d blocked=%d)" % (
s.get("ecosystem", ""), s.get("tool", ""), s.get("status", ""),
s.get("accepted", 0), s.get("blocked", 0)))
for e in p.get("exceptions", []):
print(" accepted exception: %s" % e.get("id", ""))
if blocked:
sys.exit(4)
if not p.get("attested"):
sys.exit(3)
POSTURE_EOF
python3 "$POSTURE_CHECK" "$TMP_MANIFEST"
POSTURE_RC=$?
rm -f "$POSTURE_CHECK"
if [ "$POSTURE_RC" = "4" ]; then
rm -f "$TMP_MANIFEST"
echo "ERROR: Server build carries un-accepted dependency vulnerabilities — refusing to install." >&2
exit 1
elif [ "$POSTURE_RC" = "3" ]; then
echo "WARNING: This server was built outside RedFlag's gated release pipeline — its dependency posture is not independently attested." >&2
elif [ "$POSTURE_RC" = "0" ]; then
echo "✓ Supply-chain posture attested"
fi
# Resolve components → artifacts for this platform/arch.
# The Python helper reads the manifest, pairs each component with its matching
# artifact entry, and emits tab-separated lines: