vault/audit/entry_formatter.go
Marc Boudreau d66fdb4dfd
Use non-persistent Salter for logging test message (#22308)
* use non-persistent Salter for logging test message

* adjust tests based on code changes to ProcessManual

* suggestion for log test message fix (#22320)

* clean up test code and fix misnamed elements

---------

Co-authored-by: Peter Wilson <peter.wilson@hashicorp.com>
2023-08-14 15:00:49 +00:00

593 lines
18 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package audit
import (
"context"
"crypto/tls"
"errors"
"fmt"
"strings"
"time"
"github.com/jefferai/jsonx"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/logical"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/hashicorp/vault/internal/observability/event"
"github.com/hashicorp/vault/sdk/helper/jsonutil"
"github.com/hashicorp/eventlogger"
)
var (
_ Formatter = (*EntryFormatter)(nil)
_ eventlogger.Node = (*EntryFormatter)(nil)
)
// NewEntryFormatter should be used to create an EntryFormatter.
// Accepted options: WithPrefix.
func NewEntryFormatter(config FormatterConfig, salter Salter, opt ...Option) (*EntryFormatter, error) {
const op = "audit.NewEntryFormatter"
if salter == nil {
return nil, fmt.Errorf("%s: cannot create a new audit formatter with nil salter: %w", op, event.ErrInvalidParameter)
}
// We need to ensure that the format isn't just some default empty string.
if err := config.RequiredFormat.validate(); err != nil {
return nil, fmt.Errorf("%s: format not valid: %w", op, err)
}
opts, err := getOpts(opt...)
if err != nil {
return nil, fmt.Errorf("%s: error applying options: %w", op, err)
}
return &EntryFormatter{
salter: salter,
config: config,
headerFormatter: opts.withHeaderFormatter,
prefix: opts.withPrefix,
}, nil
}
// Reopen is a no-op for the formatter node.
func (*EntryFormatter) Reopen() error {
return nil
}
// Type describes the type of this node (formatter).
func (*EntryFormatter) Type() eventlogger.NodeType {
return eventlogger.NodeTypeFormatter
}
// Process will attempt to parse the incoming event data into a corresponding
// audit Request/Response which is serialized to JSON/JSONx and stored within the event.
func (f *EntryFormatter) Process(ctx context.Context, e *eventlogger.Event) (*eventlogger.Event, error) {
const op = "audit.(EntryFormatter).Process"
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if e == nil {
return nil, fmt.Errorf("%s: event is nil: %w", op, event.ErrInvalidParameter)
}
a, ok := e.Payload.(*auditEvent)
if !ok {
return nil, fmt.Errorf("%s: cannot parse event payload: %w", op, event.ErrInvalidParameter)
}
var result []byte
data := new(logical.LogInput)
headers := make(map[string][]string)
if a.Data != nil {
*data = *a.Data
if a.Data.Request != nil && a.Data.Request.Headers != nil {
headers = a.Data.Request.Headers
}
}
if f.headerFormatter != nil {
adjustedHeaders, err := f.headerFormatter.ApplyConfig(ctx, headers, f.salter)
if err != nil {
return nil, fmt.Errorf("%s: unable to transform headers for auditing: %w", op, err)
}
data.Request.Headers = adjustedHeaders
}
switch a.Subtype {
case RequestType:
entry, err := f.FormatRequest(ctx, data)
if err != nil {
return nil, fmt.Errorf("%s: unable to parse request from audit event: %w", op, err)
}
result, err = jsonutil.EncodeJSON(entry)
if err != nil {
return nil, fmt.Errorf("%s: unable to format request: %w", op, err)
}
case ResponseType:
entry, err := f.FormatResponse(ctx, data)
if err != nil {
return nil, fmt.Errorf("%s: unable to parse response from audit event: %w", op, err)
}
result, err = jsonutil.EncodeJSON(entry)
if err != nil {
return nil, fmt.Errorf("%s: unable to format response: %w", op, err)
}
default:
return nil, fmt.Errorf("%s: unknown audit event subtype: %q", op, a.Subtype)
}
if f.config.RequiredFormat == JSONxFormat {
var err error
result, err = jsonx.EncodeJSONBytes(result)
if err != nil {
return nil, fmt.Errorf("%s: unable to encode JSONx using JSON data: %w", op, err)
}
if result == nil {
return nil, fmt.Errorf("%s: encoded JSONx was nil: %w", op, err)
}
}
// This makes a bit of a mess of the 'format' since both JSON and XML (JSONx)
// don't support a prefix just sitting there.
// However, this would be a breaking change to how Vault currently works to
// include the prefix as part of the JSON object or XML document.
if f.prefix != "" {
result = append([]byte(f.prefix), result...)
}
// Store the final format.
e.FormattedAs(f.config.RequiredFormat.String(), result)
return e, nil
}
// FormatRequest attempts to format the specified logical.LogInput into a RequestEntry.
func (f *EntryFormatter) FormatRequest(ctx context.Context, in *logical.LogInput) (*RequestEntry, error) {
switch {
case in == nil || in.Request == nil:
return nil, errors.New("request to request-audit a nil request")
case f.salter == nil:
return nil, errors.New("salt func not configured")
}
// Set these to the input values at first
auth := in.Auth
req := in.Request
var connState *tls.ConnectionState
if auth == nil {
auth = new(logical.Auth)
}
if in.Request.Connection != nil && in.Request.Connection.ConnState != nil {
connState = in.Request.Connection.ConnState
}
if !f.config.Raw {
var err error
auth, err = HashAuth(ctx, f.salter, auth, f.config.HMACAccessor)
if err != nil {
return nil, err
}
req, err = HashRequest(ctx, f.salter, req, f.config.HMACAccessor, in.NonHMACReqDataKeys)
if err != nil {
return nil, err
}
}
var errString string
if in.OuterErr != nil {
errString = in.OuterErr.Error()
}
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, err
}
reqType := in.Type
if reqType == "" {
reqType = "request"
}
reqEntry := &RequestEntry{
Type: reqType,
Error: errString,
ForwardedFrom: req.ForwardedFrom,
Auth: &Auth{
ClientToken: auth.ClientToken,
Accessor: auth.Accessor,
DisplayName: auth.DisplayName,
Policies: auth.Policies,
TokenPolicies: auth.TokenPolicies,
IdentityPolicies: auth.IdentityPolicies,
ExternalNamespacePolicies: auth.ExternalNamespacePolicies,
NoDefaultPolicy: auth.NoDefaultPolicy,
Metadata: auth.Metadata,
EntityID: auth.EntityID,
RemainingUses: req.ClientTokenRemainingUses,
TokenType: auth.TokenType.String(),
TokenTTL: int64(auth.TTL.Seconds()),
},
Request: &Request{
ID: req.ID,
ClientID: req.ClientID,
ClientToken: req.ClientToken,
ClientTokenAccessor: req.ClientTokenAccessor,
Operation: req.Operation,
MountPoint: req.MountPoint,
MountType: req.MountType,
MountAccessor: req.MountAccessor,
MountRunningVersion: req.MountRunningVersion(),
MountRunningSha256: req.MountRunningSha256(),
MountIsExternalPlugin: req.MountIsExternalPlugin(),
MountClass: req.MountClass(),
Namespace: &Namespace{
ID: ns.ID,
Path: ns.Path,
},
Path: req.Path,
Data: req.Data,
PolicyOverride: req.PolicyOverride,
RemoteAddr: getRemoteAddr(req),
RemotePort: getRemotePort(req),
ReplicationCluster: req.ReplicationCluster,
Headers: req.Headers,
ClientCertificateSerialNumber: getClientCertificateSerialNumber(connState),
},
}
if !auth.IssueTime.IsZero() {
reqEntry.Auth.TokenIssueTime = auth.IssueTime.Format(time.RFC3339)
}
if auth.PolicyResults != nil {
reqEntry.Auth.PolicyResults = &PolicyResults{
Allowed: auth.PolicyResults.Allowed,
}
for _, p := range auth.PolicyResults.GrantingPolicies {
reqEntry.Auth.PolicyResults.GrantingPolicies = append(reqEntry.Auth.PolicyResults.GrantingPolicies, PolicyInfo{
Name: p.Name,
NamespaceId: p.NamespaceId,
NamespacePath: p.NamespacePath,
Type: p.Type,
})
}
}
if req.WrapInfo != nil {
reqEntry.Request.WrapTTL = int(req.WrapInfo.TTL / time.Second)
}
if !f.config.OmitTime {
reqEntry.Time = time.Now().UTC().Format(time.RFC3339Nano)
}
return reqEntry, nil
}
// FormatResponse attempts to format the specified logical.LogInput into a ResponseEntry.
func (f *EntryFormatter) FormatResponse(ctx context.Context, in *logical.LogInput) (*ResponseEntry, error) {
switch {
case f == nil:
return nil, errors.New("formatter is nil")
case in == nil || in.Request == nil:
return nil, errors.New("request to response-audit a nil request")
case f.salter == nil:
return nil, errors.New("salt func not configured")
}
// Set these to the input values at first
auth, req, resp := in.Auth, in.Request, in.Response
if auth == nil {
auth = new(logical.Auth)
}
if resp == nil {
resp = new(logical.Response)
}
var connState *tls.ConnectionState
if in.Request.Connection != nil && in.Request.Connection.ConnState != nil {
connState = in.Request.Connection.ConnState
}
elideListResponseData := f.config.ElideListResponses && req.Operation == logical.ListOperation
var respData map[string]interface{}
if f.config.Raw {
// In the non-raw case, elision of list response data occurs inside HashResponse, to avoid redundant deep
// copies and hashing of data only to elide it later. In the raw case, we need to do it here.
if elideListResponseData && resp.Data != nil {
// Copy the data map before making changes, but we only need to go one level deep in this case
respData = make(map[string]interface{}, len(resp.Data))
for k, v := range resp.Data {
respData[k] = v
}
doElideListResponseData(respData)
} else {
respData = resp.Data
}
} else {
var err error
auth, err = HashAuth(ctx, f.salter, auth, f.config.HMACAccessor)
if err != nil {
return nil, err
}
req, err = HashRequest(ctx, f.salter, req, f.config.HMACAccessor, in.NonHMACReqDataKeys)
if err != nil {
return nil, err
}
resp, err = HashResponse(ctx, f.salter, resp, f.config.HMACAccessor, in.NonHMACRespDataKeys, elideListResponseData)
if err != nil {
return nil, err
}
respData = resp.Data
}
var errString string
if in.OuterErr != nil {
errString = in.OuterErr.Error()
}
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, err
}
var respAuth *Auth
if resp.Auth != nil {
respAuth = &Auth{
ClientToken: resp.Auth.ClientToken,
Accessor: resp.Auth.Accessor,
DisplayName: resp.Auth.DisplayName,
Policies: resp.Auth.Policies,
TokenPolicies: resp.Auth.TokenPolicies,
IdentityPolicies: resp.Auth.IdentityPolicies,
ExternalNamespacePolicies: resp.Auth.ExternalNamespacePolicies,
NoDefaultPolicy: resp.Auth.NoDefaultPolicy,
Metadata: resp.Auth.Metadata,
NumUses: resp.Auth.NumUses,
EntityID: resp.Auth.EntityID,
TokenType: resp.Auth.TokenType.String(),
TokenTTL: int64(resp.Auth.TTL.Seconds()),
}
if !resp.Auth.IssueTime.IsZero() {
respAuth.TokenIssueTime = resp.Auth.IssueTime.Format(time.RFC3339)
}
}
var respSecret *Secret
if resp.Secret != nil {
respSecret = &Secret{
LeaseID: resp.Secret.LeaseID,
}
}
var respWrapInfo *ResponseWrapInfo
if resp.WrapInfo != nil {
token := resp.WrapInfo.Token
if jwtToken := parseVaultTokenFromJWT(token); jwtToken != nil {
token = *jwtToken
}
respWrapInfo = &ResponseWrapInfo{
TTL: int(resp.WrapInfo.TTL / time.Second),
Token: token,
Accessor: resp.WrapInfo.Accessor,
CreationTime: resp.WrapInfo.CreationTime.UTC().Format(time.RFC3339Nano),
CreationPath: resp.WrapInfo.CreationPath,
WrappedAccessor: resp.WrapInfo.WrappedAccessor,
}
}
respType := in.Type
if respType == "" {
respType = "response"
}
respEntry := &ResponseEntry{
Type: respType,
Error: errString,
Forwarded: req.ForwardedFrom != "",
Auth: &Auth{
ClientToken: auth.ClientToken,
Accessor: auth.Accessor,
DisplayName: auth.DisplayName,
Policies: auth.Policies,
TokenPolicies: auth.TokenPolicies,
IdentityPolicies: auth.IdentityPolicies,
ExternalNamespacePolicies: auth.ExternalNamespacePolicies,
NoDefaultPolicy: auth.NoDefaultPolicy,
Metadata: auth.Metadata,
RemainingUses: req.ClientTokenRemainingUses,
EntityID: auth.EntityID,
EntityCreated: auth.EntityCreated,
TokenType: auth.TokenType.String(),
TokenTTL: int64(auth.TTL.Seconds()),
},
Request: &Request{
ID: req.ID,
ClientToken: req.ClientToken,
ClientTokenAccessor: req.ClientTokenAccessor,
ClientID: req.ClientID,
Operation: req.Operation,
MountPoint: req.MountPoint,
MountType: req.MountType,
MountAccessor: req.MountAccessor,
MountRunningVersion: req.MountRunningVersion(),
MountRunningSha256: req.MountRunningSha256(),
MountIsExternalPlugin: req.MountIsExternalPlugin(),
MountClass: req.MountClass(),
Namespace: &Namespace{
ID: ns.ID,
Path: ns.Path,
},
Path: req.Path,
Data: req.Data,
PolicyOverride: req.PolicyOverride,
RemoteAddr: getRemoteAddr(req),
RemotePort: getRemotePort(req),
ClientCertificateSerialNumber: getClientCertificateSerialNumber(connState),
ReplicationCluster: req.ReplicationCluster,
Headers: req.Headers,
},
Response: &Response{
MountPoint: req.MountPoint,
MountType: req.MountType,
MountAccessor: req.MountAccessor,
MountRunningVersion: req.MountRunningVersion(),
MountRunningSha256: req.MountRunningSha256(),
MountIsExternalPlugin: req.MountIsExternalPlugin(),
MountClass: req.MountClass(),
Auth: respAuth,
Secret: respSecret,
Data: respData,
Warnings: resp.Warnings,
Redirect: resp.Redirect,
WrapInfo: respWrapInfo,
Headers: resp.Headers,
},
}
if auth.PolicyResults != nil {
respEntry.Auth.PolicyResults = &PolicyResults{
Allowed: auth.PolicyResults.Allowed,
}
for _, p := range auth.PolicyResults.GrantingPolicies {
respEntry.Auth.PolicyResults.GrantingPolicies = append(respEntry.Auth.PolicyResults.GrantingPolicies, PolicyInfo{
Name: p.Name,
NamespaceId: p.NamespaceId,
NamespacePath: p.NamespacePath,
Type: p.Type,
})
}
}
if !auth.IssueTime.IsZero() {
respEntry.Auth.TokenIssueTime = auth.IssueTime.Format(time.RFC3339)
}
if req.WrapInfo != nil {
respEntry.Request.WrapTTL = int(req.WrapInfo.TTL / time.Second)
}
if !f.config.OmitTime {
respEntry.Time = time.Now().UTC().Format(time.RFC3339Nano)
}
return respEntry, nil
}
// NewFormatterConfig should be used to create a FormatterConfig.
// Accepted options: WithElision, WithHMACAccessor, WithOmitTime, WithRaw, WithFormat.
func NewFormatterConfig(opt ...Option) (FormatterConfig, error) {
const op = "audit.NewFormatterConfig"
opts, err := getOpts(opt...)
if err != nil {
return FormatterConfig{}, fmt.Errorf("%s: error applying options: %w", op, err)
}
return FormatterConfig{
ElideListResponses: opts.withElision,
HMACAccessor: opts.withHMACAccessor,
OmitTime: opts.withOmitTime,
Raw: opts.withRaw,
RequiredFormat: opts.withFormat,
}, nil
}
// getRemoteAddr safely gets the remote address avoiding a nil pointer
func getRemoteAddr(req *logical.Request) string {
if req != nil && req.Connection != nil {
return req.Connection.RemoteAddr
}
return ""
}
// getRemotePort safely gets the remote port avoiding a nil pointer
func getRemotePort(req *logical.Request) int {
if req != nil && req.Connection != nil {
return req.Connection.RemotePort
}
return 0
}
// getClientCertificateSerialNumber attempts the retrieve the serial number of
// the peer certificate from the specified tls.ConnectionState.
func getClientCertificateSerialNumber(connState *tls.ConnectionState) string {
if connState == nil || len(connState.VerifiedChains) == 0 || len(connState.VerifiedChains[0]) == 0 {
return ""
}
return connState.VerifiedChains[0][0].SerialNumber.String()
}
// parseVaultTokenFromJWT returns a string iff the token was a JWT, and we could
// extract the original token ID from inside
func parseVaultTokenFromJWT(token string) *string {
if strings.Count(token, ".") != 2 {
return nil
}
parsedJWT, err := jwt.ParseSigned(token)
if err != nil {
return nil
}
var claims jwt.Claims
if err = parsedJWT.UnsafeClaimsWithoutVerification(&claims); err != nil {
return nil
}
return &claims.ID
}
// doElideListResponseData performs the actual elision of list operation response data, once surrounding code has
// determined it should apply to a particular request. The data map that is passed in must be a copy that is safe to
// modify in place, but need not be a full recursive deep copy, as only top-level keys are changed.
//
// See the documentation of the controlling option in FormatterConfig for more information on the purpose.
func doElideListResponseData(data map[string]interface{}) {
for k, v := range data {
if k == "keys" {
if vSlice, ok := v.([]string); ok {
data[k] = len(vSlice)
}
} else if k == "key_info" {
if vMap, ok := v.(map[string]interface{}); ok {
data[k] = len(vMap)
}
}
}
}
// newTemporaryEntryFormatter creates a cloned EntryFormatter instance with a non-persistent Salter.
func newTemporaryEntryFormatter(n *EntryFormatter) *EntryFormatter {
return &EntryFormatter{
salter: &nonPersistentSalt{},
headerFormatter: n.headerFormatter,
config: n.config,
prefix: n.prefix,
}
}