diff --git a/caddy.Dockerfile b/caddy.Dockerfile index 2af6e1cc..0acb3bf1 100644 --- a/caddy.Dockerfile +++ b/caddy.Dockerfile @@ -69,18 +69,36 @@ # Verify the LB is `externalTrafficPolicy: Local` + the parent # Caddy's `trustedProxies` covers the LB CIDR range. -FROM caddy:2.11.4-builder@sha256:198d47eaee306d4d0c38a9960c89ff2c959aa29ad51d3e2dafa3e93ac961782a AS builder +FROM caddy:2.11.4-builder@sha256:4bdeabce8e79d36b23d1cba7d20598cec2c1117ace960d8ca06071f945e8fc9b AS builder # `$CADDY_VERSION` is set by the base image itself (v2.11.4 here), so xcaddy # builds exactly the version the builder ships and a skew is impossible by # construction — there is no second version literal to forget. The tag on the # FROM line is informational only; the digest is what resolves. +# +# The three `--replace` lines lift Caddy 2.11.4's own vendored deps past +# CVE-2026-46600 (x/net), CVE-2026-56852 (x/text) and GHSA-hrxh-6v49-42gf +# (grpc), which upstream has not yet re-released. `--replace` and not `--with`: +# `--with` also writes a blank import, and none of these modules has a package +# at its root, so it fails with "cannot find module providing package". +# Refresh or drop each line when Caddy ships a release that already carries the +# fixed version — a replace pinning an OLDER version than upstream would +# silently downgrade. +# +# cel-go is deliberately NOT in this list. v0.29.0 renames +# interpreter.Interpretable to InterpretableV2, which does not compile against +# 2.11.4's modules/caddyhttp/celmatcher.go — an upstream code change, not a +# version bump. GHSA-gcjh-h69q-9w9g (MEDIUM) therefore stays open until Caddy +# adopts it; see the SCA PR for the reachability note. RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ --mount=type=cache,target=/root/.cache,sharing=locked \ test -n "${CADDY_VERSION}" \ && xcaddy build "${CADDY_VERSION}" \ --with github.com/grafana/certmagic-gcs@v0.1.7 \ --with github.com/mholt/caddy-ratelimit@16aecbbcb8ca07dc1c671e263379606ff9493c55 \ + --replace golang.org/x/net=golang.org/x/net@v0.58.0 \ + --replace golang.org/x/text=golang.org/x/text@v0.41.0 \ + --replace google.golang.org/grpc=google.golang.org/grpc@v1.82.1 \ && caddy version | grep -qF "${CADDY_VERSION} " \ && caddy list-modules | grep -qE '^http\.handlers\.rate_limit$' \ && caddy list-modules | grep -qE '^caddy\.storage\.gcs$' @@ -93,7 +111,7 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ # falls back to local-filesystem cert storage, so a multi-replica parent # stack gets per-pod ACME state and risks Let's Encrypt rate-limit lockout. -FROM caddy:2.11.4@sha256:844f60b64e4724a5aa8245e019dace0d3f199f7433ce6c57676cb30a920dbad9 +FROM caddy:2.11.4@sha256:df7f1c2fb114453b951de51a98efc010db1655a92c2e86be6706714e2417a78d RUN apk update && apk upgrade --no-cache && rm -rf /var/cache/apk/* diff --git a/cloud-helpers.aws.Dockerfile b/cloud-helpers.aws.Dockerfile index 9c1998ca..7b6215b1 100644 --- a/cloud-helpers.aws.Dockerfile +++ b/cloud-helpers.aws.Dockerfile @@ -6,6 +6,22 @@ RUN dnf upgrade -y --setopt=tsflags=nodocs \ && dnf clean all \ && rm -rf /var/cache/dnf +# Drop the Runtime Interface Emulator. It is the base image's LOCAL-testing +# shim: /lambda-entrypoint.sh execs it only when AWS_LAMBDA_RUNTIME_API is +# unset, and the ENTRYPOINT below replaces that script with /cloud-helpers +# outright — so the RIE binary is never executed in this image, in Lambda or +# anywhere else. It is also 9 MB of Go built by AWS with an older toolchain, +# which is where every stdlib finding in this image came from (8 HIGH at the +# al2023 digest pinned above, all fixed in Go >= 1.26.6; the currently-tagged +# al2023 digest carries 30). Deleting it is the fix, not a suppression: nothing +# links it and no workflow invokes it. +# +# Local debugging is unaffected — `welder run debug-aws-cloud-helpers` runs on +# the HOST and uses the host's own `aws-lambda-rie` (welder.yaml), not this +# image's copy. What this does remove is `docker run --entrypoint +# /lambda-entrypoint.sh /cloud-helpers`; use the welder task instead. +RUN rm -f /usr/local/bin/aws-lambda-rie && test ! -e /usr/local/bin/aws-lambda-rie + WORKDIR / COPY dist/cloud-helpers /cloud-helpers # actions/upload-artifact does not preserve the executable bit, and the release diff --git a/docs/TESTING.md b/docs/TESTING.md index 41c89637..15e9b6c7 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -14,20 +14,21 @@ new tests for every code change. ## Current state — to be updated each pass -| Metric | Value as of 2026-05-19 | +| Metric | Value as of 2026-08-21 | |---|---| -| Total test files | 87 (`*_test.go`) | -| Test files using **gomega** | 67 (77 %) | -| Test files using **testify** | 3 (3 %; mock-only) | -| Test files using **plain `testing`** | 19 (22 %; mostly fuzz + small utilities) | -| Table-driven tests | 43 files | -| Sub-tests via `t.Run` | 66 files | -| Integration tests (`*_integration_test.go`) | 5 packages | -| Mocks generated by `mockery v2.53.4` | `pkg/api/git/mocks/`, `pkg/clouds/pulumi/mocks/` | -| **Overall statement coverage** | **~16 %** | -| Coverage on `pkg/security/...` | 42 – 66 % per sub-pkg | - -These numbers are the baseline. Every PR should hold or improve them. +| Total test files | 197 (`*_test.go`) | +| Test files using **gomega** | 166 (84 %) | +| Test files using **testify** | 12 (6 %; mock-only) | +| Sub-tests via `t.Run` | 145 files | +| Mocks generated by `mockery v2.53.6` | `pkg/api/git/mocks/`, `pkg/clouds/pulumi/mocks/` | +| **Included-set statement coverage** (Gold-tier denominator) | **90.5 %** | +| Full-set statement coverage (whole repo, unfiltered) | 29.0 % | +| Coverage on `pkg/security/...` | 79.6 – 97.8 % per sub-pkg | + +These numbers are the baseline. Every PR should hold or improve them. The two +coverage figures and the scope that separates them are defined under +[Coverage scope](#coverage-scope) below; the included-set figure is the one the +OpenSSF criterion is judged on. ## Test framework — choose gomega @@ -339,9 +340,17 @@ this table: Everything not excluded above — the unit-testable core: `pkg/api/*`, `pkg/util`, `pkg/security/*`, `pkg/clouds/{github,aws,k8s,gcloud,compose,fs,telegram,slack,discord,…}`, -`pkg/template`, and the rest. This is roughly **5,760 statements** and is +`pkg/template`, and the rest. This is roughly **5,975 statements** and is the denominator the Gold-tier criterion is judged on. +`pkg/security/tools/cosigntest` is deliberately NOT excluded even though it is +a test harness. Go attributes coverage per test binary, so a helper package +consumed only by *other* packages' tests reads as 0 % — which is a real gap, +not a measurement artifact: the harness generates a `/bin/sh` script whose +quoting, invocation counter and stream selection three retry suites silently +depend on. It has its own tests (`cosigntest_test.go`) instead of an +exclusion. + The `coverage` task emits two numbers on every run: - **included-set** aggregate → `dist/cover.out` (the Gold-tier figure) @@ -353,8 +362,8 @@ The `coverage` task emits two numbers on every run: | Target | Threshold | Status | |---|---|---| | **Per-PR no-regression** | Included-set aggregate must not decrease vs `main` | Observed by `.github/workflows/coverage.yml` (sticky PR comment); hard gate deferred until the baseline stabilises | -| **Silver badge** (`test_statement_coverage80`) | ≥ 80 % included-set aggregate | Met | -| **Gold badge** (`test_statement_coverage90`) | ≥ 90 % included-set aggregate | Met | +| **Silver badge** (`test_statement_coverage80`) | ≥ 80 % included-set aggregate | Met (90.5 %) | +| **Gold badge** (`test_statement_coverage90`) | ≥ 90 % included-set aggregate | Met (90.5 %, from 89.6 % on 2026-08-21) | Both numbers are reported by `welder run coverage` and by the coverage workflow; the included-set aggregate is the one the OpenSSF criterion is diff --git a/go.mod b/go.mod index 25210066..a6176dbd 100644 --- a/go.mod +++ b/go.mod @@ -59,11 +59,11 @@ require ( go.mongodb.org/mongo-driver v1.17.9 go.uber.org/atomic v1.11.0 gocloud.dev v0.46.0 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.22.0 golang.org/x/term v0.45.0 - golang.org/x/text v0.40.0 + golang.org/x/text v0.41.0 google.golang.org/api v0.284.0 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 @@ -165,7 +165,7 @@ require ( github.com/chavacava/garif v0.1.0 // indirect github.com/cheggaaa/pb v1.0.29 // indirect github.com/chigopher/pathlib v0.19.1 // indirect - github.com/cilium/ebpf v0.11.0 // indirect + github.com/cilium/ebpf v0.22.0 // indirect github.com/ckaznocha/intrange v0.3.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect @@ -393,7 +393,7 @@ require ( github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.3.5 // indirect github.com/sergi/go-diff v1.4.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/sivchari/containedctx v1.0.3 // indirect github.com/sivchari/tenv v1.12.1 // indirect github.com/skeema/knownhosts v1.3.1 // indirect @@ -463,13 +463,13 @@ require ( gocloud.dev/secrets/hashivault v0.46.0 // indirect golang.org/x/arch v0.11.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect - golang.org/x/mod v0.38.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect + golang.org/x/mod v0.40.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect + golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.48.0 // indirect + golang.org/x/tools v0.49.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect @@ -480,7 +480,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - honnef.co/go/tools v0.6.1 // indirect + honnef.co/go/tools v0.7.0 // indirect k8s.io/api v0.36.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect @@ -492,3 +492,20 @@ require ( sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) + +// honnef.co/go/tools is staticcheck, imported ONLY by +// github.com/golangci/golangci-lint (the `unused` linter). golangci-lint +// v1.64.8 requires v0.6.1 and does not compile against v0.7.0's analysis API: +// it panics mid-run with "typedness: panic during analysis: interface +// conversion: interface {} is nil, not *buildir.IR" and exits 7, which fails +// the `golangci-lint run` step in branch.yaml / push.yaml. +// +// MVS raises it to v0.7.0 because github.com/cilium/ebpf v0.22.0 names that +// version in its own go.mod — ebpf needs staticcheck for ITS CI, not for any +// code we compile, and v0.22.0 is the fix for GO-2026-6238 so it cannot be +// held back. A `require` line loses to ebpf's; only `replace` pins it. +// +// Remove this when golangci-lint moves to v2 (v2.x builds against honnef +// v0.7.x) — that is a separate migration: v2 renames the module path and +// changes the .golangci.yml schema. +replace honnef.co/go/tools => honnef.co/go/tools v0.6.1 diff --git a/go.sum b/go.sum index 9b20dd97..4b6738cb 100644 --- a/go.sum +++ b/go.sum @@ -237,8 +237,8 @@ github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo= github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30= github.com/chigopher/pathlib v0.19.1 h1:RoLlUJc0CqBGwq239cilyhxPNLXTK+HXoASGyGznx5A= github.com/chigopher/pathlib v0.19.1/go.mod h1:tzC1dZLW8o33UQpWkNkhvPwL5n4yyFRFm/jL1YGWFvY= -github.com/cilium/ebpf v0.11.0 h1:V8gS/bTCCjX9uUnkUFUpPsksM8n1lXBAvHcpiFk1X2Y= -github.com/cilium/ebpf v0.11.0/go.mod h1:WE7CZAnqOL2RouJ4f1uyNhqr2P4CCvXFIqdRDUgWsVs= +github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY= +github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4= github.com/ckaznocha/intrange v0.3.0 h1:VqnxtK32pxgkhJgYQEeOArVidIPg+ahLP7WBOXZd5ZY= github.com/ckaznocha/intrange v0.3.0/go.mod h1:+I/o2d2A1FBHgGELbGxzIcyd3/9l9DuwjM8FsbSS3Lo= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= @@ -574,6 +574,10 @@ github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM= +github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= @@ -660,6 +664,10 @@ github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/a github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/mgechev/revive v1.7.0 h1:JyeQ4yO5K8aZhIKf5rec56u0376h8AlKNQEmjfkjKlY= github.com/mgechev/revive v1.7.0/go.mod h1:qZnwcNhoguE58dfi96IJeSTPeZQejNeoMQLUZGi4SW4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= @@ -868,8 +876,8 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= github.com/sivchari/tenv v1.12.1 h1:+E0QzjktdnExv/wwsnnyk4oqZBUfuh89YMQT1cyuvSY= @@ -911,7 +919,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -1073,14 +1080,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rPv1pNOQtl1I8knsO4S9trOxNMOLVP4= -golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk= +golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -1094,8 +1101,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= 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= @@ -1113,8 +1120,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1160,8 +1167,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= -golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -1183,8 +1190,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1209,8 +1216,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= diff --git a/pkg/clouds/aws/helpers/ct_enrichment_test.go b/pkg/clouds/aws/helpers/ct_enrichment_test.go index 832238f6..a320f3a9 100644 --- a/pkg/clouds/aws/helpers/ct_enrichment_test.go +++ b/pkg/clouds/aws/helpers/ct_enrichment_test.go @@ -4,6 +4,7 @@ package helpers import ( + "context" "testing" "time" @@ -157,9 +158,17 @@ func TestLookupTriggeringEventsShortCircuits(t *testing.T) { // refactors don't accidentally try to open a session with empty inputs. RegisterTestingT(t) + // Already-cancelled, rather than nil or context.TODO(): the point of these + // cases is that the short-circuit happens BEFORE any AWS call. A cancelled + // context makes a regression fail with context.Canceled instead of reaching + // the network, and it keeps staticcheck's SA1012 (never pass a nil Context) + // satisfied. + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + t.Run("missing log group name — no call, no error", func(t *testing.T) { RegisterTestingT(t) - events, total, err := lookupTriggeringEvents(nil, nil, enrichmentConfig{FilterPattern: "x"}, time.Now(), 5) + events, total, err := lookupTriggeringEvents(cancelled, nil, enrichmentConfig{FilterPattern: "x"}, time.Now(), 5) Expect(err).To(BeNil()) Expect(events).To(BeNil()) Expect(total).To(Equal(0)) @@ -167,7 +176,7 @@ func TestLookupTriggeringEventsShortCircuits(t *testing.T) { t.Run("missing filter pattern — no call, no error", func(t *testing.T) { RegisterTestingT(t) - events, total, err := lookupTriggeringEvents(nil, nil, enrichmentConfig{LogGroupName: "g"}, time.Now(), 5) + events, total, err := lookupTriggeringEvents(cancelled, nil, enrichmentConfig{LogGroupName: "g"}, time.Now(), 5) Expect(err).To(BeNil()) Expect(events).To(BeNil()) Expect(total).To(Equal(0)) diff --git a/pkg/clouds/cloudflare/cloudflare_test.go b/pkg/clouds/cloudflare/cloudflare_test.go index 3c2d5467..cfd6439a 100644 --- a/pkg/clouds/cloudflare/cloudflare_test.go +++ b/pkg/clouds/cloudflare/cloudflare_test.go @@ -66,3 +66,37 @@ func TestProviderConstants(t *testing.T) { Expect(ProviderType).To(Equal("cloudflare")) Expect(RegistrarType).To(Equal("cloudflare")) } + +func TestReadRegistrarConfig(t *testing.T) { + RegisterTestingT(t) + + t.Run("happy path", func(t *testing.T) { + RegisterTestingT(t) + cfg := &api.Config{Config: map[string]any{ + "credentials": "cf-token", + "accountId": "acct-12345", + "zoneName": "example.com", + "dnsRecords": []any{ + map[string]any{"name": "www", "type": "CNAME", "value": "example.com"}, + }, + }} + out, err := ReadRegistrarConfig(cfg) + Expect(err).ToNot(HaveOccurred()) + rc, ok := out.Config.(*RegistrarConfig) + Expect(ok).To(BeTrue()) + Expect(rc.ZoneName).To(Equal("example.com")) + Expect(rc.AccountId).To(Equal("acct-12345")) + Expect(rc.CredentialsValue()).To(Equal("cf-token")) + Expect(rc.DnsRecords()).To(HaveLen(1)) + Expect(rc.DnsRecords()[0].Type).To(Equal("CNAME")) + + var _ api.RegistrarConfig = rc + }) + + t.Run("error path", func(t *testing.T) { + RegisterTestingT(t) + cfg := &api.Config{Config: map[string]any{"zoneName": []int{1, 2, 3}}} + _, err := ReadRegistrarConfig(cfg) + Expect(err).To(HaveOccurred()) + }) +} diff --git a/pkg/clouds/compose/compose_test.go b/pkg/clouds/compose/compose_test.go index 148f0e21..ae89e15f 100644 --- a/pkg/clouds/compose/compose_test.go +++ b/pkg/clouds/compose/compose_test.go @@ -5,6 +5,8 @@ package compose import ( "context" + "os" + "path/filepath" "testing" "github.com/compose-spec/compose-go/types" @@ -31,3 +33,51 @@ func TestComposeLoad(t *testing.T) { Expect(uiFound).To(BeTrue()) Expect(ui.ContainerName).To(Equal("refapp-ui")) } + +// An absolute composeFilePath must re-root workingDir onto the file's own +// directory: relative build contexts inside the compose file are resolved +// against workingDir, so honouring a caller-supplied one would point them at +// the wrong tree. +func TestComposeLoad_AbsolutePathRerootsWorkingDir(t *testing.T) { + RegisterTestingT(t) + + abs, err := filepath.Abs("testdata/stacks/refapp/docker-compose.yaml") + Expect(err).ToNot(HaveOccurred()) + + cfg, err := ReadDockerCompose(context.Background(), "/nonexistent/working/dir", abs) + Expect(err).ToNot(HaveOccurred()) + Expect(cfg.Project).ToNot(BeNil()) + Expect(cfg.Project.WorkingDir).To(Equal(filepath.Dir(abs)), + "workingDir must follow the compose file, not the caller's argument") + Expect(cfg.Project.Services).To(HaveLen(3)) +} + +// Interpolation reads the process environment through syscall.Getenv. Loading +// with SkipResolveEnvironment still interpolates ${VAR} in the compose file +// itself, so a broken lookup silently ships the literal placeholder. +func TestComposeLoad_InterpolatesFromProcessEnv(t *testing.T) { + RegisterTestingT(t) + + t.Setenv("SC_COMPOSE_TEST_IMAGE", "mongo:7") + + dir := t.TempDir() + file := filepath.Join(dir, "docker-compose.yaml") + Expect(os.WriteFile(file, []byte(`services: + db: + image: ${SC_COMPOSE_TEST_IMAGE} + container_name: interp-db +`), 0o600)).To(Succeed()) + + cfg, err := ReadDockerCompose(context.Background(), dir, "docker-compose.yaml") + Expect(err).ToNot(HaveOccurred()) + Expect(cfg.Project.Services).To(HaveLen(1)) + Expect(cfg.Project.Services[0].Image).To(Equal("mongo:7")) +} + +func TestComposeLoad_ReturnsErrorForMissingFile(t *testing.T) { + RegisterTestingT(t) + + cfg, err := ReadDockerCompose(context.Background(), t.TempDir(), "docker-compose.yaml") + Expect(err).To(HaveOccurred()) + Expect(cfg.Project).To(BeNil(), "a load failure must not return a half-built project") +} diff --git a/pkg/clouds/docker/types_test.go b/pkg/clouds/docker/types_test.go index 59775945..df14f30b 100644 --- a/pkg/clouds/docker/types_test.go +++ b/pkg/clouds/docker/types_test.go @@ -46,3 +46,40 @@ func Test_GenerateImagePullSecret(t *testing.T) { }) } } + +// RegistryRequiresAuth gates whether an imagePullSecret is generated at all, so +// a partially-configured registry (username without password) must read as +// "no auth" rather than produce a half-formed secret. +func TestRegistryCredentials_RegistryRequiresAuth(t *testing.T) { + RegisterTestingT(t) + + for _, tc := range []struct { + name string + creds RegistryCredentials + want bool + }{ + {name: "both set", want: true, creds: RegistryCredentials{ + DockerRegistryUsername: lo.ToPtr("user"), + DockerRegistryPassword: lo.ToPtr("password"), + }}, + {name: "username only", want: false, creds: RegistryCredentials{ + DockerRegistryUsername: lo.ToPtr("user"), + }}, + {name: "password only", want: false, creds: RegistryCredentials{ + DockerRegistryPassword: lo.ToPtr("password"), + }}, + {name: "neither", want: false, creds: RegistryCredentials{}}, + // An empty-but-present pointer is a declared-yet-blank secret. It still + // counts as "auth requested" so the deploy fails on a bad credential + // rather than silently pulling anonymously. + {name: "both present but empty", want: true, creds: RegistryCredentials{ + DockerRegistryUsername: lo.ToPtr(""), + DockerRegistryPassword: lo.ToPtr(""), + }}, + } { + t.Run(tc.name, func(t *testing.T) { + RegisterTestingT(t) + Expect(tc.creds.RegistryRequiresAuth()).To(Equal(tc.want)) + }) + } +} diff --git a/pkg/clouds/fs/fs_state_test.go b/pkg/clouds/fs/fs_state_test.go index 69720470..17fb1157 100644 --- a/pkg/clouds/fs/fs_state_test.go +++ b/pkg/clouds/fs/fs_state_test.go @@ -7,6 +7,8 @@ import ( "testing" . "github.com/onsi/gomega" + + "github.com/simple-container-com/api/pkg/api" ) func TestFileSystemStateStorage(t *testing.T) { @@ -64,3 +66,39 @@ func TestProviderTypeConstants(t *testing.T) { Expect(StateStorageTypeFileSystem).To(Equal("fs")) Expect(SecretsProviderTypePassphrase).To(Equal("passphrase")) } + +// init() registers the two read funcs under their type keys. A typo in either +// key, or a reader wired to the wrong struct, only shows up as "unknown +// provisioner field config type" at descriptor-read time — so assert the +// registration through the same lookup the reader uses. +func TestInit_RegistersReadersUnderTheirTypeKeys(t *testing.T) { + RegisterTestingT(t) + + t.Run("fs state storage", func(t *testing.T) { + RegisterTestingT(t) + cfg := &api.Config{Config: map[string]any{"path": "/var/state/sc"}} + out, err := api.ReadProvisionerFieldConfig(StateStorageTypeFileSystem, cfg) + Expect(err).ToNot(HaveOccurred()) + s, ok := out.Config.(*FileSystemStateStorage) + Expect(ok).To(BeTrue(), "the fs key must resolve to FileSystemStateStorage, not the passphrase provider") + Expect(s.StorageUrl()).To(Equal("/var/state/sc")) + }) + + t.Run("passphrase secrets provider", func(t *testing.T) { + RegisterTestingT(t) + cfg := &api.Config{Config: map[string]any{"passPhrase": "correct horse battery staple"}} + out, err := api.ReadProvisionerFieldConfig(SecretsProviderTypePassphrase, cfg) + Expect(err).ToNot(HaveOccurred()) + p, ok := out.Config.(*PassphraseSecretsProvider) + Expect(ok).To(BeTrue()) + Expect(p.PassPhrase).To(Equal("correct horse battery staple")) + Expect(p.KeyUrl()).To(Equal("passphrase")) + }) + + t.Run("unregistered type", func(t *testing.T) { + RegisterTestingT(t) + _, err := api.ReadProvisionerFieldConfig("fs-typo", &api.Config{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown provisioner field config type")) + }) +} diff --git a/pkg/clouds/gcloud/auth_test.go b/pkg/clouds/gcloud/auth_test.go index 259e5c00..7aba865b 100644 --- a/pkg/clouds/gcloud/auth_test.go +++ b/pkg/clouds/gcloud/auth_test.go @@ -8,6 +8,7 @@ import ( "testing" . "github.com/onsi/gomega" + "github.com/samber/lo" "github.com/simple-container-com/api/pkg/api" ) @@ -277,3 +278,61 @@ func TestCredentials_RoundTripJSON(t *testing.T) { Expect(json.Unmarshal([]byte(c.CredentialsValue()), &parsed)).To(Succeed()) Expect(parsed.Type).To(Equal("service_account")) } + +// The read path validates, not just the provisioner: an already-provisioned +// secrets-provider stack is skipped by the provisioner (its URL export exists), +// so a bad rotation period would otherwise surface only at a DR rebuild. +func TestReadSecretsProviderConfig_RejectsBadRotationPeriodAtReadTime(t *testing.T) { + RegisterTestingT(t) + + for _, tc := range []struct { + name string + period string + errSubstr string + }{ + {name: "missing s suffix", period: "90d", errSubstr: "must be a duration in seconds"}, + {name: "not a number", period: "ninetys", errSubstr: "whole number of seconds"}, + {name: "below GCP floor", period: "3600s", errSubstr: "GCP requires at least"}, + {name: "below 30 day minimum", period: "604800s", errSubstr: "allowShortKeyRotation"}, + } { + t.Run(tc.name, func(t *testing.T) { + RegisterTestingT(t) + cfg := &api.Config{Config: map[string]any{ + "projectId": "my-gcp-project", + "keyName": "gcpkms://projects/p/locations/global/keyRings/r/cryptoKeys/k", + "keyLocation": "global", + "keyRotationPeriod": tc.period, + "provision": true, + }} + _, err := ReadSecretsProviderConfig(cfg) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(tc.errSubstr)) + }) + } + + // provision:false means the period is a BYO-key leftover and must not block. + t.Run("ignored when not provisioning", func(t *testing.T) { + RegisterTestingT(t) + cfg := &api.Config{Config: map[string]any{ + "keyName": "gcpkms://projects/p/locations/global/keyRings/r/cryptoKeys/k", + "keyRotationPeriod": "90d", + "provision": false, + }} + _, err := ReadSecretsProviderConfig(cfg) + Expect(err).ToNot(HaveOccurred()) + }) +} + +// Nil means "apply the default", zero means "disable the rule" — the two are +// not interchangeable, and collapsing them would silently drop the rollback +// horizon on every state bucket that never set the field. +func TestStateStorageConfig_EffectiveNoncurrentVersionRetentionDays(t *testing.T) { + RegisterTestingT(t) + + Expect((&StateStorageConfig{}).EffectiveNoncurrentVersionRetentionDays()). + To(Equal(DefaultNoncurrentVersionRetentionDays)) + Expect((&StateStorageConfig{NoncurrentVersionRetentionDays: lo.ToPtr(0)}).EffectiveNoncurrentVersionRetentionDays()). + To(Equal(0), "an explicit zero disables the rule and must not fall back to the default") + Expect((&StateStorageConfig{NoncurrentVersionRetentionDays: lo.ToPtr(7)}).EffectiveNoncurrentVersionRetentionDays()). + To(Equal(7)) +} diff --git a/pkg/clouds/mongodb/mongodb_test.go b/pkg/clouds/mongodb/mongodb_test.go index 8eae8158..d444d9ed 100644 --- a/pkg/clouds/mongodb/mongodb_test.go +++ b/pkg/clouds/mongodb/mongodb_test.go @@ -83,3 +83,51 @@ func TestAtlasBackup_FieldRoundTrip(t *testing.T) { Expect(b.Every).To(Equal("2h")) Expect(b.Retention).To(Equal("168h")) } + +func TestReadAtlasConfig(t *testing.T) { + RegisterTestingT(t) + + t.Run("happy path", func(t *testing.T) { + RegisterTestingT(t) + cfg := &api.Config{Config: map[string]any{ + "orgId": "org-1", + "projectId": "proj-1", + "projectName": "sc", + "region": "EU_WEST_1", + "instanceSize": "M10", + "privateKey": "atlas-private-key", + "publicKey": "atlas-public-key", + "admins": []any{"admin@example.com"}, + // The pointer fields distinguish "unset" from "explicitly zero", so + // they must survive the yaml round-trip ConvertConfig performs. + "diskSizeGB": 10.5, + "numShards": 2, + "namingStrategyVersion": 1, + }} + out, err := ReadAtlasConfig(cfg) + Expect(err).ToNot(HaveOccurred()) + ac, ok := out.Config.(*AtlasConfig) + Expect(ok).To(BeTrue()) + Expect(ac.OrgId).To(Equal("org-1")) + Expect(ac.InstanceSize).To(Equal("M10")) + Expect(ac.CredentialsValue()).To(Equal("atlas-private-key")) + Expect(ac.ProjectIdValue()).To(Equal("proj-1")) + Expect(ac.Admins).To(ConsistOf("admin@example.com")) + Expect(ac.DiskSizeGB).ToNot(BeNil()) + Expect(*ac.DiskSizeGB).To(Equal(10.5)) + Expect(ac.NumShards).ToNot(BeNil()) + Expect(*ac.NumShards).To(Equal(2)) + Expect(ac.NamingStrategyVersion).ToNot(BeNil()) + Expect(*ac.NamingStrategyVersion).To(Equal(1)) + // Unset pointers stay nil so downstream defaults still apply. + Expect(ac.Backup).To(BeNil()) + Expect(ac.NetworkConfig).To(BeNil()) + }) + + t.Run("error path", func(t *testing.T) { + RegisterTestingT(t) + cfg := &api.Config{Config: map[string]any{"instanceSize": []int{1, 2, 3}}} + _, err := ReadAtlasConfig(cfg) + Expect(err).To(HaveOccurred()) + }) +} diff --git a/pkg/security/tools/cosigntest/cosigntest_test.go b/pkg/security/tools/cosigntest/cosigntest_test.go new file mode 100644 index 00000000..763708d6 --- /dev/null +++ b/pkg/security/tools/cosigntest/cosigntest_test.go @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) Simple Container + +package cosigntest + +import ( + "context" + "os/exec" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/gomega" + + "github.com/simple-container-com/api/pkg/security/tools" +) + +// The harness is what three retry suites assert against, so a silent break in +// it (a dropped counter, a marker on the wrong stream, a path that needs +// quoting) turns those suites green for the wrong reason. Drive the stub the +// way production drives cosign — tools.ExecCommand, which is exactly what +// signing.RunCosignWithRetry calls — so stdout and stderr stay separated here +// the same way they do there. + +const stubTimeout = 10 * time.Second + +func runStub(args ...string) (stdout, stderr string, err error) { + return tools.ExecCommand(context.Background(), "cosign", args, nil, stubTimeout) +} + +func TestInstall_StubWinsOnPathAndReportsSuccess(t *testing.T) { + RegisterTestingT(t) + + fake := Install(t, Options{}) + + resolved, err := exec.LookPath("cosign") + Expect(err).ToNot(HaveOccurred()) + Expect(resolved).To(Equal(filepath.Join(filepath.Dir(fake.counter), "cosign")), + "the stub must resolve ahead of any real cosign on the runner") + + stdout, stderr, err := runStub("attest", "--yes") + Expect(err).ToNot(HaveOccurred()) + Expect(stdout).To(ContainSubstring("tlog entry created with index")) + Expect(stderr).To(BeEmpty()) + Expect(fake.Calls(t)).To(Equal(1)) +} + +func TestInstall_ConflictsBeforeFailsThenSucceeds(t *testing.T) { + RegisterTestingT(t) + + fake := Install(t, Options{ConflictsBefore: 2}) + + for attempt := 1; attempt <= 2; attempt++ { + stdout, stderr, err := runStub("attest") + Expect(err).To(HaveOccurred(), "invocation %d must fail", attempt) + Expect(stderr).To(ContainSubstring("createLogEntryConflict")) + Expect(stdout).ToNot(ContainSubstring("createLogEntryConflict"), + "the conflict must not leak onto stdout when ConflictOnStdout is unset") + } + + stdout, _, err := runStub("attest") + Expect(err).ToNot(HaveOccurred(), "invocation 3 is past ConflictsBefore") + Expect(stdout).To(ContainSubstring("tlog entry created with index")) + Expect(fake.Calls(t)).To(Equal(3), "the counter must survive across the three processes") +} + +func TestInstall_ConflictOnStdoutKeepsStderrNonEmptyWithoutTheMarker(t *testing.T) { + RegisterTestingT(t) + + Install(t, Options{ConflictsBefore: 1, ConflictOnStdout: true}) + + stdout, stderr, err := runStub("attest") + Expect(err).To(HaveOccurred()) + Expect(stdout).To(ContainSubstring("createLogEntryConflict")) + // Both halves matter: a caller that classifies only stderr must still see + // output, and it must not be the marker — otherwise a stdout-classification + // bug passes because the marker was on stderr too. + Expect(stderr).ToNot(BeEmpty()) + Expect(stderr).ToNot(ContainSubstring("createLogEntryConflict")) +} + +func TestInstall_FailStderrIsTerminalAndIgnoresConflictsBefore(t *testing.T) { + RegisterTestingT(t) + + fake := Install(t, Options{FailStderr: "Error: UNAUTHORIZED: authentication required", ConflictsBefore: 1}) + + for attempt := 1; attempt <= 2; attempt++ { + _, stderr, err := runStub("attest") + Expect(err).To(HaveOccurred(), "invocation %d must fail", attempt) + Expect(stderr).To(ContainSubstring("UNAUTHORIZED")) + Expect(stderr).ToNot(ContainSubstring("createLogEntryConflict"), + "FailStderr must suppress the retryable conflict entirely") + } + Expect(fake.Calls(t)).To(Equal(2)) +} + +// A stderr body carrying a single quote is what broke an earlier harness: the +// generated /bin/sh script stopped parsing and every invocation failed for the +// wrong reason. +func TestInstall_FailStderrSurvivesShellQuoting(t *testing.T) { + RegisterTestingT(t) + + body := `Error: signing 'registry.example.com/team/app': it's fatal` + Install(t, Options{FailStderr: body}) + + _, stderr, err := runStub("sign") + Expect(err).To(HaveOccurred()) + Expect(stderr).To(ContainSubstring(body), "the quoted body must reach stderr verbatim") +} + +func TestInstall_DelayEachAppliesToEveryInvocation(t *testing.T) { + RegisterTestingT(t) + + const delay = 300 * time.Millisecond + Install(t, Options{DelayEach: delay}) + + for attempt := 1; attempt <= 2; attempt++ { + start := time.Now() + _, _, err := runStub("attest") + Expect(err).ToNot(HaveOccurred()) + Expect(time.Since(start)).To(BeNumerically(">=", delay), + "invocation %d must get its own delay, not a shared one", attempt) + } +} + +func TestInstall_DelayFirstAppliesOnlyToTheFirstInvocation(t *testing.T) { + RegisterTestingT(t) + + const delay = 700 * time.Millisecond + Install(t, Options{DelayFirst: delay}) + + start := time.Now() + _, _, err := runStub("attest") + Expect(err).ToNot(HaveOccurred()) + first := time.Since(start) + + start = time.Now() + _, _, err = runStub("attest") + Expect(err).ToNot(HaveOccurred()) + second := time.Since(start) + + Expect(first).To(BeNumerically(">=", delay)) + Expect(second).To(BeNumerically("<", delay), "only invocation 1 is delayed") +} + +func TestSeconds_RendersDecimalsPosixSleepAccepts(t *testing.T) { + RegisterTestingT(t) + + // `sleep 150ms` is a Go duration string, not a number — /bin/sh rejects it. + for _, tt := range []struct { + in time.Duration + want string + }{ + {0, "0.000"}, + {150 * time.Millisecond, "0.150"}, + {1500 * time.Millisecond, "1.500"}, + {2 * time.Second, "2.000"}, + } { + Expect(seconds(tt.in)).To(Equal(tt.want)) + } +} + +func TestShellQuote_QuotesForPosixShell(t *testing.T) { + RegisterTestingT(t) + + for _, tt := range []struct { + name string + in string + want string + }{ + {"plain", "abc", `'abc'`}, + {"with space", "/tmp/go build/cosign", `'/tmp/go build/cosign'`}, + {"single quote", "it's", `'it'\''s'`}, + {"empty", "", `''`}, + } { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + Expect(shellQuote(tt.in)).To(Equal(tt.want)) + + // Round-trip through the shell the generated stub actually runs + // under: string equality alone would not catch a quoting form that + // /bin/sh re-splits or expands. + out, err := exec.Command("/bin/sh", "-c", "printf '%s' "+shellQuote(tt.in)).Output() + Expect(err).ToNot(HaveOccurred()) + Expect(string(out)).To(Equal(tt.in)) + }) + } +} + +func TestShellQuote_NeutralisesShellMetacharacters(t *testing.T) { + RegisterTestingT(t) + + // A temp dir or stderr body is untrusted input as far as the generated + // script is concerned; quoting must stop it executing. + out, err := exec.Command("/bin/sh", "-c", "printf '%s' "+shellQuote("$(echo pwned)`id`;rm -rf /")).Output() + Expect(err).ToNot(HaveOccurred()) + Expect(string(out)).To(Equal("$(echo pwned)`id`;rm -rf /")) +} + +func TestRekorConflictStderr_MatchesWhatCallersClassifyOn(t *testing.T) { + RegisterTestingT(t) + + // The retry classifier anchors on the swagger error shape, not on "409" + // alone. If the fixture drifts off that shape the retry suites stop + // exercising the retry path and silently assert the failure branch. + Expect(RekorConflictStderr).To(ContainSubstring("[POST /api/v1/log/entries][409]")) + Expect(RekorConflictStderr).To(ContainSubstring("createLogEntryConflict")) +} diff --git a/vex/openvex.json b/vex/openvex.json index 098f155c..2f9eee5d 100644 --- a/vex/openvex.json +++ b/vex/openvex.json @@ -2,8 +2,8 @@ "@context": "https://openvex.dev/ns/v0.2.0", "@id": "https://simple-container.com/.well-known/openvex/api.json", "author": "Simple Container maintainers ", - "timestamp": "2026-08-10T00:00:00Z", - "version": 3, + "timestamp": "2026-08-21T00:00:00Z", + "version": 4, "tooling": "Hand-authored. Reachability evidence from `govulncheck -mode=source ./...`, `go list -deps ./...` and `go mod why -m`.", "statements": [ { @@ -23,7 +23,7 @@ ], "status": "not_affected", "justification": "component_not_present", - "impact_statement": "Corrected 2026-08-10 (was wrongly `fixed`). The advisory has `introduced: 0` and NO fix event in OSV: AWS never patched the v1 s3crypto client, the remediation is migrating to the V2 encryption client. `fixed` was therefore never a reachable status and was acting as an indefinite suppression. Scope of `component_not_present`: the SHIPPED ARTIFACT. aws-sdk-go is absent from go.mod (0 matches) and from go.sum (0 matches), and ZERO of its packages link into any binary we ship — `go list -buildvcs=false -deps ./... | grep -c '^github.com/aws/aws-sdk-go/'` returns 0, `go version -m dist/github-actions | grep -c aws-sdk-go` returns 0, and `go mod why -m github.com/aws/aws-sdk-go` reports \"main module does not need module github.com/aws/aws-sdk-go\". The only residual trace is a requirement-graph edge: `go list -m all` still resolves v1.55.8 because a transitive dependency's own go.mod names it. s3crypto specifically is never imported; S3 access goes through aws-sdk-go-v2. Neither govulncheck -mode=source nor osv-scanner reports this advisory against the repo (osv-scanner reads go.mod's own requirements), so an osv-scanner.toml mirror entry would be an unused ignore. Closing condition: the requirement edge disappears from `go list -m all`, at which point this statement can be deleted." + "impact_statement": "Corrected 2026-08-10 (was wrongly `fixed`). The advisory has `introduced: 0` and NO fix event in OSV: AWS never patched the v1 s3crypto client, the remediation is migrating to the V2 encryption client. `fixed` was therefore never a reachable status and was acting as an indefinite suppression. Scope of `component_not_present`: the SHIPPED ARTIFACT. aws-sdk-go is absent from go.mod (0 matches) and from go.sum (0 matches), and ZERO of its packages link into any binary we ship — `go list -buildvcs=false -deps ./... | grep -c '^github.com/aws/aws-sdk-go/'` returns 0, `go version -m dist/github-actions | grep -c aws-sdk-go` returns 0, and `go mod why -m github.com/aws/aws-sdk-go` reports \"main module does not need module github.com/aws/aws-sdk-go\". The only residual trace is a requirement-graph edge: `go list -m all` still resolves v1.55.8 because a transitive dependency's own go.mod names it. s3crypto specifically is never imported; S3 access goes through aws-sdk-go-v2. Neither govulncheck -mode=source nor osv-scanner reports this advisory against the repo (osv-scanner reads go.mod's own requirements), so an osv-scanner.toml mirror entry would be an unused ignore. Closing condition: the requirement edge disappears from `go list -m all`, at which point this statement can be deleted. Re-triaged 2026-08-21 (SCA pass): still 0 matches in go.mod and go.sum, `go mod why -m` still reports \"main module does not need\", and `go list -m all` still resolves v1.55.8 through a transitive go.mod — evidence unchanged, statement kept as-is." }, { "vulnerability": { @@ -42,7 +42,7 @@ ], "status": "not_affected", "justification": "component_not_present", - "impact_statement": "Same module, same evidence and same correction as GO-2022-0635 — see that statement. Also `introduced: 0` with no fix event, so it was likewise mis-stated as `fixed` before 2026-08-10." + "impact_statement": "Same module, same evidence and same correction as GO-2022-0635 — see that statement. Also `introduced: 0` with no fix event, so it was likewise mis-stated as `fixed` before 2026-08-10. Re-triaged 2026-08-21 with the same result." }, { "vulnerability": { @@ -60,7 +60,7 @@ ], "status": "not_affected", "justification": "vulnerable_code_not_present", - "impact_statement": "Re-confirmed 2026-08-10 against the current tree. golang.org/x/crypto is a direct dependency for chacha20poly1305, ed25519, hkdf and ssh (pkg/api/secrets, pkg/api/secrets/ciphers); 24 of its packages link into shipped binaries, including ssh, ssh/agent, ssh/knownhosts, pkcs12 and ocsp. None of them is openpgp or an openpgp subpackage: `go list -buildvcs=false -deps ./... | grep -c '^golang.org/x/crypto/openpgp'` returns 0, so the Go linker never includes that code in any produced binary. (Use -buildvcs=false: a plain `go list -deps` can exit non-zero on a VCS error and print nothing, which greps identically to a clean result.) OpenPGP operations go through github.com/ProtonMail/go-crypto v1.1.6, the maintained fork the advisory itself recommends. govulncheck -mode=source ./... reports 0 reachable vulnerabilities. Unfixable by bumping: the advisory has `introduced: 0` and no fix event; it is a permanent 'package is unmaintained' notice. x/crypto is kept current regardless — pinned at v0.54.0, which is the latest release." + "impact_statement": "Re-confirmed 2026-08-10 against the current tree. golang.org/x/crypto is a direct dependency for chacha20poly1305, ed25519, hkdf and ssh (pkg/api/secrets, pkg/api/secrets/ciphers); 24 of its packages link into shipped binaries, including ssh, ssh/agent, ssh/knownhosts, pkcs12 and ocsp. None of them is openpgp or an openpgp subpackage: `go list -buildvcs=false -deps ./... | grep -c '^golang.org/x/crypto/openpgp'` returns 0, so the Go linker never includes that code in any produced binary. (Use -buildvcs=false: a plain `go list -deps` can exit non-zero on a VCS error and print nothing, which greps identically to a clean result.) OpenPGP operations go through github.com/ProtonMail/go-crypto v1.1.6, the maintained fork the advisory itself recommends. govulncheck -mode=source ./... reports 0 reachable vulnerabilities. Unfixable by bumping: the advisory has `introduced: 0` and no fix event; it is a permanent 'package is unmaintained' notice. x/crypto is kept current regardless — pinned at v0.55.0, which is the latest release. Re-triaged 2026-08-21 (SCA pass): `go list -buildvcs=false -deps ./...` still links 24 x/crypto packages and 0 openpgp packages, ProtonMail/go-crypto is still the OpenPGP path, and no fix event has appeared upstream — statement kept, x/crypto bumped v0.54.0 → v0.55.0 in the same pass." } ] }