mirror of
https://github.com/hashicorp/vault.git
synced 2025-08-06 14:47:01 +02:00
617 lines
18 KiB
Go
617 lines
18 KiB
Go
// 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
|
|
}
|