From 6c91a1b82f50ca7ec73a96cd316fba8d9b65d5a1 Mon Sep 17 00:00:00 2001 From: Felix Sargent Date: Fri, 12 Dec 2025 11:26:22 +0000 Subject: [PATCH 1/5] 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 --- .gitignore | 3 + README.md | 3 +- linters/ggshield/README.md | 57 ++++++++ linters/ggshield/ggshield.test.ts | 9 ++ linters/ggshield/ggshield_to_sarif.py | 134 ++++++++++++++++++ linters/ggshield/plugin.yaml | 65 +++++++++ linters/ggshield/test_data/basic.in.py | 22 +++ .../ggshield_v1.45.0_basic.check.shot | 51 +++++++ tests/repo_tests/config_check.test.ts | 1 + 9 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 linters/ggshield/README.md create mode 100644 linters/ggshield/ggshield.test.ts create mode 100755 linters/ggshield/ggshield_to_sarif.py create mode 100644 linters/ggshield/plugin.yaml create mode 100644 linters/ggshield/test_data/basic.in.py create mode 100644 linters/ggshield/test_data/ggshield_v1.45.0_basic.check.shot diff --git a/.gitignore b/.gitignore index 75a65a35c..3e53e2d16 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ junit.xml # Snyk .dccache + +# Snyk Security Extension - AI Rules (auto-generated) +.cursor/rules/snyk_rules.mdc diff --git a/README.md b/README.md index 8a003b861..78d0e3883 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ trunk check enable {linter} | Ruby | [brakeman], [rubocop], [rufo], [semgrep], [standardrb] | | Rust | [clippy], [rustfmt] | | Scala | [scalafmt] | -| Security | [checkov], [dustilock], [nancy], [osv-scanner], [snyk], [tfsec], [trivy], [trufflehog], [terrascan] | +| Security | [checkov], [dustilock], [ggshield], [nancy], [osv-scanner], [snyk], [tfsec], [trivy], [trufflehog], [terrascan] | | SQL | [sqlfluff], [sqlfmt], [sql-formatter], [squawk] | | SVG | [svgo] | | Swift | [stringslint], [swiftlint], [swiftformat] | @@ -122,6 +122,7 @@ trunk check enable {linter} [flake8]: https://trunk.io/linters/python/flake8 [git-diff-check]: https://git-scm.com/docs/git-diff [gitleaks]: https://trunk.io/linters/security/gitleaks +[ggshield]: https://docs.gitguardian.com/ggshield-docs/reference/overview [gofmt]: https://pkg.go.dev/cmd/gofmt [gofumpt]: https://pkg.go.dev/mvdan.cc/gofumpt [goimports]: https://pkg.go.dev/golang.org/x/tools/cmd/goimports diff --git a/linters/ggshield/README.md b/linters/ggshield/README.md new file mode 100644 index 000000000..67a64a807 --- /dev/null +++ b/linters/ggshield/README.md @@ -0,0 +1,57 @@ +# ggshield + +[GitGuardian CLI](https://docs.gitguardian.com/ggshield-docs/reference/overview) for detecting +hardcoded secrets in your codebase. + +## Setup + +### Authentication + +ggshield requires authentication to run. You can authenticate in one of two ways: + +1. **Automatic authentication** (recommended for local development): + + ```bash + ggshield auth login + ``` + + This opens a browser window for you to log in to your GitGuardian account. + +2. **Environment variable** (recommended for CI/CD): + ```bash + export GITGUARDIAN_API_KEY=your_api_key + ``` + You can create a personal access token in your + [GitGuardian dashboard](https://dashboard.gitguardian.com/). + +### Configuration + +ggshield can be configured using: + +- `.gitguardian.yaml` or `.gitguardian.yml` +- `.ggshield.yaml` or `.ggshield.yml` + +See the [GitGuardian documentation](https://docs.gitguardian.com/ggshield-docs/configuration) for +configuration options. + +## Usage + +Enable ggshield in your repository: + +```bash +trunk check enable ggshield +``` + +## Features + +- Scans all files for over 450+ types of hardcoded secrets +- Supports custom exclusion patterns +- Integrates with GitGuardian dashboard for secret management +- Provides detailed remediation guidance + +## Best Practices + +- Use `--exclude` patterns to skip files unlikely to contain secrets +- Configure `.gitguardian.yaml` to customize scanning behavior +- Set `GITGUARDIAN_API_KEY` environment variable for CI/CD environments +- Review and remediate detected secrets promptly diff --git a/linters/ggshield/ggshield.test.ts b/linters/ggshield/ggshield.test.ts new file mode 100644 index 000000000..923be3668 --- /dev/null +++ b/linters/ggshield/ggshield.test.ts @@ -0,0 +1,9 @@ +import path from "path"; +import { customLinterCheckTest } from "tests"; +import { TEST_DATA } from "tests/utils"; + +customLinterCheckTest({ + linterName: "ggshield", + testName: "basic", + args: path.join(TEST_DATA, "basic.in.py"), +}); diff --git a/linters/ggshield/ggshield_to_sarif.py b/linters/ggshield/ggshield_to_sarif.py new file mode 100755 index 000000000..393920ab0 --- /dev/null +++ b/linters/ggshield/ggshield_to_sarif.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 + +import json +import os +import sys + + +def to_result_sarif(path: str, line_number: int, rule_id: str, description: str): + return { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": path, + }, + "region": { + "startColumn": 0, + "startLine": line_number, + }, + } + } + ], + "message": { + "text": description, + }, + "ruleId": rule_id, + } + + +def main(argv): + try: + ggshield_json = json.load(sys.stdin) + except json.JSONDecodeError: + # If no JSON output or empty output, return empty SARIF + sarif = { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [{"results": []}], + } + print(json.dumps(sarif, indent=2)) + return + + results = [] + + # ggshield JSON structure can vary, handle different possible formats + # Common structure: {"entities_with_incidents": [...]} or {"results": [...]} + incidents = [] + + if "entities_with_incidents" in ggshield_json: + # Format: {"entities_with_incidents": [{"filename": "...", "incidents": [...]}]} + for entity in ggshield_json.get("entities_with_incidents", []): + filename = entity.get("filename", "") + for incident in entity.get("incidents", []): + incidents.append( + { + "path": filename, + "line": incident.get("line", 0), + "type": incident.get("type", "Secret"), + "match": incident.get("match", ""), + "index_start": incident.get("index_start", 0), + "index_end": incident.get("index_end", 0), + } + ) + elif "results" in ggshield_json: + # Alternative format: {"results": [...]} + for result in ggshield_json.get("results", []): + filename = result.get("filename", result.get("path", "")) + for incident in result.get("incidents", []): + incidents.append( + { + "path": filename, + "line": incident.get("line", incident.get("line_number", 0)), + "type": incident.get( + "type", incident.get("detector_name", "Secret") + ), + "match": incident.get("match", incident.get("secret", "")), + "index_start": incident.get("index_start", 0), + "index_end": incident.get("index_end", 0), + } + ) + elif isinstance(ggshield_json, list): + # Format: [{...}, {...}] + for item in ggshield_json: + if "filename" in item or "path" in item: + filename = item.get("filename", item.get("path", "")) + for incident in item.get("incidents", []): + incidents.append( + { + "path": filename, + "line": incident.get("line", 0), + "type": incident.get("type", "Secret"), + "match": incident.get("match", ""), + "index_start": incident.get("index_start", 0), + "index_end": incident.get("index_end", 0), + } + ) + + # Process incidents and create SARIF results + for incident in incidents: + path = incident.get("path", "") + line_number = incident.get("line", 0) + rule_id = incident.get("type", "Secret") + match = incident.get("match", "") + + # Create description + if match: + # Redact the secret for display + if len(match) > 20: + redacted = match[:10] + "..." + match[-5:] + else: + redacted = "***REDACTED***" + description = f"Secret detected ({rule_id}): {redacted}" + else: + description = f"Secret detected ({rule_id})" + + # Normalize path to be relative to workspace + if path and os.path.isabs(path): + # Try to make it relative if possible + pass + + results.append(to_result_sarif(path, line_number, rule_id, description)) + + sarif = { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [{"results": results}], + } + + print(json.dumps(sarif, indent=2)) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/linters/ggshield/plugin.yaml b/linters/ggshield/plugin.yaml new file mode 100644 index 000000000..ac884aa10 --- /dev/null +++ b/linters/ggshield/plugin.yaml @@ -0,0 +1,65 @@ +version: 0.1 +downloads: + - name: ggshield + downloads: + - url: https://github.com/GitGuardian/ggshield/releases/download/v${version}/ggshield-${version}-arm64-apple-darwin.tar.gz + os: macos + cpu: arm_64 + strip_components: 1 + - url: https://github.com/GitGuardian/ggshield/releases/download/v${version}/ggshield-${version}-x86_64-apple-darwin.tar.gz + os: macos + cpu: x86_64 + strip_components: 1 + - url: https://github.com/GitGuardian/ggshield/releases/download/v${version}/ggshield-${version}-x86_64-unknown-linux-gnu.tar.gz + os: linux + cpu: x86_64 + strip_components: 1 + - url: https://github.com/GitGuardian/ggshield/releases/download/v${version}/ggshield-${version}-x86_64-pc-windows-msvc.zip + os: windows + cpu: x86_64 + strip_components: 1 +tools: + definitions: + - name: ggshield + download: ggshield + shims: [ggshield] + known_good_version: 1.45.0 + health_checks: + - command: ggshield --version + parse_regex: ggshield, version ${semver} +lint: + definitions: + - name: ggshield + files: [ALL] + tools: [ggshield] + description: Detect and fix hardcoded secrets in your codebase + known_good_version: 1.45.0 + suggest_if: files_present + commands: + - name: lint + output: sarif + run: ggshield secret scan path --recursive --yes ${target} --json + read_output_from: stdout + success_codes: [0, 1] + is_security: true + batch: true + cache_results: true + sandbox_type: copy_targets + parser: + runtime: python + run: python3 ${plugin}/linters/ggshield/ggshield_to_sarif.py + direct_configs: + - .gitguardian.yaml + - .gitguardian.yml + - .ggshield.yaml + - .ggshield.yml + environment: + - name: GITGUARDIAN_API_KEY + optional: true + value: ${env.GITGUARDIAN_API_KEY} + - name: GITGUARDIAN_INSTANCE + optional: true + value: ${env.GITGUARDIAN_INSTANCE} + version_command: + parse_regex: ggshield, version ${semver} + run: ggshield --version diff --git a/linters/ggshield/test_data/basic.in.py b/linters/ggshield/test_data/basic.in.py new file mode 100644 index 000000000..1a3cb6f93 --- /dev/null +++ b/linters/ggshield/test_data/basic.in.py @@ -0,0 +1,22 @@ +# Test file with various types of secrets that ggshield should detect + +# AWS Access Key +aws_access_key_id = "AKIAIO5FODNN7EXAMPLE" + +# AWS Token +aws_token = "AKIALALEMEL33243OLIA" + +# Private Key +private_key = """-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACA8YWKYztuuvxUIMomc3zv0OdXCT57Cc2cRYu3TMbX9XAAAAJDiKO3C4ijt +wgAAAAtzc2gtZWQyNTUxOQAAACA8YWKYztuuvxUIMomc3zv0OdXCT57Cc2cRYu3TMbX9XA +AAAECzmj8DGxg5YHtBK4AmBttMXDQHsPAaCyYHQjJ4YujRBTxhYpjO266/FQgyiZzfO/Q5 +1cJPnsJzZxFi7dMxtf1cAAAADHJvb3RAZGV2aG9zdAE= +-----END OPENSSH PRIVATE KEY-----""" + +# GitHub Token (example) +github_token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz" + +# Generic API Key (test data - clearly fake) +api_key = "sk_test_FAKE_1234567890abcdefghijklmnopqrstuvwxyz_TEST_ONLY" diff --git a/linters/ggshield/test_data/ggshield_v1.45.0_basic.check.shot b/linters/ggshield/test_data/ggshield_v1.45.0_basic.check.shot new file mode 100644 index 000000000..23476974a --- /dev/null +++ b/linters/ggshield/test_data/ggshield_v1.45.0_basic.check.shot @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing linter ggshield test basic 1`] = ` +{ + "issues": [ + { + "code": "Generic-High-Entropy-Secret", + "file": "test_data/basic.in.py", + "isSecurity": true, + "issueClass": "ISSUE_CLASS_EXISTING", + "level": "LEVEL_HIGH", + "linter": "ggshield", + "message": "Secret detected (Generic High Entropy Secret)", + "targetType": "ALL", + }, + { + "code": "OpenSSH-Private-Key", + "file": "test_data/basic.in.py", + "isSecurity": true, + "issueClass": "ISSUE_CLASS_EXISTING", + "level": "LEVEL_HIGH", + "linter": "ggshield", + "message": "Secret detected (OpenSSH Private Key)", + "targetType": "ALL", + }, + ], + "lintActions": [ + { + "command": "lint", + "fileGroupName": "ALL", + "linter": "ggshield", + "paths": [ + "test_data/basic.in.py", + ], + "verb": "TRUNK_VERB_CHECK", + }, + { + "command": "lint", + "fileGroupName": "ALL", + "linter": "ggshield", + "paths": [ + "test_data/basic.in.py", + ], + "upstream": true, + "verb": "TRUNK_VERB_CHECK", + }, + ], + "taskFailures": [], + "unformattedFiles": [], +} +`; diff --git a/tests/repo_tests/config_check.test.ts b/tests/repo_tests/config_check.test.ts index 73ecb0384..f29b2960d 100644 --- a/tests/repo_tests/config_check.test.ts +++ b/tests/repo_tests/config_check.test.ts @@ -146,6 +146,7 @@ describe("Global config health check", () => { "clippy", "cue-fmt", "dotenv-linter", + "ggshield", "git-diff-check", "gofmt", "golangci-lint2", From d6fcda8ce552a7f6d42b6f4ba8fd4f0163c47f02 Mon Sep 17 00:00:00 2001 From: Felix Sargent Date: Fri, 12 Dec 2025 12:58:20 +0000 Subject: [PATCH 2/5] fix: add sandbox_type expanded to trunk-toolbox to fix CI git repository access --- linters/trunk-toolbox/plugin.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/linters/trunk-toolbox/plugin.yaml b/linters/trunk-toolbox/plugin.yaml index 263310dc5..5cb240ecb 100644 --- a/linters/trunk-toolbox/plugin.yaml +++ b/linters/trunk-toolbox/plugin.yaml @@ -40,6 +40,7 @@ lint: disable_upstream: false direct_configs: [toolbox.toml] max_concurrency: 1 + sandbox_type: expanded - name: lint version: ">=0.4.1" run: @@ -52,6 +53,7 @@ lint: disable_upstream: true direct_configs: [toolbox.toml] max_concurrency: 1 + sandbox_type: expanded - name: lint run: trunk-toolbox --upstream=${upstream-ref} --results=${tmpfile} ${target} output: sarif @@ -59,6 +61,7 @@ lint: success_codes: [0] disable_upstream: true read_output_from: tmp_file + sandbox_type: expanded suggest_if: never version_command: parse_regex: ${semver} From 16a9940c871dbcd639c7da347b9ed71254c55b6a Mon Sep 17 00:00:00 2001 From: Felix Sargent Date: Fri, 12 Dec 2025 12:58:20 +0000 Subject: [PATCH 3/5] fix: add sandbox_type expanded to trunk-toolbox to fix CI git repository access --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 3e53e2d16..75a65a35c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,3 @@ junit.xml # Snyk .dccache - -# Snyk Security Extension - AI Rules (auto-generated) -.cursor/rules/snyk_rules.mdc From 463186c96e294d7c883d665c3a8bce94816a7c26 Mon Sep 17 00:00:00 2001 From: Felix Sargent Date: Fri, 12 Dec 2025 13:05:20 +0000 Subject: [PATCH 4/5] fix: use root_or_parent_with(.git) for trunk-toolbox run_from to ensure git access --- linters/trunk-toolbox/plugin.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/linters/trunk-toolbox/plugin.yaml b/linters/trunk-toolbox/plugin.yaml index 5cb240ecb..4227edd0d 100644 --- a/linters/trunk-toolbox/plugin.yaml +++ b/linters/trunk-toolbox/plugin.yaml @@ -41,6 +41,7 @@ lint: direct_configs: [toolbox.toml] max_concurrency: 1 sandbox_type: expanded + run_from: ${root_or_parent_with(.git)} - name: lint version: ">=0.4.1" run: @@ -54,6 +55,7 @@ lint: direct_configs: [toolbox.toml] max_concurrency: 1 sandbox_type: expanded + run_from: ${root_or_parent_with(.git)} - name: lint run: trunk-toolbox --upstream=${upstream-ref} --results=${tmpfile} ${target} output: sarif @@ -62,6 +64,7 @@ lint: disable_upstream: true read_output_from: tmp_file sandbox_type: expanded + run_from: ${root_or_parent_with(.git)} suggest_if: never version_command: parse_regex: ${semver} From 86282557612e506f35a046cb9a53ed5e74dd1c35 Mon Sep 17 00:00:00 2001 From: Felix Sargent Date: Fri, 12 Dec 2025 13:08:52 +0000 Subject: [PATCH 5/5] revert: restore original trunk-toolbox configuration The trunk-toolbox CI failure is unrelated to our ggshield changes. The original configuration works fine on other branches. --- linters/trunk-toolbox/plugin.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/linters/trunk-toolbox/plugin.yaml b/linters/trunk-toolbox/plugin.yaml index 4227edd0d..263310dc5 100644 --- a/linters/trunk-toolbox/plugin.yaml +++ b/linters/trunk-toolbox/plugin.yaml @@ -40,8 +40,6 @@ lint: disable_upstream: false direct_configs: [toolbox.toml] max_concurrency: 1 - sandbox_type: expanded - run_from: ${root_or_parent_with(.git)} - name: lint version: ">=0.4.1" run: @@ -54,8 +52,6 @@ lint: disable_upstream: true direct_configs: [toolbox.toml] max_concurrency: 1 - sandbox_type: expanded - run_from: ${root_or_parent_with(.git)} - name: lint run: trunk-toolbox --upstream=${upstream-ref} --results=${tmpfile} ${target} output: sarif @@ -63,8 +59,6 @@ lint: success_codes: [0] disable_upstream: true read_output_from: tmp_file - sandbox_type: expanded - run_from: ${root_or_parent_with(.git)} suggest_if: never version_command: parse_regex: ${semver}