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.
This commit is contained in:
Martin Atkins 2020-12-18 15:46:30 -08:00
parent 0b5c4a6a2c
commit 89b2405080
7 changed files with 444 additions and 0 deletions

66
lang/funcs/sensitive.go Normal file
View File

@ -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})
}

View File

@ -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)
}
})
}
}

View File

@ -102,6 +102,8 @@ func (s *Scope) Functions() map[string]function.Function {
"replace": funcs.ReplaceFunc, "replace": funcs.ReplaceFunc,
"reverse": stdlib.ReverseListFunc, "reverse": stdlib.ReverseListFunc,
"rsadecrypt": funcs.RsaDecryptFunc, "rsadecrypt": funcs.RsaDecryptFunc,
"sensitive": funcs.SensitiveFunc,
"nonsensitive": funcs.NonsensitiveFunc,
"setintersection": stdlib.SetIntersectionFunc, "setintersection": stdlib.SetIntersectionFunc,
"setproduct": stdlib.SetProductFunc, "setproduct": stdlib.SetProductFunc,
"setsubtract": stdlib.SetSubtractFunc, "setsubtract": stdlib.SetSubtractFunc,

View File

@ -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": {
{ {
`parseint("100", 10)`, `parseint("100", 10)`,
@ -689,6 +701,13 @@ func TestFunctions(t *testing.T) {
}, },
}, },
"sensitive": {
{
`sensitive(1)`,
cty.NumberIntVal(1).Mark("sensitive"),
},
},
"setintersection": { "setintersection": {
{ {
`setintersection(["a", "b"], ["b", "c"], ["b", "d"])`, `setintersection(["a", "b"], ["b", "c"], ["b", "d"])`,

View File

@ -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)
<<EOT
{
"username": "zqb",
"password": "p4ssw0rd"
}
EOT
> nonsensitive(local.mixed_content)
{
"password" = "p4ssw0rd"
"username" = "zqb"
}
> nonsensitive(local.mixed_content["password"])
"p4ssw0rd"
```

View File

@ -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)
```

View File

@ -793,6 +793,14 @@
<a href="/docs/language/functions/defaults.html">defaults</a> <a href="/docs/language/functions/defaults.html">defaults</a>
</li> </li>
<li>
<a href="/docs/language/functions/nonsensitive.html">nonsensitive</a>
</li>
<li>
<a href="/docs/language/functions/sensitive.html">sensitive</a>
</li>
<li> <li>
<a href="/docs/language/functions/tobool.html">tobool</a> <a href="/docs/language/functions/tobool.html">tobool</a>
</li> </li>