Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ predicate.json

_bin
.envrc

examples/encrypted-secrets/output.json
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ go run . agent \
> - [./agent.yaml](./agent.yaml).
> - [./examples/one-shot-secret.yaml](./examples/one-shot-secret.yaml).
> - [./examples/cert-manager-agent.yaml](./examples/cert-manager-agent.yaml).
> - [./examples/encrypted-secrets](./examples/encrypted-secrets) - Send encrypted Kubernetes secrets to CyberArk.

You might also want to run a local echo server to monitor requests sent by the agent:

Expand Down
7 changes: 7 additions & 0 deletions deploy/charts/disco-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@ This cluster name will be associated with the data that the agent uploads to the
A short description of the cluster where the agent is deployed (optional).

This description will be associated with the data that the agent uploads to the Discovery and Context service. The description may include contact information such as the email address of the cluster administrator, so that any problems and risks identified by the Discovery and Context service can be communicated to the people responsible for the affected secrets.
#### **config.sendSecrets** ~ `bool`
> Default value:
> ```yaml
> false
> ```

Enable sending of Secret data to CyberArk, in addition to the metadata. When enabled, Secret data is encrypted using envelope encryption using a key managed by CyberArk. Default: false (but default will change to true for a future release)
#### **authentication.secretName** ~ `string`
> Default value:
> ```yaml
Expand Down
2 changes: 2 additions & 0 deletions deploy/charts/disco-agent/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ spec:
name: {{ .Values.authentication.secretName }}
key: ARK_DISCOVERY_API
optional: true
- name: ARK_SEND_SECRETS
value: {{ .Values.config.sendSecrets | default "false" | quote }}
Comment on lines +79 to +80
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this defaults to false for now (while the feature is in development) but the planned default for this is "true" (i.e. this will be an opt-out feature, not opt-in)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just my 2c. Although the name of the flag refers to secrets, even now secrets are being sent, but only their metadata. Perhaps we should clarify that this concerns the private data of the secrets.

{{- with .Values.http_proxy }}
- name: HTTP_PROXY
value: {{ . }}
Expand Down
8 changes: 8 additions & 0 deletions deploy/charts/disco-agent/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@
},
"period": {
"$ref": "#/$defs/helm-values.config.period"
},
"sendSecrets": {
"$ref": "#/$defs/helm-values.config.sendSecrets"
}
},
"type": "object"
Expand Down Expand Up @@ -148,6 +151,11 @@
"description": "Push data every 12 hours unless changed.",
"type": "string"
},
"helm-values.config.sendSecrets": {
"default": false,
"description": "Enable sending of Secret data to CyberArk, in addition to the metadata. When enabled, Secret data is encrypted using envelope encryption using a key managed by CyberArk. Default: false (but default will change to true for a future release)",
"type": "boolean"
},
"helm-values.extraArgs": {
"default": [],
"description": "extraArgs:\n- --logging-format=json\n- --log-level=6 # To enable HTTP request logging",
Expand Down
6 changes: 6 additions & 0 deletions deploy/charts/disco-agent/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ config:
# be communicated to the people responsible for the affected secrets.
clusterDescription: ""

# Enable sending of Secret data to CyberArk, in addition to the metadata.
# When enabled, Secret data is encrypted using envelope encryption using
# a key managed by CyberArk.
# Default: false (but default will change to true for a future release)
sendSecrets: false

authentication:
secretName: agent-credentials

Expand Down
47 changes: 47 additions & 0 deletions examples/encrypted-secrets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Encrypted Secrets Example

This example demonstrates how to use the disco agent to gather Kubernetes secrets and encrypt their data fields.

## Overview

When the `ARK_SEND_SECRETS` environment variable is set to `"true"`, the disco agent will:

0. Fetch an encryption key from the configured endpoint (if running in production) or use a local key for testing
1. Discover Kubernetes secrets in your cluster (excluding common system secret types)
2. Encrypt each secret's data fields using RSA envelope encryption with JWE (JSON Web Encryption) format
3. If running in production, send the encrypted secrets to the configured endpoint; otherwise, write them to `output.json` for testing

The encryption uses:

- **Key Algorithm**: RSA-OAEP-256 (for encrypting the content encryption key)
- **Content Encryption**: AES-256-GCM (for encrypting the actual secret data)
- **Format**: JWE Compact Serialization

Metadata (names, namespaces, labels, annotations) remains in plaintext for discovery purposes, while the sensitive secret data is encrypted. Some keys in Secret data fields are also preserved in the `data` section, for backwards compatibility.

## Prerequisites

1. A running Kubernetes cluster with secrets to discover
3. Go installed

## Configuration File

The `config.yaml` file configures:

- The data gatherer to collect Kubernetes secrets
- Field selectors to exclude system secrets (service account tokens, docker configs, etc.)
- The cluster ID and organization ID for grouping data

## Running the Example

Test the agent locally by running this script:

```bash
./test.sh
```

This will:

- Connect to your current Kubernetes context
- Gather all non-system secrets
- Write the raw data to `output.json`
41 changes: 41 additions & 0 deletions examples/encrypted-secrets/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# encrypted-secrets config.yaml
#
# An example configuration file demonstrating how to use the disco agent
# to send encrypted secrets to CyberArk Discovery & Context.
#
# The agent will:
# 1. Discover Kubernetes secrets in the cluster
# 2. Encrypt the secret data fields using RSA envelope encryption (JWE format)
# 3. Upload the encrypted secrets to CyberArk Discovery & Context
#
# Example usage:
#
# export ARK_SUBDOMAIN="your-subdomain"
# export ARK_USERNAME="your-username"
# export ARK_SECRET="your-secret"
# export ARK_SEND_SECRETS="true"
#
# go run . agent \
# --agent-config-file examples/encrypted-secrets/config.yaml \
# --one-shot \
# --output-path output.json
#
organization_id: "my-organization"
cluster_id: "my_cluster"
period: 1m
data-gatherers:
- kind: "k8s-dynamic"
name: "k8s/secrets"
config:
resource-type:
version: v1
resource: secrets
# Filter out common system secret types to focus on application secrets
field-selectors:
- type!=kubernetes.io/service-account-token
- type!=kubernetes.io/dockercfg
- type!=kubernetes.io/dockerconfigjson
- type!=kubernetes.io/basic-auth
- type!=kubernetes.io/ssh-auth
- type!=bootstrap.kubernetes.io/token
- type!=helm.sh/release.v1
65 changes: 65 additions & 0 deletions examples/encrypted-secrets/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# test.sh - Test script for the encrypted secrets example
#
# This script demonstrates running the disco agent with encrypted secrets enabled.
# It will run in one-shot mode and output to a local file for inspection.

set -euo pipefail

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

echo -e "${GREEN}=== Encrypted Secrets Example Test ===${NC}\n"

echo -e "${GREEN}Testing agent with Kubernetes secrets${NC}"
echo ""

# Enable encrypted secrets
export ARK_SEND_SECRETS="true"

# Check Kubernetes connectivity
if ! kubectl cluster-info &> /dev/null; then
echo -e "${RED}Error: Unable to connect to Kubernetes cluster${NC}"
echo "Please ensure your kubeconfig is configured correctly."
exit 1
fi

echo -e "${GREEN}✓ Connected to Kubernetes cluster${NC}"
CONTEXT=$(kubectl config current-context)
echo " Context: ${CONTEXT}"
echo ""

# Check for secrets
SECRET_COUNT=$(kubectl get secrets --all-namespaces --no-headers 2>/dev/null | wc -l | tr -d ' ')
echo "Found ${SECRET_COUNT} secrets in cluster"
echo ""

# Run the agent in one-shot mode with output to file
OUTPUT_FILE="output.json"
echo -e "${GREEN}Running disco agent with encrypted secrets enabled...${NC}"
echo "Command: go run ../.. agent --agent-config-file config.yaml --one-shot --output-path ${OUTPUT_FILE}"
echo ""

if go run ../.. agent \
--agent-config-file config.yaml \
--one-shot \
--output-path "${OUTPUT_FILE}"; then

echo ""
echo -e "${GREEN}✓ Agent completed successfully${NC}"

# Check if output file was created
if [ -f "${OUTPUT_FILE}" ]; then
echo -e "${GREEN}✓ Output file created: ${OUTPUT_FILE}${NC}"
else
echo -e "${RED}✗ Output file was not created${NC}"
exit 1
fi
else
echo ""
echo -e "${RED}✗ Agent failed${NC}"
exit 1
fi
32 changes: 32 additions & 0 deletions internal/envelope/rsa/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@ import (

// This file contains helpers for loading keys. In practice we'll retrieve keys in some format from a DisCo endpoint

const (
// HardcodedPublicKeyPEM contains a temporary hardcoded RSA public key (2048-bit) for envelope encryption.
// This is a TEMPORARY solution for initial development and testing.
// TODO: Replace with dynamic key fetching from CyberArk Discovery & Context API.
HardcodedPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoeq+dk4aoGdV9xjrnGJt
VbUh5jvkQgynkP+9Ph2NVeoasXWqYOmOVeKOI7Yr58W/L8Mro6C22iSEJrPFgPF6
t+RJsLAsAY6w1Pocq16COeelAWtxhHQGXt77WQKk0kmwhOJZ4VSeiQC4hWLUnq4N
Ft7lwLw/50opTXLuSErrwec/bEV7G/Xp11BMsHGEL7dzpwWAfIrbCEomyWrO/L6p
O3SAgYMdfup5ddnszeCU2FbFQziOkuMLOyir91XXk8wgdSy4IGAEGpwNx88i8fuj
Qafze2aGWUtpWlOEQPP8lH2cj2TGUgLxGITbczJRcwuGIoJBOzAmPDWi/bapj4b6
zQIDAQAB
-----END PUBLIC KEY-----`

// hardcodedUID is a temporary hardcoded UID associated with the hardcoded public key
// It was randomly generated with the macOS "uuidgen" command
hardcodedUID = "A39798E6-8CE7-4E6E-9CF6-24A3C923B3A7"
)

// LoadPublicKeyFromPEM parses an RSA public key from PEM-encoded bytes.
// The PEM block should be of type "PUBLIC KEY" or "RSA PUBLIC KEY".
func LoadPublicKeyFromPEM(pemBytes []byte) (*rsa.PublicKey, error) {
Expand Down Expand Up @@ -55,3 +74,16 @@ func LoadPublicKeyFromPEMFile(path string) (*rsa.PublicKey, error) {

return LoadPublicKeyFromPEM(pemBytes)
}

// LoadHardcodedPublicKey loads and parses the hardcoded RSA public key.
// Returns a hardcoded UID associated with the key.
// This is a temporary solution for initial development and testing.
// Returns an error if the hardcoded key is invalid or cannot be parsed.
func LoadHardcodedPublicKey() (*rsa.PublicKey, string, error) {
key, err := LoadPublicKeyFromPEM([]byte(HardcodedPublicKeyPEM))
if err != nil {
return nil, "", err
}

return key, hardcodedUID, nil
}
21 changes: 21 additions & 0 deletions internal/envelope/rsa/keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,24 @@ func TestLoadPublicKeyFromPEMFile_InvalidContent(t *testing.T) {
require.Error(t, err)
require.Nil(t, key)
}

func TestLoadHardcodedPublicKey_CanBeUsedWithEncryptor(t *testing.T) {
// Test that the hardcoded key can be used to create an encryptor
// First, test that the key can be loaded successfully
key, uid, err := internalrsa.LoadHardcodedPublicKey()
require.NoError(t, err)
require.NotNil(t, key)
require.NotEmpty(t, uid)

encryptor, err := internalrsa.NewEncryptor(uid, key)
require.NoError(t, err)
require.NotNil(t, encryptor)

// Test that the encryptor can encrypt data
testData := []byte("test data for encryption")
encryptedData, err := encryptor.Encrypt(testData)
require.NoError(t, err)
require.NotNil(t, encryptedData)
require.NotEmpty(t, encryptedData.Data)
require.Equal(t, "JWE-RSA", encryptedData.Type)
}
26 changes: 24 additions & 2 deletions internal/envelope/types.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
package envelope

import "encoding/json"

// EncryptedData represents encrypted data along with metadata about the encryption type.
type EncryptedData struct {
// Data contains the encrypted payload
Data []byte
Data []byte `json:"data"`
// Type indicates the encryption format (e.g., "JWE-RSA")
Type string
Type string `json:"type"`
}

// ToMap converts the EncryptedData struct to a map representation. Since we store data as an "_encryptedData" field in
// a Kubernetes unstructured object, passing a raw struct would cause a panic due to the behaviour of
// https://pkg.go.dev/k8s.io/apimachinery/pkg/runtime#DeepCopyJSONValue
// Passing a map to unstructured.SetNestedField avoids this issue.
func (ed *EncryptedData) ToMap() map[string]any {
marshalled, err := json.Marshal(ed)
if err != nil {
return nil
}

var out map[string]any

err = json.Unmarshal(marshalled, &out)
if err != nil {
return nil
}

return out
}

// Encryptor performs envelope encryption on arbitrary data.
Expand Down
20 changes: 20 additions & 0 deletions pkg/agent/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/manager"

"github.com/jetstack/preflight/api"
"github.com/jetstack/preflight/internal/envelope/rsa"
"github.com/jetstack/preflight/pkg/client"
"github.com/jetstack/preflight/pkg/datagatherer"
"github.com/jetstack/preflight/pkg/datagatherer/k8sdynamic"
Expand Down Expand Up @@ -181,6 +182,25 @@ func Run(cmd *cobra.Command, args []string) (returnErr error) {
if isDynamicGatherer {
dynDg.ExcludeAnnotKeys = config.ExcludeAnnotationKeysRegex
dynDg.ExcludeLabelKeys = config.ExcludeLabelKeysRegex

// Check if secret encryption is enabled via environment variable
// When enabled, secret data will be kept for encryption instead of being redacted
encryptSecrets := strings.ToLower(os.Getenv("ARK_SEND_SECRETS"))

if encryptSecrets == "true" {
// TODO(@SgtCoDFish): this will fetch a key from JWKS endpoint when that endpoint is available
key, keyID, err := rsa.LoadHardcodedPublicKey()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Help me understand something. Does this mean that the public keys will only be loaded once at startup? Will there be an option to refresh them while the agent is still running?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That won't be the case when JWKS is actually available - we'll fetch regularly. I was intending to fetch before every upload attempt.

I just did a one-off here because there's only one key that never changes!

if err == nil {
encryptor, err := rsa.NewEncryptor(keyID, key)
if err == nil {
dynDg.Encryptor = encryptor
} else {
log.Error(err, "Failed to create encryptor for secret encryption, secrets will not be sent to backend")
}
} else {
log.Error(err, "Failed to load public key for secret encryption, secrets will not be sent to backend")
}
}
}

log.V(logs.Debug).Info("Starting DataGatherer", "name", dgConfig.Name)
Expand Down
2 changes: 2 additions & 0 deletions pkg/client/client_cyberark.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ var _ Client = &CyberArkClient{}
// NewCyberArk initializes a CyberArk client using configuration from environment variables.
// It requires an HTTP client to be provided, which will be used for making requests.
// The environment variables ARK_SUBDOMAIN, ARK_USERNAME, and ARK_SECRET must be set for authentication.
// Sending secrets is controlled by the ARK_SEND_SECRETS environment variable (defaults to "false").
// If sending secrets is enabled, the hardcoded public key will be loaded and an encryptor will be created.
// If the configuration is invalid or missing, an error is returned.
func NewCyberArk(httpClient *http.Client) (*CyberArkClient, error) {
configLoader := cyberark.LoadClientConfigFromEnvironment
Expand Down
Loading
Loading