Skip to content

Commit aeb1b1e

Browse files
authored
Add option 'elide_list_responses' to audit backends (hashicorp#18128)
This PR relates to a feature request logged through HashiCorp commercial support. Vault lacks pagination in its APIs. As a result, certain list operations can return **very** large responses. The user's chosen audit sinks may experience difficulty consuming audit records that swell to tens of megabytes of JSON. In our case, one of the systems consuming audit log data could not cope, and failed. The responses of list operations are typically not very interesting, as they are mostly lists of keys, or, even when they include a "key_info" field, are not returning confidential information. They become even less interesting once HMAC-ed by the audit system. Some example Vault "list" operations that are prone to becoming very large in an active Vault installation are: auth/token/accessors/ identity/entity/id/ identity/entity-alias/id/ pki/certs/ In response, I've coded a new option that can be applied to audit backends, `elide_list_responses`. When enabled, response data is elided from audit logs, only when the operation type is "list". For added safety, the elision only applies to the "keys" and "key_info" fields within the response data - these are conventionally the only fields present in a list response - see logical.ListResponse, and logical.ListResponseWithInfo. However, other fields are technically possible if a plugin author writes unusual code, and these will be preserved in the audit log even with this option enabled. The elision replaces the values of the "keys" and "key_info" fields with an integer count of the number of entries. This allows even the elided audit logs to still be useful for answering questions like "Was any data returned?" or "How many records were listed?".
1 parent 8abcde7 commit aeb1b1e

File tree

13 files changed

+362
-71
lines changed

13 files changed

+362
-71
lines changed

audit/format.go

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,24 @@ func (f *AuditFormatter) FormatResponse(ctx context.Context, w io.Writer, config
192192
connState = in.Request.Connection.ConnState
193193
}
194194

195-
if !config.Raw {
195+
elideListResponseData := config.ElideListResponses && req.Operation == logical.ListOperation
196+
197+
var respData map[string]interface{}
198+
if config.Raw {
199+
// In the non-raw case, elision of list response data occurs inside HashResponse, to avoid redundant deep
200+
// copies and hashing of data only to elide it later. In the raw case, we need to do it here.
201+
if elideListResponseData && resp.Data != nil {
202+
// Copy the data map before making changes, but we only need to go one level deep in this case
203+
respData = make(map[string]interface{}, len(resp.Data))
204+
for k, v := range resp.Data {
205+
respData[k] = v
206+
}
207+
208+
doElideListResponseData(respData)
209+
} else {
210+
respData = resp.Data
211+
}
212+
} else {
196213
auth, err = HashAuth(salt, auth, config.HMACAccessor)
197214
if err != nil {
198215
return err
@@ -203,10 +220,12 @@ func (f *AuditFormatter) FormatResponse(ctx context.Context, w io.Writer, config
203220
return err
204221
}
205222

206-
resp, err = HashResponse(salt, resp, config.HMACAccessor, in.NonHMACRespDataKeys)
223+
resp, err = HashResponse(salt, resp, config.HMACAccessor, in.NonHMACRespDataKeys, elideListResponseData)
207224
if err != nil {
208225
return err
209226
}
227+
228+
respData = resp.Data
210229
}
211230

212231
var errString string
@@ -315,7 +334,7 @@ func (f *AuditFormatter) FormatResponse(ctx context.Context, w io.Writer, config
315334
MountAccessor: req.MountAccessor,
316335
Auth: respAuth,
317336
Secret: respSecret,
318-
Data: resp.Data,
337+
Data: respData,
319338
Warnings: resp.Warnings,
320339
Redirect: resp.Redirect,
321340
WrapInfo: respWrapInfo,
@@ -495,7 +514,7 @@ func parseVaultTokenFromJWT(token string) *string {
495514
return &claims.ID
496515
}
497516

498-
// Create a formatter not backed by a persistent salt.
517+
// NewTemporaryFormatter creates a formatter not backed by a persistent salt
499518
func NewTemporaryFormatter(format, prefix string) *AuditFormatter {
500519
temporarySalt := func(ctx context.Context) (*salt.Salt, error) {
501520
return salt.NewNonpersistentSalt(), nil
@@ -516,3 +535,22 @@ func NewTemporaryFormatter(format, prefix string) *AuditFormatter {
516535
}
517536
return ret
518537
}
538+
539+
// doElideListResponseData performs the actual elision of list operation response data, once surrounding code has
540+
// determined it should apply to a particular request. The data map that is passed in must be a copy that is safe to
541+
// modify in place, but need not be a full recursive deep copy, as only top-level keys are changed.
542+
//
543+
// See the documentation of the controlling option in FormatterConfig for more information on the purpose.
544+
func doElideListResponseData(data map[string]interface{}) {
545+
for k, v := range data {
546+
if k == "keys" {
547+
if vSlice, ok := v.([]string); ok {
548+
data[k] = len(vSlice)
549+
}
550+
} else if k == "key_info" {
551+
if vMap, ok := v.(map[string]interface{}); ok {
552+
data[k] = len(vMap)
553+
}
554+
}
555+
}
556+
}

audit/format_test.go

Lines changed: 164 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,45 +3,74 @@ package audit
33
import (
44
"context"
55
"io"
6-
"io/ioutil"
76
"testing"
87

8+
"github.com/hashicorp/vault/helper/namespace"
99
"github.com/hashicorp/vault/sdk/helper/salt"
1010
"github.com/hashicorp/vault/sdk/logical"
11+
"github.com/mitchellh/copystructure"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
1114
)
1215

13-
type noopFormatWriter struct {
14-
salt *salt.Salt
15-
SaltFunc func() (*salt.Salt, error)
16+
type testingFormatWriter struct {
17+
salt *salt.Salt
18+
lastRequest *AuditRequestEntry
19+
lastResponse *AuditResponseEntry
1620
}
1721

18-
func (n *noopFormatWriter) WriteRequest(_ io.Writer, _ *AuditRequestEntry) error {
22+
func (fw *testingFormatWriter) WriteRequest(_ io.Writer, entry *AuditRequestEntry) error {
23+
fw.lastRequest = entry
1924
return nil
2025
}
2126

22-
func (n *noopFormatWriter) WriteResponse(_ io.Writer, _ *AuditResponseEntry) error {
27+
func (fw *testingFormatWriter) WriteResponse(_ io.Writer, entry *AuditResponseEntry) error {
28+
fw.lastResponse = entry
2329
return nil
2430
}
2531

26-
func (n *noopFormatWriter) Salt(ctx context.Context) (*salt.Salt, error) {
27-
if n.salt != nil {
28-
return n.salt, nil
32+
func (fw *testingFormatWriter) Salt(ctx context.Context) (*salt.Salt, error) {
33+
if fw.salt != nil {
34+
return fw.salt, nil
2935
}
3036
var err error
31-
n.salt, err = salt.NewSalt(ctx, nil, nil)
37+
fw.salt, err = salt.NewSalt(ctx, nil, nil)
3238
if err != nil {
3339
return nil, err
3440
}
35-
return n.salt, nil
41+
return fw.salt, nil
42+
}
43+
44+
// hashExpectedValueForComparison replicates enough of the audit HMAC process on a piece of expected data in a test,
45+
// so that we can use assert.Equal to compare the expected and output values.
46+
func (fw *testingFormatWriter) hashExpectedValueForComparison(input map[string]interface{}) map[string]interface{} {
47+
// Copy input before modifying, since we may re-use the same data in another test
48+
copied, err := copystructure.Copy(input)
49+
if err != nil {
50+
panic(err)
51+
}
52+
copiedAsMap := copied.(map[string]interface{})
53+
54+
salter, err := fw.Salt(context.Background())
55+
if err != nil {
56+
panic(err)
57+
}
58+
59+
err = hashMap(salter.GetIdentifiedHMAC, copiedAsMap, nil)
60+
if err != nil {
61+
panic(err)
62+
}
63+
64+
return copiedAsMap
3665
}
3766

3867
func TestFormatRequestErrors(t *testing.T) {
3968
config := FormatterConfig{}
4069
formatter := AuditFormatter{
41-
AuditFormatWriter: &noopFormatWriter{},
70+
AuditFormatWriter: &testingFormatWriter{},
4271
}
4372

44-
if err := formatter.FormatRequest(context.Background(), ioutil.Discard, config, &logical.LogInput{}); err == nil {
73+
if err := formatter.FormatRequest(context.Background(), io.Discard, config, &logical.LogInput{}); err == nil {
4574
t.Fatal("expected error due to nil request")
4675
}
4776

@@ -56,10 +85,10 @@ func TestFormatRequestErrors(t *testing.T) {
5685
func TestFormatResponseErrors(t *testing.T) {
5786
config := FormatterConfig{}
5887
formatter := AuditFormatter{
59-
AuditFormatWriter: &noopFormatWriter{},
88+
AuditFormatWriter: &testingFormatWriter{},
6089
}
6190

62-
if err := formatter.FormatResponse(context.Background(), ioutil.Discard, config, &logical.LogInput{}); err == nil {
91+
if err := formatter.FormatResponse(context.Background(), io.Discard, config, &logical.LogInput{}); err == nil {
6392
t.Fatal("expected error due to nil request")
6493
}
6594

@@ -70,3 +99,123 @@ func TestFormatResponseErrors(t *testing.T) {
7099
t.Fatal("expected error due to nil writer")
71100
}
72101
}
102+
103+
func TestElideListResponses(t *testing.T) {
104+
tfw := testingFormatWriter{}
105+
formatter := AuditFormatter{&tfw}
106+
ctx := namespace.RootContext(context.Background())
107+
108+
type test struct {
109+
name string
110+
inputData map[string]interface{}
111+
expectedData map[string]interface{}
112+
}
113+
114+
tests := []test{
115+
{
116+
"nil data",
117+
nil,
118+
nil,
119+
},
120+
{
121+
"Normal list (keys only)",
122+
map[string]interface{}{
123+
"keys": []string{"foo", "bar", "baz"},
124+
},
125+
map[string]interface{}{
126+
"keys": 3,
127+
},
128+
},
129+
{
130+
"Enhanced list (has key_info)",
131+
map[string]interface{}{
132+
"keys": []string{"foo", "bar", "baz", "quux"},
133+
"key_info": map[string]interface{}{
134+
"foo": "alpha",
135+
"bar": "beta",
136+
"baz": "gamma",
137+
"quux": "delta",
138+
},
139+
},
140+
map[string]interface{}{
141+
"keys": 4,
142+
"key_info": 4,
143+
},
144+
},
145+
{
146+
"Unconventional other values in a list response are not touched",
147+
map[string]interface{}{
148+
"keys": []string{"foo", "bar"},
149+
"something_else": "baz",
150+
},
151+
map[string]interface{}{
152+
"keys": 2,
153+
"something_else": "baz",
154+
},
155+
},
156+
{
157+
"Conventional values in a list response are not elided if their data types are unconventional",
158+
map[string]interface{}{
159+
"keys": map[string]interface{}{
160+
"You wouldn't expect keys to be a map": nil,
161+
},
162+
"key_info": []string{
163+
"You wouldn't expect key_info to be a slice",
164+
},
165+
},
166+
map[string]interface{}{
167+
"keys": map[string]interface{}{
168+
"You wouldn't expect keys to be a map": nil,
169+
},
170+
"key_info": []string{
171+
"You wouldn't expect key_info to be a slice",
172+
},
173+
},
174+
},
175+
}
176+
oneInterestingTestCase := tests[2]
177+
178+
formatResponse := func(
179+
t *testing.T,
180+
config FormatterConfig,
181+
operation logical.Operation,
182+
inputData map[string]interface{},
183+
) {
184+
err := formatter.FormatResponse(ctx, io.Discard, config, &logical.LogInput{
185+
Request: &logical.Request{Operation: operation},
186+
Response: &logical.Response{Data: inputData},
187+
})
188+
require.Nil(t, err)
189+
}
190+
191+
t.Run("Default case", func(t *testing.T) {
192+
config := FormatterConfig{ElideListResponses: true}
193+
for _, tc := range tests {
194+
t.Run(tc.name, func(t *testing.T) {
195+
formatResponse(t, config, logical.ListOperation, tc.inputData)
196+
assert.Equal(t, tfw.hashExpectedValueForComparison(tc.expectedData), tfw.lastResponse.Response.Data)
197+
})
198+
}
199+
})
200+
201+
t.Run("When Operation is not list, eliding does not happen", func(t *testing.T) {
202+
config := FormatterConfig{ElideListResponses: true}
203+
tc := oneInterestingTestCase
204+
formatResponse(t, config, logical.ReadOperation, tc.inputData)
205+
assert.Equal(t, tfw.hashExpectedValueForComparison(tc.inputData), tfw.lastResponse.Response.Data)
206+
})
207+
208+
t.Run("When ElideListResponses is false, eliding does not happen", func(t *testing.T) {
209+
config := FormatterConfig{ElideListResponses: false}
210+
tc := oneInterestingTestCase
211+
formatResponse(t, config, logical.ListOperation, tc.inputData)
212+
assert.Equal(t, tfw.hashExpectedValueForComparison(tc.inputData), tfw.lastResponse.Response.Data)
213+
})
214+
215+
t.Run("When Raw is true, eliding still happens", func(t *testing.T) {
216+
config := FormatterConfig{ElideListResponses: true, Raw: true}
217+
tc := oneInterestingTestCase
218+
formatResponse(t, config, logical.ListOperation, tc.inputData)
219+
assert.Equal(t, tc.expectedData, tfw.lastResponse.Response.Data)
220+
})
221+
}

audit/formatter.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"github.com/hashicorp/vault/sdk/logical"
88
)
99

10-
// Formatter is an interface that is responsible for formating a
10+
// Formatter is an interface that is responsible for formatting a
1111
// request/response into some format. Formatters write their output
1212
// to an io.Writer.
1313
//
@@ -21,6 +21,28 @@ type FormatterConfig struct {
2121
Raw bool
2222
HMACAccessor bool
2323

24+
// Vault lacks pagination in its APIs. As a result, certain list operations can return **very** large responses.
25+
// The user's chosen audit sinks may experience difficulty consuming audit records that swell to tens of megabytes
26+
// of JSON. The responses of list operations are typically not very interesting, as they are mostly lists of keys,
27+
// or, even when they include a "key_info" field, are not returning confidential information. They become even less
28+
// interesting once HMAC-ed by the audit system.
29+
//
30+
// Some example Vault "list" operations that are prone to becoming very large in an active Vault installation are:
31+
// auth/token/accessors/
32+
// identity/entity/id/
33+
// identity/entity-alias/id/
34+
// pki/certs/
35+
//
36+
// This option exists to provide such users with the option to have response data elided from audit logs, only when
37+
// the operation type is "list". For added safety, the elision only applies to the "keys" and "key_info" fields
38+
// within the response data - these are conventionally the only fields present in a list response - see
39+
// logical.ListResponse, and logical.ListResponseWithInfo. However, other fields are technically possible if a
40+
// plugin author writes unusual code, and these will be preserved in the audit log even with this option enabled.
41+
// The elision replaces the values of the "keys" and "key_info" fields with an integer count of the number of
42+
// entries. This allows even the elided audit logs to still be useful for answering questions like
43+
// "Was any data returned?" or "How many records were listed?".
44+
ElideListResponses bool
45+
2446
// This should only ever be used in a testing context
2547
OmitTime bool
2648
}

audit/hashstructure.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,13 @@ func hashMap(fn func(string) string, data map[string]interface{}, nonHMACDataKey
9898
}
9999

100100
// HashResponse returns a hashed copy of the logical.Request input.
101-
func HashResponse(salter *salt.Salt, in *logical.Response, HMACAccessor bool, nonHMACDataKeys []string) (*logical.Response, error) {
101+
func HashResponse(
102+
salter *salt.Salt,
103+
in *logical.Response,
104+
HMACAccessor bool,
105+
nonHMACDataKeys []string,
106+
elideListResponseData bool,
107+
) (*logical.Response, error) {
102108
if in == nil {
103109
return nil, nil
104110
}
@@ -129,12 +135,20 @@ func HashResponse(salter *salt.Salt, in *logical.Response, HMACAccessor bool, no
129135
mapCopy[logical.HTTPRawBody] = string(b)
130136
}
131137

138+
// Processing list response data elision takes place at this point in the code for performance reasons:
139+
// - take advantage of the deep copy of resp.Data that was going to be done anyway for hashing
140+
// - but elide data before potentially spending time hashing it
141+
if elideListResponseData {
142+
doElideListResponseData(mapCopy)
143+
}
144+
132145
err = hashMap(fn, mapCopy, nonHMACDataKeys)
133146
if err != nil {
134147
return nil, err
135148
}
136149
resp.Data = mapCopy
137150
}
151+
138152
if resp.WrapInfo != nil {
139153
var err error
140154
resp.WrapInfo, err = HashWrapInfo(salter, resp.WrapInfo, HMACAccessor)

audit/hashstructure_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ func TestHashResponse(t *testing.T) {
293293
}
294294
for _, tc := range cases {
295295
input := fmt.Sprintf("%#v", tc.Input)
296-
out, err := HashResponse(localSalt, tc.Input, tc.HMACAccessor, tc.NonHMACDataKeys)
296+
out, err := HashResponse(localSalt, tc.Input, tc.HMACAccessor, tc.NonHMACDataKeys, false)
297297
if err != nil {
298298
t.Fatalf("err: %s\n\n%s", err, input)
299299
}

0 commit comments

Comments
 (0)