diff --git a/audit/entry_formatter.go b/audit/entry_formatter.go index 9771452154..90b33221c6 100644 --- a/audit/entry_formatter.go +++ b/audit/entry_formatter.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "maps" "reflect" "runtime/debug" "strings" @@ -503,6 +504,7 @@ func newResponse(resp *logical.Response, req *logical.Request, isElisionRequired Secret: s, WrapInfo: wrapInfo, Warnings: warnings, + SupplementalAuditData: resp.SupplementalAuditResponseData, }, nil } @@ -545,6 +547,12 @@ func (f *entryFormatter) createEntry(ctx context.Context, a *Event) (*entry, err if err != nil { return nil, fmt.Errorf("cannot convert response: %w", err) } + + // If the plugin's response contained any additional audit request fields, + // lets populate them on our original request. + if data.Response != nil && data.Response.SupplementalAuditRequestData != nil { + req.SupplementalAuditData = maps.Clone(data.Response.SupplementalAuditRequestData) + } } var outerErr string diff --git a/audit/hashstructure.go b/audit/hashstructure.go index f36ab45be4..05041d5cb5 100644 --- a/audit/hashstructure.go +++ b/audit/hashstructure.go @@ -83,6 +83,13 @@ func hashRequest(ctx context.Context, salter Salter, req *request, hmacAccessor } } + if req.SupplementalAuditData != nil { + err = hashMap(fn, req.SupplementalAuditData, nonHMACDataKeys) + if err != nil { + return err + } + } + return nil } @@ -136,6 +143,13 @@ func hashResponse(ctx context.Context, salter Salter, resp *response, hmacAccess } } + if resp.SupplementalAuditData != nil { + err = hashMap(fn, resp.SupplementalAuditData, nonHMACDataKeys) + if err != nil { + return err + } + } + if resp.WrapInfo != nil { var err error err = hashWrapInfo(fn, resp.WrapInfo, hmacAccessor) diff --git a/audit/types.go b/audit/types.go index cd775022cc..2ddec32374 100644 --- a/audit/types.go +++ b/audit/types.go @@ -44,6 +44,9 @@ type request struct { ReplicationCluster string `json:"replication_cluster,omitempty"` RequestURI string `json:"request_uri,omitempty"` WrapTTL int `json:"wrap_ttl,omitempty"` + // SupplementalAuditData A plugin can influence the request logged within an audit entry of type response, to + // provide additional details on the parsed request. Useful for binary protocols. + SupplementalAuditData map[string]any `json:"supplemental_audit_data,omitempty"` } type response struct { @@ -61,6 +64,10 @@ type response struct { Secret *secret `json:"secret,omitempty"` WrapInfo *responseWrapInfo `json:"wrap_info,omitempty"` Warnings []string `json:"warnings,omitempty"` + // SupplementalAuditData A plugin can influence the response logged within an audit entry of type response, to + // provide additional details on the response outside what was returned within the Data map. + // Useful for binary protocols. + SupplementalAuditData map[string]any `json:"supplemental_audit_data,omitempty"` } type auth struct { diff --git a/builtin/logical/pki/path_ocsp.go b/builtin/logical/pki/path_ocsp.go index ebf851759e..3ca0ea75aa 100644 --- a/builtin/logical/pki/path_ocsp.go +++ b/builtin/logical/pki/path_ocsp.go @@ -163,17 +163,17 @@ func (b *backend) ocspHandler(ctx context.Context, request *logical.Request, dat sc := b.makeStorageContext(ctx, request.Storage) cfg, err := b.CrlBuilder().GetConfigWithUpdate(sc) if err != nil || cfg.OcspDisable || (isUnifiedOcspPath(request) && !cfg.UnifiedCRL) { - return OcspUnauthorizedResponse, nil + return wrapWithAuditError(OcspUnauthorizedResponse, fmt.Errorf("OCSP disabled")), nil } derReq, err := fetchDerEncodedRequest(request, data) if err != nil { - return OcspMalformedResponse, nil + return wrapWithAuditError(OcspMalformedResponse, fmt.Errorf("failed to DER decode: %w", err)), nil } ocspReq, err := ocsp.ParseRequest(derReq) if err != nil { - return OcspMalformedResponse, nil + return wrapWithAuditError(OcspMalformedResponse, fmt.Errorf("failed to parse request: %w", err)), nil } useUnifiedStorage := canUseUnifiedStorage(request, cfg) @@ -196,12 +196,12 @@ func (b *backend) ocspHandler(ctx context.Context, request *logical.Request, dat // we should be responding with an Unauthorized response as we don't have the // ability to sign the response. // https://www.rfc-editor.org/rfc/rfc5019#section-2.2.3 - return OcspUnauthorizedResponse, nil + return wrapWithAuditError(OcspUnauthorizedResponse, errors.New("issuer is missing OCSP usage")), nil } return logAndReturnInternalError(b.Logger(), err), nil } - byteResp, err := genResponse(cfg, caBundle, ocspStatus, ocspReq.HashAlgorithm, issuer.RevocationSigAlg) + byteResp, ocspResp, err := genResponse(cfg, caBundle, ocspStatus, ocspReq.HashAlgorithm, issuer.RevocationSigAlg) if err != nil { return logAndReturnInternalError(b.Logger(), err), nil } @@ -218,15 +218,55 @@ func (b *backend) ocspHandler(ctx context.Context, request *logical.Request, dat observe.NewAdditionalPKIMetadata("ocsp_status", ocspStatus.ocspStatus), ) + auditReq, auditResp := genAuditFields(ocspReq, ocspResp, ocspStatus.issuerID) + return &logical.Response{ Data: map[string]interface{}{ logical.HTTPContentType: ocspResponseContentType, logical.HTTPStatusCode: http.StatusOK, logical.HTTPRawBody: byteResp, }, + SupplementalAuditRequestData: auditReq, + SupplementalAuditResponseData: auditResp, }, nil } +func genAuditFields(ocspReq *ocsp.Request, ocspResp *ocsp.Response, issuerID issuing.IssuerID) (map[string]any, map[string]any) { + serialNumber := parsing.SerialFromBigInt(ocspReq.SerialNumber) + + auditRequest := map[string]any{ + "serial_number": serialNumber, + "hash_algorithm": ocspReq.HashAlgorithm.String(), + } + + auditResp := map[string]any{ + "serial_number": serialNumber, + "ocsp_status": ocspStatusToString(ocspResp.Status), + "issuer_id": issuerID.String(), + } + + if !ocspResp.ThisUpdate.IsZero() { + auditResp["this_update"] = ocspResp.ThisUpdate.Format(time.RFC3339) + } + + if !ocspResp.NextUpdate.IsZero() { + auditResp["next_update"] = ocspResp.NextUpdate.Format(time.RFC3339) + } + + if !ocspResp.ProducedAt.IsZero() { + auditResp["produced_at"] = ocspResp.ProducedAt.Format(time.RFC3339) + } + + if ocspResp.Status == ocsp.Revoked { + if !ocspResp.RevokedAt.IsZero() { + auditResp["revoked_at"] = ocspResp.RevokedAt.Format(time.RFC3339) + } + auditResp["revocation_reason"] = revokedReasonToString(ocspResp.RevocationReason) + } + + return auditRequest, auditResp +} + func canUseUnifiedStorage(req *logical.Request, cfg *pki_backend.CrlConfig) bool { if isUnifiedOcspPath(req) { return true @@ -275,17 +315,63 @@ func generateUnknownResponse(cfg *pki_backend.CrlConfig, sc *storageContext, ocs ocspStatus: ocsp.Unknown, } - byteResp, err := genResponse(cfg, caBundle, info, ocspReq.HashAlgorithm, issuer.RevocationSigAlg) + byteResp, ocspResp, err := genResponse(cfg, caBundle, info, ocspReq.HashAlgorithm, issuer.RevocationSigAlg) if err != nil { return logAndReturnInternalError(sc.Logger(), err) } + auditReq, auditResp := genAuditFields(ocspReq, ocspResp, issuer.ID) + return &logical.Response{ Data: map[string]interface{}{ logical.HTTPContentType: ocspResponseContentType, logical.HTTPStatusCode: http.StatusOK, logical.HTTPRawBody: byteResp, }, + SupplementalAuditRequestData: auditReq, + SupplementalAuditResponseData: auditResp, + } +} + +func ocspStatusToString(status int) string { + switch status { + case ocsp.Good: + return "Good" + case ocsp.Revoked: + return "Revoked" + case ocsp.Unknown: + return "Unknown" + case ocsp.ServerFailed: + return "ServerFailed" + default: + return fmt.Sprintf("Unknown OCSP status (%d)", status) + } +} + +func revokedReasonToString(reason int) string { + switch reason { + case ocsp.Unspecified: + return "Unspecified" + case ocsp.KeyCompromise: + return "KeyCompromise" + case ocsp.CACompromise: + return "CACompromise" + case ocsp.AffiliationChanged: + return "AffiliationChanged" + case ocsp.Superseded: + return "Superseded" + case ocsp.CessationOfOperation: + return "CessationOfOperation" + case ocsp.CertificateHold: + return "CertificateHold" + case ocsp.RemoveFromCRL: + return "RemoveFromCRL" + case ocsp.PrivilegeWithdrawn: + return "PrivilegeWithdrawn" + case ocsp.AACompromise: + return "AACompromise" + default: + return fmt.Sprintf("Unknown OCSP revocation reason (%d)", reason) } } @@ -336,7 +422,16 @@ func logAndReturnInternalError(logger hclog.Logger, err error) *logical.Response // errors, so we rely on the log statement to help in debugging possible // issues in the field. logger.Debug("OCSP internal error", "error", err) - return OcspInternalErrorResponse + return wrapWithAuditError(OcspInternalErrorResponse, err) +} + +func wrapWithAuditError(response *logical.Response, err error) *logical.Response { + return &logical.Response{ + Data: response.Data, + SupplementalAuditResponseData: map[string]interface{}{ + "ocsp_error": err.Error(), + }, + } } func getOcspStatus(sc *storageContext, ocspReq *ocsp.Request, useUnifiedStorage bool) (*ocspRespInfo, error) { @@ -491,11 +586,11 @@ func doesRequestMatchIssuer(parsedBundle *certutil.ParsedCertBundle, req *ocsp.R return bytes.Equal(req.IssuerKeyHash, issuerKeyHash) && bytes.Equal(req.IssuerNameHash, issuerNameHash), nil } -func genResponse(cfg *pki_backend.CrlConfig, caBundle *certutil.ParsedCertBundle, info *ocspRespInfo, reqHash crypto.Hash, revSigAlg x509.SignatureAlgorithm) ([]byte, error) { +func genResponse(cfg *pki_backend.CrlConfig, caBundle *certutil.ParsedCertBundle, info *ocspRespInfo, reqHash crypto.Hash, revSigAlg x509.SignatureAlgorithm) ([]byte, *ocsp.Response, error) { curTime := time.Now() duration, err := parseutil.ParseDurationSecond(cfg.OcspExpiry) if err != nil { - return nil, err + return nil, nil, err } // x/crypto/ocsp lives outside of the standard library's crypto/x509 and includes @@ -541,7 +636,11 @@ func genResponse(cfg *pki_backend.CrlConfig, caBundle *certutil.ParsedCertBundle template.RevocationReason = ocsp.Unspecified } - return ocsp.CreateResponse(caBundle.Certificate, caBundle.Certificate, template, caBundle.PrivateKey) + byteResp, err := ocsp.CreateResponse(caBundle.Certificate, caBundle.Certificate, template, caBundle.PrivateKey) + if err != nil { + return nil, nil, err + } + return byteResp, &template, nil } const pathOcspHelpSyn = ` diff --git a/changelog/_11018.txt b/changelog/_11018.txt new file mode 100644 index 0000000000..7d999d2b4a --- /dev/null +++ b/changelog/_11018.txt @@ -0,0 +1,6 @@ +```release-note:change +audit: A new top-level key called `supplemental_audit_data` can now appear within audit entries of type "response" within the request and response data structures. These new fields can contain data that further describe the request/response data and are mainly used for non-JSON based requests and responses to help auditing. The `audit-non-hmac-request-keys` and `audit-non-hmac-response-keys` apply to keys within `supplemental_audit_data` to remove the HMAC of the field values if so desired. +``` +```release-note:improvement +secrets/pki: OCSP populate details of the response within the new `supplemental_audit_data` section of audit log response entries. Details such as issuer_id, next_update, ocsp_status, serial_number, revoked_at will appear as hmac values by default unless added to the mount's `audit-non-hmac-response-keys` set of keys. +``` diff --git a/sdk/framework/openapi.go b/sdk/framework/openapi.go index 3a232537b1..d31c0107f9 100644 --- a/sdk/framework/openapi.go +++ b/sdk/framework/openapi.go @@ -1227,26 +1227,30 @@ func hyphenatedToTitleCase(in string) string { // cleanedResponse is identical to logical.Response but with nulls // removed from from JSON encoding type cleanedResponse struct { - Secret *logical.Secret `json:"secret,omitempty"` - Auth *logical.Auth `json:"auth,omitempty"` - Data map[string]interface{} `json:"data,omitempty"` - Redirect string `json:"redirect,omitempty"` - Warnings []string `json:"warnings,omitempty"` - WrapInfo *wrapping.ResponseWrapInfo `json:"wrap_info,omitempty"` - Headers map[string][]string `json:"headers,omitempty"` - MountType string `json:"mount_type,omitempty"` + Secret *logical.Secret `json:"secret,omitempty"` + Auth *logical.Auth `json:"auth,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` + Redirect string `json:"redirect,omitempty"` + Warnings []string `json:"warnings,omitempty"` + WrapInfo *wrapping.ResponseWrapInfo `json:"wrap_info,omitempty"` + Headers map[string][]string `json:"headers,omitempty"` + MountType string `json:"mount_type,omitempty"` + SupplementalAuditResponseData map[string]any `json:"supplemental_audit_response_data,omitempty"` + SupplementalAuditRequestData map[string]any `json:"supplemental_audit_request_data,omitempty"` } func cleanResponse(resp *logical.Response) *cleanedResponse { return &cleanedResponse{ - Secret: resp.Secret, - Auth: resp.Auth, - Data: resp.Data, - Redirect: resp.Redirect, - Warnings: resp.Warnings, - WrapInfo: resp.WrapInfo, - Headers: resp.Headers, - MountType: resp.MountType, + Secret: resp.Secret, + Auth: resp.Auth, + Data: resp.Data, + Redirect: resp.Redirect, + Warnings: resp.Warnings, + WrapInfo: resp.WrapInfo, + Headers: resp.Headers, + MountType: resp.MountType, + SupplementalAuditResponseData: resp.SupplementalAuditResponseData, + SupplementalAuditRequestData: resp.SupplementalAuditRequestData, } } diff --git a/sdk/framework/openapi_test.go b/sdk/framework/openapi_test.go index dc4e50fc27..e5e0142303 100644 --- a/sdk/framework/openapi_test.go +++ b/sdk/framework/openapi_test.go @@ -760,14 +760,16 @@ func TestOpenAPI_CleanResponse(t *testing.T) { // logical.Response. This will fail if logical.Response changes without a corresponding // change to cleanResponse() orig = &logical.Response{ - Secret: new(logical.Secret), - Auth: new(logical.Auth), - Data: map[string]interface{}{"foo": 42}, - Redirect: "foo", - Warnings: []string{"foo"}, - WrapInfo: &wrapping.ResponseWrapInfo{Token: "foo"}, - Headers: map[string][]string{"foo": {"bar"}}, - MountType: "mount", + Secret: new(logical.Secret), + Auth: new(logical.Auth), + Data: map[string]interface{}{"foo": 42}, + Redirect: "foo", + Warnings: []string{"foo"}, + WrapInfo: &wrapping.ResponseWrapInfo{Token: "foo"}, + Headers: map[string][]string{"foo": {"bar"}}, + MountType: "mount", + SupplementalAuditResponseData: map[string]any{"baz": "qux"}, + SupplementalAuditRequestData: map[string]any{"qux": "baz"}, } origJSON := mustJSONMarshal(t, orig) diff --git a/sdk/logical/response.go b/sdk/logical/response.go index e1f57bba81..f80cb83093 100644 --- a/sdk/logical/response.go +++ b/sdk/logical/response.go @@ -89,6 +89,18 @@ type Response struct { // MountType, if non-empty, provides some information about what kind // of mount this secret came from. MountType string `json:"mount_type" structs:"mount_type" mapstructure:"mount_type"` + + // SupplementalAuditResponseData, provides additional keys to be formatted by the audit engine that + // don't appear within the final client response. Useful when implementing binary responses to clients, this + // can provide parseable values within the audit log. Keys will appear within the type response log entries + // within the response "supplemental_audit_data" section. + SupplementalAuditResponseData map[string]any `json:"supplemental_audit_response_data" structs:"supplemental_audit_response_data" mapstructure:"supplemental_audit_response_data"` + + // SupplementalAuditRequestData, provides additional keys to be formatted by the audit engine that + // don't appear within the final client response. These values will appear in the request section of + // audit records of type response within the "supplemental_audit_data", they do not influence the + // original request audit log. + SupplementalAuditRequestData map[string]any `json:"supplemental_audit_request_data" structs:"supplemental_audit_request_data" mapstructure:"supplemental_audit_request_data"` } // AddWarning adds a warning into the response's warning list diff --git a/vault/external_tests/audit/audit_only_fields_test.go b/vault/external_tests/audit/audit_only_fields_test.go new file mode 100644 index 0000000000..01a9ef3664 --- /dev/null +++ b/vault/external_tests/audit/audit_only_fields_test.go @@ -0,0 +1,258 @@ +// Copyright IBM Corp. 2016, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package audit + +import ( + "bufio" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/helper/testhelpers/corehelpers" + "github.com/hashicorp/vault/helper/testhelpers/minimal" + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" + "github.com/hashicorp/vault/vault" + "github.com/stretchr/testify/require" +) + +// TestSupplementalAuditData validates if a plugin populates the logical.Response SupplementalAuditRequestData and +// SupplementalAuditResponseData that we populate the audit entry appropriately also applying the mount's +// HMAC keys to the appropriate request/response fields +func TestSupplementalAuditData(t *testing.T) { + t.Parallel() + + testHandlerWithAuditOnly := func(ctx context.Context, l *logical.Request, data *framework.FieldData) (*logical.Response, error) { + return &logical.Response{ + Data: map[string]interface{}{ + "secret": "my-fancy-secret", + }, + SupplementalAuditRequestData: map[string]any{ + "foo": "bar", + "baz": "qux", + }, + SupplementalAuditResponseData: map[string]any{ + "foo": "bar", + "baz": "qux", + "quux": "corge", + }, + }, nil + } + + testHandlerNoAudit := func(ctx context.Context, l *logical.Request, data *framework.FieldData) (*logical.Response, error) { + return &logical.Response{ + Data: map[string]interface{}{ + "secret": "my-fancy-secret", + }, + }, nil + } + + operationsWithAudit := map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{Callback: testHandlerWithAuditOnly}, + } + + operationsNoAudit := map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{Callback: testHandlerNoAudit}, + } + + conf := &vault.CoreConfig{ + BuiltinRegistry: corehelpers.NewMockBuiltinRegistry(), + LogicalBackends: map[string]logical.Factory{ + "audittest": func(ctx context.Context, config *logical.BackendConfig) (logical.Backend, error) { + b := new(framework.Backend) + b.BackendType = logical.TypeLogical + b.Paths = []*framework.Path{ + {Pattern: "with-audit-fields", Operations: operationsWithAudit}, + {Pattern: "no-audit-fields", Operations: operationsNoAudit}, + } + err := b.Setup(ctx, config) + return b, err + }, + }, + } + + cluster := minimal.NewTestSoloCluster(t, conf) + client := cluster.Cores[0].Client + + auditLog := filepath.Join(t.TempDir(), "audit.log") + devicePath := "file" + deviceData := map[string]any{ + "type": "file", + "options": map[string]any{ + "file_path": auditLog, + }, + } + _, err := client.Logical().Write("sys/audit/"+devicePath, deviceData) + require.NoError(t, err) + + devices, err := client.Sys().ListAudit() + require.NoError(t, err) + require.Len(t, devices, 1) + + err = client.Sys().Mount("audittest", &api.MountInput{ + Type: "audittest", + Config: api.MountConfigInput{ + AuditNonHMACRequestKeys: []string{"foo"}, + AuditNonHMACResponseKeys: []string{"baz", "secret"}, + }, + }) + require.NoError(t, err) + + // Call our API with audit fields + resp, err := client.Logical().Write("audittest/with-audit-fields", map[string]interface{}{}) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.Data) + // Make sure we only have 1 element and it's secret within the Data field + require.Len(t, resp.Data, 1) + require.Contains(t, resp.Data, "secret") + + // Call our API with no audit fields + resp, err = client.Logical().Write("audittest/no-audit-fields", map[string]interface{}{}) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.Data) + // Make sure we only have 1 element and it's secret within the Data field + require.Len(t, resp.Data, 1) + require.Contains(t, resp.Data, "secret") + + entries := make([]map[string]interface{}, 0) + auditFile, err := os.OpenFile(auditLog, os.O_RDONLY, 0o644) + require.NoError(t, err, "failed to open audit log") + scanner := bufio.NewScanner(auditFile) + + // Collect the two entries we really care about + for scanner.Scan() { + entry := make(map[string]interface{}) + + err := json.Unmarshal(scanner.Bytes(), &entry) + require.NoError(t, err) + + if isResponseEntryForPath(entry, "audittest/with-audit-fields") { + entries = append(entries, entry) + } + + if isResponseEntryForPath(entry, "audittest/no-audit-fields") { + entries = append(entries, entry) + } + } + + // We expect to have 2 entries, the first with audit_only_fields set, the other doesn't + require.Equal(t, 2, len(entries)) + + { + // Make sure the request object within has audit_only_fields, it should contain a different + // set of keys than the response audit_only_fields, and the values should have been hmac'd + // based on the mount's AuditNonHMACRequestKeys value which is ["foo"] + entryWithAuditFields := entries[0] + + entryRequest := castToMap(t, entryWithAuditFields["request"]) + require.Contains(t, entryRequest, "supplemental_audit_data") + entryRequestAuditOnlyFields := castToStringMap(t, entryRequest["supplemental_audit_data"]) + require.Contains(t, entryRequestAuditOnlyFields, "foo") + require.Contains(t, entryRequestAuditOnlyFields, "baz") + requireHmaced(t, entryRequestAuditOnlyFields["baz"]) + require.Equal(t, "bar", entryRequestAuditOnlyFields["foo"]) + require.Len(t, entryRequestAuditOnlyFields, 2) + } + + { + // Make sure the audit response data field only contains the secret field and not any of our audit only fields + entryWithAuditFields := entries[0] + entryResponse := castToMap(t, entryWithAuditFields["response"]) + entryResponseData := castToStringMap(t, entryResponse["data"]) + require.Contains(t, entryResponseData, "secret") + require.Len(t, entryResponseData, 1) + require.Equal(t, "my-fancy-secret", entryResponseData["secret"]) + } + + { + // Make sure the audit response audit only fields contains the three fields we set, and we properly + // applied the AuditNonHMACResponseKeys to those keys, see mount config above, but we expect keys + // ["baz", "secret"] to be cleared + entryWithAuditFields := entries[0] + entryResponse := castToMap(t, entryWithAuditFields["response"]) + entryResponseAuditOnly := castToStringMap(t, entryResponse["supplemental_audit_data"]) + require.Contains(t, entryResponseAuditOnly, "foo") + require.Contains(t, entryResponseAuditOnly, "baz") + require.Contains(t, entryResponseAuditOnly, "quux") + requireHmaced(t, entryResponseAuditOnly["foo"]) + requireHmaced(t, entryResponseAuditOnly["quux"]) + require.Equal(t, "qux", entryResponseAuditOnly["baz"]) + require.Len(t, entryResponseAuditOnly, 3) + } + + { + // Now validate the audit entry with no additional audit entries doesn't have the new fields in the audit entry + entryNoAudit := entries[1] + entryRequest := castToMap(t, entryNoAudit["request"]) + require.NotContains(t, entryRequest, "supplemental_audit_data") + + entryResponseNoAudit := castToMap(t, entryNoAudit["response"]) + require.NotContains(t, entryResponseNoAudit, "supplemental_audit_data") + + // We still should see the secret not hmac'd in the response data + entryResponseData := castToStringMap(t, entryResponseNoAudit["data"]) + require.Contains(t, entryResponseData, "secret") + require.Len(t, entryResponseData, 1) + require.Equal(t, "my-fancy-secret", entryResponseData["secret"]) + } +} + +func requireHmaced(t testing.TB, val string) { + t.Helper() + + parts := strings.Split(val, ":") + require.Len(t, parts, 2, "splitting hmac value %q should have 2 parts found %d", val, len(parts)) + require.Equal(t, "hmac-sha256", parts[0]) + require.Equal(t, 64, len(parts[1]), "expected hmac'd field %q to have a length of 64 characters", val) +} + +func castToStringMap(t testing.TB, val interface{}) map[string]string { + valMap, ok := val.(map[string]interface{}) + if !ok { + t.Fatalf("value is not a map was: %T", val) + } + + stringMap := make(map[string]string) + for k, v := range valMap { + if s, ok := v.(string); ok { + stringMap[k] = s + } else { + t.Fatalf("value for key %q is not a string was: %T", k, v) + } + } + + return stringMap +} + +func castToMap(t testing.TB, val interface{}) map[string]interface{} { + t.Helper() + + if valMap, ok := val.(map[string]interface{}); ok { + return valMap + } + + t.Fatalf("Value is not a map was %T", val) + return nil +} + +func isResponseEntryForPath(entry map[string]interface{}, desiredPath string) bool { + if typeRaw, ok := entry["type"]; !ok || typeRaw != "response" { + return false + } + requestRaw, ok := entry["request"] + if !ok || requestRaw == nil { + return false + } + request := requestRaw.(map[string]interface{}) + if pathRaw, ok := request["path"]; !ok || pathRaw != desiredPath { + return false + } + return true +}