diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 7d56b139..51fda665 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -29,17 +29,39 @@ jobs: # Fails on ANY vulnerable module version (deps + stdlib for the CI toolchain), # regardless of reachability — matches external (Trivy-style) scanners. # - # ALLOWLIST holds advisories we knowingly accept: each must have no fixed - # version available AND not be present in our build graph (unreachable). - # Everything else — including any future fixable advisory — still fails. + # DEP_ALLOWLIST holds dependency advisories we knowingly accept: each must + # have no fixed version available AND not be present in our build graph + # (unreachable). Everything else still fails. # - GO-2026-5932: golang.org/x/crypto/openpgp is unmaintained (Fixed in: N/A). # We do not import openpgp anywhere; verify with: # go list -deps ./... | grep openpgp # (expect no output) + # + # STDLIB_ALLOWLIST holds standard-library advisories whose only remedy is a + # toolchain bump. Every workflow here — including the release build — pins + # 1.25.12, so moving off it is its own change and is deliberately deferred. + # Listing the IDs rather than skipping stdlib wholesale keeps the gate + # honest: a newly published stdlib advisory still fails until someone + # decides to accept or fix it. - name: Run govulncheck (module versions) env: CGO_ENABLED: "0" run: | - ALLOWLIST="GO-2026-5932" + DEP_ALLOWLIST="GO-2026-5932" + + # Fixed in stdlib@go1.25.13, except GO-2026-5942 (stdlib@go1.26.6). + # Delete these once the pinned toolchain covers them. + STDLIB_ALLOWLIST=" + GO-2026-5026 + GO-2026-5942 + GO-2026-5972 + GO-2026-6088 + GO-2026-6089 + GO-2026-6090 + GO-2026-6091 + GO-2026-6218 + " + + ALLOWLIST="$DEP_ALLOWLIST $STDLIB_ALLOWLIST" # Human-readable report (informational; govulncheck exits 3 when it # finds anything, so don't let that fail the step on its own). @@ -48,15 +70,21 @@ jobs: # Machine-readable gate: fail only on advisories not in the allowlist. govulncheck -scan module -format json > govulncheck.json || true findings="$(jq -r 'select(.finding != null) | .finding.osv' govulncheck.json | sort -u)" + allowed="$(tr ' ' '\n' <<< "$ALLOWLIST" | grep -v '^$' | sort -u)" unexpected="" for id in $findings; do - if ! grep -qxF "$id" <<< "$(tr ' ' '\n' <<< "$ALLOWLIST")"; then + if ! grep -qxF "$id" <<< "$allowed"; then unexpected="$unexpected $id" fi done - echo "=== Allowlisted (no fix available, not in build graph): $ALLOWLIST ===" + echo "=== Allowlisted: $(tr '\n' ' ' <<< "$allowed")===" + + stale="$(comm -23 <(echo "$allowed") <(echo "$findings"))" + if [ -n "$stale" ]; then + echo "::warning::Allowlist entries no longer reported, drop them: $(tr '\n' ' ' <<< "$stale")" + fi if [ -n "${unexpected// /}" ]; then echo "::error::Non-allowlisted vulnerabilities found:$unexpected" diff --git a/e2e/go.mod b/e2e/go.mod index 81c1389c..ed358a56 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -168,7 +168,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/indece-official/go-ebcdic v1.2.0 // indirect github.com/infisical/go-sdk v0.7.0 // indirect - github.com/infisical/infisical-kmip v0.3.19 // indirect + github.com/infisical/infisical-kmip v0.3.20 // indirect github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/e2e/go.sum b/e2e/go.sum index 04c3e52c..e873f8fb 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -591,8 +591,8 @@ github.com/indece-official/go-ebcdic v1.2.0 h1:nKCubkNoXrGvBp3MSYuplOQnhANCDEY51 github.com/indece-official/go-ebcdic v1.2.0/go.mod h1:RBddVJt0Ks0eDLRG5dhPwBDRiTNA7n+yv0dVFpSs46Q= github.com/infisical/go-sdk v0.7.0 h1:x9/1PczL+ioVD1jCp4LHQzPpBawatbmkjAC0S1OAtUA= github.com/infisical/go-sdk v0.7.0/go.mod h1:yEfXF+3YDDXiJ9zzJUSzW6me6XXPPEDK52fSU6JfpCA= -github.com/infisical/infisical-kmip v0.3.19 h1:JDndxhM9+GoHTqSOX47H3KIxIuDmrEHsq17wvhw3RL8= -github.com/infisical/infisical-kmip v0.3.19/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= +github.com/infisical/infisical-kmip v0.3.20 h1:FAc/SK90qgSyQg24+gpkudmGmGT5+75/1o3EcsEewtQ= +github.com/infisical/infisical-kmip v0.3.20/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/go.mod b/go.mod index bc4dc53d..e5acef0f 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/google/uuid v1.6.0 github.com/h2non/filetype v1.1.3 github.com/infisical/go-sdk v0.7.0 - github.com/infisical/infisical-kmip v0.3.19 + github.com/infisical/infisical-kmip v0.3.20 github.com/jackc/pgx/v5 v5.9.2 github.com/jcmturner/gokrb5/v8 v8.4.4 github.com/masterzen/winrm v0.0.0-20260407182533-5570be7f80cf diff --git a/go.sum b/go.sum index 3ba40cf0..993a0108 100644 --- a/go.sum +++ b/go.sum @@ -416,8 +416,8 @@ github.com/indece-official/go-ebcdic v1.2.0 h1:nKCubkNoXrGvBp3MSYuplOQnhANCDEY51 github.com/indece-official/go-ebcdic v1.2.0/go.mod h1:RBddVJt0Ks0eDLRG5dhPwBDRiTNA7n+yv0dVFpSs46Q= github.com/infisical/go-sdk v0.7.0 h1:x9/1PczL+ioVD1jCp4LHQzPpBawatbmkjAC0S1OAtUA= github.com/infisical/go-sdk v0.7.0/go.mod h1:yEfXF+3YDDXiJ9zzJUSzW6me6XXPPEDK52fSU6JfpCA= -github.com/infisical/infisical-kmip v0.3.19 h1:JDndxhM9+GoHTqSOX47H3KIxIuDmrEHsq17wvhw3RL8= -github.com/infisical/infisical-kmip v0.3.19/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= +github.com/infisical/infisical-kmip v0.3.20 h1:FAc/SK90qgSyQg24+gpkudmGmGT5+75/1o3EcsEewtQ= +github.com/infisical/infisical-kmip v0.3.20/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/packages/cmd/kmip.go b/packages/cmd/kmip.go index 49d33051..51664d07 100644 --- a/packages/cmd/kmip.go +++ b/packages/cmd/kmip.go @@ -4,11 +4,13 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( + "context" "errors" "fmt" "os" "os/exec" "runtime" + "time" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" @@ -128,7 +130,7 @@ func startKmipServer(cmd *cobra.Command, args []string) { isResourceAuth := enrollMethod == localkmip.EnrollMethodToken || enrollMethod == localkmip.EnrollMethodAws if isResourceAuth { - serverConfig.AccessToken = enrollKmipServer(cmd, enrollMethod, serverName) + serverConfig.AccessToken, serverConfig.RefreshAccessToken = enrollKmipServer(cmd, enrollMethod, serverName) } else { // No enroll method given: reuse the stored enrollment access token unless explicit identity // credentials were passed. This keeps a manual restart of an enrolled server from silently @@ -138,6 +140,9 @@ func startKmipServer(cmd *cobra.Command, args []string) { if storedToken, _ := localkmip.LoadStoredAccessToken(serverName); storedToken != "" { log.Info().Msg("Using stored KMIP server access token") serverConfig.AccessToken = storedToken + if serverID, canRefresh := localkmip.ResolveAwsRefreshServerID(serverName); canRefresh { + serverConfig.RefreshAccessToken = newAwsRefreshAccessTokenFunc(serverName, serverID) + } } } if serverConfig.AccessToken == "" { @@ -148,9 +153,38 @@ func startKmipServer(cmd *cobra.Command, args []string) { kmip.StartServer(serverConfig) } +// newAwsRefreshAccessTokenFunc returns a function that re-authenticates the KMIP server via +// AWS STS and persists the new access token, used by the renewal loop when the current token +// is rejected. Bounded timeouts so a hung API call cannot stall renewal indefinitely. +func newAwsRefreshAccessTokenFunc(serverName, kmipServerID string) func() (string, error) { + return func() (string, error) { + refreshClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + return "", fmt.Errorf("unable to create HTTP client: %w", err) + } + refreshClient.SetTimeout(30 * time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + newToken, err := localkmip.LoginKmipServerWithAws(ctx, refreshClient, kmipServerID) + if err != nil { + return "", fmt.Errorf("AWS Auth re-login failed: %w", err) + } + + if err := localkmip.SaveAccessToken(serverName, newToken); err != nil { + log.Warn().Msgf("failed to persist refreshed KMIP access token: %v", err) + } + + return newToken, nil + } +} + // enrollKmipServer obtains a KMIP server access token via token or AWS enrollment, -// persisting the relevant state under the KMIP server's config file. -func enrollKmipServer(cmd *cobra.Command, enrollMethod, serverName string) string { +// persisting the relevant state under the KMIP server's config file. For AWS enrollment it +// also returns a refresh function so the server can re-authenticate when its access token +// expires before its certificate does; token enrollment is single-use, so no refresh exists. +func enrollKmipServer(cmd *cobra.Command, enrollMethod, serverName string) (string, func() (string, error)) { httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { util.HandleError(err, "unable to create HTTP client") @@ -178,12 +212,16 @@ func enrollKmipServer(cmd *cobra.Command, enrollMethod, serverName string) strin if err := localkmip.SaveServerID(serverName, kmipServerID); err != nil { util.HandleError(err, "failed to save KMIP server id to config") } + if err := localkmip.SaveEnrollMethod(serverName, localkmip.EnrollMethodAws); err != nil { + util.HandleError(err, "failed to save KMIP enroll method to config") + } if err := localkmip.SaveDomain(serverName, config.INFISICAL_URL); err != nil { util.HandleError(err, "failed to save domain to config") } log.Info().Msgf("KMIP server authenticated via AWS Auth. State saved to %s", localkmip.GetConfPathDisplay(serverName)) - return accessToken + + return accessToken, newAwsRefreshAccessTokenFunc(serverName, kmipServerID) } // Enrollment token path @@ -197,7 +235,7 @@ func enrollKmipServer(cmd *cobra.Command, enrollMethod, serverName string) strin storedAccessToken, err := localkmip.LoadStoredAccessToken(serverName) if err == nil && storedAccessToken != "" { log.Info().Msg("Reusing stored KMIP server access token.") - return storedAccessToken + return storedAccessToken, nil } if enrollToken == "" { util.HandleError(errors.New("--token is required when --enroll-method=token and no access token is stored")) @@ -220,6 +258,9 @@ func enrollKmipServer(cmd *cobra.Command, enrollMethod, serverName string) strin if err := localkmip.SaveAccessToken(serverName, enrollResp.AccessToken); err != nil { util.HandleError(err, "failed to save KMIP server access token") } + if err := localkmip.SaveEnrollMethod(serverName, localkmip.EnrollMethodToken); err != nil { + util.HandleError(err, "failed to save KMIP enroll method to config") + } if err := localkmip.SaveEnrollmentToken(serverName, enrollToken); err != nil { util.HandleError(err, "failed to save enrollment token to config") } @@ -228,7 +269,7 @@ func enrollKmipServer(cmd *cobra.Command, enrollMethod, serverName string) strin } log.Info().Msgf("KMIP server enrolled successfully. Access token saved to %s", localkmip.GetConfPathDisplay(serverName)) - return enrollResp.AccessToken + return enrollResp.AccessToken, nil } // resolveKmipIdentityCredentials parses the legacy machine-identity credentials. diff --git a/packages/kmip/enroll.go b/packages/kmip/enroll.go index f37318c3..ee752ad5 100644 --- a/packages/kmip/enroll.go +++ b/packages/kmip/enroll.go @@ -131,6 +131,31 @@ func SaveServerID(name, kmipServerID string) error { return saveConfKey(name, INFISICAL_KMIP_SERVER_ID_KEY, kmipServerID) } +// Conf-file only: the env var says what this run asked for, not how the server enrolled. +func LoadStoredEnrollMethod(name string) (string, error) { + return loadConfKey(name, INFISICAL_KMIP_ENROLL_METHOD_KEY) +} + +func SaveEnrollMethod(name, method string) error { + return saveConfKey(name, INFISICAL_KMIP_ENROLL_METHOD_KEY, method) +} + +func LoadPersistedServerID(name string) (string, error) { + return loadConfKey(name, INFISICAL_KMIP_SERVER_ID_KEY) +} + +// Ignores the environment throughout: honouring INFISICAL_KMIP_SERVER_ID here would wire a +// token-enrolled server for an STS refresh it cannot perform. A missing method means the server +// enrolled before it was recorded, where a conf-file server ID identifies AWS enrollment. +func ResolveAwsRefreshServerID(name string) (string, bool) { + method, _ := LoadStoredEnrollMethod(name) + serverID, _ := LoadPersistedServerID(name) + if serverID == "" { + return "", false + } + return serverID, method == EnrollMethodAws || method == "" +} + func GetConfPathDisplay(name string) string { path, err := kmipConfPath(name) if err != nil { diff --git a/packages/kmip/enroll_test.go b/packages/kmip/enroll_test.go new file mode 100644 index 00000000..2af9abcb --- /dev/null +++ b/packages/kmip/enroll_test.go @@ -0,0 +1,149 @@ +package kmip + +import "os" +import "testing" + +func cleanup(t *testing.T, name string) { + t.Helper() + if p, err := kmipConfPath(name); err == nil { + _ = os.Remove(p) + } +} + +func TestAwsRefreshOnlyForAwsEnrolledServers(t *testing.T) { + t.Run("token enrolled with env server id", func(t *testing.T) { + name := "probe-token" + defer cleanup(t, name) + if err := SaveEnrollMethod(name, EnrollMethodToken); err != nil { + t.Fatal(err) + } + t.Setenv(INFISICAL_KMIP_SERVER_ID_KEY, "srv-from-environment") + if _, aws := ResolveAwsRefreshServerID(name); aws { + t.Fatal("token-enrolled server must not be wired for STS refresh") + } + }) + + t.Run("aws enrolled", func(t *testing.T) { + name := "probe-aws" + defer cleanup(t, name) + if err := SaveEnrollMethod(name, EnrollMethodAws); err != nil { + t.Fatal(err) + } + if err := SaveServerID(name, "srv-real"); err != nil { + t.Fatal(err) + } + id, aws := ResolveAwsRefreshServerID(name) + if !aws || id != "srv-real" { + t.Fatalf("aws-enrolled server should refresh via STS, got id=%q aws=%v", id, aws) + } + }) + + t.Run("legacy aws enrolled without a recorded method", func(t *testing.T) { + name := "probe-legacy" + defer cleanup(t, name) + if err := SaveServerID(name, "srv-legacy"); err != nil { + t.Fatal(err) + } + id, aws := ResolveAwsRefreshServerID(name) + if !aws || id != "srv-legacy" { + t.Fatalf("legacy aws server should still refresh, got id=%q aws=%v", id, aws) + } + }) + + t.Run("legacy token enrolled with env server id", func(t *testing.T) { + name := "probe-legacy-token" + defer cleanup(t, name) + if err := SaveEnrollmentToken(name, "tok"); err != nil { + t.Fatal(err) + } + t.Setenv(INFISICAL_KMIP_SERVER_ID_KEY, "srv-from-environment") + if _, aws := ResolveAwsRefreshServerID(name); aws { + t.Fatal("env var must not make a legacy token-enrolled server look aws-enrolled") + } + }) +} + +func TestResolveAwsRefreshServerIDEdgeCases(t *testing.T) { + t.Run("never enrolled", func(t *testing.T) { + name := "probe-absent" + defer cleanup(t, name) + if _, aws := ResolveAwsRefreshServerID(name); aws { + t.Fatal("a server with no config must not refresh") + } + }) + + t.Run("aws method recorded but no server id", func(t *testing.T) { + name := "probe-aws-no-id" + defer cleanup(t, name) + if err := SaveEnrollMethod(name, EnrollMethodAws); err != nil { + t.Fatal(err) + } + if _, aws := ResolveAwsRefreshServerID(name); aws { + t.Fatal("there is nothing to re-authenticate with without a server id") + } + }) + + t.Run("stale server id from a previous aws enrollment", func(t *testing.T) { + name := "probe-reenrolled" + defer cleanup(t, name) + if err := SaveServerID(name, "srv-old-aws"); err != nil { + t.Fatal(err) + } + if err := SaveEnrollMethod(name, EnrollMethodToken); err != nil { + t.Fatal(err) + } + if _, aws := ResolveAwsRefreshServerID(name); aws { + t.Fatal("a token re-enrollment must not keep refreshing via the old aws server id") + } + }) + + t.Run("env server id differs from the recorded one", func(t *testing.T) { + name := "probe-env-differs" + defer cleanup(t, name) + if err := SaveEnrollMethod(name, EnrollMethodAws); err != nil { + t.Fatal(err) + } + if err := SaveServerID(name, "srv-recorded"); err != nil { + t.Fatal(err) + } + t.Setenv(INFISICAL_KMIP_SERVER_ID_KEY, "srv-from-environment") + id, aws := ResolveAwsRefreshServerID(name) + if !aws || id != "srv-recorded" { + t.Fatalf("must re-authenticate as the recorded server, got id=%q aws=%v", id, aws) + } + }) + + t.Run("unrecognised method", func(t *testing.T) { + name := "probe-unknown" + defer cleanup(t, name) + if err := SaveEnrollMethod(name, "kubernetes"); err != nil { + t.Fatal(err) + } + if err := SaveServerID(name, "srv-x"); err != nil { + t.Fatal(err) + } + if _, aws := ResolveAwsRefreshServerID(name); aws { + t.Fatal("only aws enrollment refreshes via STS") + } + }) + + t.Run("method is overwritten on re-enrollment", func(t *testing.T) { + name := "probe-overwrite" + defer cleanup(t, name) + if err := SaveEnrollMethod(name, EnrollMethodToken); err != nil { + t.Fatal(err) + } + if err := SaveEnrollMethod(name, EnrollMethodAws); err != nil { + t.Fatal(err) + } + if err := SaveServerID(name, "srv-now-aws"); err != nil { + t.Fatal(err) + } + if got, _ := LoadStoredEnrollMethod(name); got != EnrollMethodAws { + t.Fatalf("re-enrollment should replace the method, got %q", got) + } + if id, aws := ResolveAwsRefreshServerID(name); !aws || id != "srv-now-aws" { + t.Fatalf("expected refresh as srv-now-aws, got id=%q aws=%v", id, aws) + } + }) +}