mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-18 18:38:08 -05:00
* 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:
parent
4943d033f2
commit
c6170d36a8
9 changed files with 444 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
6
changelog/_11018.txt
Normal 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.
|
||||
```
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
258
vault/external_tests/audit/audit_only_fields_test.go
Normal file
258
vault/external_tests/audit/audit_only_fields_test.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue