// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package audit import ( "context" "errors" "fmt" "reflect" "runtime/debug" "strings" "time" "github.com/go-jose/go-jose/v3/jwt" "github.com/hashicorp/eventlogger" "github.com/hashicorp/go-hclog" nshelper "github.com/hashicorp/vault/helper/namespace" "github.com/hashicorp/vault/sdk/helper/jsonutil" "github.com/hashicorp/vault/sdk/helper/salt" "github.com/hashicorp/vault/sdk/logical" "github.com/jefferai/jsonx" "github.com/mitchellh/copystructure" ) var _ eventlogger.Node = (*entryFormatter)(nil) // timeProvider offers a way to supply a pre-configured time. type timeProvider interface { // formatTime provides the pre-configured time in a particular format. formattedTime() string } // nonPersistentSalt is used for obtaining a salt that is not persisted. type nonPersistentSalt struct{} // entryFormatter should be used to format audit requests and responses. // NOTE: Use newEntryFormatter to initialize the entryFormatter struct. type entryFormatter struct { config formatterConfig salter Salter logger hclog.Logger name string } // newEntryFormatter should be used to create an entryFormatter. func newEntryFormatter(name string, config formatterConfig, salter Salter, logger hclog.Logger) (*entryFormatter, error) { name = strings.TrimSpace(name) if name == "" { return nil, fmt.Errorf("name is required: %w", ErrInvalidParameter) } if salter == nil { return nil, fmt.Errorf("cannot create a new audit formatter with nil salter: %w", ErrInvalidParameter) } if logger == nil || reflect.ValueOf(logger).IsNil() { return nil, fmt.Errorf("cannot create a new audit formatter with nil logger: %w", ErrInvalidParameter) } return &entryFormatter{ config: config, salter: salter, logger: logger, name: name, }, 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, retErr error) { // Return early if the context was cancelled, eventlogger will not carry on // asking nodes to process, so any sink node in the pipeline won't be called. select { case <-ctx.Done(): return nil, ctx.Err() default: } // Perform validation on the event, then retrieve the underlying AuditEvent // and LogInput (from the AuditEvent Data). if e == nil { return nil, fmt.Errorf("event is nil: %w", ErrInvalidParameter) } a, ok := e.Payload.(*Event) if !ok { return nil, fmt.Errorf("cannot parse event payload: %w", ErrInvalidParameter) } if a.Data == nil { return nil, fmt.Errorf("cannot audit a '%s' event with no data: %w", a.Subtype, ErrInvalidParameter) } // Handle panics defer func() { r := recover() if r == nil { return } path := "unknown" if a.Data.Request != nil { path = a.Data.Request.Path } f.logger.Error("panic during logging", "request_path", path, "audit_device_path", f.name, "error", r, "stacktrace", string(debug.Stack())) // Ensure that we add this error onto any pre-existing error that was being returned. retErr = errors.Join(retErr, fmt.Errorf("panic generating audit log: %q", f.name)) }() // Using 'any' to make exclusion easier, the JSON encoder doesn't care about types. var entry any var err error entry, err = f.createEntry(ctx, a) if err != nil { return nil, err } // If this pipeline has been configured with (Enterprise-only) exclusions then // attempt to exclude the fields from the audit entry. if f.shouldExclude() { m, err := f.excludeFields(entry) if err != nil { return nil, fmt.Errorf("unable to exclude %s audit data from %q: %w", a.Subtype, f.name, err) } entry = m } result, err := jsonutil.EncodeJSON(entry) if err != nil { return nil, fmt.Errorf("unable to format %s: %w", a.Subtype, err) } if f.config.requiredFormat == jsonxFormat { var err error result, err = jsonx.EncodeJSONBytes(result) if err != nil { return nil, fmt.Errorf("unable to encode JSONx using JSON data: %w", err) } if result == nil { return nil, fmt.Errorf("encoded JSONx was nil: %w", 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.config.prefix != "" { result = append([]byte(f.config.prefix), result...) } // Create a new event, so we can store our formatted data without conflict. e2 := &eventlogger.Event{ Type: e.Type, CreatedAt: e.CreatedAt, Formatted: make(map[string][]byte), // we are about to set this ourselves. Payload: a, } e2.FormattedAs(f.config.requiredFormat.String(), result) return e2, nil } // remoteAddr safely gets the remote address avoiding a nil pointer. func remoteAddr(req *logical.Request) string { if req != nil && req.Connection != nil { return req.Connection.RemoteAddr } return "" } // remotePort safely gets the remote port avoiding a nil pointer. func remotePort(req *logical.Request) int { if req != nil && req.Connection != nil { return req.Connection.RemotePort } return 0 } // clientCertSerialNumber attempts the retrieve the serial number of the peer // certificate from the specified tls.ConnectionState. func clientCertSerialNumber(req *logical.Request) string { if req == nil || req.Connection == nil { return "" } connState := req.Connection.ConnState 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 } // newTemporaryEntryFormatter creates a cloned entryFormatter instance with a non-persistent Salter. func newTemporaryEntryFormatter(n *entryFormatter) *entryFormatter { return &entryFormatter{ salter: &nonPersistentSalt{}, config: n.config, } } // Salt returns a new salt with default configuration and no storage usage, and no error. func (s *nonPersistentSalt) Salt(_ context.Context) (*salt.Salt, error) { return salt.NewNonpersistentSalt(), nil } // clone can be used to deep clone the specified type. func clone[V any](s V) (V, error) { s2, err := copystructure.Copy(s) return s2.(V), err } // newAuth takes a logical.Auth and the number of remaining client token uses // (which should be supplied from the logical.Request's client token), and creates // an audit auth. // tokenRemainingUses should be the client token remaining uses to include in auth. // This usually can be found in logical.Request.ClientTokenRemainingUses. // NOTE: supplying a nil value for auth will result in a nil return value and // (nil) error. The caller should check the return value before attempting to use it. // ignore-nil-nil-function-check. func newAuth(input *logical.Auth, tokenRemainingUses int) (*auth, error) { if input == nil { return nil, nil } extNSPolicies, err := clone(input.ExternalNamespacePolicies) if err != nil { return nil, fmt.Errorf("unable to clone logical auth: external namespace policies: %w", err) } identityPolicies, err := clone(input.IdentityPolicies) if err != nil { return nil, fmt.Errorf("unable to clone logical auth: identity policies: %w", err) } metadata, err := clone(input.Metadata) if err != nil { return nil, fmt.Errorf("unable to clone logical auth: metadata: %w", err) } policies, err := clone(input.Policies) if err != nil { return nil, fmt.Errorf("unable to clone logical auth: policies: %w", err) } var polRes *policyResults if input.PolicyResults != nil { polRes = &policyResults{ Allowed: input.PolicyResults.Allowed, GrantingPolicies: make([]policyInfo, len(input.PolicyResults.GrantingPolicies)), } for _, p := range input.PolicyResults.GrantingPolicies { polRes.GrantingPolicies = append(polRes.GrantingPolicies, policyInfo{ Name: p.Name, NamespaceId: p.NamespaceId, NamespacePath: p.NamespacePath, Type: p.Type, }) } } tokenPolicies, err := clone(input.TokenPolicies) if err != nil { return nil, fmt.Errorf("unable to clone logical auth: token policies: %w", err) } var tokenIssueTime string if !input.IssueTime.IsZero() { tokenIssueTime = input.IssueTime.Format(time.RFC3339) } return &auth{ Accessor: input.Accessor, ClientToken: input.ClientToken, DisplayName: input.DisplayName, EntityCreated: input.EntityCreated, EntityID: input.EntityID, ExternalNamespacePolicies: extNSPolicies, IdentityPolicies: identityPolicies, Metadata: metadata, NoDefaultPolicy: input.NoDefaultPolicy, NumUses: input.NumUses, Policies: policies, PolicyResults: polRes, RemainingUses: tokenRemainingUses, TokenPolicies: tokenPolicies, TokenIssueTime: tokenIssueTime, TokenTTL: int64(input.TTL.Seconds()), TokenType: input.TokenType.String(), }, nil } // newRequest takes a logical.Request and namespace.Namespace, transforms and // aggregates them into an audit request. func newRequest(req *logical.Request, ns *nshelper.Namespace) (*request, error) { if req == nil { return nil, fmt.Errorf("request cannot be nil") } remoteAddr := remoteAddr(req) remotePort := remotePort(req) clientCertSerial := clientCertSerialNumber(req) data, err := clone(req.Data) if err != nil { return nil, fmt.Errorf("unable to clone logical request: data: %w", err) } headers, err := clone(req.Headers) if err != nil { return nil, fmt.Errorf("unable to clone logical request: headers: %w", err) } var reqURI string if req.HTTPRequest != nil && req.HTTPRequest.RequestURI != req.Path { reqURI = req.HTTPRequest.RequestURI } var wrapTTL int if req.WrapInfo != nil { wrapTTL = int(req.WrapInfo.TTL / time.Second) } return &request{ ClientCertificateSerialNumber: clientCertSerial, ClientID: req.ClientID, ClientToken: req.ClientToken, ClientTokenAccessor: req.ClientTokenAccessor, Data: data, Headers: headers, ID: req.ID, MountAccessor: req.MountAccessor, MountClass: req.MountClass(), MountIsExternalPlugin: req.MountIsExternalPlugin(), MountPoint: req.MountPoint, MountRunningSha256: req.MountRunningSha256(), MountRunningVersion: req.MountRunningVersion(), MountType: req.MountType, Namespace: &namespace{ ID: ns.ID, Path: ns.Path, }, Operation: req.Operation, Path: req.Path, PolicyOverride: req.PolicyOverride, RemoteAddr: remoteAddr, RemotePort: remotePort, ReplicationCluster: req.ReplicationCluster, RequestURI: reqURI, WrapTTL: wrapTTL, }, nil } // newResponse takes a logical.Response and logical.Request, transforms and // aggregates them into an audit response. // isElisionRequired is used to indicate that response 'Data' should be elided. // NOTE: supplying a nil value for response will result in a nil return value and // (nil) error. The caller should check the return value before attempting to use it. // ignore-nil-nil-function-check. func newResponse(resp *logical.Response, req *logical.Request, isElisionRequired bool) (*response, error) { if resp == nil { return nil, nil } if req == nil { // Request should never be nil, even for a response. return nil, fmt.Errorf("request cannot be nil") } auth, err := newAuth(resp.Auth, req.ClientTokenRemainingUses) if err != nil { return nil, fmt.Errorf("unable to convert logical auth response: %w", err) } var data map[string]any if resp.Data != nil { data = make(map[string]any, len(resp.Data)) if isElisionRequired { // Performs the actual elision (ideally for list operations) of response data, // once surrounding code has determined it should apply to a particular request. // If the value for a key should not be elided, then it will be cloned. for k, v := range resp.Data { isCloneRequired := true switch k { case "keys": if vSlice, ok := v.([]string); ok { data[k] = len(vSlice) isCloneRequired = false } case "key_info": if vMap, ok := v.(map[string]any); ok { data[k] = len(vMap) isCloneRequired = false } } // Clone values if they weren't legitimate keys or key_info. if isCloneRequired { v2, err := clone(v) if err != nil { return nil, fmt.Errorf("unable to clone response data while eliding: %w", err) } data[k] = v2 } } } else { // Deep clone all values, no shortcuts here. data, err = clone(resp.Data) if err != nil { return nil, fmt.Errorf("unable to clone response data: %w", err) } } } headers, err := clone(resp.Headers) if err != nil { return nil, fmt.Errorf("unable to clone logical response: headers: %w", err) } var s *secret if resp.Secret != nil { s = &secret{LeaseID: resp.Secret.LeaseID} } var wrapInfo *responseWrapInfo if resp.WrapInfo != nil { token := resp.WrapInfo.Token if jwtToken := parseVaultTokenFromJWT(token); jwtToken != nil { token = *jwtToken } ttl := int(resp.WrapInfo.TTL / time.Second) wrapInfo = &responseWrapInfo{ TTL: ttl, Token: token, Accessor: resp.WrapInfo.Accessor, CreationTime: resp.WrapInfo.CreationTime.UTC().Format(time.RFC3339Nano), CreationPath: resp.WrapInfo.CreationPath, WrappedAccessor: resp.WrapInfo.WrappedAccessor, } } warnings, err := clone(resp.Warnings) if err != nil { return nil, fmt.Errorf("unable to clone logical response: warnings: %w", err) } return &response{ Auth: auth, Data: data, Headers: headers, MountAccessor: req.MountAccessor, MountClass: req.MountClass(), MountIsExternalPlugin: req.MountIsExternalPlugin(), MountPoint: req.MountPoint, MountRunningSha256: req.MountRunningSha256(), MountRunningVersion: req.MountRunningVersion(), MountType: req.MountType, Redirect: resp.Redirect, Secret: s, WrapInfo: wrapInfo, Warnings: warnings, }, nil } // createEntry takes the AuditEvent and builds an audit entry. // The entry will be HMAC'd and elided where required. func (f *entryFormatter) createEntry(ctx context.Context, a *Event) (*entry, error) { select { case <-ctx.Done(): return nil, ctx.Err() default: } data := a.Data if data.Request == nil { // Request should never be nil, even for a response. return nil, fmt.Errorf("unable to parse request from '%s' audit event: request cannot be nil", a.Subtype) } ns, err := nshelper.FromContext(ctx) if err != nil { return nil, fmt.Errorf("unable to retrieve namespace from context: %w", err) } auth, err := newAuth(data.Auth, data.Request.ClientTokenRemainingUses) if err != nil { return nil, fmt.Errorf("cannot convert auth: %w", err) } req, err := newRequest(data.Request, ns) if err != nil { return nil, fmt.Errorf("cannot convert request: %w", err) } var resp *response if a.Subtype == ResponseType { shouldElide := f.config.elideListResponses && req.Operation == logical.ListOperation resp, err = newResponse(data.Response, data.Request, shouldElide) if err != nil { return nil, fmt.Errorf("cannot convert response: %w", err) } } var outerErr string if data.OuterErr != nil { outerErr = data.OuterErr.Error() } entryType := data.Type if entryType == "" { entryType = a.Subtype.String() } entry := &entry{ Auth: auth, Error: outerErr, Forwarded: false, ForwardedFrom: data.Request.ForwardedFrom, Request: req, Response: resp, Type: entryType, } if !f.config.omitTime { // Use the time provider to supply the time for this entry. entry.Time = a.timeProvider().formattedTime() } // If the request is present in the input data, apply header configuration // regardless. We shouldn't be in a situation where the header formatter isn't // present as it's required. if entry.Request != nil { // Ensure that any headers in the request, are formatted as required, and are // only present if they have been configured to appear in the audit log. // e.g. via: /sys/config/auditing/request-headers/:name entry.Request.Headers, err = f.config.headerFormatter.ApplyConfig(ctx, entry.Request.Headers, f.salter) if err != nil { return nil, fmt.Errorf("unable to transform headers for auditing: %w", err) } } // If the request contains a Server-Side Consistency Token (SSCT), and we // have an auth response, overwrite the existing client token with the SSCT, // so that the SSCT appears in the audit log for this entry. if data.Request != nil && data.Request.InboundSSCToken != "" && entry.Auth != nil { entry.Auth.ClientToken = data.Request.InboundSSCToken } // Hash the entry if we aren't expecting raw output. if !f.config.raw { // Requests and responses have auth and request. err = hashAuth(ctx, f.salter, entry.Auth, f.config.hmacAccessor) if err != nil { return nil, err } err = hashRequest(ctx, f.salter, entry.Request, f.config.hmacAccessor, data.NonHMACReqDataKeys) if err != nil { return nil, err } if a.Subtype == ResponseType { if err = hashResponse(ctx, f.salter, entry.Response, f.config.hmacAccessor, data.NonHMACRespDataKeys); err != nil { return nil, err } } } return entry, nil }