Skip to content

Commit 5c06ffc

Browse files
Add a parser for Gitleaks JSON reports (#1498)
1 parent 6b5b6bc commit 5c06ffc

8 files changed

Lines changed: 425 additions & 1 deletion

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package edu.hm.hafner.analysis.parser;
2+
3+
import org.apache.commons.lang3.StringUtils;
4+
import org.json.JSONArray;
5+
import org.json.JSONObject;
6+
7+
import edu.hm.hafner.analysis.Issue;
8+
import edu.hm.hafner.analysis.IssueBuilder;
9+
import edu.hm.hafner.analysis.Report;
10+
11+
import java.io.Serial;
12+
13+
/**
14+
* Parser for Gitleaks JSON reports.
15+
*
16+
* @author Akash Manna
17+
* @see <a href="https://github.com/zricethezav/gitleaks">gitleaks on GitHub</a>
18+
*/
19+
public class GitleaksParser extends JsonIssueParser {
20+
@Serial
21+
private static final long serialVersionUID = -1234567890123456789L;
22+
23+
private static final String LEAKS = "leaks";
24+
private static final String RULE = "rule";
25+
private static final String RULE_ID = "rule_id";
26+
private static final String DESCRIPTION = "description";
27+
private static final String FILE = "file";
28+
private static final String START_LINE = "start_line";
29+
private static final String END_LINE = "end_line";
30+
private static final String LINE = "line";
31+
private static final String SEVERITY = "severity";
32+
33+
@Override
34+
protected void parseJsonObject(final Report report, final JSONObject jsonReport, final IssueBuilder issueBuilder) {
35+
if (jsonReport.has(LEAKS)) {
36+
var leaks = jsonReport.getJSONArray(LEAKS);
37+
parseLeaksArray(report, leaks, issueBuilder);
38+
}
39+
}
40+
41+
@Override
42+
protected void parseJsonArray(final Report report, final JSONArray jsonReport, final IssueBuilder issueBuilder) {
43+
parseLeaksArray(report, jsonReport, issueBuilder);
44+
}
45+
46+
private void parseLeaksArray(final Report report, final JSONArray array, final IssueBuilder issueBuilder) {
47+
for (int i = 0; i < array.length(); i++) {
48+
var leak = array.getJSONObject(i);
49+
report.add(convertToIssue(leak, issueBuilder));
50+
}
51+
}
52+
53+
private Issue convertToIssue(final JSONObject leak, final IssueBuilder issueBuilder) {
54+
var ruleId = firstNonBlank(leak, RULE_ID, RULE);
55+
var description = leak.optString(DESCRIPTION, "");
56+
var fileName = firstNonBlank(leak, FILE);
57+
58+
issueBuilder.setType(StringUtils.defaultIfBlank(ruleId, "-"));
59+
issueBuilder.setMessage(StringUtils.defaultIfBlank(description, ruleId));
60+
issueBuilder.setFileName(fileName);
61+
62+
if (leak.has(START_LINE) || leak.has(END_LINE)) {
63+
issueBuilder.setLineStart(leak.optInt(START_LINE)).setLineEnd(leak.optInt(END_LINE));
64+
}
65+
else if (leak.has(LINE)) {
66+
var line = leak.optInt(LINE);
67+
issueBuilder.setLineStart(line).setLineEnd(line);
68+
}
69+
70+
issueBuilder.guessSeverity(leak.optString(SEVERITY, "warning"));
71+
72+
return issueBuilder.buildAndClean();
73+
}
74+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package edu.hm.hafner.analysis.registry;
2+
3+
import edu.hm.hafner.analysis.IssueParser;
4+
import edu.hm.hafner.analysis.Report.IssueType;
5+
import edu.hm.hafner.analysis.parser.GitleaksParser;
6+
7+
/**
8+
* Descriptor for Gitleaks JSON reports.
9+
*
10+
* @author Akash Manna
11+
*/
12+
class GitleaksDescriptor extends ParserDescriptor {
13+
private static final String ID = "gitleaks";
14+
private static final String NAME = "Gitleaks";
15+
16+
GitleaksDescriptor() {
17+
super(ID, NAME);
18+
}
19+
20+
@Override
21+
public IssueType getType() {
22+
return IssueType.VULNERABILITY;
23+
}
24+
25+
@Override
26+
public IssueParser create(final Option... options) {
27+
return new GitleaksParser();
28+
}
29+
30+
@Override
31+
public String getPattern() {
32+
return "**/gitleaks.json";
33+
}
34+
35+
@Override
36+
public String getHelp() {
37+
return "Use commandline <code>gitleaks detect --report-format json</code> to generate JSON output.<br/>"
38+
+ "See <a href='https://github.com/zricethezav/gitleaks'>gitleaks on GitHub</a> for usage details.";
39+
}
40+
41+
@Override
42+
public String getUrl() {
43+
return "https://github.com/zricethezav/gitleaks";
44+
}
45+
}

src/main/java/edu/hm/hafner/analysis/registry/ParserRegistry.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ public class ParserRegistry {
9797
new GendarmeDescriptor(),
9898
new GhsMultiDescriptor(),
9999
new GitGuardianDescriptor(),
100+
new GitleaksDescriptor(),
100101
new GnatDescriptor(),
101102
new GnuFortranDescriptor(),
102103
new GolangCiLintDescriptor(),
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
package edu.hm.hafner.analysis.parser;
2+
3+
import java.nio.file.FileSystems;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
import edu.hm.hafner.analysis.FileReaderFactory;
8+
import edu.hm.hafner.analysis.IssueParser;
9+
import edu.hm.hafner.analysis.ParsingException;
10+
import edu.hm.hafner.analysis.Report;
11+
import edu.hm.hafner.analysis.Severity;
12+
import edu.hm.hafner.analysis.assertions.SoftAssertions;
13+
import edu.hm.hafner.analysis.registry.AbstractParserTest;
14+
import edu.hm.hafner.analysis.registry.ParserRegistry;
15+
16+
import static edu.hm.hafner.analysis.assertions.Assertions.*;
17+
18+
/**
19+
* Tests the class {@link GitleaksParser}.
20+
*
21+
* @author Akash Manna
22+
*/
23+
class GitleaksParserTest extends AbstractParserTest {
24+
GitleaksParserTest() {
25+
super("gitleaks.json");
26+
}
27+
28+
@Override
29+
protected void assertThatIssuesArePresent(final Report report, final SoftAssertions softly) {
30+
assertThat(report).hasSize(3);
31+
32+
softly.assertThat(report.get(0))
33+
.hasFileName("src/main/App.java")
34+
.hasLineStart(12)
35+
.hasLineEnd(12)
36+
.hasType("G101")
37+
.hasMessage("Potential API key")
38+
.hasSeverity(Severity.WARNING_HIGH);
39+
40+
softly.assertThat(report.get(1))
41+
.hasFileName("config/passwords.yml")
42+
.hasLineStart(3)
43+
.hasLineEnd(4)
44+
.hasType("G102")
45+
.hasMessage("Hardcoded credentials")
46+
.hasSeverity(Severity.WARNING_NORMAL);
47+
48+
softly.assertThat(report.get(2))
49+
.hasFileName("-")
50+
.hasLineStart(0)
51+
.hasLineEnd(0)
52+
.hasType("G103")
53+
.hasMessage("Missing file and line")
54+
.hasSeverity(Severity.WARNING_NORMAL);
55+
}
56+
57+
@Override
58+
protected IssueParser createParser() {
59+
return new GitleaksParser();
60+
}
61+
62+
@Test
63+
void accepts() {
64+
assertThat(new GitleaksParser().accepts(
65+
new FileReaderFactory(FileSystems.getDefault().getPath("gitleaks.json")))).isTrue();
66+
assertThat(new GitleaksParser().accepts(
67+
new FileReaderFactory(FileSystems.getDefault().getPath("foo.txt")))).isFalse();
68+
}
69+
70+
@Test
71+
void brokenInput() {
72+
assertThatThrownBy(() -> parse("eclipse.txt"))
73+
.isInstanceOf(ParsingException.class);
74+
}
75+
76+
@Test
77+
void emptyInput() throws ParsingException {
78+
var report = parse("gitleaks-no-issues.json");
79+
80+
assertThat(report).isEmpty();
81+
}
82+
83+
@Test
84+
void shouldProvideDescriptorMetadata() {
85+
var descriptor = new ParserRegistry().get("gitleaks");
86+
87+
assertThat(descriptor.getPattern()).isEqualTo("**/gitleaks.json");
88+
assertThat(descriptor.getHelp()).contains("gitleaks detect --report-format json");
89+
assertThat(descriptor.getUrl()).isEqualTo("https://github.com/zricethezav/gitleaks");
90+
assertThat(descriptor.hasHelp()).isTrue();
91+
assertThat(descriptor.hasUrl()).isTrue();
92+
}
93+
94+
@Test
95+
void shouldVerifyDescriptorType() {
96+
var descriptor = new ParserRegistry().get("gitleaks");
97+
98+
assertThat(descriptor.getType()).isEqualTo(Report.IssueType.VULNERABILITY);
99+
}
100+
101+
@Test
102+
void shouldHandleMissingFileAndLineInfo() {
103+
var report = parseStringContent("""
104+
{
105+
"leaks": [
106+
{
107+
"rule_id": "G104",
108+
"description": "No location info"
109+
}
110+
],
111+
"errors": []
112+
}
113+
""");
114+
115+
assertThat(report).hasSize(1);
116+
assertThat(report.get(0))
117+
.hasType("G104")
118+
.hasMessage("No location info")
119+
.hasFileName("-")
120+
.hasLineStart(0)
121+
.hasLineEnd(0)
122+
.hasSeverity(Severity.WARNING_NORMAL);
123+
}
124+
125+
@Test
126+
void shouldHandleMissingRuleId() {
127+
var report = parseStringContent("""
128+
{
129+
"leaks": [
130+
{
131+
"description": "Missing rule ID",
132+
"file": "test.txt",
133+
"start_line": 5,
134+
"end_line": 5,
135+
"severity": "HIGH"
136+
}
137+
],
138+
"errors": []
139+
}
140+
""");
141+
142+
assertThat(report).hasSize(1);
143+
assertThat(report.get(0))
144+
.hasType("-")
145+
.hasMessage("Missing rule ID")
146+
.hasFileName("test.txt")
147+
.hasLineStart(5)
148+
.hasLineEnd(5)
149+
.hasSeverity(Severity.WARNING_HIGH);
150+
}
151+
152+
@Test
153+
void shouldHandleSingleLineNumber() {
154+
var report = parseStringContent("""
155+
{
156+
"leaks": [
157+
{
158+
"rule_id": "G105",
159+
"description": "Single line number",
160+
"file": "config.txt",
161+
"line": 42,
162+
"severity": "MEDIUM"
163+
}
164+
],
165+
"errors": []
166+
}
167+
""");
168+
169+
assertThat(report).hasSize(1);
170+
assertThat(report.get(0))
171+
.hasType("G105")
172+
.hasMessage("Single line number")
173+
.hasFileName("config.txt")
174+
.hasLineStart(42)
175+
.hasLineEnd(42)
176+
.hasSeverity(Severity.WARNING_NORMAL);
177+
}
178+
179+
@Test
180+
void shouldMapSeverities() {
181+
var report = parseStringContent("""
182+
{
183+
"leaks": [
184+
{
185+
"rule_id": "HIGH_SEV",
186+
"description": "High severity",
187+
"file": "file1.txt",
188+
"line": 1,
189+
"severity": "CRITICAL"
190+
},
191+
{
192+
"rule_id": "MEDIUM_SEV",
193+
"description": "Medium severity",
194+
"file": "file2.txt",
195+
"line": 2,
196+
"severity": "MEDIUM"
197+
},
198+
{
199+
"rule_id": "LOW_SEV",
200+
"description": "Low severity",
201+
"file": "file3.txt",
202+
"line": 3,
203+
"severity": "LOW"
204+
}
205+
],
206+
"errors": []
207+
}
208+
""");
209+
210+
assertThat(report).hasSize(3);
211+
assertThat(report.get(0)).hasSeverity(Severity.ERROR);
212+
assertThat(report.get(1)).hasSeverity(Severity.WARNING_NORMAL);
213+
assertThat(report.get(2)).hasSeverity(Severity.WARNING_LOW);
214+
}
215+
216+
@Test
217+
void shouldParseArrayFormat() {
218+
var report = parseStringContent("""
219+
[
220+
{
221+
"rule_id": "G106",
222+
"description": "Array format leak",
223+
"file": "src/main.java",
224+
"start_line": 10,
225+
"end_line": 10,
226+
"severity": "HIGH"
227+
},
228+
{
229+
"rule_id": "G107",
230+
"description": "Another leak",
231+
"file": "src/config.java",
232+
"line": 20
233+
}
234+
]
235+
""");
236+
237+
assertThat(report).hasSize(2);
238+
assertThat(report.get(0))
239+
.hasType("G106")
240+
.hasMessage("Array format leak")
241+
.hasFileName("src/main.java")
242+
.hasLineStart(10)
243+
.hasLineEnd(10);
244+
assertThat(report.get(1))
245+
.hasType("G107")
246+
.hasMessage("Another leak")
247+
.hasFileName("src/config.java")
248+
.hasLineStart(20)
249+
.hasLineEnd(20);
250+
}
251+
252+
@Test
253+
void shouldHandleJsonObjectWithoutLeaksKey() {
254+
var report = parseStringContent("""
255+
{
256+
"errors": [],
257+
"metadata": {}
258+
}
259+
""");
260+
261+
assertThat(report).isEmpty();
262+
}
263+
}

0 commit comments

Comments
 (0)