From 89b240508044a159a333809a1f1888a83f439ea3 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 18 Dec 2020 15:46:30 -0800 Subject: [PATCH] lang/funcs: "sensitive" and "nonsensitive" functions These aim to allow hinting to Terraform about situations where it's not able to automatically infer value sensitivity. "nonsensitive" is for situations where Terraform's behavior is too conservative, such as when a new value is derived from a sensitive value in such a way that all of the sensitive content is removed. "sensitive", on the other hand, is for situations where Terraform can't otherwise infer that a value is sensitive. These situations should be pretty rare in a module that's making effective use of sensitive input variables and output values, but the documentation shows one example of an uncommon situation where a more direct hint via this function would be needed. Both of these functions are aimed at only occasional use in unusual situations. They are here for reasons of pragmatism, not because we expect them to be used routinely or recommend their use. --- lang/funcs/sensitive.go | 66 +++++++ lang/funcs/sensitive_test.go | 175 ++++++++++++++++++ lang/functions.go | 2 + lang/functions_test.go | 19 ++ .../language/functions/nonsensitive.html.md | 129 +++++++++++++ .../docs/language/functions/sensitive.html.md | 45 +++++ website/layouts/language.erb | 8 + 7 files changed, 444 insertions(+) create mode 100644 lang/funcs/sensitive.go create mode 100644 lang/funcs/sensitive_test.go create mode 100644 website/docs/language/functions/nonsensitive.html.md create mode 100644 website/docs/language/functions/sensitive.html.md 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