Skip to content

Commit

Permalink
Sanitize exported stack state by scrubbing secrets (#107)
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
thomas11 and danielrbradley authored Sep 9, 2024
1 parent cecfbc7 commit c0ea332
Show file tree
Hide file tree
Showing 7 changed files with 559 additions and 5 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ snapshots need to be recorded anew on the new version.

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

Expand Down
17 changes: 15 additions & 2 deletions grpclog/grpclog.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"

"github.com/pulumi/providertest/pulumitest/sanitize"
rpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
jsonpb "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protoreflect"
Expand Down Expand Up @@ -147,10 +148,11 @@ func unmarshalTypedEntries[TRequest, TResponse any](entries []GrpcLogEntry) ([]T
func unmarshalTypedEntry[TRequest, TResponse any](entry GrpcLogEntry) (*TypedEntry[TRequest, TResponse], error) {
reqSlot := new(TRequest)
resSlot := new(TResponse)
if err := jsonpb.Unmarshal([]byte(entry.Request), any(reqSlot).(protoreflect.ProtoMessage)); err != nil {
jsonOpts := jsonpb.UnmarshalOptions{DiscardUnknown: true, AllowPartial: true}
if err := jsonOpts.Unmarshal([]byte(entry.Request), any(reqSlot).(protoreflect.ProtoMessage)); err != nil {
return nil, err
}
if err := jsonpb.Unmarshal([]byte(entry.Response), any(resSlot).(protoreflect.ProtoMessage)); err != nil {
if err := jsonOpts.Unmarshal([]byte(entry.Response), any(resSlot).(protoreflect.ProtoMessage)); err != nil {
return nil, err
}
typedEntry := TypedEntry[TRequest, TResponse]{
Expand Down Expand Up @@ -196,6 +198,17 @@ func (l *GrpcLog) WhereMethod(method Method) []GrpcLogEntry {
return matching
}

func (l *GrpcLog) SanitizeSecrets() {
for i := range l.Entries {
l.Entries[i].SanitizeSecrets()
}
}

func (e *GrpcLogEntry) SanitizeSecrets() {
e.Request = sanitize.SanitizeSecretsInGrpcLog(e.Request)
e.Response = sanitize.SanitizeSecretsInGrpcLog(e.Response)
}

// WriteTo writes the log to the given path.
// Creates any directories needed.
func (l *GrpcLog) WriteTo(path string) error {
Expand Down
1 change: 1 addition & 0 deletions previewProviderUpgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func PreviewProviderUpgrade(t pulumitest.PT, pulumiTest *pulumitest.PulumiTest,
grptLog := test.GrpcLog(t)
grpcLogPath := filepath.Join(cacheDir, "grpc.json")
t.Log(fmt.Sprintf("writing grpc log to %s", grpcLogPath))
grptLog.SanitizeSecrets()
grptLog.WriteTo(grpcLogPath)
},
optrun.WithCache(filepath.Join(cacheDir, "stack.json")),
Expand Down
9 changes: 8 additions & 1 deletion pulumitest/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/pulumi/providertest/pulumitest/optrun"
"github.com/pulumi/providertest/pulumitest/sanitize"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
)

Expand Down Expand Up @@ -42,8 +43,14 @@ func (pulumiTest *PulumiTest) Run(t PT, execute func(test *PulumiTest), opts ...
execute(isolatedTest)
exportedStack := isolatedTest.ExportStack(t)
if options.EnableCache {
ptLogF(t, "sanitizing secrets from stack state")
sanitizedStack, err := sanitize.SanitizeSecretsInStackState(&exportedStack)
if err != nil {
ptError(t, "failed to sanitize secrets from stack state: %v", err)
}

ptLogF(t, "writing stack state to %s", options.CachePath)
err = writeStackExport(options.CachePath, &exportedStack, false /* overwrite */)
err = writeStackExport(options.CachePath, sanitizedStack, false /* overwrite */)
if err != nil {
ptFatalF(t, "failed to write snapshot to %s: %v", options.CachePath, err)
}
Expand Down
94 changes: 94 additions & 0 deletions pulumitest/sanitize/sanitize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright 2016-2024, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package sanitize

import (
"encoding/json"

"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
)

const plaintextSub = "REDACTED BY PROVIDERTEST"
const secretSignature = "4dabf18193072939515e22adb298388d"

// SanitizeSecretsInStackState sanitizes secrets in the stack state by replacing them with a placeholder.
// secrets are identified by their magic signature, copied from pulumi/pulumi.
func SanitizeSecretsInStackState(stack *apitype.UntypedDeployment) (*apitype.UntypedDeployment, error) {
var d apitype.DeploymentV3
err := json.Unmarshal(stack.Deployment, &d)
if err != nil {
return nil, err
}

sanitizeSecretsInResources(d.Resources)

marshaledDeployment, err := json.Marshal(d)
if err != nil {
return nil, err
}

return &apitype.UntypedDeployment{
Version: stack.Version,
Deployment: json.RawMessage(marshaledDeployment),
}, nil
}

func SanitizeSecretsInGrpcLog(log json.RawMessage) json.RawMessage {
var data map[string]any
if err := json.Unmarshal(log, &data); err != nil {
return log
}

sanitized := sanitizeSecretsInObject(data, map[string]any{
secretSignature: "1b47061264138c4ac30d75fd1eb44270",
"value": plaintextSub,
})
sanitizedBytes, err := json.Marshal(sanitized)
if err != nil {
return log
}
return sanitizedBytes
}

func sanitizeSecretsInResources(resources []apitype.ResourceV3) {
for i, r := range resources {
r.Inputs = sanitizeSecretsInObject(r.Inputs, stateSecretReplacement)
r.Outputs = sanitizeSecretsInObject(r.Outputs, stateSecretReplacement)
resources[i] = r
}
}

var stateSecretReplacement = map[string]any{
secretSignature: "1b47061264138c4ac30d75fd1eb44270",
"plaintext": `"` + plaintextSub + `"`, // must be valid JSON, hence quoted
}

func sanitizeSecretsInObject(obj map[string]any, secretReplacement map[string]any) map[string]any {
copy := map[string]any{}
for k, v := range obj {
innerObj, ok := v.(map[string]any)
if ok {
_, hasSecret := innerObj[secretSignature]
if hasSecret {
copy[k] = secretReplacement
} else {
copy[k] = sanitizeSecretsInObject(innerObj, secretReplacement)
}
} else {
copy[k] = v
}
}
return copy
}
Loading

0 comments on commit c0ea332

Please sign in to comment.