Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<dest>` 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-<binary>` 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.
Expand Down
1 change: 1 addition & 0 deletions docs/static-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<binary>` 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 |
Expand Down
2 changes: 2 additions & 0 deletions nix/checks/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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; };
Expand Down
83 changes: 83 additions & 0 deletions nix/checks/govulncheck.nix
Original file line number Diff line number Diff line change
@@ -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
''
24 changes: 24 additions & 0 deletions nix/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-<binary attr>: 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
Expand Down Expand Up @@ -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 = {
Expand Down
42 changes: 42 additions & 0 deletions nix/govulncheck-fresh.nix
Original file line number Diff line number Diff line change
@@ -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}"
'';
}
57 changes: 57 additions & 0 deletions nix/lib/govulndb.nix
Original file line number Diff line number Diff line change
@@ -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/<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)"
''
25 changes: 25 additions & 0 deletions nix/lib/sbom.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# nix/lib/sbom.nix
#
# A CycloneDX SBOM for a Go binary package: one <binary>.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
''
4 changes: 4 additions & 0 deletions nix/versions.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading