Skip to content

Commit

Permalink
feat(*): mask sensitive values in console output
Browse files Browse the repository at this point in the history
  • Loading branch information
vdice committed Apr 8, 2019
1 parent af9bd09 commit 4b8ec02
Show file tree
Hide file tree
Showing 10 changed files with 257 additions and 10 deletions.
7 changes: 7 additions & 0 deletions docs/content/authoring-bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ parameters:
- name: mysql_user
type: string
default: azureuser
- name: mysql_password
type: string
sensitive: true
- name: database_name
type: string
default: "wordpress"
Expand All @@ -76,6 +79,7 @@ parameters:
* `destination`: The destination in the bundle to define the parameter.
* `env`: The name for the environment variable. Defaults to the name of the parameter in upper case.
* `path`: The path for the file. Required for file paths, there is no default.
* `sensitive`: Designate this parameter's value as sensitive, for masking in console output.

## Credentials

Expand All @@ -85,6 +89,8 @@ you to pass in sensitive data when you execute the bundle, such as passwords or
When the bundle is executed, for example when you run `duffle install`, the installer will look on your local system
for the named credential and then place the value or file found in the bundle as either an environment variable or file.

By default, all credential values are considered sensitive and will be masked in console output.

```yaml
credentials:
- name: SUBSCRIPTION_ID
Expand Down Expand Up @@ -133,6 +139,7 @@ install:
* `MIXIN`: The name of the mixin that will handle this step. In the example above, `helm` is the mixin.
* `outputs`: Any outputs provided by the steps. The `name` is required but the rest of the the schema for the
output is specific to the mixin. In the example above, the mixin will make the Kubernetes secret data available as outputs.
By default, all output values are considered sensitive and will be masked in console output.

## Dependencies

Expand Down
15 changes: 11 additions & 4 deletions examples/azure-mysql-wordpress/porter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ parameters:

- name: mysql_password
type: string
default: "!Th1s1s4p4ss!"
sensitive: true

- name: database_name
type: string
Expand Down Expand Up @@ -73,9 +73,16 @@ install:
source: bundle.parameters.mysql_password
externalDatabase.database:
source: bundle.parameters.database_name

uninstall:
- azure:
description: "Uninstall Wordpress"
name: porter-ci-wordpress
# TODO: enable once the porter-azure mixin supports this action
# - azure:
# description: "Uninstall Mysql"
# name: mysql-azure-porter-demo-wordpress
- helm:
description: "Helm Uninstall Wordpress"
purge: true
releases:
- "porter-ci-wordpress"


43 changes: 39 additions & 4 deletions pkg/config/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import (

type Manifest struct {
// path where the manifest was loaded, used to resolve local bundle references
path string
outputs map[string]string
path string
outputs map[string]string
sensitiveValues []string

Name string `yaml:"name,omitempty"`
Description string `yaml:"description,omitempty"`
Expand Down Expand Up @@ -48,6 +49,7 @@ type ParameterDefinition struct {
MaxLength *int `yaml:"maxLength,omitempty"`
Metadata ParameterMetadata `yaml:"metadata,omitempty"`
Destination *Location `yaml:"destination,omitempty"`
Sensitive bool `yaml:"sensitive"`
}

type CredentialDefinition struct {
Expand Down Expand Up @@ -97,6 +99,10 @@ func (d *Dependency) resolveValue(key string) (interface{}, error) {
switch sourceType {
case "outputs":
replacement = d.m.outputs[sourceName]
// Porter considers all outputs as sensitive
if replacement != nil {
d.m.sensitiveValues = append(d.m.sensitiveValues, reflect.ValueOf(replacement).String())
}
case "parameters":
for _, param := range d.m.Parameters {
if param.Name == sourceName {
Expand All @@ -113,6 +119,10 @@ func (d *Dependency) resolveValue(key string) (interface{}, error) {
"unknown parameter definition, no environment variable or path specified",
)
}
// if replacement has been set and parameter is designated sensitive, add to list of sensitive values
if replacement != nil && param.Sensitive {
d.m.sensitiveValues = append(d.m.sensitiveValues, reflect.ValueOf(replacement).String())
}
}
}
default:
Expand Down Expand Up @@ -272,6 +282,13 @@ func (m *Manifest) Validate() error {
return result
}

func (m *Manifest) GetSensitiveValues() []string {
if m.sensitiveValues == nil {
return []string{}
}
return m.sensitiveValues
}

func (m *Manifest) GetSteps(action Action) (Steps, error) {
var steps Steps
switch action {
Expand Down Expand Up @@ -671,7 +688,7 @@ func (m *Manifest) Struct(val reflect.Value) error {
return nil
}

// Struct implements reflectwalk's StructWalker so that we can skip private fields
// StructField implements reflectwalk's StructWalker so that we can skip private fields
func (m *Manifest) StructField(field reflect.StructField, val reflect.Value) error {
isUnexported := func() bool {
return field.PkgPath != ""
Expand Down Expand Up @@ -709,6 +726,10 @@ func (m *Manifest) resolveValue(key string) (interface{}, error) {
"unknown parameter definition, no environment variable or path specified",
)
}
// if replacement has been set and parameter is designated sensitive, add to list of sensitive values
if replacement != nil && param.Sensitive {
m.sensitiveValues = append(m.sensitiveValues, reflect.ValueOf(replacement).String())
}
}
}
case "credentials":
Expand All @@ -723,16 +744,30 @@ func (m *Manifest) resolveValue(key string) (interface{}, error) {
"unknown credential definition, no environment variable or path specified",
)
}

// if replacement has been set, add to list of sensitive values
if replacement != nil {
m.sensitiveValues = append(m.sensitiveValues, reflect.ValueOf(replacement).String())
}
}
}
case "outputs":
if o, exists := m.outputs[sourceName]; exists {
replacement = o
// Porter considers all outputs as sensitive
if replacement != nil {
m.sensitiveValues = append(m.sensitiveValues, reflect.ValueOf(replacement).String())
}
}
case "dependencies":
for _, dep := range m.Dependencies {
if dep.Name == sourceName {
return dep.resolveValue(key)
replacement, err := dep.resolveValue(key)
// Retrieve updated list of sensitive values from dependency and add to our list
for _, val := range dep.m.GetSensitiveValues() {
m.sensitiveValues = append(m.sensitiveValues, val)
}
return replacement, err
}
}
default:
Expand Down
115 changes: 115 additions & 0 deletions pkg/config/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,121 @@ func TestResolveArray(t *testing.T) {
assert.Equal(t, "Ralpha", args[0])
}

func TestResolveSensitiveParameter(t *testing.T) {
m := &Manifest{
Parameters: []ParameterDefinition{
{
Name: "sensitive_param",
Sensitive: true,
},
{
Name: "regular_param",
},
},
}

os.Setenv("SENSITIVE_PARAM", "deliciou$dubonnet")
os.Setenv("REGULAR_PARAM", "regular param value")
s := &Step{
Data: map[string]interface{}{
"description": "a test step",
"Arguments": []string{
"source: bundle.parameters.sensitive_param",
"source: bundle.parameters.regular_param",
},
},
}

// Prior to resolving step values, this method should return an empty string array
assert.Equal(t, m.GetSensitiveValues(), []string{})

err := m.ResolveStep(s)
require.NoError(t, err)
args, ok := s.Data["Arguments"].([]string)
assert.True(t, ok)
assert.Equal(t, 2, len(args))
assert.Equal(t, "deliciou$dubonnet", args[0])
assert.Equal(t, "regular param value", args[1])

// There should now be one sensitive value tracked under the manifest
assert.Equal(t, []string{"deliciou$dubonnet"}, m.GetSensitiveValues())
}

func TestResolveCredential(t *testing.T) {
m := &Manifest{
Credentials: []CredentialDefinition{
{
Name: "password",
EnvironmentVariable: "PASSWORD",
},
},
}

os.Setenv("PASSWORD", "deliciou$dubonnet")
s := &Step{
Data: map[string]interface{}{
"description": "a test step",
"Arguments": []string{
"source: bundle.credentials.password",
},
},
}

// Prior to resolving step values, this method should return an empty string array
assert.Equal(t, m.GetSensitiveValues(), []string{})

err := m.ResolveStep(s)
require.NoError(t, err)
args, ok := s.Data["Arguments"].([]string)
assert.True(t, ok)
assert.Equal(t, "deliciou$dubonnet", args[0])

// There should now be a sensitive value tracked under the manifest
assert.Equal(t, []string{"deliciou$dubonnet"}, m.GetSensitiveValues())
}

func TestResolveOutputs(t *testing.T) {
m := &Manifest{
outputs: map[string]string{
"output": "output_value",
},
Dependencies: []*Dependency{
&Dependency{
Name: "dep",
m: &Manifest{
outputs: map[string]string{
"dep_output": "dep_output_value",
},
},
},
},
}

s := &Step{
Data: map[string]interface{}{
"description": "a test step",
"Arguments": []string{
"source: bundle.outputs.output",
"source: bundle.dependencies.dep.outputs.dep_output",
},
},
}

// Prior to resolving step values, this method should return an empty string array
assert.Equal(t, m.GetSensitiveValues(), []string{})

err := m.ResolveStep(s)
require.NoError(t, err)
args, ok := s.Data["Arguments"].([]string)
assert.True(t, ok)
assert.Equal(t, 2, len(args))
assert.Equal(t, "output_value", args[0])
assert.Equal(t, "dep_output_value", args[1])

// There should now be a sensitive value tracked under the manifest
assert.Equal(t, []string{"output_value", "dep_output_value"}, m.GetSensitiveValues())
}

func TestResolveInMainDict(t *testing.T) {
c := NewTestConfig(t)
c.SetupPorterHome()
Expand Down
46 changes: 44 additions & 2 deletions pkg/context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package context

import (
"bufio"
"bytes"
"fmt"
"io"
"os"
Expand All @@ -24,15 +25,43 @@ type Context struct {
NewCommand CommandBuilder
}

// CensoredWriter is a writer wrapping the provided io.Writer with logic to censor certain values
type CensoredWriter struct {
writer io.Writer
sensitiveValues []string
}

// NewCensoredWriter returns a new CensoredWriter
func NewCensoredWriter(writer io.Writer) *CensoredWriter {
return &CensoredWriter{writer: writer, sensitiveValues: []string{}}
}

// SetSensitiveValues sets values needing masking for an CensoredWriter
func (cw *CensoredWriter) SetSensitiveValues(vals []string) {
cw.sensitiveValues = vals
}

// Write implements io.Writer's Write method, performing necessary auditing while doing so
func (cw *CensoredWriter) Write(b []byte) (int, error) {
auditedBytes := b
for _, val := range cw.sensitiveValues {
auditedBytes = bytes.Replace(auditedBytes, []byte(val), []byte("*******"), -1)
}

_, err := cw.writer.Write(auditedBytes)
return len(b), err
}

func New() *Context {
// Default to respecting the PORTER_DEBUG env variable, the cli will override if --debug is set otherwise
_, debug := os.LookupEnv("PORTER_DEBUG")

return &Context{
Debug: debug,
FileSystem: &afero.Afero{Fs: afero.NewOsFs()},
In: os.Stdin,
Out: os.Stdout,
Err: os.Stderr,
Out: NewCensoredWriter(os.Stdout),
Err: NewCensoredWriter(os.Stderr),
NewCommand: exec.Command,
}
}
Expand Down Expand Up @@ -109,3 +138,16 @@ func (c *Context) WriteOutput(lines []string) error {
}
return nil
}

// SetSensitiveValues sets the sensitive values needing masking on output/err streams
func (c *Context) SetSensitiveValues(vals []string) {
if len(vals) > 0 {
out := NewCensoredWriter(os.Stdout)
out.SetSensitiveValues(vals)
c.Out = out

err := NewCensoredWriter(os.Stderr)
err.SetSensitiveValues(vals)
c.Err = err
}
}
Loading

0 comments on commit 4b8ec02

Please sign in to comment.