diff --git a/lang/funcs/sensitive.go b/lang/funcs/sensitive.go new file mode 100644 index 000000000..b132dc12b --- /dev/null +++ b/lang/funcs/sensitive.go @@ -0,0 +1,66 @@ +package funcs + +import ( + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +// SensitiveFunc returns a value identical to its argument except that +// Terraform will consider it to be sensitive. +var SensitiveFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "value", + Type: cty.DynamicPseudoType, + AllowUnknown: true, + AllowNull: true, + AllowMarked: true, + AllowDynamicType: true, + }, + }, + Type: func(args []cty.Value) (cty.Type, error) { + // This function only affects the value's marks, so the result + // type is always the same as the argument type. + return args[0].Type(), nil + }, + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + val, _ := args[0].Unmark() + return val.Mark("sensitive"), nil + }, +}) + +// NonsensitiveFunc takes a sensitive value and returns the same value without +// the sensitive marking, effectively exposing the value. +var NonsensitiveFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "value", + Type: cty.DynamicPseudoType, + AllowUnknown: true, + AllowNull: true, + AllowMarked: true, + AllowDynamicType: true, + }, + }, + Type: func(args []cty.Value) (cty.Type, error) { + // This function only affects the value's marks, so the result + // type is always the same as the argument type. + return args[0].Type(), nil + }, + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + if !args[0].HasMark("sensitive") { + return cty.DynamicVal, function.NewArgErrorf(0, "the given value is not sensitive, so this call is redundant") + } + v, marks := args[0].Unmark() + delete(marks, "sensitive") // remove the sensitive marking + return v.WithMarks(marks), nil + }, +}) + +func Sensitive(v cty.Value) (cty.Value, error) { + return SensitiveFunc.Call([]cty.Value{v}) +} + +func Nonsensitive(v cty.Value) (cty.Value, error) { + return NonsensitiveFunc.Call([]cty.Value{v}) +} diff --git a/lang/funcs/sensitive_test.go b/lang/funcs/sensitive_test.go new file mode 100644 index 000000000..522346b4c --- /dev/null +++ b/lang/funcs/sensitive_test.go @@ -0,0 +1,175 @@ +package funcs + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestSensitive(t *testing.T) { + tests := []struct { + Input cty.Value + WantErr string + }{ + { + cty.NumberIntVal(1), + ``, + }, + { + // Unknown values stay unknown while becoming sensitive + cty.UnknownVal(cty.String), + ``, + }, + { + // Null values stay unknown while becoming sensitive + cty.NullVal(cty.String), + ``, + }, + { + // DynamicVal can be marked as sensitive + cty.DynamicVal, + ``, + }, + { + // The marking is shallow only + cty.ListVal([]cty.Value{cty.NumberIntVal(1)}), + ``, + }, + { + // A value already marked is allowed and stays marked + cty.NumberIntVal(1).Mark("sensitive"), + ``, + }, + { + // A value with some non-standard mark gets "fixed" to be marked + // with the standard "sensitive" mark. (This situation occurring + // would imply an inconsistency/bug elsewhere, so we're just + // being robust about it here.) + cty.NumberIntVal(1).Mark("bloop"), + ``, + }, + { + // A value deep already marked is allowed and stays marked, + // _and_ we'll also mark the outer collection as sensitive. + cty.ListVal([]cty.Value{cty.NumberIntVal(1).Mark("sensitive")}), + ``, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("sensitive(%#v)", test.Input), func(t *testing.T) { + got, err := Sensitive(test.Input) + + if test.WantErr != "" { + if err == nil { + t.Fatal("succeeded; want error") + } + if got, want := err.Error(), test.WantErr; got != want { + t.Fatalf("wrong error\ngot: %s\nwant: %s", got, want) + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.HasMark("sensitive") { + t.Errorf("result is not marked sensitive") + } + + gotRaw, gotMarks := got.Unmark() + if len(gotMarks) != 1 { + // We're only expecting to have the "sensitive" mark we checked + // above. Any others are an error, even if they happen to + // appear alongside "sensitive". (We might change this rule + // if someday we decide to use marks for some additional + // unrelated thing in Terraform, but currently we assume that + // _all_ marks imply sensitive, and so returning any other + // marks would be confusing.) + t.Errorf("extraneous marks %#v", gotMarks) + } + + // Disregarding shallow marks, the result should have the same + // effective value as the input. + wantRaw, _ := test.Input.Unmark() + if !gotRaw.RawEquals(wantRaw) { + t.Errorf("wrong unmarked result\ngot: %#v\nwant: %#v", got, wantRaw) + } + }) + } +} + +func TestNonsensitive(t *testing.T) { + tests := []struct { + Input cty.Value + WantErr string + }{ + { + cty.NumberIntVal(1).Mark("sensitive"), + ``, + }, + { + cty.DynamicVal.Mark("sensitive"), + ``, + }, + { + cty.UnknownVal(cty.String).Mark("sensitive"), + ``, + }, + { + cty.NullVal(cty.EmptyObject).Mark("sensitive"), + ``, + }, + { + // The inner sensitive remains afterwards + cty.ListVal([]cty.Value{cty.NumberIntVal(1).Mark("sensitive")}).Mark("sensitive"), + ``, + }, + + // Passing a value that is already non-sensitive is an error, + // because this function should always be used with specific + // intention, not just as a "make everything visible" hammer. + { + cty.NumberIntVal(1), + `the given value is not sensitive, so this call is redundant`, + }, + { + cty.DynamicVal, + `the given value is not sensitive, so this call is redundant`, + }, + { + cty.NullVal(cty.String), + `the given value is not sensitive, so this call is redundant`, + }, + { + cty.UnknownVal(cty.String), + `the given value is not sensitive, so this call is redundant`, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("nonsensitive(%#v)", test.Input), func(t *testing.T) { + got, err := Nonsensitive(test.Input) + + if test.WantErr != "" { + if err == nil { + t.Fatal("succeeded; want error") + } + if got, want := err.Error(), test.WantErr; got != want { + t.Fatalf("wrong error\ngot: %s\nwant: %s", got, want) + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if got.HasMark("sensitive") { + t.Errorf("result is still marked sensitive") + } + wantRaw, _ := test.Input.Unmark() + if !got.RawEquals(wantRaw) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Input) + } + }) + } +} diff --git a/lang/functions.go b/lang/functions.go index 3a604f7c5..3365d42b4 100644 --- a/lang/functions.go +++ b/lang/functions.go @@ -102,6 +102,8 @@ func (s *Scope) Functions() map[string]function.Function { "replace": funcs.ReplaceFunc, "reverse": stdlib.ReverseListFunc, "rsadecrypt": funcs.RsaDecryptFunc, + "sensitive": funcs.SensitiveFunc, + "nonsensitive": funcs.NonsensitiveFunc, "setintersection": stdlib.SetIntersectionFunc, "setproduct": stdlib.SetProductFunc, "setsubtract": stdlib.SetSubtractFunc, diff --git a/lang/functions_test.go b/lang/functions_test.go index f893bb9ab..7c9b069fb 100644 --- a/lang/functions_test.go +++ b/lang/functions_test.go @@ -602,6 +602,18 @@ func TestFunctions(t *testing.T) { }, }, + "nonsensitive": { + { + // Due to how this test is set up we have no way to get + // a sensitive value other than to generate one with + // another function, so this is a bit odd but does still + // meet the goal of verifying that the "nonsensitive" + // function is correctly registered. + `nonsensitive(sensitive(1))`, + cty.NumberIntVal(1), + }, + }, + "parseint": { { `parseint("100", 10)`, @@ -689,6 +701,13 @@ func TestFunctions(t *testing.T) { }, }, + "sensitive": { + { + `sensitive(1)`, + cty.NumberIntVal(1).Mark("sensitive"), + }, + }, + "setintersection": { { `setintersection(["a", "b"], ["b", "c"], ["b", "d"])`, diff --git a/website/docs/language/functions/nonsensitive.html.md b/website/docs/language/functions/nonsensitive.html.md new file mode 100644 index 000000000..8458f4e70 --- /dev/null +++ b/website/docs/language/functions/nonsensitive.html.md @@ -0,0 +1,129 @@ +--- +layout: "language" +page_title: "nonsensitive - Functions - Configuration Language" +sidebar_current: "docs-funcs-conversion-sensitive" +description: |- + The nonsensitive function removes the sensitive marking from a value that Terraform considers to be sensitive. +--- + +# `nonsensitive` Function + +-> **Note:** This function is only available in Terraform v0.14 and later. + +`nonsensitive` takes a sensitive value and returns a copy of that value with +the sensitive marking removed, thereby exposing the sensitive value. + +~> **Warning:** Using this function indiscriminately will cause values that +Terraform would normally have considered as sensitive to be treated as normal +values and shown clearly in Terraform's output. Use this function only when +you've derived a new value from a sensitive value in a way that eliminates the +sensitive portions of the value. + +Normally Terraform tracks when you use expressions to derive a new value from +a value that is marked as sensitive, so that the result can also be marked +as sensitive. + +However, you may wish to write expressions that derive non-sensitive results +from sensitive values. For example, if you know based on details of your +particular system and its threat model that a SHA256 hash of a particular +sensitive value is safe to include clearly in Terraform output, you could use +the `nonsensitive` function to indicate that, overriding Terraform's normal +conservative behavior: + +```hcl +output "sensitive_example_hash" { + value = nonsensitive(sha256(var.sensitive_example)) +} +``` + +Another example might be if the original value is only partially sensitive and +you've written expressions to separate the sensitive and non-sensitive parts: + +```hcl +variable "mixed_content_json" { + description = "A JSON string containing a mixture of sensitive and non-sensitive values." + type = string + sensitive = true +} + +locals { + # mixed_content is derived from var.mixed_content_json, so it + # is also considered to be sensitive. + mixed_content = jsondecode(var.mixed_content_json) + + # password_from_json is derived from mixed_content, so it's + # also considered to be sensitive. + password_from_json = local.mixed_content["password"] + + # username_from_json would normally be considered to be + # sensitive too, but system-specific knowledge tells us + # that the username is a non-sensitive fragment of the + # original document, and so we can override Terraform's + # determination. + username_from_json = nonsensitive(local.mixed_content["username"]) +} +``` + +When you use this function, it's your responsibility to ensure that the +expression passed as its argument will remove all sensitive content from +the sensitive value it depends on. By passing a value to `nonsensitive` you are +declaring to Terraform that you have done all that is necessary to ensure that +the resulting value has no sensitive content, even though it was derived +from sensitive content. If a sensitive value appears in Terraform's output +due to an inappropriate call to `nonsensitive` in your module, that's a bug in +your module and not a bug in Terraform itself. +**Use this function sparingly and only with due care.** + +`nonsensitive` will return an error if you pass a value that isn't marked +as sensitive, because such a call would be redundant and potentially confusing +or misleading to a future maintainer of your module. Use `nonsensitive` only +after careful consideration and with definite intent. + +Consider including a comment adjacent to your call to explain to future +maintainers what makes the usage safe and thus what invariants they must take +care to preserve under future modifications. + +## Examples + +The following examples are from `terraform console` when running in the +context of the example above with `variable "mixed_content_json"` and +the local value `mixed_content`, with a valid JSON string assigned to +`var.mixed_content_json`. + +``` +> var.mixed_content_json +(sensitive) +> local.mixed_content +(sensitive) +> local.mixed_content["password"] +(sensitive) +> nonsensitive(local.mixed_content["username"]) +"zqb" +> nonsensitive("clear") + +Error: Invalid function argument + +Invalid value for "value" parameter: the given value is not sensitive, so this +call is redundant. +``` + +Note though that it's always your responsibility to use `nonsensitive` only +when it's safe to do so. If you use `nonsensitive` with content that +_ought to be_ considered sensitive then that content will be disclosed: + +``` +> nonsensitive(var.mixed_content_json) +< nonsensitive(local.mixed_content) +{ + "password" = "p4ssw0rd" + "username" = "zqb" +} +> nonsensitive(local.mixed_content["password"]) +"p4ssw0rd" +``` diff --git a/website/docs/language/functions/sensitive.html.md b/website/docs/language/functions/sensitive.html.md new file mode 100644 index 000000000..bcc9bfecf --- /dev/null +++ b/website/docs/language/functions/sensitive.html.md @@ -0,0 +1,45 @@ +--- +layout: "language" +page_title: "sensitive - Functions - Configuration Language" +sidebar_current: "docs-funcs-conversion-sensitive" +description: |- + The sensitive function marks a value as being sensitive. +--- + +# `sensitive` Function + +-> **Note:** This function is only available in Terraform v0.14 and later. + +`sensitive` takes any value and returns a copy of it marked so that Terraform +will treat it as sensitive, with the same meaning and behavior as for +[sensitive input variables](/docs/language/values/variables.html#suppressing-values-in-cli-output). + +Whereever possible we recommend marking your input variable and/or output value +declarations as sensitive directly, instead of using this function, because +in that case you can be sure that there is no way to refer to those values +without Terraform automatically considering them as sensitive. + +The `sensitive` function might be useful in some less-common situations where a +sensitive value arises from a definition _within_ your module, such as if +you've loaded sensitive data from a file on disk as part of your configuration: + +``` +locals { + sensitive_content = sensitive(file("${path.module}/sensitive.txt")) +} +``` + +However, we generally don't recommend writing sensitive values directly within +your module any of the files you distribute statically as part of that module, +because they may be exposed in other ways outside of Terraform's control. + +## Examples + +``` +> sensitive(1) +(sensitive) +> sensitive("hello") +(sensitive) +> sensitive([]) +(sensitive) +``` diff --git a/website/layouts/language.erb b/website/layouts/language.erb index f6c5145cb..2e0e12b5f 100644 --- a/website/layouts/language.erb +++ b/website/layouts/language.erb @@ -793,6 +793,14 @@ defaults +
  • + nonsensitive +
  • + +
  • + sensitive +
  • +
  • tobool