Skip to content

Commit

Permalink
Add new logic for looking up Strongbox Secrets
Browse files Browse the repository at this point in the history
We now try to deduce if we need to lookup Strongbox keyring/identity
Secret. We do this by checking for `filter=strongbox` in
`.gitattributes` in a given Namespace.

This should cover majority of cases and allow use default settings. For
those who **only** have Strongbox files in remote bases that are loaded
via Kustomize, need to enable Strongbox functionality via
`STRONGBOX_FORCE="true"`.

We are also adding a safeguard, we now check `kustomize build` output
and check Secret data values for Strongbox headers. Plugin will fail if
it finds a Strongbox header in Secret data.
  • Loading branch information
george-angel committed Nov 14, 2024
1 parent a69db60 commit d177cb3
Show file tree
Hide file tree
Showing 9 changed files with 300 additions and 60 deletions.
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ resources:
### Strongbox envvars
Secret name containing Strongbox keyring/identity file MUST be `argocd-voodoobox-strongbox-keyring`.
Secret name containing Strongbox keyring/identity file MUST be
`argocd-voodoobox-strongbox-keyring`.

Key name for keyring MUST be `.strongbox_keyring`

Expand All @@ -52,6 +53,11 @@ the Secret should have an annotation called "argocd.voodoobox.plugin.io/allowed-
Since ArgoCD Application can be used to create a namespace, wild card is not supported in the allow list. It is an exact match.
If this env is not specified then it defaults to the same namespace as the app's destination NS.

`STRONGBOX_FORCE` Plugin will try to determine if it needs to setup Strongbox
or not based on presence of `filter=strongbox` in `.gitattributes`. If you ONLY
have Strongbox files in remote Kustomize base - you need to tell plugin to
setup Strongbox explicitly by setting this to "true".

```yaml
# secret example the following secret can be used by namespaces "ns-a", "ns-b" and "ns-c":
Expand Down Expand Up @@ -91,6 +97,8 @@ spec:
env:
- name: STRONGBOX_SECRET_NAMESPACE
value: team-a
- name: STRONGBOX_FORCE
value: "true"
```

### Git SSH Keys envvars
Expand Down Expand Up @@ -258,7 +266,7 @@ subjects:
|-|-|-|
| ARGOCD_APP_NAME | set by argocd | name of application |
| ARGOCD_APP_NAMESPACE | set by argocd | application's destination namespace |
| STRONGBOX_ENABLED | "true" | Enable Strongbox for decryption |
| STRONGBOX_FORCE | "false" | If your Namespace **only** has Strongbox Secrets in remote Kustomize bases - you need to force Strongbox functionality |
| STRONGBOX_SECRET_NAMESPACE | | the name of a namespace where secret resource containing strongbox keyring is located, defaults to current |
| GIT_SSH_CUSTOM_KEY_ENABLED | "false" | Enable Git SSH building using custom (non global) key |
| GIT_SSH_SECRET_NAMESPACE | | the value should be the name of a namespace where secret resource containing ssh keys are located, defaults to current |
54 changes: 51 additions & 3 deletions decrypt.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"bufio"
"bytes"
"context"
"errors"
Expand All @@ -26,7 +27,17 @@ var (
errEncryptedFileFound = errors.New("encrypted file found")
)

func ensureDecryption(ctx context.Context, cwd string, app applicationInfo) error {
func ensureDecryption(force bool, ctx context.Context, cwd string, app applicationInfo) error {
hsf, err := hasStrongboxFilter(ctx, cwd)
if err != nil {
return err
}

// If we are not forcing and we can't find a filter - return
if !force && !hsf {
return nil
}

keyringData, identityData, err := secretData(ctx, app.destinationNamespace, app.keyringSecret)
if err != nil {
return err
Expand All @@ -35,9 +46,9 @@ func ensureDecryption(ctx context.Context, cwd string, app applicationInfo) erro
return nil
}

// create strongbox keyRing file
// create Strongbox keyRing file
if keyringData != nil {
keyRingPath := filepath.Join(cwd, strongboxKeyRingFile)
keyRingPath := filepath.Join(cwd, strongboxKeyringFilename)
if err := os.WriteFile(keyRingPath, keyringData, 0644); err != nil {
return err
}
Expand Down Expand Up @@ -129,3 +140,40 @@ func strongboxAgeRecursiveDecrypt(ctx context.Context, cwd string, identityData
return file.Truncate(n)
})
}

func hasStrongboxFilter(ctx context.Context, dir string) (bool, error) {
var found bool
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || filepath.Base(path) != ".gitattributes" {
return nil
}

file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()

scanner := bufio.NewScanner(file)
for scanner.Scan() {
select {
case <-ctx.Done():
return ctx.Err()
default:
if strings.Contains(scanner.Text(), "filter=strongbox") {
found = true
return filepath.SkipDir // Stop search at current directory level
}
}
}
return scanner.Err()
})

if err != nil && err != filepath.SkipDir {
return false, err
}
return found, nil
}
73 changes: 70 additions & 3 deletions decrypt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"testing"

Expand Down Expand Up @@ -184,7 +185,7 @@ func Test_ensureDecryption(t *testing.T) {
},
}
t.Run("no-encrypted-files-with-secret", func(t *testing.T) {
err = ensureDecryption(context.Background(), withRemoteBaseTestDir, bar2)
err = ensureDecryption(true, context.Background(), withRemoteBaseTestDir, bar2)
if err != nil {
t.Fatal(err)
}
Expand All @@ -204,7 +205,7 @@ func Test_ensureDecryption(t *testing.T) {
},
}
t.Run("encrypted-files-with-secret", func(t *testing.T) {
err = ensureDecryption(context.Background(), encryptedTestDir1, foo)
err = ensureDecryption(true, context.Background(), encryptedTestDir1, foo)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -238,7 +239,7 @@ func Test_ensureDecryption(t *testing.T) {
},
}
t.Run("encrypted-files-with-secret-from-diff-ns", func(t *testing.T) {
err = ensureDecryption(context.Background(), encryptedTestDir2, baz)
err = ensureDecryption(true, context.Background(), encryptedTestDir2, baz)
if err != nil {
t.Fatal(err)
}
Expand All @@ -262,3 +263,69 @@ func Test_ensureDecryption(t *testing.T) {
})

}

func TestHasStrongboxFilter(t *testing.T) {
ctx := context.Background()

// Case 1: Pre-existing .gitattributes at the root with "filter=strongbox"
withStrongboxPath := filepath.Join("testData", "app-with-secrets")
strongboxFile := filepath.Join(withStrongboxPath, ".gitattributes")

// Verify that the checked-in .gitattributes is detected correctly
found, err := hasStrongboxFilter(ctx, withStrongboxPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !found {
t.Errorf("expected to find 'filter=strongbox' in %s, but got false", withStrongboxPath)
} else {
t.Logf("Successfully found 'filter=strongbox' in %s", withStrongboxPath)
}

// Temporarily rename the top-level .gitattributes to test the nested case
tempRename := strongboxFile + ".bak"
if err := os.Rename(strongboxFile, tempRename); err != nil {
t.Fatalf("failed to rename .gitattributes file: %v", err)
}
defer os.Rename(tempRename, strongboxFile) // Restore after test

// Case 2: .gitattributes one level deeper with "filter=strongbox"
nestedPath := filepath.Join(withStrongboxPath, "app")
nestedStrongboxFile := filepath.Join(nestedPath, ".gitattributes")

t.Logf("Creating nested .gitattributes with strongbox filter at: %s", nestedStrongboxFile)
if err := os.WriteFile(nestedStrongboxFile, []byte("*.json filter=strongbox\n"), 0644); err != nil {
t.Fatalf("failed to create nested .gitattributes file: %v", err)
}
defer os.Remove(nestedStrongboxFile)

found, err = hasStrongboxFilter(ctx, withStrongboxPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !found {
t.Errorf("expected to find 'filter=strongbox' in nested %s, but got false", nestedPath)
} else {
t.Logf("Successfully found 'filter=strongbox' in nested %s", nestedPath)
}

// Case 3: .gitattributes file without "filter=strongbox"
withoutStrongboxPath := filepath.Join("testData", "app-with-remote-base")
noStrongboxFile := filepath.Join(withoutStrongboxPath, ".gitattributes")

t.Logf("Creating .gitattributes without strongbox filter at: %s", noStrongboxFile)
if err := os.WriteFile(noStrongboxFile, []byte("*.yaml filter=anotherFilter\n"), 0644); err != nil {
t.Fatalf("failed to create .gitattributes file: %v", err)
}
defer os.Remove(noStrongboxFile)

found, err = hasStrongboxFilter(ctx, withoutStrongboxPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if found {
t.Errorf("expected not to find 'filter=strongbox' in %s, but got true", withoutStrongboxPath)
} else {
t.Logf("Correctly did not find 'filter=strongbox' in %s", withoutStrongboxPath)
}
}
91 changes: 61 additions & 30 deletions generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,22 @@ import (
"os/exec"
"path/filepath"
"strings"

"filippo.io/age/armor"
"github.com/ghodss/yaml"
v1 "k8s.io/api/core/v1"
)

func ensureBuild(ctx context.Context, cwd, globalKeyPath, globalKnownHostFile string, app applicationInfo) (string, error) {
func ensureBuild(ctx context.Context, cwd, globalKeyPath, globalKnownHostFile string, app applicationInfo) ([]byte, error) {
// Even when there is no git SSH secret defined, we still override the
// git ssh command (pointing the key to /dev/null) in order to avoid
// using ssh keys in default system locations and to surface the error
// if bases over ssh have been configured.
// Git SSH command (pointing the key to /dev/null) in order to avoid
// using SSH keys in default system locations and to surface the error
// if bases over SSH have been configured.
sshCmdEnv := `GIT_SSH_COMMAND=ssh -q -F none -o IdentitiesOnly=yes -o IdentityFile=/dev/null -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no`

kFiles, err := findKustomizeFiles(cwd)
if err != nil {
return "", fmt.Errorf("unable to ge kustomize files paths err:%s", err)
return nil, fmt.Errorf("unable to get Kustomize files paths err:%s", err)
}

if len(kFiles) == 0 {
Expand All @@ -29,13 +33,13 @@ func ensureBuild(ctx context.Context, cwd, globalKeyPath, globalKnownHostFile st

hasRemoteBase, err := hasSSHRemoteBaseURL(kFiles)
if err != nil {
return "", fmt.Errorf("unable to look for ssh protocol err:%s", err)
return nil, fmt.Errorf("unable to look for SSH protocol err:%s", err)
}

if hasRemoteBase {
sshCmdEnv, err = setupGitSSH(ctx, cwd, globalKeyPath, globalKnownHostFile, app)
if err != nil {
return "", err
return nil, err
}
}

Expand All @@ -50,20 +54,25 @@ func ensureBuild(ctx context.Context, cwd, globalKeyPath, globalKnownHostFile st

env = append(env, sshCmdEnv)

// setup git config if .strongbox_keyring exits
if _, err = os.Stat(filepath.Join(cwd, strongboxKeyRingFile)); err == nil {
// setup Git config if .strongbox_keyring or .strongbox_identity exits
if fileExists(filepath.Join(cwd, strongboxKeyringFilename)) || fileExists(filepath.Join(cwd, strongboxIdentityFilename)) {
// setup SB home for kustomize run
env = append(env, fmt.Sprintf("STRONGBOX_HOME=%s", cwd))

// getup git config via `strongbox -git-config`
// setup git config via `strongbox -git-config`
if err := setupGitConfigForSB(ctx, cwd, env); err != nil {
return "", fmt.Errorf("unable setup git config for strongbox err:%s", err)
return nil, fmt.Errorf("unable setup git config for strongbox err:%s", err)
}
}

return runKustomizeBuild(ctx, cwd, env)
}

func fileExists(filepath string) bool {
_, err := os.Stat(filepath)
return err == nil
}

func findKustomizeFiles(cwd string) ([]string, error) {
kFiles := []string{}

Expand Down Expand Up @@ -95,7 +104,7 @@ func hasSSHRemoteBaseURL(kFiles []string) (bool, error) {
return false, nil
}

// setupGitConfigForSB will setup git filters to run strongbox
// setupGitConfigForSB will setup git filters to run Strongbox
func setupGitConfigForSB(ctx context.Context, cwd string, env []string) error {
s := exec.CommandContext(ctx, "strongbox", "-git-config")
s.Dir = cwd
Expand All @@ -109,45 +118,67 @@ func setupGitConfigForSB(ctx context.Context, cwd string, env []string) error {
return nil
}

// runKustomizeBuild will run `kustomize build` cmd and return generated yaml or error
func runKustomizeBuild(ctx context.Context, cwd string, env []string) (string, error) {
// runKustomizeBuild runs `kustomize build` and returns the generated YAML or an error.
func runKustomizeBuild(ctx context.Context, cwd string, env []string) ([]byte, error) {
k := exec.CommandContext(ctx, "kustomize", "build", ".")

k.Dir = cwd
k.Env = env

var stdout bytes.Buffer
var stderr bytes.Buffer

k.Stdout = &stdout
k.Stderr = &stderr

if err := k.Start(); err != nil {
return "", fmt.Errorf("unable to start kustomize cmd err:%s", err)
output, err := k.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("error running kustomize: %s", strings.TrimSpace(string(output)))
}

if err := k.Wait(); err != nil {
return "", fmt.Errorf("error running kustomize err:%s", strings.TrimSpace(stderr.String()))
checkSecrets(output)
if err != nil {
return nil, err
}

return stdout.String(), nil
return output, nil
}

func findAndReadYamlFiles(cwd string) (string, error) {
var content string
func findAndReadYamlFiles(cwd string) ([]byte, error) {
var content []byte
err := filepath.WalkDir(cwd, func(path string, info fs.DirEntry, err error) error {
if filepath.Ext(path) == ".yaml" || filepath.Base(path) == ".yml" {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("unable to read file %s err:%s", path, err)
}
content += fmt.Sprintf("%s\n---\n", data)
content = append(content, []byte(fmt.Sprintf("%s\n---\n", data))...)
}
return nil
})
if err != nil {
return "", err
return nil, err
}

return content, nil
}

func checkSecrets(yamlData []byte) error {
// Split input YAML into multiple documents by "---"
docs := bytes.Split(yamlData, []byte("\n---\n"))

for _, doc := range docs {
if len(bytes.TrimSpace(doc)) == 0 {
continue // Skip empty documents
}

var secret v1.Secret
if err := yaml.Unmarshal(doc, &secret); err != nil {
// Unmarshaling will fail for Secret like object, like ConfigMap
continue
}

// Check if the decoded document is a Secret
if secret.Kind == "Secret" {
for key, val := range secret.Data {
if bytes.HasPrefix(val, encryptedFilePrefix) || strings.HasPrefix(string(val), armor.Header) {
return fmt.Errorf("found ciphertext in Secret: secret=%s key=%s", secret.Name, key)
}
}
}
}
return nil
}
Loading

0 comments on commit d177cb3

Please sign in to comment.