Skip to content

Commit a20b53d

Browse files
jymaireclaude
andcommitted
fix(security): scrub secrets in lists and stop over-redacting benign keys
scrubSensitiveValues now recurses through List values (not just Maps), so secrets nested inside a list of connection maps are redacted too. isSensitiveKey now matches whole tokens (split on separators and camelCase) plus a compound allowlist, instead of substring containment — so benign keys like keyspace, sortkey and author are no longer redacted while api_key/apiKey/client_secret/ account_info_json still are. Adds unit tests for both behaviors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6b4b7dd commit a20b53d

2 files changed

Lines changed: 119 additions & 13 deletions

File tree

src/main/java/io/kestra/plugin/soda/Scan.java

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import java.io.IOException;
44
import java.nio.file.Path;
5+
import java.util.ArrayList;
56
import java.util.LinkedHashMap;
7+
import java.util.List;
68
import java.util.Map;
79
import java.util.Optional;
810
import java.util.Set;
@@ -107,19 +109,30 @@
107109
public class Scan extends AbstractSoda implements RunnableTask<Scan.Output> {
108110
private static final String REDACTED = "******";
109111

110-
private static final Set<String> SENSITIVE_KEY_PATTERNS = Set.of(
112+
/**
113+
* Whole-token markers: a key is sensitive when one of its tokens (split on separators and
114+
* camelCase boundaries) exactly equals one of these. Matching on tokens rather than substrings
115+
* avoids over-redacting benign keys such as {@code keyspace}, {@code sortkey} or {@code author}.
116+
* Note {@code key} as a token already covers {@code api_key}, {@code access_key},
117+
* {@code private_key}, {@code apiKey}, etc.
118+
*/
119+
private static final Set<String> SENSITIVE_KEY_TOKENS = Set.of(
111120
"password", "passwd", "pwd",
112-
"secret",
113-
"token",
114-
"key",
121+
"secret", "secrets",
122+
"token", "tokens",
123+
"key", "keys",
115124
"credential", "credentials",
116-
"account_info_json",
117-
"private_key",
118-
"api_key", "apikey",
119-
"access_key",
120125
"auth"
121126
);
122127

128+
/**
129+
* Compound key names matched against the fully normalized (alphanumeric-only, lowercased) key,
130+
* for sensitive keys whose individual tokens are not themselves secret markers.
131+
*/
132+
private static final Set<String> SENSITIVE_NORMALIZED_KEYS = Set.of(
133+
"accountinfojson"
134+
);
135+
123136
@Schema(
124137
title = "SodaCL checks definition",
125138
description = "Required map rendered to `checks.yml` and executed against the `kestra` data source. Follow SodaCL syntax; failing checks mark the task accordingly."
@@ -216,31 +229,77 @@ private static Map<String, Object> scrubSensitiveValues(Map<String, Object> map)
216229

217230
if (isSensitiveKey(key)) {
218231
scrubbed.put(key, REDACTED);
219-
} else if (value instanceof Map) {
220-
scrubbed.put(key, scrubSensitiveValues((Map<String, Object>) value));
221232
} else {
222-
scrubbed.put(key, value);
233+
scrubbed.put(key, scrubValue(value));
223234
}
224235
}
225236

226237
return scrubbed;
227238
}
228239

240+
/**
241+
* Recurses through container values so sensitive keys nested inside maps <em>or lists</em>
242+
* (e.g. a list of connection maps) are scrubbed too; scalar values are returned unchanged.
243+
*/
244+
@SuppressWarnings("unchecked")
245+
private static Object scrubValue(Object value) {
246+
if (value instanceof Map) {
247+
return scrubSensitiveValues((Map<String, Object>) value);
248+
}
249+
250+
if (value instanceof List) {
251+
List<Object> scrubbedList = new ArrayList<>();
252+
for (Object element : (List<Object>) value) {
253+
scrubbedList.add(scrubValue(element));
254+
}
255+
return scrubbedList;
256+
}
257+
258+
return value;
259+
}
260+
229261
private static boolean isSensitiveKey(String key) {
230262
if (key == null) {
231263
return false;
232264
}
233265

234266
String normalized = key.toLowerCase().replaceAll("[^a-z0-9]", "");
235-
for (String pattern : SENSITIVE_KEY_PATTERNS) {
236-
if (normalized.contains(pattern.replaceAll("[^a-z0-9]", ""))) {
267+
if (SENSITIVE_NORMALIZED_KEYS.contains(normalized)) {
268+
return true;
269+
}
270+
271+
for (String token : tokenize(key)) {
272+
if (SENSITIVE_KEY_TOKENS.contains(token)) {
237273
return true;
238274
}
239275
}
240276

241277
return false;
242278
}
243279

280+
/**
281+
* Splits a key into lowercase tokens on non-alphanumeric separators and camelCase boundaries,
282+
* so {@code account_info_json}, {@code apiKey} and {@code APIKey} all yield word-level tokens.
283+
*/
284+
private static List<String> tokenize(String key) {
285+
String spaced = key
286+
.replaceAll("([A-Z]+)([A-Z][a-z])", "$1 $2")
287+
.replaceAll("([a-z0-9])([A-Z])", "$1 $2")
288+
.replaceAll("[^a-zA-Z0-9]+", " ")
289+
.trim();
290+
291+
List<String> tokens = new ArrayList<>();
292+
if (spaced.isEmpty()) {
293+
return tokens;
294+
}
295+
296+
for (String token : spaced.split("\\s+")) {
297+
tokens.add(token.toLowerCase());
298+
}
299+
300+
return tokens;
301+
}
302+
244303
protected ScanResult parseResult(RunContext runContext, ScriptOutput output) throws IOException {
245304
ScanResult scanResult = JacksonMapper.ofJson(false).readValue(
246305
runContext.storage().getFile(output.getOutputFiles().get("result.json")),

src/test/java/io/kestra/plugin/soda/SecurityHardeningTest.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package io.kestra.plugin.soda;
22

33
import java.lang.reflect.Method;
4+
import java.util.ArrayList;
45
import java.util.LinkedHashMap;
6+
import java.util.List;
57
import java.util.Map;
68

79
import org.junit.jupiter.api.Test;
@@ -104,15 +106,60 @@ void scrub_recursesIntoNestedMaps() throws Exception {
104106
void scrub_redactsSecretKeyVariants() throws Exception {
105107
Map<String, Object> input = new LinkedHashMap<>();
106108
input.put("api_key", "abc");
109+
input.put("apiKey", "abc");
107110
input.put("token", "xyz");
108111
input.put("account_info_json", "{...}");
112+
input.put("client_secret", "shh");
109113
input.put("type", "postgres");
110114

111115
Map<String, Object> result = scrub(input);
112116

113117
assertThat(result.get("api_key"), is("******"));
118+
assertThat(result.get("apiKey"), is("******"));
114119
assertThat(result.get("token"), is("******"));
115120
assertThat(result.get("account_info_json"), is("******"));
121+
assertThat(result.get("client_secret"), is("******"));
116122
assertThat(result.get("type"), is("postgres"));
117123
}
124+
125+
@Test
126+
void scrub_recursesIntoLists() throws Exception {
127+
// A non-sensitive key whose value is a list of maps: secrets nested in list elements
128+
// must still be redacted (the recursion used to stop at Map values only).
129+
Map<String, Object> node1 = new LinkedHashMap<>();
130+
node1.put("host", "db1");
131+
node1.put("password", "s1");
132+
Map<String, Object> node2 = new LinkedHashMap<>();
133+
node2.put("host", "db2");
134+
node2.put("token", "t2");
135+
136+
Map<String, Object> input = new LinkedHashMap<>();
137+
input.put("nodes", new ArrayList<>(List.of(node1, node2)));
138+
139+
Map<String, Object> result = scrub(input);
140+
141+
@SuppressWarnings("unchecked")
142+
List<Map<String, Object>> nodes = (List<Map<String, Object>>) result.get("nodes");
143+
assertThat(nodes, hasSize(2));
144+
assertThat(nodes.get(0).get("password"), is("******"));
145+
assertThat(nodes.get(0).get("host"), is("db1"));
146+
assertThat(nodes.get(1).get("token"), is("******"));
147+
assertThat(nodes.get(1).get("host"), is("db2"));
148+
}
149+
150+
@Test
151+
void scrub_doesNotOverRedactBenignKeys() throws Exception {
152+
// These all contain a sensitive pattern as a substring but no sensitive whole token,
153+
// so their values must be preserved.
154+
Map<String, Object> input = new LinkedHashMap<>();
155+
input.put("keyspace", "analytics"); // contains "key"
156+
input.put("sortkey", "created_at"); // contains "key"
157+
input.put("author", "alice"); // contains "auth"
158+
159+
Map<String, Object> result = scrub(input);
160+
161+
assertThat(result.get("keyspace"), is("analytics"));
162+
assertThat(result.get("sortkey"), is("created_at"));
163+
assertThat(result.get("author"), is("alice"));
164+
}
118165
}

0 commit comments

Comments
 (0)