Skip to content

Commit ac8d5f4

Browse files
committed
feat: add ggshield linter for secret detection
- Add ggshield plugin for GitGuardian CLI secret scanning - Supports standalone executable downloads for macOS, Linux, and Windows - Includes SARIF parser for Trunk integration - Adds test suite with snapshot validation - Requires GITGUARDIAN_API_KEY for authentication
1 parent 1052a5b commit ac8d5f4

File tree

9 files changed

+344
-1
lines changed

9 files changed

+344
-1
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ junit.xml
1111

1212
# Snyk
1313
.dccache
14+
15+
# Snyk Security Extension - AI Rules (auto-generated)
16+
.cursor/rules/snyk_rules.mdc

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ trunk check enable {linter}
8181
| Ruby | [brakeman], [rubocop], [rufo], [semgrep], [standardrb] |
8282
| Rust | [clippy], [rustfmt] |
8383
| Scala | [scalafmt] |
84-
| Security | [checkov], [dustilock], [nancy], [osv-scanner], [snyk], [tfsec], [trivy], [trufflehog], [terrascan] |
84+
| Security | [checkov], [dustilock], [ggshield], [nancy], [osv-scanner], [snyk], [tfsec], [trivy], [trufflehog], [terrascan] |
8585
| SQL | [sqlfluff], [sqlfmt], [sql-formatter], [squawk] |
8686
| SVG | [svgo] |
8787
| Swift | [stringslint], [swiftlint], [swiftformat] |
@@ -122,6 +122,7 @@ trunk check enable {linter}
122122
[flake8]: https://trunk.io/linters/python/flake8
123123
[git-diff-check]: https://git-scm.com/docs/git-diff
124124
[gitleaks]: https://trunk.io/linters/security/gitleaks
125+
[ggshield]: https://docs.gitguardian.com/ggshield-docs/reference/overview
125126
[gofmt]: https://pkg.go.dev/cmd/gofmt
126127
[gofumpt]: https://pkg.go.dev/mvdan.cc/gofumpt
127128
[goimports]: https://pkg.go.dev/golang.org/x/tools/cmd/goimports

linters/ggshield/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# ggshield
2+
3+
[GitGuardian CLI](https://docs.gitguardian.com/ggshield-docs/reference/overview) for detecting
4+
hardcoded secrets in your codebase.
5+
6+
## Setup
7+
8+
### Authentication
9+
10+
ggshield requires authentication to run. You can authenticate in one of two ways:
11+
12+
1. **Automatic authentication** (recommended for local development):
13+
14+
```bash
15+
ggshield auth login
16+
```
17+
18+
This opens a browser window for you to log in to your GitGuardian account.
19+
20+
2. **Environment variable** (recommended for CI/CD):
21+
```bash
22+
export GITGUARDIAN_API_KEY=your_api_key
23+
```
24+
You can create a personal access token in your
25+
[GitGuardian dashboard](https://dashboard.gitguardian.com/).
26+
27+
### Configuration
28+
29+
ggshield can be configured using:
30+
31+
- `.gitguardian.yaml` or `.gitguardian.yml`
32+
- `.ggshield.yaml` or `.ggshield.yml`
33+
34+
See the [GitGuardian documentation](https://docs.gitguardian.com/ggshield-docs/configuration) for
35+
configuration options.
36+
37+
## Usage
38+
39+
Enable ggshield in your repository:
40+
41+
```bash
42+
trunk check enable ggshield
43+
```
44+
45+
## Features
46+
47+
- Scans all files for over 450+ types of hardcoded secrets
48+
- Supports custom exclusion patterns
49+
- Integrates with GitGuardian dashboard for secret management
50+
- Provides detailed remediation guidance
51+
52+
## Best Practices
53+
54+
- Use `--exclude` patterns to skip files unlikely to contain secrets
55+
- Configure `.gitguardian.yaml` to customize scanning behavior
56+
- Set `GITGUARDIAN_API_KEY` environment variable for CI/CD environments
57+
- Review and remediate detected secrets promptly

linters/ggshield/ggshield.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import path from "path";
2+
import { customLinterCheckTest } from "tests";
3+
import { TEST_DATA } from "tests/utils";
4+
5+
customLinterCheckTest({
6+
linterName: "ggshield",
7+
testName: "basic",
8+
args: path.join(TEST_DATA, "basic.in.py"),
9+
});
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import os
5+
import sys
6+
7+
8+
def to_result_sarif(path: str, line_number: int, rule_id: str, description: str):
9+
return {
10+
"level": "error",
11+
"locations": [
12+
{
13+
"physicalLocation": {
14+
"artifactLocation": {
15+
"uri": path,
16+
},
17+
"region": {
18+
"startColumn": 0,
19+
"startLine": line_number,
20+
},
21+
}
22+
}
23+
],
24+
"message": {
25+
"text": description,
26+
},
27+
"ruleId": rule_id,
28+
}
29+
30+
31+
def main(argv):
32+
try:
33+
ggshield_json = json.load(sys.stdin)
34+
except json.JSONDecodeError:
35+
# If no JSON output or empty output, return empty SARIF
36+
sarif = {
37+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
38+
"version": "2.1.0",
39+
"runs": [{"results": []}],
40+
}
41+
print(json.dumps(sarif, indent=2))
42+
return
43+
44+
results = []
45+
46+
# ggshield JSON structure can vary, handle different possible formats
47+
# Common structure: {"entities_with_incidents": [...]} or {"results": [...]}
48+
incidents = []
49+
50+
if "entities_with_incidents" in ggshield_json:
51+
# Format: {"entities_with_incidents": [{"filename": "...", "incidents": [...]}]}
52+
for entity in ggshield_json.get("entities_with_incidents", []):
53+
filename = entity.get("filename", "")
54+
for incident in entity.get("incidents", []):
55+
incidents.append(
56+
{
57+
"path": filename,
58+
"line": incident.get("line", 0),
59+
"type": incident.get("type", "Secret"),
60+
"match": incident.get("match", ""),
61+
"index_start": incident.get("index_start", 0),
62+
"index_end": incident.get("index_end", 0),
63+
}
64+
)
65+
elif "results" in ggshield_json:
66+
# Alternative format: {"results": [...]}
67+
for result in ggshield_json.get("results", []):
68+
filename = result.get("filename", result.get("path", ""))
69+
for incident in result.get("incidents", []):
70+
incidents.append(
71+
{
72+
"path": filename,
73+
"line": incident.get("line", incident.get("line_number", 0)),
74+
"type": incident.get(
75+
"type", incident.get("detector_name", "Secret")
76+
),
77+
"match": incident.get("match", incident.get("secret", "")),
78+
"index_start": incident.get("index_start", 0),
79+
"index_end": incident.get("index_end", 0),
80+
}
81+
)
82+
elif isinstance(ggshield_json, list):
83+
# Format: [{...}, {...}]
84+
for item in ggshield_json:
85+
if "filename" in item or "path" in item:
86+
filename = item.get("filename", item.get("path", ""))
87+
for incident in item.get("incidents", []):
88+
incidents.append(
89+
{
90+
"path": filename,
91+
"line": incident.get("line", 0),
92+
"type": incident.get("type", "Secret"),
93+
"match": incident.get("match", ""),
94+
"index_start": incident.get("index_start", 0),
95+
"index_end": incident.get("index_end", 0),
96+
}
97+
)
98+
99+
# Process incidents and create SARIF results
100+
for incident in incidents:
101+
path = incident.get("path", "")
102+
line_number = incident.get("line", 0)
103+
rule_id = incident.get("type", "Secret")
104+
match = incident.get("match", "")
105+
106+
# Create description
107+
if match:
108+
# Redact the secret for display
109+
if len(match) > 20:
110+
redacted = match[:10] + "..." + match[-5:]
111+
else:
112+
redacted = "***REDACTED***"
113+
description = f"Secret detected ({rule_id}): {redacted}"
114+
else:
115+
description = f"Secret detected ({rule_id})"
116+
117+
# Normalize path to be relative to workspace
118+
if path and os.path.isabs(path):
119+
# Try to make it relative if possible
120+
pass
121+
122+
results.append(to_result_sarif(path, line_number, rule_id, description))
123+
124+
sarif = {
125+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
126+
"version": "2.1.0",
127+
"runs": [{"results": results}],
128+
}
129+
130+
print(json.dumps(sarif, indent=2))
131+
132+
133+
if __name__ == "__main__":
134+
main(sys.argv)

linters/ggshield/plugin.yaml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
version: 0.1
2+
downloads:
3+
- name: ggshield
4+
downloads:
5+
- url: https://github.com/GitGuardian/ggshield/releases/download/v${version}/ggshield-${version}-arm64-apple-darwin.tar.gz
6+
os: macos
7+
cpu: arm_64
8+
strip_components: 1
9+
- url: https://github.com/GitGuardian/ggshield/releases/download/v${version}/ggshield-${version}-x86_64-apple-darwin.tar.gz
10+
os: macos
11+
cpu: x86_64
12+
strip_components: 1
13+
- url: https://github.com/GitGuardian/ggshield/releases/download/v${version}/ggshield-${version}-x86_64-unknown-linux-gnu.tar.gz
14+
os: linux
15+
cpu: x86_64
16+
strip_components: 1
17+
- url: https://github.com/GitGuardian/ggshield/releases/download/v${version}/ggshield-${version}-x86_64-pc-windows-msvc.zip
18+
os: windows
19+
cpu: x86_64
20+
strip_components: 1
21+
tools:
22+
definitions:
23+
- name: ggshield
24+
download: ggshield
25+
shims: [ggshield]
26+
known_good_version: 1.45.0
27+
health_checks:
28+
- command: ggshield --version
29+
parse_regex: ggshield, version ${semver}
30+
lint:
31+
definitions:
32+
- name: ggshield
33+
files: [ALL]
34+
tools: [ggshield]
35+
description: Detect and fix hardcoded secrets in your codebase
36+
known_good_version: 1.45.0
37+
suggest_if: files_present
38+
commands:
39+
- name: lint
40+
output: sarif
41+
run: ggshield secret scan path --recursive --yes ${target} --json
42+
read_output_from: stdout
43+
success_codes: [0, 1]
44+
is_security: true
45+
batch: true
46+
cache_results: true
47+
sandbox_type: copy_targets
48+
parser:
49+
runtime: python
50+
run: python3 ${plugin}/linters/ggshield/ggshield_to_sarif.py
51+
direct_configs:
52+
- .gitguardian.yaml
53+
- .gitguardian.yml
54+
- .ggshield.yaml
55+
- .ggshield.yml
56+
environment:
57+
- name: GITGUARDIAN_API_KEY
58+
optional: true
59+
value: ${env.GITGUARDIAN_API_KEY}
60+
- name: GITGUARDIAN_INSTANCE
61+
optional: true
62+
value: ${env.GITGUARDIAN_INSTANCE}
63+
version_command:
64+
parse_regex: ggshield, version ${semver}
65+
run: ggshield --version
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Test file with various types of secrets that ggshield should detect
2+
3+
# AWS Access Key
4+
aws_access_key_id = "AKIAIO5FODNN7EXAMPLE"
5+
6+
# AWS Token
7+
aws_token = "AKIALALEMEL33243OLIA"
8+
9+
# Private Key
10+
private_key = """-----BEGIN OPENSSH PRIVATE KEY-----
11+
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
12+
QyNTUxOQAAACA8YWKYztuuvxUIMomc3zv0OdXCT57Cc2cRYu3TMbX9XAAAAJDiKO3C4ijt
13+
wgAAAAtzc2gtZWQyNTUxOQAAACA8YWKYztuuvxUIMomc3zv0OdXCT57Cc2cRYu3TMbX9XA
14+
AAAECzmj8DGxg5YHtBK4AmBttMXDQHsPAaCyYHQjJ4YujRBTxhYpjO266/FQgyiZzfO/Q5
15+
1cJPnsJzZxFi7dMxtf1cAAAADHJvb3RAZGV2aG9zdAE=
16+
-----END OPENSSH PRIVATE KEY-----"""
17+
18+
# GitHub Token (example)
19+
github_token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz"
20+
21+
# Generic API Key (test data - clearly fake)
22+
api_key = "sk_test_FAKE_1234567890abcdefghijklmnopqrstuvwxyz_TEST_ONLY"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`Testing linter ggshield test basic 1`] = `
4+
{
5+
"issues": [
6+
{
7+
"code": "Generic-High-Entropy-Secret",
8+
"file": "test_data/basic.py",
9+
"isSecurity": true,
10+
"issueClass": "ISSUE_CLASS_EXISTING",
11+
"level": "LEVEL_HIGH",
12+
"linter": "ggshield",
13+
"message": "Secret detected (Generic High Entropy Secret)",
14+
"targetType": "ALL",
15+
},
16+
{
17+
"code": "OpenSSH-Private-Key",
18+
"file": "test_data/basic.py",
19+
"isSecurity": true,
20+
"issueClass": "ISSUE_CLASS_EXISTING",
21+
"level": "LEVEL_HIGH",
22+
"linter": "ggshield",
23+
"message": "Secret detected (OpenSSH Private Key)",
24+
"targetType": "ALL",
25+
},
26+
],
27+
"lintActions": [
28+
{
29+
"command": "lint",
30+
"fileGroupName": "ALL",
31+
"linter": "ggshield",
32+
"paths": [
33+
"test_data/basic.py",
34+
],
35+
"verb": "TRUNK_VERB_CHECK",
36+
},
37+
{
38+
"command": "lint",
39+
"fileGroupName": "ALL",
40+
"linter": "ggshield",
41+
"paths": [
42+
"test_data/basic.py",
43+
],
44+
"upstream": true,
45+
"verb": "TRUNK_VERB_CHECK",
46+
},
47+
],
48+
"taskFailures": [],
49+
"unformattedFiles": [],
50+
}
51+
`;

tests/repo_tests/config_check.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ describe("Global config health check", () => {
146146
"clippy",
147147
"cue-fmt",
148148
"dotenv-linter",
149+
"ggshield",
149150
"git-diff-check",
150151
"gofmt",
151152
"golangci-lint2",

0 commit comments

Comments
 (0)