diff --git a/features/jwt/jwt_secret_retention.feature b/features/jwt/jwt_secret_retention.feature index a40a0fd..3da350b 100644 --- a/features/jwt/jwt_secret_retention.feature +++ b/features/jwt/jwt_secret_retention.feature @@ -40,6 +40,16 @@ Feature: JWT Secret Retention Policy Then the primary secret should not be removed And the primary secret should remain active + @critical @admin-introspection + Scenario: Admin metadata endpoint exposes structure without leaking secret values + Given a primary JWT secret exists + And I add a secondary JWT secret "test-secret-do-not-leak-please-12345" + When I request the JWT secrets metadata endpoint + Then the status code should be 200 + And the metadata should contain 2 secrets + And the metadata should NOT contain the secret value "test-secret-do-not-leak-please-12345" + And every secret in the metadata should have a SHA-256 fingerprint + @todo Scenario: Multiple secrets with different ages Given I have 3 JWT secrets of different ages diff --git a/pkg/bdd/steps/jwt_retention_steps.go b/pkg/bdd/steps/jwt_retention_steps.go index c32729b..428c16a 100644 --- a/pkg/bdd/steps/jwt_retention_steps.go +++ b/pkg/bdd/steps/jwt_retention_steps.go @@ -822,3 +822,61 @@ func (s *JWTRetentionSteps) andSuggestRemediationSteps() error { // Verify remediation suggestions return godog.ErrPending } + +// ===================================================================== +// Admin metadata introspection steps (PR #51 + this scenario) +// ===================================================================== + +// iAddASecondaryJWTSecretNamed adds a secret with a specific value via the +// admin API. Used by the admin-introspection scenario to verify that the +// metadata endpoint returns metadata only, not the secret value. +func (s *JWTRetentionSteps) iAddASecondaryJWTSecretNamed(secretValue string) error { + s.SetLastSecret(secretValue) + return s.client.Request("POST", "/api/v1/admin/jwt/secrets", map[string]string{ + "secret": secretValue, + "is_primary": "false", + }) +} + +// iRequestTheJWTSecretsMetadataEndpoint hits GET /api/v1/admin/jwt/secrets. +func (s *JWTRetentionSteps) iRequestTheJWTSecretsMetadataEndpoint() error { + return s.client.Request("GET", "/api/v1/admin/jwt/secrets", nil) +} + +// theMetadataShouldContainNSecrets verifies the response count field. +func (s *JWTRetentionSteps) theMetadataShouldContainNSecrets(expected int) error { + body := string(s.client.GetLastBody()) + expectedFragment := `"count":` + strconv.Itoa(expected) + if !strings.Contains(body, expectedFragment) { + return fmt.Errorf("expected response to contain %q, got: %s", expectedFragment, body) + } + return nil +} + +// theMetadataShouldNotContainTheSecretValue is the SECURITY-CRITICAL +// assertion. If the response contains the raw secret string anywhere, +// the endpoint has leaked. This is the property the metadata-only design +// is supposed to guarantee. +func (s *JWTRetentionSteps) theMetadataShouldNotContainTheSecretValue(secretValue string) error { + body := string(s.client.GetLastBody()) + if strings.Contains(body, secretValue) { + return fmt.Errorf("SECURITY: response leaked the secret value %q (response body: %s)", secretValue, body) + } + return nil +} + +// everySecretInTheMetadataShouldHaveASHA256Fingerprint asserts the +// secret_sha256 field is present and non-empty for each entry. Cheap +// regex-style check on the JSON body. +func (s *JWTRetentionSteps) everySecretInTheMetadataShouldHaveASHA256Fingerprint() error { + body := string(s.client.GetLastBody()) + // Expect at least one occurrence of "secret_sha256":"" + if !strings.Contains(body, `"secret_sha256":"`) { + return fmt.Errorf("response does not include any secret_sha256 fingerprint: %s", body) + } + // Reject obviously-empty values + if strings.Contains(body, `"secret_sha256":""`) { + return fmt.Errorf("at least one secret_sha256 fingerprint is empty in response: %s", body) + } + return nil +} diff --git a/pkg/bdd/steps/steps.go b/pkg/bdd/steps/steps.go index 625636d..c3d1b49 100644 --- a/pkg/bdd/steps/steps.go +++ b/pkg/bdd/steps/steps.go @@ -173,6 +173,12 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s ctx.Step(`^I should receive configuration validation error$`, sc.jwtRetentionSteps.iShouldReceiveConfigurationValidationError) ctx.Step(`^the error should mention "([^"]*)"$`, sc.jwtRetentionSteps.theErrorShouldMention) ctx.Step(`^I have enabled Prometheus metrics$`, sc.jwtRetentionSteps.iHaveEnabledPrometheusMetrics) + // Admin metadata introspection steps (PR #51 + admin-introspection scenario) + ctx.Step(`^I add a secondary JWT secret "([^"]*)"$`, sc.jwtRetentionSteps.iAddASecondaryJWTSecretNamed) + ctx.Step(`^I request the JWT secrets metadata endpoint$`, sc.jwtRetentionSteps.iRequestTheJWTSecretsMetadataEndpoint) + ctx.Step(`^the metadata should contain (\d+) secrets$`, sc.jwtRetentionSteps.theMetadataShouldContainNSecrets) + ctx.Step(`^the metadata should NOT contain the secret value "([^"]*)"$`, sc.jwtRetentionSteps.theMetadataShouldNotContainTheSecretValue) + ctx.Step(`^every secret in the metadata should have a SHA-256 fingerprint$`, sc.jwtRetentionSteps.everySecretInTheMetadataShouldHaveASHA256Fingerprint) ctx.Step(`^I should see "([^"]*)" metric increment$`, sc.jwtRetentionSteps.iShouldSeeMetricIncrement) ctx.Step(`^I should see "([^"]*)" metric decrease$`, sc.jwtRetentionSteps.iShouldSeeMetricDecrease) ctx.Step(`^I should see "([^"]*)" histogram update$`, sc.jwtRetentionSteps.iShouldSeeHistogramUpdate)