Skip to content

Commit c0ea332

Browse files
Sanitize exported stack state by scrubbing secrets (#107)
When upgrade tests persist stack.json, it contains secrets, making it unsuitable for committing. This PR adds a sanitization step that scrubs secrets, as identified by the p/p signature, from the state. Fixes #106 --------- Co-authored-by: Daniel Bradley <[email protected]>
1 parent cecfbc7 commit c0ea332

File tree

7 files changed

+559
-5
lines changed

7 files changed

+559
-5
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ snapshots need to be recorded anew on the new version.
8989

9090
### Fixing failing tests
9191
- If the tests fail by flagging unwanted resource updates or replacements that are actually
92-
acceptable, configure or custom
92+
acceptable, configure a custom
9393
[DiffValidation](https://github.com/pulumi/providertest/blob/5f23c3ec7cee882392ea356a54c0f74f56b0f7d5/upgrade.go#L241)
9494
setting with more relaxed asserts.
9595

grpclog/grpclog.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"path/filepath"
77
"strings"
88

9+
"github.com/pulumi/providertest/pulumitest/sanitize"
910
rpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
1011
jsonpb "google.golang.org/protobuf/encoding/protojson"
1112
"google.golang.org/protobuf/reflect/protoreflect"
@@ -147,10 +148,11 @@ func unmarshalTypedEntries[TRequest, TResponse any](entries []GrpcLogEntry) ([]T
147148
func unmarshalTypedEntry[TRequest, TResponse any](entry GrpcLogEntry) (*TypedEntry[TRequest, TResponse], error) {
148149
reqSlot := new(TRequest)
149150
resSlot := new(TResponse)
150-
if err := jsonpb.Unmarshal([]byte(entry.Request), any(reqSlot).(protoreflect.ProtoMessage)); err != nil {
151+
jsonOpts := jsonpb.UnmarshalOptions{DiscardUnknown: true, AllowPartial: true}
152+
if err := jsonOpts.Unmarshal([]byte(entry.Request), any(reqSlot).(protoreflect.ProtoMessage)); err != nil {
151153
return nil, err
152154
}
153-
if err := jsonpb.Unmarshal([]byte(entry.Response), any(resSlot).(protoreflect.ProtoMessage)); err != nil {
155+
if err := jsonOpts.Unmarshal([]byte(entry.Response), any(resSlot).(protoreflect.ProtoMessage)); err != nil {
154156
return nil, err
155157
}
156158
typedEntry := TypedEntry[TRequest, TResponse]{
@@ -196,6 +198,17 @@ func (l *GrpcLog) WhereMethod(method Method) []GrpcLogEntry {
196198
return matching
197199
}
198200

201+
func (l *GrpcLog) SanitizeSecrets() {
202+
for i := range l.Entries {
203+
l.Entries[i].SanitizeSecrets()
204+
}
205+
}
206+
207+
func (e *GrpcLogEntry) SanitizeSecrets() {
208+
e.Request = sanitize.SanitizeSecretsInGrpcLog(e.Request)
209+
e.Response = sanitize.SanitizeSecretsInGrpcLog(e.Response)
210+
}
211+
199212
// WriteTo writes the log to the given path.
200213
// Creates any directories needed.
201214
func (l *GrpcLog) WriteTo(path string) error {

previewProviderUpgrade.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func PreviewProviderUpgrade(t pulumitest.PT, pulumiTest *pulumitest.PulumiTest,
3131
grptLog := test.GrpcLog(t)
3232
grpcLogPath := filepath.Join(cacheDir, "grpc.json")
3333
t.Log(fmt.Sprintf("writing grpc log to %s", grpcLogPath))
34+
grptLog.SanitizeSecrets()
3435
grptLog.WriteTo(grpcLogPath)
3536
},
3637
optrun.WithCache(filepath.Join(cacheDir, "stack.json")),

pulumitest/run.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010

1111
"github.com/pulumi/providertest/pulumitest/optrun"
12+
"github.com/pulumi/providertest/pulumitest/sanitize"
1213
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
1314
)
1415

@@ -42,8 +43,14 @@ func (pulumiTest *PulumiTest) Run(t PT, execute func(test *PulumiTest), opts ...
4243
execute(isolatedTest)
4344
exportedStack := isolatedTest.ExportStack(t)
4445
if options.EnableCache {
46+
ptLogF(t, "sanitizing secrets from stack state")
47+
sanitizedStack, err := sanitize.SanitizeSecretsInStackState(&exportedStack)
48+
if err != nil {
49+
ptError(t, "failed to sanitize secrets from stack state: %v", err)
50+
}
51+
4552
ptLogF(t, "writing stack state to %s", options.CachePath)
46-
err = writeStackExport(options.CachePath, &exportedStack, false /* overwrite */)
53+
err = writeStackExport(options.CachePath, sanitizedStack, false /* overwrite */)
4754
if err != nil {
4855
ptFatalF(t, "failed to write snapshot to %s: %v", options.CachePath, err)
4956
}

pulumitest/sanitize/sanitize.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright 2016-2024, Pulumi Corporation.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package sanitize
16+
17+
import (
18+
"encoding/json"
19+
20+
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
21+
)
22+
23+
const plaintextSub = "REDACTED BY PROVIDERTEST"
24+
const secretSignature = "4dabf18193072939515e22adb298388d"
25+
26+
// SanitizeSecretsInStackState sanitizes secrets in the stack state by replacing them with a placeholder.
27+
// secrets are identified by their magic signature, copied from pulumi/pulumi.
28+
func SanitizeSecretsInStackState(stack *apitype.UntypedDeployment) (*apitype.UntypedDeployment, error) {
29+
var d apitype.DeploymentV3
30+
err := json.Unmarshal(stack.Deployment, &d)
31+
if err != nil {
32+
return nil, err
33+
}
34+
35+
sanitizeSecretsInResources(d.Resources)
36+
37+
marshaledDeployment, err := json.Marshal(d)
38+
if err != nil {
39+
return nil, err
40+
}
41+
42+
return &apitype.UntypedDeployment{
43+
Version: stack.Version,
44+
Deployment: json.RawMessage(marshaledDeployment),
45+
}, nil
46+
}
47+
48+
func SanitizeSecretsInGrpcLog(log json.RawMessage) json.RawMessage {
49+
var data map[string]any
50+
if err := json.Unmarshal(log, &data); err != nil {
51+
return log
52+
}
53+
54+
sanitized := sanitizeSecretsInObject(data, map[string]any{
55+
secretSignature: "1b47061264138c4ac30d75fd1eb44270",
56+
"value": plaintextSub,
57+
})
58+
sanitizedBytes, err := json.Marshal(sanitized)
59+
if err != nil {
60+
return log
61+
}
62+
return sanitizedBytes
63+
}
64+
65+
func sanitizeSecretsInResources(resources []apitype.ResourceV3) {
66+
for i, r := range resources {
67+
r.Inputs = sanitizeSecretsInObject(r.Inputs, stateSecretReplacement)
68+
r.Outputs = sanitizeSecretsInObject(r.Outputs, stateSecretReplacement)
69+
resources[i] = r
70+
}
71+
}
72+
73+
var stateSecretReplacement = map[string]any{
74+
secretSignature: "1b47061264138c4ac30d75fd1eb44270",
75+
"plaintext": `"` + plaintextSub + `"`, // must be valid JSON, hence quoted
76+
}
77+
78+
func sanitizeSecretsInObject(obj map[string]any, secretReplacement map[string]any) map[string]any {
79+
copy := map[string]any{}
80+
for k, v := range obj {
81+
innerObj, ok := v.(map[string]any)
82+
if ok {
83+
_, hasSecret := innerObj[secretSignature]
84+
if hasSecret {
85+
copy[k] = secretReplacement
86+
} else {
87+
copy[k] = sanitizeSecretsInObject(innerObj, secretReplacement)
88+
}
89+
} else {
90+
copy[k] = v
91+
}
92+
}
93+
return copy
94+
}

0 commit comments

Comments
 (0)