-
Notifications
You must be signed in to change notification settings - Fork 115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Tracks Secrets in __inputs
and lastAppliedConfig
#741
Conversation
d09875b
to
e0e7890
Compare
@pgavlin @lblackstone Worthwhile I think to get your eyes on this now, as I continue my validation. At a high level, this does the following:
@lblackstone Interested in understanding if there are other places where we logically duplicate inputs somewhere else in the state. We drop the secretness when we convert these PropertyMaps to Unstructure'd, so the core of the provider doesn't change much. |
@lblackstone thinks that the travis failure here is due to a race since the helm chart does not namespace the config maps. I'll be adding some directed testing before merging. |
Looks like you're on the right track, but I'm getting an error trying to add a secret to a ConfigMap: import * as k8s from "@pulumi/kubernetes";
const pw = pulumi.secret("supers3kr3t");
const cm = new k8s.core.v1.ConfigMap("cm", {binaryData: {password: pw}});
import * as k8s from "@pulumi/kubernetes";
const pw = pulumi.secret("supers3kr3t");
const cm = new k8s.core.v1.ConfigMap("cm", {data: {password: pw}}); works because k8s base64 encodes it server-side when using Here's what the ConfigMap looks like in k8s: apiVersion: v1
data:
password: supers3kr3t
kind: ConfigMap
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"v1","data":{"password":"supers3kr3t"},"kind":"ConfigMap","metadata":{"annotations":{"pulumi.com/autonamed":"true"},"labels":{"app.kubernetes.io/managed-by":"pulumi"},"name":"cm-wuhxd54q"}}
pulumi.com/autonamed: "true"
creationTimestamp: "2019-08-23T23:18:20Z"
labels:
app.kubernetes.io/managed-by: pulumi
name: cm-wuhxd54q
namespace: default
resourceVersion: "813035"
selfLink: /api/v1/namespaces/default/configmaps/cm-wuhxd54q
uid: 4ba3bb71-c5fc-11e9-b2bd-025000000001 and here's what the ConfigMap looks like in the Pulumi SaaS Console: {
"__inputs": {
"apiVersion": "v1",
"data": {
"password": {
"4dabf18193072939515e22adb298388d": "1b47061264138c4ac30d75fd1eb44270",
"ciphertext": "AAABANZuLjRjIT4Ky6L9ETGKR1JG7G3zp/aH1QZFZqXuWfH2KSN8cXtKaWRK"
}
},
"kind": "ConfigMap",
"metadata": {
"annotations": {
"pulumi.com/autonamed": "true"
},
"labels": {
"app.kubernetes.io/managed-by": "pulumi"
},
"name": "cm-wuhxd54q"
}
},
"apiVersion": "v1",
"data": {
"password": {
"4dabf18193072939515e22adb298388d": "1b47061264138c4ac30d75fd1eb44270",
"ciphertext": "AAABAJDrt163iRrnQoG1MPQLb4Coyj22h9QDqEGoheIP+DiQULKpPq+dEPE2"
}
},
"kind": "ConfigMap",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": {
"4dabf18193072939515e22adb298388d": "1b47061264138c4ac30d75fd1eb44270",
"ciphertext": "AAABAAqYN+3FS9t51ROk1sz18AEP07eJIv3XjYZQ124TU67AzIw0wIHlxfN2fAuV8QOneh86J/mrw0Zylpj+0O8xy0Qlrk3CV9pGl8WvZH/7g3C5vLNvnwb0kawk3nMeWqjaPltve9rkk6jcnWFpNuZBVN+cxa1cYqeZU4z5uf+w++3le7baoBYTtmzfNn+a9PWd/BlYJ0J6JlCUaY1PS+xTyc6k6wh3fTyubUXjxFGMLvXNvYuCTgS3QzMZ5znIcf3kACvD1C/s/0OHh5pH1QcLuogtLgWQVkV88UpeGGfFVKyQ77WIcRPr+yShqKXBEJPcE/ErNW+97UjKPt1kfJRVEdcf++ta39PhyLqttw=="
},
"pulumi.com/autonamed": "true"
},
"creationTimestamp": "2019-08-23T23:18:20Z",
"labels": {
"app.kubernetes.io/managed-by": "pulumi"
},
"name": "cm-wuhxd54q",
"namespace": "default",
"resourceVersion": "813035",
"selfLink": "/api/v1/namespaces/default/configmaps/cm-wuhxd54q",
"uid": "4ba3bb71-c5fc-11e9-b2bd-025000000001"
}
} |
Actually, that may be correct behavior. I think you have to base64 encode the data yourself if you use the binaryData parameter. Only possible concern there would be leaking the secret through the error message. |
To first approximation yes - I think that is correct behaviour. In general, it is possible for providers which have these values in plain text to leak them through error messages via errors coming from the cloud provider. That is generally tied to places where the cloud provider does not properly treat these as secret (which Kubernetes does not in this case). To solve this, we likely need to expand our masking technology in the CLI layer from masking on the values of secret config to being able to mask the values of any secrets that flow through the program. I've opened pulumi/pulumi#3138 to track that. |
e0e7890
to
25ffc20
Compare
__inputs
and lastAppliedConfig
__inputs
and lastAppliedConfig
Okay, @lukehoban @pgavlin @lblackstone this should now be ready for review, I wrote a small test that ensures the state file does not contain any plaintext copies of a secret, vs just checking the things we cared about being marked as secrets so if we start copying stuff to other places we'll see a test failure. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Change LGTM. Should add one more test for completeness.
|
||
const pw = (new pulumi.Config()).requireSecret("message"); | ||
const cm = new k8s.core.v1.ConfigMap("cm", { | ||
binaryData: { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you also test with a ConfigMap like this?
const cm = new k8s.core.v1.ConfigMap("cm", {
data: {
password: pw,
}
})
k8s will base64 encode that input server-side, and it will be accessible at the same place (cm.binaryData
).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can do!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, @lblackstone I have a question about this. I'm not observing the behavior you describe. For me, if I create a ConfigMap
passing a secret in as just "data", the binaryData
property of the object is never filled in (I've confirmed this by looking at both pulum
's state file via stack export
as well as the underlying object with kubectl get cm <name> -o json
. In both cases, the object just has a data property. Also note that calling pulumi refresh
does not cause this to appear (sensible, given the above).
Assuming that the API server behaved as you said it would - this would actually expose a problem with this. While binaryData
is logically a transformed value of data
and hence should be kept as a secret, it's not clear to me what part of the system would make that decision and how. I think it would have to be logic specific to ConfigMap
itself, and I think that would require deeper surgery than what I have here (since lower level portions of the code that actually create resources would need to be able to observe secretness, right now everything is stripped off when we create the Unstructured
objects.
So I can add the test to ensure that data
is handled, but I don't think we will see the behavior you expect WRT server side encoding of data?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, whoops. I think I was mixing up Secret and ConfigMap. Let me check.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, let's add the following cases:
const stringSecret = new k8s.core.v1.Secret("stringSecret", {stringData: {password: pw}});
const dataSecret = new k8s.core.v1.Secret("dataSecret", {data: {password: pw.apply(d => new Buffer(d).toString("base64"))}})
export const secretStringData = stringSecret.data;
export const secretData = dataSecret.data;
(The .data
field will be identical in these cases)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@lblackstone - So, as I expected, this does lead to an issue. Specifically, for the stringSecret
resource, the data
property is not masked. Here's the relevant part of the state file for the object:
{
"urn": "urn:pulumi:dev-2::provider::kubernetes:core/v1:Secret::stringsecret",
"custom": true,
"id": "default/stringsecret-2nsgegwh",
"type": "kubernetes:core/v1:Secret",
"inputs": {
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"annotations": {
"pulumi.com/autonamed": "true"
},
"labels": {
"app.kubernetes.io/managed-by": "pulumi"
},
"name": "stringsecret-2nsgegwh"
},
"stringData": {
"password": {
"4dabf18193072939515e22adb298388d": "1b47061264138c4ac30d75fd1eb44270",
"ciphertext": "AAABAA/YOyfH4Zu9xLkYJKJ/pzJhUqZHf9wxjiVdPZB36SzbqpReK7t0dCUQx+kzzrY="
}
}
},
"outputs": {
"__inputs": {
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"annotations": {
"pulumi.com/autonamed": "true"
},
"labels": {
"app.kubernetes.io/managed-by": "pulumi"
},
"name": "stringsecret-2nsgegwh"
},
"stringData": {
"password": {
"4dabf18193072939515e22adb298388d": "1b47061264138c4ac30d75fd1eb44270",
"ciphertext": "AAABAO8ySfCDQbi+a52zXIQo9ptvx7RBeKU9anECcdlhihVkzsqI4ERi6CIyiDS47XA="
}
}
},
"apiVersion": "v1",
"data": {
"password": "YSBzZWNyZXQgbWVzc2FnZQ=="
},
"kind": "Secret",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": {
"4dabf18193072939515e22adb298388d": "1b47061264138c4ac30d75fd1eb44270",
"ciphertext": "AAABAHUOURpgdiT5dBi9g9Bdi4csXrxVjwMdU0x9XCCnYK7Jimq/2QzEZw+vwTfRqd++GqwU++OmlLqwjhb0AkvWQ26L0MIR7NfFCi57AQLVJ+/6kHSum1ZLsHHAb7gBPWYHvCuFZY1siWq0H/VlglY7taYC7Kg1LlRwBJgDJbEJC3n9Tm5+CQEN9lj5+u94Xq2cCKu3OebyRnStSOwUsyau4l5KDDfJLuOYz/53REhH+Olr2DlduVXTfz4JBNAcFYQezyC1Bvi+lbuTkJg2nm9A9LKzAfDo1Slpn8s4hu+X8kMa7bCQS2fzvxzlgwaV7DRfHexsP0kJhYjCmNtP0QDja/yWc7WXAre+GQXzRWgLGVvLdq7dO9zna+03NNGdFw=="
},
"pulumi.com/autonamed": "true"
},
"creationTimestamp": "2019-08-26T23:57:22Z",
"labels": {
"app.kubernetes.io/managed-by": "pulumi"
},
"name": "stringsecret-2nsgegwh",
"namespace": "default",
"resourceVersion": "67529893",
"selfLink": "/api/v1/namespaces/default/secrets/stringsecret-2nsgegwh",
"uid": "3f06a660-c85d-11e9-9301-42010a8a0098"
},
"type": "Opaque"
},
"parent": "urn:pulumi:dev-2::provider::pulumi:pulumi:Stack::provider-dev-2",
"provider": "urn:pulumi:dev-2::provider::pulumi:providers:kubernetes::default_1_0_1_alpha_1566858130_g25ffc20c_dirty::1b3d4bf7-1b7e-4d7b-9ff3-6d15e3ecf88a",
"propertyDependencies": {
"apiVersion": null,
"kind": null,
"stringData": null
}
},
Here we can see that the data
property just has the b64 encoded secret value from the API server.
I'm not sure what we can do here. It feels like we would need the secret resource to understand that stringData
is present and secret than data
itself gets marked as a secret, but I think that will require much more work to support.
Are there other resources where the API server does something like this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Indeed, just for posterity, the following program "leaks" the pulumi.secret
at the Kubernetes Secret
's field .data["foo"]
into the Deployment
:
const s = pulumi.secret("bar");
const ss = new k8s.core.v1.Secret("bar", { stringData: { foo: s } });
// nginx container, replicated 1 time.
const appName = "nginx";
const appLabels = { app: appName };
const nginx = new k8s.apps.v1.Deployment(appName, {
metadata: { name: "foo" },
spec: {
selector: { matchLabels: appLabels },
replicas: 1,
template: {
metadata: {
labels: appLabels,
annotations: { pwd: ss.data.apply(data => (<any>data)["foo"]) },
},
spec: {
containers: [
{ name: appName, image: "nginx:1.15-alpine", ports: [{ containerPort: 80 }] },
],
},
},
},
});
This should be pretty uncommon in practice. Secrets in the Kubernetes API are generally kept inside Secret
and referenced only indirectly using something like (e.g., in the case of environment variables in containers) { envFrom: { secretRef: "my-secret" } }
. But we should ship v1 knowing this is a limitation.
25ffc20
to
f1a4ce6
Compare
// the entire value in the deployment would be encrypted instead of a small chunk. It also means the entire property | ||
// would be displayed as `[secret]` in the CLI instead of a small part. | ||
// | ||
// NOTE: This means that for an array, if any value in the input version is a secret, the entire output array is |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is going to be pretty impactful for things like the containers
property of a deployment spec: that property is an array consisting of rich container specs. If any of these specs contains a secret, the whole array will be secret. This might be okay for now, but I would not be surprised if the user experience is not great there.
@lblackstone @hausdorff thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW - We already have the same problem today with the current implementation, since the engine does this same sort of secrets application when the provider does not understand secrets. So the containers
property in the state is already going to experience this problem, even without these changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually can't think of a situation where the containers array should have a secret value directly embedded in it. Every case I know of where you'd want to embed a secret, you'd make a Secret
resource, and then use the downward API to reference the relevant field in the Secret
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So if someone embedded a secret value in an envvar, would we just tell them "don't do that"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think so. As long as we're not leaking the secret value, I'm fine with this workflow not being nice to work with. We should point users to the upstream docs if they run into this:
Kubernetes secret objects let you store and manage sensitive information, such as passwords, OAuth tokens, and ssh keys. Putting this information in a secret is safer and more flexible than putting it verbatim in a Pod definition or in a container image .
That also makes it work nicely with our stuff.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perfect; thanks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changes look good overall, just one question about arrays.
f1a4ce6
to
1c5d218
Compare
There's an issue with this patch (which was not caught by the test I wrote) which causes a no-op update to propose changes to parts of the object. Looking into this now. |
@ellismg Actually, we could just special case the data field for Secrets to always be marked as secret. That’s the only case like that I’m aware of. |
1c5d218
to
07a913a
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We probably can get away with not implementing the "transitive secret flow" thing I talk about here. I think we probably can't get away with not marking the auto-populated .data
field in Secret
, as secret when the user populates .stringData
.
As was mentioned elsewhere, populating .stringData
in Secret
will cause the Kubernetes API server to remove that field, populating the contents of .data
with b64-encoded version of whatever used to be in .stringData
. This is a very common use case—right now in this case .data
"leaks" this secret to the statefile.
I think it's enough to take a pretty naive approach to this—the provider should be able to just check if the resource is of the form { apiVersion: "v1", kind: "Secret", …}
, and mark .data
as secret as well? Could be wrong.
Weird, in following program, the secret value shows up as "partially leaked" and "partially const s = pulumi.secret({ bar: "bat" });
const ss = new k8s.core.v1.Secret("bar", { type: "Opaque", stringData: s });
Yet, it is correctly reported in the statefile:
|
The output makes me believe that this is from doing an initial update, modifying the source and then doing another update with some changes. Could you please share the complete set of steps? |
Indeed, this does not happen on a fresh create. The previous version of the program is below. Change that program to the program above and it should replicate. const s = pulumi.secret("bat");
const ss = new k8s.core.v1.Secret("bar", { type: "Opaque", stringData: { foo: s } }); |
Diffing behavior is kind of confusing when you change secrets. Change the // nginx container, replicated 1 time.
const appName = "nginx";
const appLabels = { app: appName };
const nginx = new k8s.apps.v1.Deployment(appName, {
metadata: { name: "foo" },
spec: {
selector: { matchLabels: appLabels },
replicas: 1,
template: {
metadata: { labels: appLabels },
spec: {
containers: [
{ name: appName, image: "nginx:1.15-alpine", ports: [{ containerPort: 80 }] },
],
},
},
},
}); Secondly, and related to the last diff weirdness I mentioned, starting with this program: const s = pulumi.secret({ foo: "bat"});
const ss = new k8s.core.v1.Secret("bar", { type: "Opaque", stringData: s }); and changing it to this: const s = pulumi.secret("bat");
const ss = new k8s.core.v1.Secret("bar", { type: "Opaque", stringData: { foo: s } }); Will render no diff—and the statefile seems to keep the entire
Yet, when I change
|
FYI, I did try upgrading/downgrading kube providers, and that seems to work well. I think all my feedback is related to diff, encrypting |
07a913a
to
4fa8649
Compare
|
||
if hasStringData && hasData { | ||
if stringData.IsSecret() && !data.IsSecret() { | ||
object["data"] = resource.MakeSecret(data) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason to include this check rather than always marking data
as secret?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think we'll probably want to always mark .data
as secret. Here's the current behavior:
const foo = new k8s.core.v1.Secret("foo", {
stringData: {
password: "foo",
}
});
export const fooval = foo.data;
fooval : {
+ password: "Zm9v"
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Obviously you wouldn't want to encode secrets directly in the program like that, but they could have been generated as part of a Helm chart or similar and not marked as secret.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Obviously you wouldn't want to encode secrets directly in the program like that, but they could have been generated as part of a Helm chart or similar and not marked as secret.
My goal here was the limit the blast radius of this change given how close we are to the end of the release. When we did stuff like this on the TF side, we ran into a bunch of issues and had to roll back the chage.
What you say would be correct, and is something we should consider doing as part of the next release, but I am concerned about making this change by default, since it will impact everyone who is using a Secret today.
If you want to opt into the behavior you describe, you can use additionalSecretOutputs
to ensure the entire data
field of the Secret is marked as something that should be persisted securely.
4fa8649
to
9aa2546
Compare
In order to support tracking secretness that flows from inputs to outputs when using providers that do not understand secrets directly, the engine takes any input that is secret and if there is a coresponding output with the same name, marks it as a secret. This works in common cases, but does not work for Kubernetes for two key reasons: 1. The provider retains a copy of the inputs for a resource on an object called `__inputs` inside the state object. It uses this during Diff for reasons that are un-interesting to this PR. 2. The provider JSON stringifies the inputs and stores them as an annotation on the object iself, as `kubectl` would. These two decisions mean that if a secret value is used as an input to a k8s resource, we will persist the plaintext value in the state file, since the engine has no idea to look at `__inputs` or `lastAppliedConfig`. This change updates the provider to be able to handle secrets. The engine will now pass any secret inputs as strongly typed secrets. The provider will use this information to ensure that the relevent members in the `__inputs` bag are marked as secrets as well as ensuring that if there are any inputs that are secret, all of `lastAppliedConfig` (which is a stringified JSON object) is marked as a secret as well. An integration test confirms this behavior by stringifying the state and ensuring that our secret values do not end up in it (which will catch cases where we may copy this data to other places as well). In addition, this adds code to mark `data` as secret on `k8s.core.v1.Secret` if `stringData` is a secret (the API Server base64 encodes the `stringData` bag into `data` and so we should logically flow the secretness). Fixes #734
9aa2546
to
133c4e8
Compare
Okay, @lblackstone I have addressed all your feedback and CI should now be green. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, aside from one question: do you still need secrets.test
? Not sure what that's for.
It was the test binary (generated by VSCode when trying to "debug" the test) which was incorrectly checked in because of an errant |
In order to support tracking secretness that flows from inputs to
outputs when using providers that do not understand secrets directly,
the engine takes any input that is secret and if there is a
coresponding output with the same name, marks it as a secret. This
works in common cases, but does not work for Kubernetes for two key
reasons:
The provider retains a copy of the inputs for a resource on an
object called
__inputs
inside the state object. It uses this duringDiff for reasons that are un-interesting to this PR.
The provider JSON stringifies the inputs and stores them as an
annotation on the object iself, as
kubectl
would.These two decisions mean that if a secret value is used as an input to
a k8s resource, we will persist the plaintext value in the state
file, since the engine has no idea to look at
__inputs
orlastAppliedConfig
.This change updates the provider to be able to handle secrets. The
engine will now pass any secret inputs as strongly typed secrets. The
provider will use this information to ensure that the relevent members
in the
__inputs
bag are marked as secrets as well as ensuring thatif there are any inputs that are secret, all of
lastAppliedConfig
(which is a stringified JSON object) is marked asa secret as well.
An integration test confirms this behavior by stringifying the state
and ensuring that our secret values do not end up in it (which will
catch cases where we may copy this data to other places as well).
Fixes #734