Add secrets module: Spring Cloud Vault, AWS Secrets Manager, Kubernetes configtree and Jasypt on Spring Boot 4.1.2, with real transcripts

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b6tTs7ivgTNq4ZrurU
This commit is contained in:
2026-11-08 18:24:42 +00:00
co-authored by Claude Sonnet 5.5
parent 6a36e84858
commit 1497fba911
38 changed files with 1263 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.104 s -- in com.ankurm.secrets.JasyptTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.806 s -- in com.ankurm.secrets.VaultRotationTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.763 s -- in com.ankurm.secrets.KubernetesConfigtreeTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 13.25 s -- in com.ankurm.secrets.AwsSecretsManagerTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.317 s -- in com.ankurm.secrets.LeaksTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.828 s -- in com.ankurm.secrets.DynamicPropertyTrapTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.303 s -- in com.ankurm.secrets.VaultImportTest
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
+6
View File
@@ -0,0 +1,6 @@
Vault server: hashicorp/vault:2.2.1
spring.config.import = vault:// (default context: secret/${spring.application.name} = secret/secrets-demo)
bound: DbProperties[url=null, username=app, password=<redacted:17 chars>]
property sources that hold db.password (first one wins):
configurationProperties
secret/secrets-demo
@@ -0,0 +1,17 @@
1. spring.cloud.vault.kv.application-name=no-such-app (nothing stored at secret/no-such-app), fail-fast off:
started: yes; db.password = null
vault property sources mounted: [secret/no-such-app, secret/application]
2. same, spring.cloud.vault.fail-fast=true:
started: yes; db.password = null
3. same, with the strict profile (RequireDbPassword guard in application code):
started: no; IllegalStateException: db.password is not configured: refusing to start
4. wrong token, fail-fast off (the default):
started: yes; db.password = null
wrong token, fail-fast=true:
started: no; Forbidden: 403 Forbidden on GET request for "http://localhost:<port>/v1/secret/secrets-demo": "{"errors":["2 errors occurred:\n\t* permission denied\n\t* invalid token\n\n"]}<EOL>"
5. Vault unreachable (127.0.0.1:1), import = vault://, fail-fast off:
started: yes; db.password = null
Vault unreachable, fail-fast=true:
started: no; HttpHostConnectException: Connect to http://127.0.0.1:1 failed: Connection refused (connect failed)
6. Vault unreachable, import = optional:vault://, fail-fast=true:
started: no; HttpHostConnectException: Connect to http://127.0.0.1:1 failed: Connection refused (connect failed)
+10
View File
@@ -0,0 +1,10 @@
started with db.password = password-v1
-> vault kv put secret/secrets-demo db.password=password-v2
environment, no refresh: db.password = password-v1
DbProperties bean, no refresh: password-v1
@RefreshScope bean, no refresh: password-v1
ContextRefresher.refresh() changed keys: [db.password]
environment, after refresh: db.password = password-v2
DbProperties record bean, after refresh: password-v1
@RefreshScope bean, after refresh: password-v2
old version is still in Vault: vault kv get -version=1 -> password-v1
@@ -0,0 +1,6 @@
policy app-read (vault/app-read.hcl) attached to a 1h token
application with that token: started, db.password = password-v1
same token, vault kv get secret/other-team:
* permission denied
same token, vault kv put secret/secrets-demo db.password=hijacked:
* permission denied
+18
View File
@@ -0,0 +1,18 @@
moto: created secret /secrets/secrets-demo (JSON with db.password, db.username)
spring.config.import = aws-secretsmanager:/secrets/secrets-demo
bound: DbProperties[url=null, username=app, password=<redacted:15 chars>]
property sources that hold db.password: [configurationProperties, aws-secretsmanager:/secrets/secrets-demo]
-> PutSecretValue with db.password=aws-password-v2
environment, no reload: db.password = aws-password-v1
secret version with stages=[AWSCURRENT]
secret version with stages=[AWSPREVIOUS]
restart with reload.strategy=refresh, reload.period=PT1S: db.password = aws-password-v2
-> PutSecretValue with db.password=aws-password-v3
environment after 10 s of 1 s polling: db.password = aws-password-v2
ContextRefresher.refresh() changed keys: [db.password]
environment after manual refresh: db.password = aws-password-v3
@RefreshScope bean after manual refresh: aws-password-v3
missing secret, import = aws-secretsmanager:/secrets/nope:
started: no; ResourceNotFoundException: Secrets Manager can't find the specified secret. (Service: SecretsManager, Status Code: 404, Request ID: <id> (SDK Attempt Count: 1)
missing secret, import = optional:aws-secretsmanager:/secrets/nope:
started: yes; db.password = null
@@ -0,0 +1,22 @@
k8s/secret.yaml data[db.password] = czNjcjN0LWZyb20tazhz
base64 -d -> s3cr3t-from-k8s (encoding, not encryption: anyone with 'get secrets' can do this)
mounted directory, as the kubelet lays it out:
..2026_11_08_10_00_00.111111111/
..data -> ..2026_11_08_10_00_00.111111111
db.password -> ..data/db.password
db.username -> ..data/db.username
spring.config.import = configtree:<mount>/
bound: DbProperties[url=null, username=app, password=<redacted:15 chars>]
config tree property source: Config tree '<mount>'
keys it exposes: [db.password, db.username]
-> kubelet-style update: new generation dir, ..data link swapped atomically
environment after the swap: db.password = s3cr3t-from-k8s
DbProperties after the swap: s3cr3t-from-k8s
@RefreshScope bean after swap: s3cr3t-from-k8s
ContextRefresher.refresh() changed keys: [db.password]
environment after refresh: db.password = s3cr3t-rotated
@RefreshScope bean after refresh: s3cr3t-rotated
directory missing, import = configtree:/no/such/dir/:
started: no; ConfigDataResourceNotFoundException: Config data resource 'config tree [/no/such/dir]' via location 'configtree:/no/such/dir/' cannot be found
directory missing, import = optional:configtree:/no/such/dir/:
started: yes; db.password = null
+14
View File
@@ -0,0 +1,14 @@
StringEncryptor beans: [lazyJasyptStringEncryptor]
encrypt("jasypt-secret") twice with the starter's default encryptor:
L3j9uk0JtxuSgfME2mgcEdp4DqxtdXsrHnLaQ2dXyIEOEwzHncjtN3MJm0praytC
QcEwZQiMm5z1a1VIf17Gkbr9e0eunC0Riuy7tbvYMZ4zVMylXVoBek699CM2LRog
same ciphertext both times: false (random salt and IV per call)
db.password=ENC(...) + jasypt.encryptor.password=master-key:
environment: db.password = jasypt-secret
bound: DbProperties[url=null, username=null, password=<redacted:13 chars>]
same ENC(...) value, wrong master key:
started: no; EncryptionOperationNotPossibleException: null
same ENC(...) value, no master key configured:
started: no; IllegalStateException: either 'jasypt.encryptor.password', one of ['jasypt.encryptor.private-key-string', 'jasypt.encryptor.private-key-location'] for asymmetric encryption, or one of ['jasypt.encryptor.gcm-secret-key-string', 'jasypt.encryptor.gcm-secret-key-location', 'jasypt.encryptor.gcm-secret-key-password'] for AES/GCM encryption must be provided for Password-based or Asymmetric encryption
outside Spring, org.jasypt only, algorithm PBEWITHHMACSHA512ANDAES_256, 1000 iterations, random salt+IV:
decrypt(ciphertext) = jasypt-secret
+11
View File
@@ -0,0 +1,11 @@
1. a record's generated toString:
NaiveDb[url=jdbc:postgresql://db/app, password=hunter2]
DbProperties overrides toString: DbProperties[url=jdbc:postgresql://db/app, username=app, password=<redacted:7 chars>]
2. /actuator/env and /actuator/configprops, Boot 4.1.2 defaults:
GET /actuator/env/db.password value shown: ******
GET /actuator/env/internal.signing.material value shown: ******
GET /actuator/configprops body contains the password: false
3. same, management.endpoint.env.show-values=ALWAYS and configprops.show-values=ALWAYS:
GET /actuator/env/db.password value shown: hunter2
GET /actuator/env/internal.signing.material value shown: also-secret-but-not-named-like-one
GET /actuator/configprops body contains the password: true
@@ -0,0 +1,5 @@
@SpringBootTest properties: spring.config.import=vault://, spring.cloud.vault.token=root
@DynamicPropertySource sets spring.cloud.vault.uri to the container's mapped port
environment spring.cloud.vault.uri = http://localhost:<port>
a secret is stored in that Vault at secret/secrets-demo
environment db.password = null
+12
View File
@@ -0,0 +1,12 @@
1. the jar: src/main/resources/application-leaky.yml (a deliberately committed secret) as packaged
$ unzip -p target/secrets-1.0.0.jar BOOT-INF/classes/application-leaky.yml | grep password
password: hunter2-committed-to-git
2. command-line argument, as any user on the host sees it
$ ps -o args= -p <pid> | tr ' ' '\n' | grep db.password
--db.password=arg-secret-789
3. environment variables, readable by the same user
$ tr '\0' '\n' < /proc/<pid>/environ | grep -E 'DB_PASSWORD_FROM_ENV|SIGNING_KEY'
DB_PASSWORD_FROM_ENV=env-secret-123
SIGNING_KEY=env-signing-key-456
4. /proc/<pid>/cmdline, what the kernel keeps of the arguments
--db.password=arg-secret-789