Skip to content

Commit 97c6c69

Browse files
Add maskedResult for secret remediation and change log level from INFO to DEBUG
1 parent ee4c90c commit 97c6c69

File tree

7 files changed

+356
-2
lines changed

7 files changed

+356
-2
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.checkmarx.ast.secretsrealtime;
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
5+
import com.fasterxml.jackson.annotation.JsonInclude;
6+
import com.fasterxml.jackson.annotation.JsonProperty;
7+
import com.fasterxml.jackson.databind.JsonNode;
8+
import com.fasterxml.jackson.databind.ObjectMapper;
9+
import lombok.Value;
10+
import org.apache.commons.lang3.StringUtils;
11+
import org.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
13+
14+
import java.io.IOException;
15+
import java.util.ArrayList;
16+
import java.util.Collections;
17+
import java.util.List;
18+
19+
/**
20+
* Represents the result of a mask secrets command operation.
21+
* Contains masked secrets and the masked file content.
22+
* This is separate from realtime scanning results.
23+
*/
24+
@Value
25+
@JsonInclude(JsonInclude.Include.NON_NULL)
26+
@JsonIgnoreProperties(ignoreUnknown = true)
27+
public class MaskResult {
28+
private static final Logger log = LoggerFactory.getLogger(MaskResult.class);
29+
30+
/**
31+
* List of masked secrets found in the file
32+
*/
33+
@JsonProperty("maskedSecrets")
34+
List<MaskedSecret> maskedSecrets;
35+
36+
/**
37+
* The masked file content with secrets redacted
38+
*/
39+
@JsonProperty("maskedFile")
40+
String maskedFile;
41+
42+
@JsonCreator
43+
public MaskResult(@JsonProperty("maskedSecrets") List<MaskedSecret> maskedSecrets,
44+
@JsonProperty("maskedFile") String maskedFile) {
45+
this.maskedSecrets = maskedSecrets == null ? Collections.emptyList() : maskedSecrets;
46+
this.maskedFile = maskedFile;
47+
}
48+
49+
/**
50+
* Parses mask command output from JSON response
51+
* @param root JsonNode containing the mask command response
52+
* @return MaskResult object with parsed data
53+
*/
54+
public static MaskResult parse(JsonNode root) {
55+
if (root == null) {
56+
return new MaskResult(Collections.emptyList(), "");
57+
}
58+
59+
List<MaskedSecret> secrets = new ArrayList<>();
60+
JsonNode maskedSecretsNode = root.get("maskedSecrets");
61+
62+
if (maskedSecretsNode != null && maskedSecretsNode.isArray()) {
63+
for (JsonNode secretNode : maskedSecretsNode) {
64+
String masked = secretNode.has("masked") ? secretNode.get("masked").asText() : "";
65+
String secret = secretNode.has("secret") ? secretNode.get("secret").asText() : "";
66+
int line = secretNode.has("line") ? secretNode.get("line").asInt() : 0;
67+
68+
secrets.add(new MaskedSecret(masked, secret, line));
69+
}
70+
}
71+
72+
String maskedFile = root.has("maskedFile") ? root.get("maskedFile").asText() : "";
73+
74+
return new MaskResult(secrets, maskedFile);
75+
}
76+
77+
/**
78+
* Parses mask command output from JSON string
79+
* @param jsonString JSON string containing the mask command response
80+
* @return MaskResult object with parsed data, or null if parsing fails
81+
*/
82+
public static MaskResult fromJsonString(String jsonString) {
83+
if (StringUtils.isBlank(jsonString)) {
84+
return null;
85+
}
86+
87+
try {
88+
ObjectMapper mapper = new ObjectMapper();
89+
JsonNode root = mapper.readTree(jsonString.trim());
90+
return parse(root);
91+
} catch (IOException e) {
92+
log.debug("Failed to parse mask result JSON: {}", jsonString, e);
93+
return null;
94+
}
95+
}
96+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.checkmarx.ast.secretsrealtime;
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
5+
import com.fasterxml.jackson.annotation.JsonInclude;
6+
import com.fasterxml.jackson.annotation.JsonProperty;
7+
import lombok.Value;
8+
9+
/**
10+
* Represents a single masked secret from the mask command output.
11+
* This is used for the separate mask functionality (not realtime scan results).
12+
*/
13+
@Value
14+
@JsonInclude(JsonInclude.Include.NON_NULL)
15+
@JsonIgnoreProperties(ignoreUnknown = true)
16+
public class MaskedSecret {
17+
18+
/**
19+
* The masked/redacted version of the secret
20+
*/
21+
@JsonProperty("masked")
22+
String masked;
23+
24+
/**
25+
* The original secret value (may be empty for security reasons)
26+
*/
27+
@JsonProperty("secret")
28+
String secret;
29+
30+
/**
31+
* Line number where the secret was found
32+
*/
33+
@JsonProperty("line")
34+
int line;
35+
36+
@JsonCreator
37+
public MaskedSecret(@JsonProperty("masked") String masked,
38+
@JsonProperty("secret") String secret,
39+
@JsonProperty("line") int line) {
40+
this.masked = masked;
41+
this.secret = secret;
42+
this.line = line;
43+
}
44+
}

src/main/java/com/checkmarx/ast/wrapper/CxConstants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,5 @@ public final class CxConstants {
8080
static final String SUB_CMD_IAC_REALTIME = "iac-realtime";
8181
static final String SUB_CMD_SECRETS_REALTIME = "secrets-realtime";
8282
static final String SUB_CMD_CONTAINERS_REALTIME = "containers-realtime";
83+
static final String CMD_MASK_SECRETS = "mask";
8384
}

src/main/java/com/checkmarx/ast/wrapper/CxWrapper.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.checkmarx.ast.learnMore.LearnMore;
77
import com.checkmarx.ast.ossrealtime.OssRealtimeResults;
88
import com.checkmarx.ast.secretsrealtime.SecretsRealtimeResults;
9+
import com.checkmarx.ast.secretsrealtime.MaskResult;
910
import com.checkmarx.ast.iacrealtime.IacRealtimeResults;
1011
import com.checkmarx.ast.containersrealtime.ContainersRealtimeResults;
1112
import com.checkmarx.ast.predicate.CustomState;
@@ -441,6 +442,23 @@ public SecretsRealtimeResults secretsRealtimeScan(@NonNull String sourcePath, St
441442
return realtimeScan(CxConstants.SUB_CMD_SECRETS_REALTIME, sourcePath, ignoredFilePath, SecretsRealtimeResults::fromLine);
442443
}
443444

445+
/**
446+
* Executes mask secrets command to obfuscate/redact secrets in a file
447+
* @param filePath path to the file to mask
448+
* @return MaskResult containing masked secrets and masked file content
449+
*/
450+
public MaskResult maskSecrets(@NonNull String filePath) throws IOException, InterruptedException, CxException {
451+
this.logger.info("Executing 'mask' command using the CLI for file: {}", filePath);
452+
453+
List<String> arguments = new ArrayList<>();
454+
arguments.add(CxConstants.CMD_MASK_SECRETS);
455+
arguments.add(CxConstants.SOURCE);
456+
arguments.add(filePath);
457+
458+
String output = Execution.executeCommand(withConfigArguments(arguments), logger, line -> line);
459+
return MaskResult.fromJsonString(output);
460+
}
461+
444462
// Containers Realtime
445463
public ContainersRealtimeResults containersRealtimeScan(@NonNull String sourcePath, String ignoredFilePath)
446464
throws IOException, InterruptedException, CxException {

src/main/java/com/checkmarx/ast/wrapper/Execution.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ static <T> T executeCommand(List<String> arguments,
5757
String line;
5858
StringBuilder output = new StringBuilder();
5959
while ((line = br.readLine()) != null) {
60-
logger.info(line);
60+
logger.debug(line);
6161
output.append(line).append(LINE_SEPARATOR);
6262
T parsedLine = lineParser.apply(line);
6363
if (parsedLine != null) {
@@ -98,7 +98,7 @@ static String executeCommand(List<String> arguments,
9898
String line;
9999
StringBuilder stringBuilder = new StringBuilder();
100100
while ((line = br.readLine()) != null) {
101-
logger.info(line);
101+
logger.debug(line);
102102
stringBuilder.append(line).append(LINE_SEPARATOR);
103103
}
104104
process.waitFor();

src/test/java/com/checkmarx/ast/SecretsRealtimeResultsTest.java

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import com.checkmarx.ast.realtime.RealtimeLocation;
44
import com.checkmarx.ast.secretsrealtime.SecretsRealtimeResults;
5+
import com.checkmarx.ast.secretsrealtime.MaskResult;
6+
import com.checkmarx.ast.secretsrealtime.MaskedSecret;
57
import com.checkmarx.ast.wrapper.CxException;
68
import org.junit.jupiter.api.*;
79

@@ -204,6 +206,188 @@ void secretsScanMultipleFileTypes() {
204206
}
205207
}
206208

209+
/* ------------------------------------------------------ */
210+
/* Integration tests for Secrets Masking functionality */
211+
/* ------------------------------------------------------ */
212+
213+
/**
214+
* Tests basic mask secrets functionality - successful case.
215+
* Similar to the JavaScript test, verifies that the mask command returns proper MaskResult
216+
* with masked secrets detected in a JSON file containing API keys and passwords.
217+
*/
218+
@Test
219+
@DisplayName("Mask secrets successful case - returns masked content")
220+
void maskSecretsSuccessfulCase() throws Exception {
221+
Assumptions.assumeTrue(isCliConfigured(), "PATH_TO_EXECUTABLE not configured - skipping integration test");
222+
String secretsFile = "src/test/resources/secrets-test.json";
223+
Assumptions.assumeTrue(Files.exists(Paths.get(secretsFile)), "Secrets test file not found - cannot test masking");
224+
225+
MaskResult result = wrapper.maskSecrets(secretsFile);
226+
227+
assertNotNull(result, "Mask result should not be null");
228+
assertNotNull(result.getMaskedSecrets(), "Masked secrets list should be initialized");
229+
assertNotNull(result.getMaskedFile(), "Masked file content should be provided");
230+
231+
// Expect at least one secret to be found in our test file
232+
assertFalse(result.getMaskedSecrets().isEmpty(), "Should find masked secrets in test file");
233+
234+
// Verify structure of masked secrets
235+
MaskedSecret firstSecret = result.getMaskedSecrets().get(0);
236+
assertNotNull(firstSecret.getMasked(), "Masked value should be provided");
237+
assertTrue(firstSecret.getLine() > 0, "Line number should be positive");
238+
239+
// Masked file should contain the original structure but with secrets redacted
240+
assertFalse(result.getMaskedFile().trim().isEmpty(), "Masked file content should not be empty");
241+
assertTrue(result.getMaskedFile().contains("{"), "Masked file should preserve JSON structure");
242+
}
243+
244+
/**
245+
* Tests mask functionality across different file types.
246+
* Verifies that the mask command can handle various file extensions and formats
247+
* without crashing and produces appropriate masked results.
248+
*/
249+
@Test
250+
@DisplayName("Mask secrets handles multiple file types correctly")
251+
void maskSecretsMultipleFileTypes() {
252+
Assumptions.assumeTrue(isCliConfigured(), "PATH_TO_EXECUTABLE not configured - skipping integration test");
253+
254+
String[] testFiles = {
255+
"src/test/resources/python-vul-file.py",
256+
"src/test/resources/csharp-file.cs"
257+
};
258+
259+
for (String filePath : testFiles) {
260+
if (Files.exists(Paths.get(filePath))) {
261+
assertDoesNotThrow(() -> {
262+
MaskResult result = wrapper.maskSecrets(filePath);
263+
assertNotNull(result, "Mask result should not be null for file: " + filePath);
264+
assertNotNull(result.getMaskedSecrets(), "Masked secrets should be initialized for: " + filePath);
265+
assertNotNull(result.getMaskedFile(), "Masked file should not be null for: " + filePath);
266+
}, "Mask command should handle file type gracefully: " + filePath);
267+
}
268+
}
269+
}
270+
271+
/**
272+
* Tests error handling when masking a non-existent file.
273+
* Verifies that the mask command properly throws a CxException with meaningful error message
274+
* when provided with invalid file paths.
275+
*/
276+
@Test
277+
@DisplayName("Mask secrets throws appropriate exception for non-existent file")
278+
void maskSecretsHandlesInvalidPath() {
279+
Assumptions.assumeTrue(isCliConfigured(), "PATH_TO_EXECUTABLE not configured - skipping integration test");
280+
281+
// Test with a non-existent file path
282+
String invalidPath = "src/test/resources/NonExistentFile.py";
283+
284+
// The CLI should throw a CxException with a meaningful error message for invalid paths
285+
CxException exception = assertThrows(CxException.class, () ->
286+
wrapper.maskSecrets(invalidPath)
287+
);
288+
289+
// Verify the exception contains information about the invalid file path
290+
String errorMessage = exception.getMessage();
291+
assertNotNull(errorMessage, "Exception should contain an error message");
292+
assertTrue(errorMessage.contains("invalid file path") || errorMessage.contains("file") || errorMessage.contains("path"),
293+
"Exception message should indicate the issue is related to file path: " + errorMessage);
294+
}
295+
296+
/**
297+
* Tests that masked file content differs from original when secrets are present.
298+
* Verifies that the masking process actually modifies the file content to redact secrets.
299+
*/
300+
@Test
301+
@DisplayName("Masked file content differs from original when secrets exist")
302+
void maskedContentDiffersFromOriginal() throws Exception {
303+
Assumptions.assumeTrue(isCliConfigured(), "PATH_TO_EXECUTABLE not configured - skipping integration test");
304+
String secretsFile = "src/test/resources/secrets-test.json";
305+
Assumptions.assumeTrue(Files.exists(Paths.get(secretsFile)), "Secrets test file not found - cannot test content masking");
306+
307+
// Read original file content
308+
String originalContent = Files.readString(Paths.get(secretsFile));
309+
310+
// Get masked content
311+
MaskResult result = wrapper.maskSecrets(secretsFile);
312+
assertNotNull(result, "Mask result should not be null");
313+
314+
String maskedContent = result.getMaskedFile();
315+
assertNotNull(maskedContent, "Masked content should not be null");
316+
317+
// Since our test file contains secrets, the content should be different after masking
318+
if (!result.getMaskedSecrets().isEmpty()) {
319+
assertNotEquals(originalContent, maskedContent,
320+
"Masked content should differ from original when secrets are present");
321+
322+
// Verify that original secrets are not present in masked content
323+
assertFalse(maskedContent.contains("sk-1234567890abcdef1234567890abcdef"),
324+
"Original API key should be masked in output");
325+
assertFalse(maskedContent.contains("SuperSecret123!"),
326+
"Original password should be masked in output");
327+
}
328+
}
329+
330+
/* ------------------------------------------------------ */
331+
/* Unit tests for Mask JSON parsing functionality */
332+
/* ------------------------------------------------------ */
333+
334+
/**
335+
* Tests MaskResult JSON parsing with valid mask command response.
336+
* Verifies that well-formed mask JSON is correctly parsed into MaskResult objects.
337+
*/
338+
@Test
339+
@DisplayName("Valid mask JSON response parsing creates correct MaskResult")
340+
void testMaskResultJsonParsing() {
341+
String json = "{" +
342+
"\"maskedSecrets\":[" +
343+
"{\"masked\":\"****\",\"secret\":\"password123\",\"line\":5}," +
344+
"{\"masked\":\"***\",\"secret\":\"key\",\"line\":10}" +
345+
"]," +
346+
"\"maskedFile\":\"const password = '****';\\nconst apiKey = '***';\"" +
347+
"}";
348+
349+
MaskResult result = MaskResult.fromJsonString(json);
350+
351+
assertNotNull(result, "MaskResult should not be null");
352+
assertEquals(2, result.getMaskedSecrets().size(), "Should parse 2 masked secrets");
353+
354+
MaskedSecret firstSecret = result.getMaskedSecrets().get(0);
355+
assertEquals("****", firstSecret.getMasked());
356+
assertEquals("password123", firstSecret.getSecret());
357+
assertEquals(5, firstSecret.getLine());
358+
359+
MaskedSecret secondSecret = result.getMaskedSecrets().get(1);
360+
assertEquals("***", secondSecret.getMasked());
361+
assertEquals("key", secondSecret.getSecret());
362+
assertEquals(10, secondSecret.getLine());
363+
364+
assertTrue(result.getMaskedFile().contains("const password = '****'"));
365+
assertTrue(result.getMaskedFile().contains("const apiKey = '***'"));
366+
}
367+
368+
/**
369+
* Tests MaskResult parsing robustness with edge cases.
370+
* Verifies that the parser gracefully handles various invalid input scenarios.
371+
*/
372+
@Test
373+
@DisplayName("MaskResult handles malformed JSON and edge cases gracefully")
374+
void testMaskResultEdgeCases() {
375+
// Blank/null inputs
376+
assertNull(MaskResult.fromJsonString(""));
377+
assertNull(MaskResult.fromJsonString(" "));
378+
assertNull(MaskResult.fromJsonString(null));
379+
380+
// Invalid JSON structures
381+
assertNull(MaskResult.fromJsonString("{"));
382+
assertNull(MaskResult.fromJsonString("not a json"));
383+
384+
// Empty but valid JSON
385+
MaskResult emptyResult = MaskResult.fromJsonString("{}");
386+
assertNotNull(emptyResult);
387+
assertTrue(emptyResult.getMaskedSecrets().isEmpty());
388+
assertNotNull(emptyResult.getMaskedFile());
389+
}
390+
207391
/* ------------------------------------------------------ */
208392
/* Unit tests for JSON parsing robustness */
209393
/* ------------------------------------------------------ */

0 commit comments

Comments
 (0)