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",