Skip to content

Commit

Permalink
feat: aws support secrets
Browse files Browse the repository at this point in the history
Signed-off-by: Bence Csati <[email protected]>

feat: aws support secrets

Signed-off-by: Bence Csati <[email protected]>
  • Loading branch information
csatib02 committed Aug 2, 2024
1 parent be2b81a commit 9f882f5
Showing 1 changed file with 319 additions and 0 deletions.
319 changes: 319 additions & 0 deletions pkg/provider/aws/secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
// Copyright © 2024 Bank-Vaults Maintainers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package aws

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/secretsmanager"
"github.com/aws/aws-sdk-go/service/ssm"
corev1 "k8s.io/api/core/v1"

"github.com/bank-vaults/secrets-webhook/pkg/provider"
"github.com/bank-vaults/secrets-webhook/pkg/provider/common"
)

func (m *mutator) MutateSecret(ctx context.Context, mutateRequest provider.SecretMutateRequest) error {
// do an early exit if no mutation is needed
requiredToMutate, storeType, err := secretNeedsMutation(mutateRequest.Secret)
if err != nil {
return fmt.Errorf("checking if secret needs mutation failed: %w", err)
}

if !requiredToMutate {
return nil
}

err = m.newClient(ctx, mutateRequest.K8sClient)
if err != nil {
return fmt.Errorf("creating AWS clients failed: %w", err)
}

if value, ok := mutateRequest.Secret.Data[corev1.DockerConfigJsonKey]; ok {
var dc common.DockerCredentials
err := json.Unmarshal(value, &dc)
if err != nil {
return fmt.Errorf("unmarshal dockerconfig json failed: %w", err)
}

err = mutateDockerCreds(ctx, mutateRequest.Secret, *m.client, storeType, &dc)
if err != nil {
return fmt.Errorf("mutate docker creds failed: %w", err)
}
}

err = mutateSecretData(ctx, mutateRequest.Secret, *m.client, storeType)
if err != nil {
return fmt.Errorf("mutate secret data failed: %w", err)
}

return nil
}

func mutateDockerCreds(ctx context.Context, secret *corev1.Secret, storeClient client, storeType string, dc *common.DockerCredentials) error {
assembled := common.DockerCredentials{Auths: map[string]common.DockerAuthConfig{}}

for key, creds := range dc.Auths {
authBytes, err := base64.StdEncoding.DecodeString(creds.Auth.(string))
if err != nil {
return fmt.Errorf("auth base64 decoding failed: %w", err)
}

if isValidPrefix(string(authBytes)) {
authCreds, err := determineAuthType(authBytes)
if err != nil {
return fmt.Errorf("determining auth type failed: %w", err)
}

credentialData, err := common.AssembleCredentialData(authCreds)
if err != nil {
return fmt.Errorf("assembling credential data failed: %w", err)
}

dcCreds, err := getDataFromStore(ctx, storeClient, storeType, credentialData)
if err != nil {
return fmt.Errorf("getting data from store failed: %w", err)
}

assembled.Auths[key] = common.AssembleDockerAuthConfig(dcCreds, creds)
}
}

marshaled, err := json.Marshal(assembled)
if err != nil {
return fmt.Errorf("marshal dockerconfig json failed: %w", err)
}

secret.Data[corev1.DockerConfigJsonKey] = marshaled

return nil
}

func mutateSecretData(ctx context.Context, secret *corev1.Secret, storeClient client, storeType string) error {
convertedData := make(map[string]string, len(secret.Data))
for k := range secret.Data {
convertedData[k] = string(secret.Data[k])
}

convertedData, err := getDataFromStore(ctx, storeClient, storeType, convertedData)
if err != nil {
return fmt.Errorf("getting data from store failed: %w", err)
}

for k := range secret.Data {
secret.Data[k] = []byte(convertedData[k])
}

return nil
}

func getDataFromStore(ctx context.Context, storeClient client, storeType string, data map[string]string) (map[string]string, error) {
switch storeType {
case "sm":
return getDataFromSM(ctx, storeClient, data)
case "ssm":
return getDataFromSSM(ctx, storeClient, data)
default:
return nil, fmt.Errorf("unknown store type: %s", storeType)
}
}

func getDataFromSM(ctx context.Context, storeClient client, data map[string]string) (map[string]string, error) {
var secretsMap = make(map[string]string)
for key, value := range data {
if strings.Contains(value, "secretsmanager:") {
secret, err := storeClient.smClient.GetSecretValueWithContext(
ctx,
&secretsmanager.GetSecretValueInput{
SecretId: aws.String(value),
})
if err != nil {
return nil, fmt.Errorf("failed to get secret from AWS secrets manager: %w", err)
}

secretBytes, err := extractSecretValueFromSM(secret)
if err != nil {
return nil, fmt.Errorf("failed to extract secret value from AWS secrets manager: %w", err)
}

secretValue, err := parseSecretValueFromSM(secretBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse secret value from AWS secrets manager: %w", err)
}

secretsMap[key] = string(secretValue)
}
}

return secretsMap, nil
}

// AWS Secrets Manager can store secrets in two formats:
// - SecretString: for text-based secrets, returned as a byte slice.
// - SecretBinary: for binary secrets, returned as a byte slice without additional encoding.
// If neither is available, the function returns an error.
//
// Ref: https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html
func extractSecretValueFromSM(secret *secretsmanager.GetSecretValueOutput) ([]byte, error) {
// Secret available as string
if secret.SecretString != nil {
return []byte(aws.StringValue(secret.SecretString)), nil
}

// Secret available as binary
if secret.SecretBinary != nil {
return secret.SecretBinary, nil
}

// Handle the case where neither SecretString nor SecretBinary is available
return []byte{}, fmt.Errorf("secret does not contain a value in expected formats")
}

// parseSecretValueFromSM takes a secret and attempts to parse it.
// It unifies the handling of all secrets coming from AWS SM,
// ensuring the output is consistent in the form of a []byte slice.
func parseSecretValueFromSM(secretBytes []byte) ([]byte, error) {
// If the secret is not a JSON object, append it as a single secret
if !json.Valid(secretBytes) {
return secretBytes, nil
}

var secretValue map[string]interface{}
err := json.Unmarshal(secretBytes, &secretValue)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal secret from AWS Secrets Manager: %w", err)
}

// If the JSON object contains a single key-value pair, the value is the actual secret
if len(secretValue) == 1 {
for _, value := range secretValue {
valueBytes, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("failed to marshal secret from map: %w", err)
}

return valueBytes, nil
}
}

// For JSON objects with multiple key-value pairs, the original JSON is returned as is
return secretBytes, nil
}

func getDataFromSSM(ctx context.Context, storeClient client, data map[string]string) (map[string]string, error) {
var secretsMap = make(map[string]string)
for key, value := range data {
if strings.Contains(value, "ssm:") {
parameteredSecret, err := storeClient.ssmClient.GetParameterWithContext(
ctx,
&ssm.GetParameterInput{
Name: aws.String(value),
WithDecryption: aws.Bool(true),
})
if err != nil {
return nil, fmt.Errorf("failed to get secret from AWS SSM: %w", err)
}

secretsMap[key] = aws.StringValue(parameteredSecret.Parameter.Value)
}
}

return secretsMap, nil
}

func secretNeedsMutation(secret *corev1.Secret) (bool, string, error) {
for key, value := range secret.Data {
if key == corev1.DockerConfigJsonKey {
var dc common.DockerCredentials
err := json.Unmarshal(value, &dc)
if err != nil {
return false, "", fmt.Errorf("unmarshal dockerconfig json failed: %w", err)
}

for _, creds := range dc.Auths {
switch creds.Auth.(type) {
case string:
authBytes, err := base64.StdEncoding.DecodeString(creds.Auth.(string))
if err != nil {
return false, "", fmt.Errorf("auth base64 decoding failed: %w", err)
}

auth := string(authBytes)
if valid, storeType := isValidPrefixWithStoreType(auth); valid {
return true, storeType, nil
}

case map[string]interface{}:
// get sub-keys from the auth field
authMap, ok := creds.Auth.(map[string]interface{})
if !ok {
return false, "", fmt.Errorf("invalid auth type")
}

// check if any of the sub-keys have a vault prefix
for _, v := range authMap {
if valid, storeType := isValidPrefixWithStoreType(v.(string)); valid {
return true, storeType, nil
}
}
return false, "", nil

default:
return false, "", fmt.Errorf("invalid auth type")
}
}

} else if valid, storeType := isValidPrefixWithStoreType(string(value)); valid {
return true, storeType, nil
}
}

return false, "", nil
}

// determineAuthType takes a byte slice of authentication data and determines its type.
// It supports three formats: "username:usr:password:pass", JSON keys, and valid vault paths.
func determineAuthType(auth []byte) (map[string]string, error) {
creds := make(map[string]string)

// if the auth string is formatted as "username:usr:password:pass",
// split the string into username and password
split := strings.Split(string(auth), ":")
if len(split) >= 16 {
// concatenate the first 7 parts of the ARN back to a single string
creds["username"] = fmt.Sprintf("%s:%s", split[0], strings.Join(split[1:8], ":"))
creds["password"] = fmt.Sprintf("%s:%s", split[8], strings.Join(split[9:], ":"))
return creds, nil
}

// if the auth string is a JSON key, don't split and use it as is
if json.Valid(auth) {
creds["auth"] = string(auth)
return creds, nil
}

// if none of the above, the auth string can still be a valid AWS ARN
if isValidPrefix(string(auth)) {
creds["auth"] = string(auth)
return creds, nil
}

return nil, fmt.Errorf("invalid auth type")
}

0 comments on commit 9f882f5

Please sign in to comment.