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:
parent
938a8f6dbb
commit
e2dab2845a
18 changed files with 5563 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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
15
.govulncheck-allow
Normal 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).
|
||||
23
SECURITY.md
23
SECURITY.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
4568
desktop/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
360
helper/Cargo.lock
generated
Normal file
360
helper/Cargo.lock
generated
Normal 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
169
scripts/dep-scan.sh
Executable 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
136
scripts/govulncheck-gate.py
Executable 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())
|
||||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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 }{
|
||||
|
|
|
|||
7
server/internal/services/posture-build.json
Normal file
7
server/internal/services/posture-build.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"attested": false,
|
||||
"generated_at": 0,
|
||||
"substrate": {},
|
||||
"scans": [],
|
||||
"exceptions": []
|
||||
}
|
||||
78
server/internal/services/posture.go
Normal file
78
server/internal/services/posture.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue