Add the ability for a plugin to specify extra fields for auditing purposes (#11018) (#12167)

* Add the ability to specify extra audit only fields from a plugin

* Add extra auditing fields within the PKI OCSP handler

* Add missing copywrite headers

* Format OCSP dates when non-zero, otherwise specify not set to be clear

* Feedback 2: Only set time fields if not zero instead of non-parsable string

* Serialize JSON fields in SDK response struct

* Perform renames based on RFC feedback

* Resolve OpenAPI test failure

* add cl

Co-authored-by: Steven Clark <steven.clark@hashicorp.com>
This commit is contained in:
Vault Automation 2026-02-04 10:41:54 -05:00 committed by GitHub
parent 4943d033f2
commit c6170d36a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 444 additions and 34 deletions

View file

@ -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

View file

@ -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)

View file

@ -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 {

View file

@ -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 = `

6
changelog/_11018.txt Normal file
View file

@ -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.
```

View file

@ -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,
}
}

View file

@ -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)

View file

@ -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

View file

@ -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
}