diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b4c9b7c..223629ecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ - **`_FILE` secrets are checked.** A missing or unreadable `*_FILE` path, a non-regular file, or one readable by group or others stops startup with exit 64. It used to be ignored silently (listener tokens) or logged (S3 keys), leaving the credential empty. Mount secrets mode `0400`. - **The debug writers are build-time opt-ins.** The netlink capture writer (`-writeFiles` / `-capturePath`) is compiled in only with `-tags capture`, which no flavor or image sets; use `nix build .#xtcp2-capture`. The `file:` destination needs `-tags dest_file` and is in the full (development) flavor only, so `xtcp2-` slim flavors and their images no longer have it. Asking for either on a binary without it exits 64 at startup with the tag to rebuild with; `ConfigService.Set` refuses it with `FailedPrecondition`. A capture build reports `+capture` in its version line, and when armed logs a warning and sets `xtcp2_debug_capture_enabled 1`. +### New + +- **Vulnerability check.** `checks.govulncheck` runs govulncheck against a pinned snapshot of the Go vulnerability database, with no tags and with every flavor tag, and fails on a reachable finding not in its accepted list. One is accepted: GO-2026-6859 in `net/http`, fixed in go1.27.4, until the toolchain moves. `nix run .#govulncheck-fresh` checks against the live database and runs `go mod verify`; `nix build .#sbom-` writes a CycloneDX SBOM for any binary package. + ## 0.2.1 (2026-11) Fixes for what the first fleet rollout of 0.2.0 found: the link monitor baseline, the uplink columns, and container attribution. No schema, flag-default or `XtcpConfig` change; 0.2.0 clients and consumers work unchanged. diff --git a/docs/static-analysis.md b/docs/static-analysis.md index 82c35c0f6..8055c4d31 100644 --- a/docs/static-analysis.md +++ b/docs/static-analysis.md @@ -93,6 +93,7 @@ otherwise until 2026-10-05. | `golangci-lint` (Tier 1) | `.golangci.yml` | yes | ~2 min | `errcheck`, `gosec`, `misspell`, `contextcheck`, `noctx`, `gocritic`, `gocyclo` (promoted 2026-10-06) | | `golangci-lint-comprehensive` (Tier 2) | `.golangci-comprehensive.yml` | yes | ~10 min | **`funlen`, `goconst`, `unconvert`, `exhaustive`, `prealloc`, `dupl`, `nakedret`** | | `go-sec` | gosec directly | yes | ~2 min | gosec standalone; sees **no `_test.go`**, since it does not pass `-tests` and gosec skips them by default | +| `govulncheck` | `nix/checks/govulncheck.nix`, database `nix/lib/govulndb.nix` | yes | minutes | known vulnerabilities the code reaches, against a pinned offline database, with and without every flavor tag; fails on one not in the file's `accepted` list. `nix run .#govulncheck-fresh` checks against the live database and runs `go mod verify`. `nix build .#sbom-` writes a CycloneDX SBOM | | `go-vet` | `enable-all`, minus `fieldalignment` and `shadow` | yes | fast | — | | `gofmt` / `nix-fmt` | — | yes | fast | formatting | | `deadnix` / `statix` | `statix.toml` | yes | fast | Nix dead code and antipatterns | diff --git a/nix/checks/default.nix b/nix/checks/default.nix index 517147053..564b321a2 100644 --- a/nix/checks/default.nix +++ b/nix/checks/default.nix @@ -94,6 +94,8 @@ in inherit pkgs vendoredSource; }; go-sec = import ./go-sec.nix { inherit pkgs vendoredSource; }; + # Reachable known vulnerabilities, against a pinned offline database. + govulncheck = import ./govulncheck.nix { inherit pkgs vendoredSource; }; # Slim flavors must not carry the debug writers (dest_file, capture). flavor-tags = import ./flavor-tags.nix { inherit pkgs lib binaries; }; diff --git a/nix/checks/govulncheck.nix b/nix/checks/govulncheck.nix new file mode 100644 index 000000000..5e9d9f3de --- /dev/null +++ b/nix/checks/govulncheck.nix @@ -0,0 +1,83 @@ +# nix/checks/govulncheck.nix +# +# Known vulnerabilities reachable from xtcp2's code, by govulncheck against an +# offline snapshot of the Go vulnerability database (nix/lib/govulndb.nix), +# so the check is pure and its result changes only when the code, the Go +# toolchain or the snapshot does. A finding the code reaches fails the build +# unless it is in `accepted` below; a finding in a required module the code +# does not call is reported but does not fail. +# +# Two passes: no build tags (the min flavor), and every destination and +# enrichment tag, so code behind a flavor tag is scanned too. +# +# The snapshot ages; `nix run .#govulncheck-fresh` checks the working tree +# against the live database, and moving the pin in nix/lib/govulndb.nix brings +# what it finds into this check. +{ + pkgs, + vendoredSource, +}: + +let + versions = import ../versions.nix { inherit pkgs; }; + db = import ../lib/govulndb.nix { inherit pkgs; }; + allTags = pkgs.lib.concatStringsSep "," ( + map (d: "dest_${d}") versions.allLibraryDestinations + ++ map (e: "enrich_${e}") versions.allEnrichmentFeatures + ); + + # Reachable findings accepted for now, each with why and what removes it. + # Keep this short: an entry is a known exposure, not a suppression. + accepted = { + "GO-2026-6859" = + "net/http, fixed in go1.27.4; the pinned nixpkgs has go1.27.3. Remove with the toolchain bump."; + }; + acceptedJSON = builtins.toJSON (builtins.attrNames accepted); +in +pkgs.runCommand "xtcp2-govulncheck" + { + nativeBuildInputs = [ + versions.go + versions.govulncheck + pkgs.jq + ]; + inherit vendoredSource; + # For the govulncheck-fresh app, so both scan the same tag sets. + passthru = { inherit allTags; }; + } + '' + cp -r $vendoredSource ./xtcp2 && chmod -R +w ./xtcp2 + cd ./xtcp2 + export HOME=$(mktemp -d) + export CGO_ENABLED=0 + export GOFLAGS=-mod=vendor + export GOTOOLCHAIN=local + mkdir -p $out + # One pass per tag set: the text report for people, the JSON stream for + # the gate. A finding is reachable when its trace starts at a function. + scan() { + govulncheck -db file://${db} "$@" ./... > "$out/$name.txt" 2>&1 || true + govulncheck -db file://${db} -format json "$@" ./... > "$out/$name.json" + jq -r 'select(.finding and .finding.trace[0].function) | .finding.osv' \ + "$out/$name.json" | sort -u > "$out/$name.reachable" + } + name=default scan + name=all-tags scan -tags ${allTags} + + echo '${acceptedJSON}' | jq -r '.[]' | sort -u > accepted + sort -u $out/*.reachable > reachable + comm -23 reachable accepted > $out/unaccepted + comm -13 reachable accepted > $out/stale + + cat $out/default.txt + if [ -s $out/stale ]; then + echo "accepted but no longer reachable, remove from nix/checks/govulncheck.nix:" + cat $out/stale + fi + if [ -s $out/unaccepted ]; then + echo "FAIL: reachable vulnerabilities not in the accepted list:" + cat $out/unaccepted + cat $out/all-tags.txt + exit 1 + fi + '' diff --git a/nix/default.nix b/nix/default.nix index eb82061c1..f3939d3ab 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -277,6 +277,14 @@ let # Its hermetic counterpart, checks.upstream-pins, keeps the manifest honest. checkUpstreamPins = import ./check-upstream-pins.nix { inherit pkgs; }; + # Live-database counterpart of checks.govulncheck, for the same reason. + govulncheckFresh = import ./govulncheck-fresh.nix { + inherit pkgs versions; + inherit (checks.govulncheck) allTags; + }; + + sbomFor = import ./lib/sbom.nix { inherit pkgs versions; }; + # The five golangci-lint tier helpers (lint-quick / lint / # lint-comprehensive / lint-fix / lint-new). They live in their own file # because nix/devshell.nix puts the very same derivations on the dev @@ -973,6 +981,17 @@ in # oci-ipfeed-collector ASN artifact builder # oci-xtcp2-tcp-stress TCP_MODE-dispatched stress image // (lib.filterAttrs (n: _v: lib.hasPrefix "oci-" n) containers) + # sbom-: a CycloneDX SBOM per binary (nix/lib/sbom.nix). + // lib.mapAttrs' (name: drv: lib.nameValuePair "sbom-${name}" (sbomFor drv)) ( + lib.filterAttrs (_n: lib.isDerivation) ( + removeAttrs binaries [ + "byVariant" + "joins" + "xtcp2ByFlavor" + "xtcp2OnlyByFlavor" + ] + ) + ) // containersWithIpmetaBootstrap // linkmonitorPackages # lint-quick / lint / lint-comprehensive / lint-fix / lint-new. `all` is @@ -1202,6 +1221,11 @@ in type = "app"; program = "${checkUpstreamPins}/bin/xtcp2-check-upstream-pins"; }; + # Known vulnerabilities against the live database, and `go mod verify`. + govulncheck-fresh = { + type = "app"; + program = "${govulncheckFresh}/bin/xtcp2-govulncheck-fresh"; + }; # Run the whole microVM integration suite sequentially. Lifecycle sweep # by default; `-- --soak [--duration 1h]` adds the duration runners. integration-all = { diff --git a/nix/govulncheck-fresh.nix b/nix/govulncheck-fresh.nix new file mode 100644 index 000000000..fa01890fd --- /dev/null +++ b/nix/govulncheck-fresh.nix @@ -0,0 +1,42 @@ +# nix/govulncheck-fresh.nix +# +# Networked half of the vulnerability check: govulncheck on the working tree +# against the live Go vulnerability database, plus `go mod verify`. The flake +# check (nix/checks/govulncheck.nix) is pure, so it only knows the pinned +# snapshot in nix/lib/govulndb.nix; this runner says what the snapshot does +# not know yet. A RUNNER, not a check, for the same reason as +# check-upstream-pins: the check sandbox has no network. +# +# nix run .#govulncheck-fresh # report, exit 1 on any finding +# +# It reports every reachable finding, accepted or not: moving the snapshot pin +# brings them into the check, where the accepted list applies. +# +{ + pkgs, + versions, + allTags, +}: + +pkgs.writeShellApplication { + name = "xtcp2-govulncheck-fresh"; + runtimeInputs = [ + versions.go + versions.govulncheck + ]; + text = '' + if [[ ! -f go.mod ]]; then + echo "run from the xtcp2 repository root" >&2 + exit 2 + fi + export GOTOOLCHAIN=local + rc=0 + echo "==> go mod verify" + go mod verify || rc=1 + echo "==> govulncheck (no tags)" + govulncheck ./... || rc=1 + echo "==> govulncheck (-tags ${allTags})" + govulncheck -tags ${allTags} ./... || rc=1 + exit "''${rc}" + ''; +} diff --git a/nix/lib/govulndb.nix b/nix/lib/govulndb.nix new file mode 100644 index 000000000..20062fa71 --- /dev/null +++ b/nix/lib/govulndb.nix @@ -0,0 +1,57 @@ +# nix/lib/govulndb.nix +# +# An offline Go vulnerability database for govulncheck's `-db file://`, built +# from a pinned commit of github.com/golang/vulndb. vuln.go.dev serves the same +# OSV entries, but from mutable URLs a fixed-output fetch cannot pin; the repo +# carries them at data/osv, one GO-YYYY-NNNN.json each, so a commit is a +# reproducible snapshot. +# +# govulncheck reads the v1 layout (https://go.dev/security/vuln/database): +# index/db.json {"modified": the snapshot's date} +# index/modules.json [{path, vulns: [{id, modified}]}], one per module +# index/vulns.json [{id, modified, aliases}] +# ID/.json the OSV entry +# modules.json leaves out each vuln's optional "fixed": the client uses it only +# to skip fetching entries already fixed in the scanned version, so without it +# every entry for a module in the build is read and matched in full. +# +# Refresh: move `rev` to golang/vulndb's current master and update `hash` +# (`nix build .#checks.x86_64-linux.govulncheck` prints the new one). `nix run +# .#govulncheck-fresh` checks against the live database in between. +{ pkgs }: + +let + # The commit's date: the repo's entries carry zero timestamps (vuln.go.dev + # stamps them when it publishes), so the index says when the snapshot is from. + snapshot = "2026-11-11T00:00:00Z"; + vulndb = pkgs.fetchFromGitHub { + owner = "golang"; + repo = "vulndb"; + # master on 2026-11-11. + rev = "e91d8491530fd8782760dd3916cd2708182b9f10"; + hash = "sha256-QjH81HGQZ8yR/kYtK6pXtReRzc+uloTDSB90qxhFSck="; + sparseCheckout = [ "data/osv" ]; + }; +in +pkgs.runCommand "go-vulndb" + { + nativeBuildInputs = [ pkgs.jq ]; + passthru = { inherit vulndb; }; + } + '' + mkdir -p $out/ID $out/index + cp ${vulndb}/data/osv/GO-*.json $out/ID/ + count=$(ls $out/ID | wc -l) + if [ "$count" -lt 1000 ]; then + echo "go-vulndb: only $count entries in data/osv; the layout changed?" >&2 + exit 1 + fi + jq -cn --arg m ${snapshot} '{modified: $m}' > $out/index/db.json + jq -cs 'map({id, modified, aliases: (.aliases // [])})' $out/ID/*.json > $out/index/vulns.json + jq -cs ' + [ .[] as $v | ($v.affected // [])[] | {path: .package.name, id: $v.id, modified: $v.modified} ] + | group_by(.path) + | map({path: .[0].path, vulns: (map({id, modified}) | unique_by(.id))}) + ' $out/ID/*.json > $out/index/modules.json + echo "go-vulndb: $count entries, $(jq length $out/index/modules.json) modules, modified $(jq -r .modified $out/index/db.json)" + '' diff --git a/nix/lib/sbom.nix b/nix/lib/sbom.nix new file mode 100644 index 000000000..b29735d57 --- /dev/null +++ b/nix/lib/sbom.nix @@ -0,0 +1,25 @@ +# nix/lib/sbom.nix +# +# A CycloneDX SBOM for a Go binary package: one .cdx.json per file in +# its bin/, read by `cyclonedx-gomod bin` from the module list the Go linker +# embeds, so it names exactly the module versions that were linked in. +# +# nix build .#sbom-xtcp2-s3parquet +# +{ + pkgs, + versions, +}: + +drv: +pkgs.runCommand "${drv.pname or drv.name}-sbom" + { + nativeBuildInputs = [ versions.cyclonedx-gomod ]; + } + '' + mkdir -p $out + for b in ${drv}/bin/*; do + cyclonedx-gomod bin -json -version ${drv.version or "devel"} \ + -output "$out/$(basename "$b").cdx.json" "$b" + done + '' diff --git a/nix/versions.nix b/nix/versions.nix index 6cc50a998..9cc0e95b5 100644 --- a/nix/versions.nix +++ b/nix/versions.nix @@ -55,6 +55,10 @@ }); inherit (pkgs) nixfmt; + # Supply chain: nix/checks/govulncheck.nix (with the offline database in + # nix/lib/govulndb.nix), the govulncheck-fresh app, and the CycloneDX SBOMs. + inherit (pkgs) govulncheck cyclonedx-gomod; + # gRPC / proto inspection inherit (pkgs) grpcurl;