🧪 test(bdd): admin metadata endpoint security property — no secret leak
Activates a new @critical @admin-introspection scenario in features/jwt/jwt_secret_retention.feature that exercises the GET /api/v1/admin/jwt/secrets endpoint added in PR #51. The scenario asserts the SECURITY-CRITICAL property: the metadata endpoint exposes structure (count + per-secret is_primary, age, fingerprint) WITHOUT leaking secret values. If a future change accidentally adds the secret value to the response, this test fails loud: SECURITY: response leaked the secret value "test-secret-do-not-leak..." Specifically, the BDD asserts: - After adding a secondary secret with a known value, GET returns 200 - The response contains 2 secrets in count - The response does NOT contain the secret value anywhere - Every entry has a non-empty SHA-256 fingerprint 4 new step definitions added to pkg/bdd/steps/jwt_retention_steps.go: - iAddASecondaryJWTSecretNamed (parameterised by secret value) - iRequestTheJWTSecretsMetadataEndpoint - theMetadataShouldContainNSecrets - theMetadataShouldNotContainTheSecretValue (the security check) - everySecretInTheMetadataShouldHaveASHA256Fingerprint Tests: - Scenario passes via @admin-introspection tag filter. - Full BDD suite (auth/config/greet/health/info/jwt) green. The pre-existing @todo scenarios (Multiple secrets with different ages, Cleanup frequency configuration, etc.) remain @todo — they require arbitrary timestamp setup or manual cleanup triggers that aren't exposed via API, by design. Documented as future test-infrastructure work.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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":"<non-empty>"
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user