diff --git a/.github/workflows/gosec.yml b/.github/workflows/gosec.yml index 93b41768..16c87a87 100644 --- a/.github/workflows/gosec.yml +++ b/.github/workflows/gosec.yml @@ -30,6 +30,8 @@ jobs: FOUND=$(jq '.Stats.found // 0' results.json) echo "Gosec found $FOUND issue(s)" if [ "$FOUND" -gt 0 ]; then + jq -r '.Issues[] | + "\n[\(.rule_id)] \(.severity) severity / \(.confidence) confidence\n\(.file):\(.line):\(.column)\n\(.details)\n\(.code)"' results.json echo "❌ Security issues detected! Please fix them before merging." exit 1 else diff --git a/docs/guides/gitlab.md b/docs/guides/gitlab.md index a3f63217..5b0d2724 100644 --- a/docs/guides/gitlab.md +++ b/docs/guides/gitlab.md @@ -183,14 +183,14 @@ statuses), and `--pipeline-source schedule` to select pipelines by source is unchanged. Job-status values are validated by GitLab. ```bash -$ pipeleek gl scan --token glpat-[redacted] --url https://gitlab.example.com -c [gitlab session cookie]] -v -a -j 5 --confidence high-verified,high +$ pipeleek gl scan --token glpat-[redacted] --url https://gitlab.example.com -c [gitlab session cookie]] -v -a -j 5 --confidence verified,high 2024-09-26T13:47:09+02:00 debug Verbose log output enabled 2024-09-26T13:47:10+02:00 info Gitlab Version Check revision=2e166256199 version=17.5.0-pre 2024-09-26T13:47:10+02:00 debug Setting up queue on disk 2024-09-26T13:47:10+02:00 debug Using DB file file=file:///tmp/pipeleek-queue-db-60689531:?_journal=WAL&_timeout=5000&_fk=true -2024-09-26T13:47:10+02:00 debug Loading rules.yml from filesystem -2024-09-26T13:47:10+02:00 debug Applying confidence filter filter=high-verified,high -2024-09-26T13:47:10+02:00 debug Loaded filtered rules count=882 +2024-09-26T13:47:10+02:00 debug Loaded embedded and local rules count=15 +2024-09-26T13:47:10+02:00 debug Loaded TruffleHog rules count=858 +2024-09-26T13:47:10+02:00 debug Applying confidence filter filter=["verified","high"] 2024-09-26T13:47:10+02:00 info Fetching projects 2024-09-26T13:47:10+02:00 info Provided GitLab session cookie is valid 2024-09-26T13:47:15+02:00 debug Fetch Project jobs for url=https://gitlab.example.com/legendaryleo/WebRTC_Source diff --git a/docs/guides/scanning.md b/docs/guides/scanning.md index 5f214c4e..00320974 100644 --- a/docs/guides/scanning.md +++ b/docs/guides/scanning.md @@ -29,7 +29,9 @@ pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --cookie [redacted] - 2025-09-30T09:53:37Z hit SECRET confidence=high type=artifact file=an_artifact.txt jobName=artifact-job ruleName="Generic - 1719" url=gitlab.com/testgroup/project/-/jobs/11484162833 value="datadog_api_key=secret_artifact_value " ``` -As shown, Pipeleek can detect secrets in job logs and build artifacts using its YAML patterns, Betterleaks rules, and TruffleHog detectors. Security findings are logged at the custom `hit` level to distinguish them from regular warnings. Manually review the hits to verify if they're valid credentials. If you see `confidence=high-verified`, TruffleHog or Betterleaks confirmed the credential with its provider; enabling `--secrets-verification` can make outbound validation requests. +Findings use the `hit` log level. The sample output above uses additional YAML rules; your findings depend on the rules and credentials present. Review non-verified hits manually, or use `--confidence=verified` to show only successfully verified credentials. See [detection, verification, and rules](../introduction/secrets_verification.md) for reporting behavior and optional rule downloads. + +Job-log (`type=log`) and artifact (`type=archive`) findings link to the job. ### Live Findings Web UI @@ -45,7 +47,7 @@ pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --cookie [redacted] - 2026-10-01T11:50:13Z debug Searching for config file in standard locations 2026-10-01T11:50:13Z info Loaded config file file=/home/user/.config/pipeleek/pipeleek.yaml 2026-10-01T11:50:13Z info Findings web UI started token=[redacted] url=http://127.0.0.1:42957/?token=[redacted] -2026-10-01T11:50:13Z debug Loading rules.yml from filesystem +2026-10-01T11:50:13Z debug Loaded embedded and local rules count=15 ``` The `Findings web UI started` message appears near the start of the scan. Use the URL from your own run; its port and token are generated dynamically. diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 89c0e144..aa2acbda 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -72,14 +72,23 @@ common: threads: 2 secrets_verification: true max_artifact_size: 100Mb - confidence_filter: medium # low, medium, high, high-verified + confidence_filter: [verified, high] # [] includes all; also medium, low, trufflehog, custom labels hit_timeout: 120 # Seconds webui: false # Serve a local authenticated findings UI during scans ``` -`secrets_verification` replaces the deprecated `trufflehog_verification` key. The old key (and `PIPELEEK_COMMON_TRUFFLEHOG_VERIFICATION`) is still read, with a warning, when the new key is not set. +### Optional Rules Download -Override per-command: +Settings for [`pipeleek rules download`](secrets_verification.md#optional-external-rules-download): + +```yaml +rules: + download: + output: rules.yml # --output; PIPELEEK_RULES_DOWNLOAD_OUTPUT + force: false # --force; PIPELEEK_RULES_DOWNLOAD_FORCE +``` + +### Per-command Overrides ```yaml gitlab: diff --git a/docs/introduction/secrets_verification.md b/docs/introduction/secrets_verification.md index 96877ec6..5434cd5c 100644 --- a/docs/introduction/secrets_verification.md +++ b/docs/introduction/secrets_verification.md @@ -1,98 +1,113 @@ --- -title: Secret Verification with TruffleHog -description: Learn how Pipeleek uses TruffleHog to automatically verify detected secrets, understand confidence levels, and how to disable verification for operational security. +title: Secret Detection, Verification, and Rules +description: Understand Pipeleek's detection engines, confidence levels, custom rules, and optional rule downloads. keywords: - secret verification - TruffleHog - credential validation - confidence levels - - high-verified + - verified - secret detection - credential testing - opsec --- -Pipeleek uses [TruffleHog](https://github.com/trufflesecurity/trufflehog) and [Betterleaks](https://github.com/betterleaks/betterleaks) to detect secrets in CI/CD logs and artifacts. When verification is enabled, both engines can check supported credentials against their providers. +# Secret Detection, Verification, and Rules -### How It Works +Pipeleek scans CI/CD logs and artifacts using: -When Pipeleek scans logs or artifacts, it uses three detection sources: +- **YAML rules:** 15 embedded Pipeleek rules plus optional working-directory `rules.yml`. +- **[Betterleaks](https://github.com/betterleaks/betterleaks):** embedded rules with false-positive filtering. +- **[TruffleHog](https://github.com/trufflesecurity/trufflehog):** specialized credential detectors. -1. **Pattern-based detection**: Custom YAML rules from `rules.yml` collected by [Secrets Patterns Database](https://github.com/mazen160/secrets-patterns-db) -2. **Betterleaks rules**: Embedded default rules with keyword, path, capture, and false-positive filtering -3. **TruffleHog detectors**: Specialized detectors with active verification +## Verification -The `--secrets-verification` option controls provider verification for both TruffleHog and Betterleaks. With verification enabled, either engine may make outbound requests to credential providers for supported credentials. Disable verification to scan without those provider checks; findings are still reported, but their confidence depends on the detection source: +Verification is enabled by default. `--secrets-verification` controls provider checks for TruffleHog, Betterleaks, and the GitLab detector. These checks can send detected credentials to provider APIs. -- TruffleHog reports verified hits as `high-verified`. With verification disabled, supported detections are reported as `trufflehog-unverified`. -- Betterleaks reports a validation result of `valid` as `high-verified`. Without verification, all Betterleaks detections are reported at their rule confidence (`high`, `medium`, or `low`); Betterleaks has no separate unverified confidence level. +| Source | Verification enabled | Verification disabled | +| ------ | -------------------- | --------------------- | +| TruffleHog | Only successfully verified findings (`verified`) | Returned candidates (`trufflehog`) | +| Betterleaks | Valid credentials use `verified`; invalid/revoked results are suppressed; other retained findings use rule confidence | Rule confidence (`high`, `medium`, or `low`) | +| YAML rules | Rule confidence; no provider checks | Unchanged | -### Confidence Levels +A verification error does not prove a credential is invalid. TruffleHog findings with verification errors are suppressed when verification is enabled. Disabling verification can expose more false positives. -Pipeleek assigns confidence levels to all detected secrets: +Unverified TruffleHog results never receive `high`. The separate Pipeleek GitLab detector and YAML rules can report the same token at `high` based on its pattern. -| Level | Source | Description | Verified | -| ------------------------- | ----------------------- | ------------------------------------------------- | -------- | -| **high-verified** | TruffleHog, Betterleaks | Actively verified and confirmed working | ✅ Yes | -| **trufflehog-unverified** | TruffleHog | Detected but not verified (verification disabled) | ❌ No | -| **high** | rules.yml, Betterleaks | High confidence pattern match | ❌ No | -| **medium** | rules.yml, Betterleaks | Medium confidence pattern match | ❌ No | -| **low** | rules.yml, Betterleaks | Low confidence pattern match | ❌ No | -| **custom** | rules.yml | User-defined confidence level | ❌ No | +To skip provider checks: -### Disabling Verification +```bash +pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --secrets-verification=false +``` + +> **Important:** Use `--secrets-verification=false` with `=`. Writing `--secrets-verification false` enables verification: the flag is interpreted as `true`, and the separate `false` is treated as a positional argument. -For operational security (OpSec) or privacy, disable verification to prevent TruffleHog and Betterleaks from sending credential checks to provider APIs. +## Confidence and Filtering -Use the `--secrets-verification=false` flag: +| Level | Meaning | +| ----- | ------- | +| `verified` | Successfully verified credential | +| `trufflehog` | Non-verified TruffleHog candidate; not a confidence ranking | +| `high`, `medium`, `low` | Pattern confidence, not proof that a credential works | +| User-defined label | Confidence assigned by a custom YAML rule | + +`--confidence` filters results from every engine. An empty filter includes all levels reported under the verification policy above. Filtering does not disable provider checks. ```bash -pipeleek gl scan -u https://gitlab.com -t glpat-xxxxx --secrets-verification=false +# Higher-confidence findings, including verified credentials +pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --confidence=verified,high + +# Only successfully verified credentials +pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --confidence=verified + +# Only non-verified TruffleHog candidates +pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --secrets-verification=false --confidence=trufflehog ``` -### Confidence Filtering +## Custom Rules + +### Optional external rules download -Results can be filtered by confidence, using the `--confidence` flag. +To install the [Secrets Patterns Database](https://github.com/mazen160/secrets-patterns-db) stable ruleset: ```bash -pipeleek gl scan -u https://gitlab.com -t glpat-xxxxx --confidence=high-verified,high +pipeleek rules download ``` -## Custom Rules - -To scan for a specific pattern, edit the `rules.yml` file Pipeleek creates on the first run. You can remove/add/alter rules as you like. +This writes `rules.yml` in the current directory, which subsequent scans load alongside +the embedded rules. External regex rules can substantially increase false positives +and do not verify credentials. Scans never download or update rules automatically. +The download uses Pipeleek's HTTP client and its global proxy, TLS, and timeout settings. -By default the rules look something like this: +Existing files are protected unless you explicitly replace them: -```yaml -patterns: - - pattern: - name: AWS API Gateway - regex: "[0-9a-z]+.execute-api.[0-9a-z._-]+.amazonaws.com" - confidence: low - - pattern: - name: AWS API Key - regex: AKIA[0-9A-Z]{16} - confidence: high +```bash +pipeleek rules download --force +pipeleek rules download --output additional-rules.yml ``` -You can create additional custom rules. +`--output` only changes where the download is saved; scans still load working-directory +`rules.yml`. Downloads are validated for nonempty rule sets, required fields, duplicate +names, and valid Go regexes before saving. Failed downloads or validation leave existing +files unchanged. +Validated rules are written directly to the destination; a filesystem write failure +can leave a partial file, including when replacing a file with `--force`. -> **💡Tip:** Test your regexes at [regex101.com](https://regex101.com/) (select Golang flavor). +### Local customization -A simple example that detects strings that follow the Regex pattern `PIPELEEK_.*` and that are logged with a custom confidence: +Built-in rules include GitLab token patterns and predefined variables such as `GITLAB_USER_ID` and `KUBECONFIG`. Identifier/path matches are indicators to review, not proof of a leaked credential. + +Create `rules.yml` in your working directory to add rules. An exact-name match overrides a built-in in the YAML engine; other names add rules. Overrides do not affect the separate GitLab detector or other engines. Without this file, scans use the embedded defaults. + +For example, to report `PIPELEEK_` markers with a custom confidence: ```yaml patterns: - pattern: name: Pipeleek Custom Rule - regex: PIPELEEK_.* + regex: PIPELEEK_[A-Z0-9_]+ confidence: custom-confidence ``` -When you run Pipeleek, you'll see results for your custom rule and any built-in rules: - -```bash -pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --secrets-verification=false --verbose -2025-09-30T11:39:08Z hit SECRET confidence=custom-confidence type=log jobName=build-job-hidden ruleName="Pipeleek Custom Rule" url=gitlab.com/testgroup/project/-/jobs/11547853360 value="PIPELEEK_HIT=secret" -``` +Each rule needs a nonempty name, valid Go regex, and nonempty confidence label. Duplicate names, invalid YAML/regexes, and unreadable files fail initialization. +Set `token: true` for token patterns to reject matches embedded inside larger tokens. diff --git a/examples/compose-elk/README.md b/examples/compose-elk/README.md index a4be3455..db3e746d 100644 --- a/examples/compose-elk/README.md +++ b/examples/compose-elk/README.md @@ -80,7 +80,7 @@ Once you've run `bash start-elk.sh`, the Kibana data view is automatically confi 3. You will see: - `Pipeleek - High Hits` (count of `scan.confidence: high` hits) -- `Pipeleek - High-Verified Hits` (count of `scan.confidence: high-verified` hits) +- `Pipeleek - Verified Hits` (count of `scan.confidence: verified` hits) - `Pipeleek - Log Intake Over Time` (bar chart over the last 4 hours with fixed 10-minute buckets) - `Pipeleek - Hits by Asset Type` (pie chart, for example hit logs vs hit artifacts) - `Pipeleek - Hits by Confidence` (pie chart of confidence ratings) diff --git a/examples/compose-elk/setup-kibana.sh b/examples/compose-elk/setup-kibana.sh index 130c8d51..b9129edb 100755 --- a/examples/compose-elk/setup-kibana.sh +++ b/examples/compose-elk/setup-kibana.sh @@ -210,12 +210,12 @@ high_metric_payload='{ }' upsert_saved_object "visualization" "pipeleek-vis-high-count" "$high_metric_payload" -echo "Creating metric visualization for high-verified hits..." +echo "Creating metric visualization for verified hits..." high_verified_metric_payload='{ "attributes": { - "title": "Pipeleek - High-Verified Hits", - "description": "Count of hit events with high-verified confidence", - "visState": "{\"title\":\"Pipeleek - High-Verified Hits\",\"type\":\"metrics\",\"params\":{\"id\":\"pipeleek-high-verified-panel\",\"type\":\"metric\",\"series\":[{\"id\":\"pipeleek-high-verified-series\",\"split_mode\":\"everything\",\"split_color_mode\":\"value\",\"metrics\":[{\"id\":\"pipeleek-high-verified-count\",\"type\":\"count\"}],\"label\":\"High-Verified Hits\",\"color\":\"#D4821E\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":\"1\",\"point_size\":\"1\",\"fill\":\"0.5\",\"value_template\":\"{{value}}\",\"color_rules\":[{\"id\":\"pipeleek-high-verified-color-rule\",\"operator\":\"gte\",\"value\":0,\"color\":\"#D4821E\"}],\"background_color_rules\":[{\"id\":\"pipeleek-high-verified-bg-rule\",\"operator\":\"gte\",\"value\":0,\"color\":\"#D4821E\"}],\"gauge_color_rules\":[{\"id\":\"pipeleek-high-verified-gauge-rule\",\"operator\":\"gte\",\"value\":0,\"color\":\"#D4821E\"}]}],\"time_field\":\"@timestamp\",\"index_pattern\":\"pipeleek-logs-*\",\"default_index_pattern\":\"pipeleek-logs-*\",\"default_timefield\":\"@timestamp\",\"filter\":{\"language\":\"kuery\",\"query\":\"level:hit and confidence:\\\"high-verified\\\"\"},\"interval\":\"auto\",\"time_range_mode\":\"entire_time_range\",\"isModelInvalid\":false}}", + "title": "Pipeleek - Verified Hits", + "description": "Count of hit events with verified confidence", + "visState": "{\"title\":\"Pipeleek - Verified Hits\",\"type\":\"metrics\",\"params\":{\"id\":\"pipeleek-high-verified-panel\",\"type\":\"metric\",\"series\":[{\"id\":\"pipeleek-high-verified-series\",\"split_mode\":\"everything\",\"split_color_mode\":\"value\",\"metrics\":[{\"id\":\"pipeleek-high-verified-count\",\"type\":\"count\"}],\"label\":\"Verified Hits\",\"color\":\"#D4821E\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":\"1\",\"point_size\":\"1\",\"fill\":\"0.5\",\"value_template\":\"{{value}}\",\"color_rules\":[{\"id\":\"pipeleek-high-verified-color-rule\",\"operator\":\"gte\",\"value\":0,\"color\":\"#D4821E\"}],\"background_color_rules\":[{\"id\":\"pipeleek-high-verified-bg-rule\",\"operator\":\"gte\",\"value\":0,\"color\":\"#D4821E\"}],\"gauge_color_rules\":[{\"id\":\"pipeleek-high-verified-gauge-rule\",\"operator\":\"gte\",\"value\":0,\"color\":\"#D4821E\"}]}],\"time_field\":\"@timestamp\",\"index_pattern\":\"pipeleek-logs-*\",\"default_index_pattern\":\"pipeleek-logs-*\",\"default_timefield\":\"@timestamp\",\"filter\":{\"language\":\"kuery\",\"query\":\"level:hit and confidence:\\\"verified\\\"\"},\"interval\":\"auto\",\"time_range_mode\":\"entire_time_range\",\"isModelInvalid\":false}}", "uiStateJSON": "{}", "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" @@ -272,7 +272,7 @@ ratings_pie_payload='{ "title": "Pipeleek - Hits by Confidence", "description": "Hit count grouped by confidence rating", "visState": "{\"title\":\"Pipeleek - Hits by Confidence\",\"type\":\"pie\",\"params\":{\"addLegend\":true,\"addTooltip\":true,\"isDonut\":true,\"legendPosition\":\"right\",\"labels\":{\"show\":true,\"values\":true}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"confidence\",\"size\":10,\"order\":\"desc\",\"orderBy\":\"1\",\"missingBucket\":true,\"missingBucketLabel\":\"unknown\"}}]}", - "uiStateJSON": "{\"vis\":{\"colors\":{\"high-verified\":\"#D4821E\",\"high\":\"#2A4A25\",\"medium\":\"#B9CF7E\",\"low\":\"#8B6BA8\",\"warning\":\"#C4A035\",\"informational\":\"#B9CF7E\",\"unknown\":\"#4A8FA8\"}}}", + "uiStateJSON": "{\"vis\":{\"colors\":{\"verified\":\"#D4821E\",\"high\":\"#2A4A25\",\"medium\":\"#B9CF7E\",\"low\":\"#8B6BA8\",\"warning\":\"#C4A035\",\"informational\":\"#B9CF7E\",\"unknown\":\"#4A8FA8\"}}}", "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"level:hit\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" } @@ -293,7 +293,7 @@ confidence_timeseries_payload='{ "title": "Pipeleek - Hit Confidence Over Time", "description": "Hit count over time split by confidence", "visState": "{\"title\":\"Pipeleek - Hit Confidence Over Time\",\"type\":\"histogram\",\"params\":{\"addLegend\":true,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true},\"title\":{\"text\":\"Time\"}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true},\"title\":{\"text\":\"Hit Count\"}}],\"seriesParams\":[{\"show\":true,\"type\":\"line\",\"mode\":\"normal\",\"data\":{\"id\":\"1\",\"label\":\"Hit Count\"},\"valueAxis\":\"ValueAxis-1\",\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"lineWidth\":2}],\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"times\":[],\"addTimeMarker\":false,\"dimensions\":{\"x\":{\"accessor\":\"2\",\"format\":\"date\",\"params\":{}},\"y\":[{\"accessor\":\"1\",\"format\":{\"id\":\"number\"}}]}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"useNormalizedEsInterval\":false,\"min_doc_count\":1,\"extended_bounds\":{},\"interval\":\"10m\",\"drop_partials\":false,\"time_zone\":\"UTC\"}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"confidence\",\"size\":10,\"order\":\"desc\",\"orderBy\":\"1\",\"missingBucket\":true,\"missingBucketLabel\":\"unknown\"}}]}", - "uiStateJSON": "{\"vis\":{\"colors\":{\"high-verified\":\"#D4821E\",\"high\":\"#2A4A25\",\"medium\":\"#B9CF7E\",\"low\":\"#8B6BA8\",\"warning\":\"#C4A035\",\"informational\":\"#B9CF7E\",\"unknown\":\"#4A8FA8\"}}}", + "uiStateJSON": "{\"vis\":{\"colors\":{\"verified\":\"#D4821E\",\"high\":\"#2A4A25\",\"medium\":\"#B9CF7E\",\"low\":\"#8B6BA8\",\"warning\":\"#C4A035\",\"informational\":\"#B9CF7E\",\"unknown\":\"#4A8FA8\"}}}", "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"level:hit\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" } @@ -312,7 +312,7 @@ echo "Creating prebuilt dashboard..." dashboard_payload='{ "attributes": { "title": "Pipeleek - Hit Monitoring", - "description": "High/high-verified counters, log intake over time, confidence trend over time, pie charts for hit distributions, latest 100 scan entries, and latest 100 raw log entries. Use dashboard filters for scan.platform and scan.confidence.", + "description": "High/verified counters, log intake over time, confidence trend over time, pie charts for hit distributions, latest 100 scan entries, and latest 100 raw log entries. Use dashboard filters for scan.platform and scan.confidence.", "hits": 0, "optionsJSON": "{\"useMargins\":true,\"hidePanelTitles\":false}", "panelsJSON": "[{\"type\":\"visualization\",\"panelIndex\":\"1\",\"gridData\":{\"x\":0,\"y\":0,\"w\":12,\"h\":8,\"i\":\"1\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_1\",\"version\":\"8.13.4\"},{\"type\":\"visualization\",\"panelIndex\":\"2\",\"gridData\":{\"x\":12,\"y\":0,\"w\":12,\"h\":8,\"i\":\"2\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_2\",\"version\":\"8.13.4\"},{\"type\":\"visualization\",\"panelIndex\":\"3\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":8,\"i\":\"3\"},\"embeddableConfig\":{\"timeRange\":{\"from\":\"now-4h\",\"to\":\"now\"}},\"panelRefName\":\"panel_3\",\"version\":\"8.13.4\"},{\"type\":\"visualization\",\"panelIndex\":\"4\",\"gridData\":{\"x\":0,\"y\":8,\"w\":24,\"h\":12,\"i\":\"4\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_4\",\"version\":\"8.13.4\"},{\"type\":\"visualization\",\"panelIndex\":\"5\",\"gridData\":{\"x\":24,\"y\":8,\"w\":24,\"h\":12,\"i\":\"5\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_5\",\"version\":\"8.13.4\"},{\"type\":\"visualization\",\"panelIndex\":\"8\",\"gridData\":{\"x\":0,\"y\":20,\"w\":48,\"h\":10,\"i\":\"8\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_8\",\"version\":\"8.13.4\"},{\"type\":\"search\",\"panelIndex\":\"6\",\"gridData\":{\"x\":0,\"y\":30,\"w\":48,\"h\":12,\"i\":\"6\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_6\",\"version\":\"8.13.4\"},{\"type\":\"search\",\"panelIndex\":\"7\",\"gridData\":{\"x\":0,\"y\":42,\"w\":48,\"h\":12,\"i\":\"7\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_7\",\"version\":\"8.13.4\"}]", diff --git a/internal/cmd/gitlab/scan/scan.go b/internal/cmd/gitlab/scan/scan.go index b9cbbe1b..11796488 100644 --- a/internal/cmd/gitlab/scan/scan.go +++ b/internal/cmd/gitlab/scan/scan.go @@ -1,6 +1,7 @@ package scan import ( + "fmt" "strconv" "time" @@ -91,7 +92,8 @@ pipeleek gl scan --token [redacted] --url https://gitlab.example.com --pipeline- # Scan all repositories in a namespace pipeleek gl scan --token glpat-xxxxxxxxxxx --url https://gitlab.example.com --namespace mygroup `, - Run: Scan, + Args: validateScanArgs, + Run: Scan, } flags.AddCommonScanFlags(scanCmd, &options.CommonScanOptions, &maxArtifactSize) @@ -108,6 +110,13 @@ pipeleek gl scan --token glpat-xxxxxxxxxxx --url https://gitlab.example.com --na return scanCmd } +func validateScanArgs(cmd *cobra.Command, args []string) error { + if err := cobra.NoArgs(cmd, args); err != nil { + return fmt.Errorf("%w; boolean flags require '=' for explicit values, e.g. --secrets-verification=false (not --secrets-verification false)", err) + } + return nil +} + func Scan(cmd *cobra.Command, args []string) { config.NewCommandSetup(cmd). WithFlagBindings(flagBindings). diff --git a/internal/cmd/gitlab/scan/scan_test.go b/internal/cmd/gitlab/scan/scan_test.go index c5b7dfe0..8cc5647e 100644 --- a/internal/cmd/gitlab/scan/scan_test.go +++ b/internal/cmd/gitlab/scan/scan_test.go @@ -65,6 +65,18 @@ func TestNewScanCmd(t *testing.T) { } } +func TestScanRejectsSpaceSeparatedBooleanValue(t *testing.T) { + cmd := NewScanCmd() + require.NoError(t, cmd.ParseFlags([]string{"--secrets-verification", "false"})) + err := cmd.Args(cmd, cmd.Flags().Args()) + require.ErrorContains(t, err, "--secrets-verification=false") + + cmd = NewScanCmd() + require.NoError(t, cmd.ParseFlags([]string{"--secrets-verification=false"})) + require.NoError(t, cmd.Args(cmd, cmd.Flags().Args())) + require.Equal(t, "false", cmd.Flags().Lookup("secrets-verification").Value.String()) +} + func TestJobStatusConfig(t *testing.T) { tests := []struct { name string diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 7a1b8091..f3573cbb 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -16,6 +16,7 @@ import ( "github.com/CompassSecurity/pipeleek/internal/cmd/github" "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab" "github.com/CompassSecurity/pipeleek/internal/cmd/jenkins" + "github.com/CompassSecurity/pipeleek/internal/cmd/rules" "github.com/CompassSecurity/pipeleek/pkg/config" "github.com/CompassSecurity/pipeleek/pkg/format" "github.com/CompassSecurity/pipeleek/pkg/httpclient" @@ -90,6 +91,7 @@ func init() { rootCmd.AddCommand(circle.NewCircleRootCmd()) rootCmd.AddCommand(docs.NewDocsCmd(rootCmd)) rootCmd.AddCommand(configcmd.NewConfigRootCmd()) + rootCmd.AddCommand(rules.NewRulesCmd()) rootCmd.PersistentFlags().StringVar(&ConfigFile, "config", "", "Config file path. Example: ~/.config/pipeleek/pipeleek.yaml") rootCmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format") rootCmd.PersistentFlags().StringVarP(&LogFile, "logfile", "l", "", "Log output to a file") diff --git a/internal/cmd/rules/rules.go b/internal/cmd/rules/rules.go new file mode 100644 index 00000000..4af57f36 --- /dev/null +++ b/internal/cmd/rules/rules.go @@ -0,0 +1,50 @@ +package rules + +import ( + "fmt" + "strings" + + "github.com/CompassSecurity/pipeleek/pkg/config" + scannerrules "github.com/CompassSecurity/pipeleek/pkg/scanner/rules" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +func NewRulesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "rules", Short: "Manage optional secret-detection rules", GroupID: "Helper", + } + download := &cobra.Command{ + Use: "download", Short: "Download the optional Secrets Patterns Database stable rules", + Long: "Download additional regex rules. These rules may substantially increase false positives and do not verify credentials.", + Args: cobra.NoArgs, RunE: RunDownload, SilenceUsage: true, + } + download.Flags().String("output", "rules.yml", "Destination file (scans load rules.yml from the working directory)") + download.Flags().Bool("force", false, "Replace an existing destination file") + cmd.AddCommand(download) + return cmd +} + +func RunDownload(cmd *cobra.Command, args []string) error { + config.NewCommandSetup(cmd). + WithFlagBindings(map[string]string{ + "output": "rules.download.output", + "force": "rules.download.force", + }).MustBind() + opts := struct { + output string + force bool + }{ + output: config.GetString("rules.download.output"), + force: config.GetBool("rules.download.force"), + } + if strings.TrimSpace(opts.output) == "" { + return fmt.Errorf("rules download output must not be empty") + } + count, err := scannerrules.Download(cmd.Context(), opts.output, opts.force) + if err != nil { + return err + } + log.Info().Str("source", scannerrules.ExternalRulesURL).Str("output", opts.output).Int("count", count).Msg("Downloaded optional rules") + return nil +} diff --git a/internal/cmd/rules/rules_test.go b/internal/cmd/rules/rules_test.go new file mode 100644 index 00000000..651917be --- /dev/null +++ b/internal/cmd/rules/rules_test.go @@ -0,0 +1,20 @@ +package rules + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRulesCommand(t *testing.T) { + root := NewRulesCmd() + cmd, args, err := root.Find([]string{"download"}) + require.NoError(t, err) + require.Empty(t, args) + require.Equal(t, "download", cmd.Name()) + require.Equal(t, reflect.ValueOf(RunDownload).Pointer(), reflect.ValueOf(cmd.RunE).Pointer()) + require.Equal(t, "rules.yml", cmd.Flags().Lookup("output").DefValue) + require.Equal(t, "false", cmd.Flags().Lookup("force").DefValue) + require.Error(t, cmd.Args(cmd, []string{"unexpected"})) +} diff --git a/pkg/config/gen/gen.go b/pkg/config/gen/gen.go index 06557e0e..4d15c82b 100644 --- a/pkg/config/gen/gen.go +++ b/pkg/config/gen/gen.go @@ -39,7 +39,6 @@ var rootFlagsToSkip = map[string]struct{}{ "ignore-proxy": {}, "help": {}, "version": {}, - "output": {}, } var platformNameByCommand = map[string]string{ @@ -51,6 +50,7 @@ var platformNameByCommand = map[string]string{ "gitea": "gitea", "jenkins": "jenkins", "circle": "circle", + "rules": "rules", } // GenerateExampleConfig builds a YAML template from the currently registered CLI commands and flags. diff --git a/pkg/docs/generator.go b/pkg/docs/generator.go index 02029eab..6414c326 100644 --- a/pkg/docs/generator.go +++ b/pkg/docs/generator.go @@ -170,7 +170,7 @@ func writeMkdocsYaml(rootCmd *cobra.Command, outputDir string, githubPages bool) "Introduction": []map[string]interface{}{ {"Getting Started": prefix + "/introduction/getting_started/"}, {"Logging": prefix + "/introduction/logging/"}, - {"Secrets Verification": prefix + "/introduction/secrets_verification/"}, + {"Secrets Detection": prefix + "/introduction/secrets_verification/"}, {"Proxying": prefix + "/introduction/proxying/"}, {"Configuration": prefix + "/introduction/configuration/"}, }, diff --git a/pkg/docs/generator_test.go b/pkg/docs/generator_test.go index d89e9d6e..4b1fb30c 100644 --- a/pkg/docs/generator_test.go +++ b/pkg/docs/generator_test.go @@ -120,7 +120,8 @@ func TestWriteMkdocsYaml(t *testing.T) { assert.Contains(t, introMap, "Introduction") introItems, ok := introMap["Introduction"].([]interface{}) assert.True(t, ok) - assert.Equal(t, 5, len(introItems)) // Getting Started, Configuration, Logging, Secrets Verification, Proxying + assert.Equal(t, 5, len(introItems)) // Getting Started, Configuration, Logging, Secrets Detection, Proxying + assert.Contains(t, introItems, map[string]interface{}{"Secrets Detection": "/introduction/secrets_verification/"}) // Guides second (was Methodology) guidesMap, ok := navAny[1].(map[string]interface{}) diff --git a/pkg/gitlab/scan/pipeline.go b/pkg/gitlab/scan/pipeline.go index cdc9b1ba..96200312 100644 --- a/pkg/gitlab/scan/pipeline.go +++ b/pkg/gitlab/scan/pipeline.go @@ -242,10 +242,20 @@ func getAllJobs(git *gitlab.Client, project *gitlab.Project, options *ScanOption return } + configPath := project.CIConfigPath + if configPath == "" { + configPath = ".gitlab-ci.yml" + } + configURL := project.WebURL + // GitLab also accepts remote URLs and file@project[:ref] configurations. + if !strings.ContainsAny(configPath, "@:") && !strings.HasPrefix(configPath, "//") { + configURL = strings.TrimRight(project.WebURL, "/") + "/-/blob/HEAD/" + + (&url.URL{Path: strings.TrimLeft(configPath, "/")}).EscapedPath() + } enqueueItem(globQueue, QueueItemCICDYaml, QueueMeta{ ProjectId: int(project.ID), - JobWebUrl: project.WebURL, - JobName: ".gitlab-ci.yml", + JobWebUrl: configURL, + JobName: configPath, ProjectPathWithNamespace: project.PathWithNamespace, }, waitGroup) diff --git a/pkg/gitlab/scan/pipeline_test.go b/pkg/gitlab/scan/pipeline_test.go index dfb93001..46d3e9f2 100644 --- a/pkg/gitlab/scan/pipeline_test.go +++ b/pkg/gitlab/scan/pipeline_test.go @@ -23,6 +23,7 @@ func TestGetAllJobs_StatusScope(t *testing.T) { if authenticated { name = "authenticated" } + if filtered { name += "/filtered" } else { @@ -78,7 +79,10 @@ func TestGetAllJobs_StatusScope(t *testing.T) { globQueue, waitGroup = oldQueue, oldWG }() - getAllJobs(client, &gitlab.Project{ID: 1, PathWithNamespace: "group/project"}, opts) + getAllJobs(client, &gitlab.Project{ + ID: 1, PathWithNamespace: "group/project", + WebURL: "https://gitlab.example/group/project/", + }, opts) assert.Equal(t, int32(2), jobPages.Load(), "job-limit should stop pagination after two matching jobs") wantItems := 4 if authenticated { @@ -98,6 +102,7 @@ func TestGetAllJobs_StatusScope(t *testing.T) { artifacts = append(artifacts, item.Meta.JobId) case QueueItemCICDYaml: yamlItems++ + assert.Equal(t, "https://gitlab.example/group/project/-/blob/HEAD/.gitlab-ci.yml", item.Meta.JobWebUrl) } waitGroup.Done() case <-time.After(2 * time.Second): @@ -112,6 +117,44 @@ func TestGetAllJobs_StatusScope(t *testing.T) { } } +func TestGetAllJobsCICDYamlSource(t *testing.T) { + for _, tc := range []struct { + path, name, wantURL string + }{ + {"", ".gitlab-ci.yml", "https://gitlab.example/group/project/-/blob/HEAD/.gitlab-ci.yml"}, + {"ci/build.yml", "ci/build.yml", "https://gitlab.example/group/project/-/blob/HEAD/ci/build.yml"}, + {"/ci/build.yml", "/ci/build.yml", "https://gitlab.example/group/project/-/blob/HEAD/ci/build.yml"}, + {"ci/build #1.yml", "ci/build #1.yml", "https://gitlab.example/group/project/-/blob/HEAD/ci/build%20%231.yml"}, + {"https://example.test/build.yml", "https://example.test/build.yml", "https://gitlab.example/group/project/"}, + {"ci/build.yml@group/other:main", "ci/build.yml@group/other:main", "https://gitlab.example/group/project/"}, + } { + t.Run(tc.path, func(t *testing.T) { + opts := &ScanOptions{CICDYamlOnly: true, GitlabApiToken: "test-token", QueueFolder: t.TempDir()} + queue, _ := setupQueue(opts) + oldQueue, oldWG := globQueue, waitGroup + globQueue, waitGroup = queue, new(sync.WaitGroup) + defer func() { + assert.NoError(t, queue.Delete()) + globQueue, waitGroup = oldQueue, oldWG + }() + getAllJobs(nil, &gitlab.Project{ + ID: 1, WebURL: "https://gitlab.example/group/project/", CIConfigPath: tc.path, + }, opts) + select { + case data := <-queue.ReadChan(): + var item QueueItem + require.NoError(t, json.Unmarshal(data, &item)) + assert.Equal(t, QueueItemCICDYaml, item.Type) + assert.Equal(t, tc.name, item.Meta.JobName) + assert.Equal(t, tc.wantURL, item.Meta.JobWebUrl) + waitGroup.Done() + case <-time.After(2 * time.Second): + t.Fatal("timed out reading queued CI/CD YAML") + } + }) + } +} + func TestGetAllJobs_PipelineSource(t *testing.T) { for _, authenticated := range []bool{true, false} { for _, limit := range []int{0, 3} { diff --git a/pkg/gitlab/scan/queue.go b/pkg/gitlab/scan/queue.go index 14ac3112..d294de68 100644 --- a/pkg/gitlab/scan/queue.go +++ b/pkg/gitlab/scan/queue.go @@ -240,6 +240,7 @@ func analyzeCICDYaml(git *gitlab.Client, item QueueItem, options *ScanOptions) { result.ReportFindings(logResult.Findings, result.ReportOptions{ LocationURL: item.Meta.JobWebUrl, JobName: item.Meta.JobName, + Type: logging.SecretTypeCICDYaml, }) } diff --git a/pkg/logging/hit.go b/pkg/logging/hit.go index 5cb26538..fb1a3732 100644 --- a/pkg/logging/hit.go +++ b/pkg/logging/hit.go @@ -22,7 +22,8 @@ const ( // NestedArchive indicates a secret found in a nested archive. SecretTypeNestedArchive SecretType = "nested-archive" // SecretTypeDotenv indicates a secret found in a dotenv file. - SecretTypeDotenv SecretType = "dotenv" + SecretTypeDotenv SecretType = "dotenv" + SecretTypeCICDYaml SecretType = "cicd-yaml" // #nosec G101 -- Finding source label, not a credential. ) // HitLevel defines a custom log level for security finding hits. diff --git a/pkg/scan/result/levels_test.go b/pkg/scan/result/levels_test.go new file mode 100644 index 00000000..77eaba8b --- /dev/null +++ b/pkg/scan/result/levels_test.go @@ -0,0 +1,40 @@ +package result + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/CompassSecurity/pipeleek/pkg/logging" + "github.com/CompassSecurity/pipeleek/pkg/scanner" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReportDetectionLevels(t *testing.T) { + var output bytes.Buffer + original, level := log.Logger, zerolog.GlobalLevel() + writer := logging.NewHitLevelWriter(&output) + log.Logger = zerolog.New(writer) + logging.SetGlobalHitWriter(writer) + zerolog.SetGlobalLevel(zerolog.DebugLevel) + t.Cleanup(func() { + log.Logger = original + logging.SetGlobalHitWriter(nil) + zerolog.SetGlobalLevel(level) + }) + for _, confidence := range []string{"verified", "trufflehog"} { + output.Reset() + ReportFinding(scanner.Finding{ + Pattern: scanner.PatternElement{Pattern: scanner.PatternPattern{Name: "fixture", Confidence: confidence}}, + Text: "fixture-value", + Engine: "trufflehog", + }, ReportOptions{}) + var record map[string]interface{} + require.NoError(t, json.Unmarshal(output.Bytes(), &record)) + assert.Equal(t, confidence, record["confidence"]) + assert.Equal(t, "trufflehog", record["engine"]) + } +} diff --git a/pkg/scanner/detectors/gitlab_patterns_test.go b/pkg/scanner/detectors/gitlab_patterns_test.go new file mode 100644 index 00000000..39c6510f --- /dev/null +++ b/pkg/scanner/detectors/gitlab_patterns_test.go @@ -0,0 +1,76 @@ +package detectors + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGitLabSharedPATFormats(t *testing.T) { + detector := NewGitLabURLDetector() + for _, test := range []struct { + name, token, wantName string + }{ + {"v2", "glpat-" + strings.Repeat("a", 20), "Gitlab - Personal Access Token v2"}, + {"v3", "glpat-" + strings.Repeat("a", 27) + ".ab.123456789", "Gitlab - Personal Access Token v3"}, + {"oversized v2", "glpat-" + strings.Repeat("a", 23), ""}, + {"malformed v3", "glpat-" + strings.Repeat("a", 27) + "XabX123456789", ""}, + {"embedded prefix", "prefixglpat-" + strings.Repeat("a", 20), ""}, + } { + t.Run(test.name, func(t *testing.T) { + results, err := detector.FromDataWithURL(context.Background(), false, []byte("TOKEN="+test.token+","), "") + require.NoError(t, err) + if test.wantName == "" { + assert.Empty(t, results) + return + } + + require.Len(t, results, 1) + assert.Equal(t, test.wantName, results[0].DetectorName) + assert.Equal(t, test.token, string(results[0].Raw)) + assert.False(t, results[0].Verified) + }) + } +} + +func TestGitLabColoredTokens(t *testing.T) { + token := "glpat-" + strings.Repeat("a", 20) + detector := NewGitLabURLDetector() + for _, data := range []string{ + "\x1b[32m" + token + "\x1b[0m", + "TOKEN=\x1b[32m" + token + "\x1b[0m", + "\x1b[32m" + token + "\x1b[0m " + token, + } { + results, err := detector.FromDataWithURL(context.Background(), false, []byte(data), "") + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, token, string(results[0].Raw)) + } +} + +func TestGitLabColoredTokenVerification(t *testing.T) { + token := "glpat-" + strings.Repeat("a", 20) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + assert.Equal(t, "/api/v4/user", r.URL.Path) + assert.Equal(t, token, r.Header.Get("Private-Token")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":1}`)) + })) + defer server.Close() + results, err := NewGitLabURLDetector().FromDataWithURL( + context.Background(), true, []byte("\x1b[32m"+token+"\x1b[0m"), server.URL, + ) + require.NoError(t, err) + require.Len(t, results, 1) + assert.True(t, results[0].Verified) + assert.Equal(t, token, string(results[0].Raw)) + assert.Equal(t, int32(1), requests.Load()) +} diff --git a/pkg/scanner/detectors/gitlab_url_detector.go b/pkg/scanner/detectors/gitlab_url_detector.go index 5cc22c72..8ec77324 100644 --- a/pkg/scanner/detectors/gitlab_url_detector.go +++ b/pkg/scanner/detectors/gitlab_url_detector.go @@ -2,10 +2,11 @@ package detectors import ( "context" - "regexp" "sync" "github.com/CompassSecurity/pipeleek/pkg/gitlab/util" + "github.com/CompassSecurity/pipeleek/pkg/scanner/rules" + "github.com/acarl005/stripansi" "github.com/rs/zerolog/log" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" gitlab "gitlab.com/gitlab-org/api/client-go" @@ -20,7 +21,7 @@ var ( type gitlabPattern struct { name string - regex *regexp.Regexp + pattern rules.CompiledPattern strategy verificationStrategy } @@ -56,21 +57,32 @@ type GitLabURLDetector struct { } func NewGitLabURLDetector() *GitLabURLDetector { - patterns := []gitlabPattern{ - {name: "Gitlab - Personal Access Token v2", regex: regexp.MustCompile(`glpat-[a-zA-Z0-9\-=_]{20,22}`), strategy: verifyUserAPI}, - {name: "Gitlab - Personal Access Token v3", regex: regexp.MustCompile(`\b(glpat-[a-zA-Z0-9\-=_]{27,300}.[0-9a-z]{2}.[a-z0-9]{9})\b`), strategy: verifyUserAPI}, - {name: "Gitlab - Pipeline Trigger Token", regex: regexp.MustCompile(`glptt-[a-zA-Z0-9\-=_]{20,}`), strategy: verifyNone}, - {name: "Gitlab - Runner Authentication Token", regex: regexp.MustCompile(`glrt-[a-zA-Z0-9\-=_]{20,}`), strategy: verifyRunnerAPI}, - {name: "Gitlab - Runner Registration Token", regex: regexp.MustCompile(`glrtr-[a-zA-Z0-9\-=_]{20,}`), strategy: verifyNone}, - {name: "Gitlab - Deploy Token", regex: regexp.MustCompile(`gldt-[a-zA-Z0-9\-=_]{20,}`), strategy: verifyNone}, - {name: "Gitlab - CI Build Token", regex: regexp.MustCompile(`glcbt-[a-zA-Z0-9\-=_]{20,}`), strategy: verifyNone}, - {name: "Gitlab - OAuth Application Secret", regex: regexp.MustCompile(`gloas-[a-zA-Z0-9\-=_]{20,}`), strategy: verifyNone}, - {name: "Gitlab - SCIM/OAuth Access Token", regex: regexp.MustCompile(`glsoat-[a-zA-Z0-9\-=_]{20,}`), strategy: verifyUserAPI}, - {name: "Gitlab - Feed Token", regex: regexp.MustCompile(`glft-[a-zA-Z0-9\-=_]{20,}`), strategy: verifyNone}, - {name: "Gitlab - Incoming Mail Token", regex: regexp.MustCompile(`glimt-[a-zA-Z0-9\-=_]{20,}`), strategy: verifyNone}, - {name: "Gitlab - Feature Flags Client Token", regex: regexp.MustCompile(`glffct-[a-zA-Z0-9\-=_]{20,}`), strategy: verifyNone}, - {name: "Gitlab - Agent for Kubernetes Token", regex: regexp.MustCompile(`glagent-[a-zA-Z0-9\-=_]{20,}`), strategy: verifyNone}, - {name: "Gitlab - Runner Token (Legacy)", regex: regexp.MustCompile(`GR1348941[a-zA-Z0-9\-=_]{20,}`), strategy: verifyRunnerAPI}, + strategies := map[string]verificationStrategy{ + "Gitlab - Personal Access Token v2": verifyUserAPI, + "Gitlab - Personal Access Token v3": verifyUserAPI, + "Gitlab - SCIM/OAuth Access Token": verifyUserAPI, + "Gitlab - Runner Authentication Token": verifyRunnerAPI, + "Gitlab - Runner Token (Legacy)": verifyRunnerAPI, + "Gitlab - Pipeline Trigger Token": verifyNone, + "Gitlab - Runner Registration Token": verifyNone, + "Gitlab - Deploy Token": verifyNone, + "Gitlab - CI Build Token": verifyNone, + "Gitlab - OAuth Application Secret": verifyNone, + "Gitlab - Feed Token": verifyNone, + "Gitlab - Incoming Mail Token": verifyNone, + "Gitlab - Feature Flags Client Token": verifyNone, + "Gitlab - Agent for Kubernetes Token": verifyNone, + } + var patterns []gitlabPattern + for _, pattern := range rules.BuiltinPatterns() { + name := pattern.Pattern.Pattern.Name + if strategy, ok := strategies[name]; ok { + patterns = append(patterns, gitlabPattern{name: name, pattern: pattern, strategy: strategy}) + delete(strategies, name) + } + } + if len(strategies) != 0 { + log.Fatal().Msg("Missing built-in GitLab token definitions") } return &GitLabURLDetector{patterns: patterns} @@ -89,6 +101,7 @@ func (d *GitLabURLDetector) FromData(ctx context.Context, verify bool, data []by // FromDataWithURL verifies matches against url instead of the package-global URL. func (d *GitLabURLDetector) FromDataWithURL(ctx context.Context, verify bool, data []byte, url string) ([]detectors.Result, error) { + data = []byte(stripansi.Strip(string(data))) var results []detectors.Result for _, pattern := range d.patterns { @@ -96,13 +109,14 @@ func (d *GitLabURLDetector) FromDataWithURL(ctx context.Context, verify bool, da return results, err } - matches := pattern.regex.FindAll(data, -1) + matches := pattern.pattern.FindAllIndex(data) seenMatches := make(map[string]struct{}, len(matches)) - for _, matchBytes := range matches { + for _, index := range matches { if err := ctx.Err(); err != nil { return results, err } + matchBytes := data[index[0]:index[1]] match := string(matchBytes) if _, seen := seenMatches[match]; seen { continue diff --git a/pkg/scanner/engine/betterleaks.go b/pkg/scanner/engine/betterleaks.go index a0310972..06803e88 100644 --- a/pkg/scanner/engine/betterleaks.go +++ b/pkg/scanner/engine/betterleaks.go @@ -3,14 +3,12 @@ package engine import ( "bytes" "context" - "slices" "strconv" "strings" "sync" "time" "github.com/CompassSecurity/pipeleek/pkg/scanner/detectors" - "github.com/CompassSecurity/pipeleek/pkg/scanner/rules" "github.com/CompassSecurity/pipeleek/pkg/scanner/types" blanalyze "github.com/betterleaks/betterleaks/v2/analyze" blconfig "github.com/betterleaks/betterleaks/v2/config" @@ -161,22 +159,19 @@ func detectBetterleaks(ctx context.Context, content []byte, workers int, verify } findings, err := runtime.scan(ctx, content, options.Path) - return mapBetterleaksFindings(findings, rules.GetConfidenceFilter()), err + return mapBetterleaksFindings(findings), err } -func mapBetterleaksFindings(findings []blreport.Finding, confidenceFilter []string) []types.Finding { +func mapBetterleaksFindings(findings []blreport.Finding) []types.Finding { mapped := make([]types.Finding, 0, len(findings)) for _, finding := range findings { confidence := finding.Confidence switch finding.Analysis.Status { case blreport.ValidationStatusValid: - confidence = "high-verified" + confidence = "verified" case blreport.ValidationStatusInvalid, blreport.ValidationStatusRevoked: continue } - if len(confidenceFilter) > 0 && !slices.Contains(confidenceFilter, confidence) { - continue - } value := finding.Match.Value if value == "" { value = finding.Match.Full diff --git a/pkg/scanner/engine/engine.go b/pkg/scanner/engine/engine.go index 1b44974b..88182544 100644 --- a/pkg/scanner/engine/engine.go +++ b/pkg/scanner/engine/engine.go @@ -3,7 +3,6 @@ package engine import ( "context" "errors" - "regexp" "slices" "strings" "sync" @@ -15,7 +14,7 @@ import ( "github.com/acarl005/stripansi" "github.com/rs/zerolog/log" "github.com/rxwycdh/rxhash" - "github.com/trufflesecurity/trufflehog/v3/pkg/engine/defaults" + trdetectors "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" "github.com/wandb/parallel" ) @@ -66,38 +65,8 @@ func detectHits(ctx context.Context, text []byte, maxThreads int, enableSecretsV if options.GitLabURL == "" { options.GitLabURL = detectors.GetGitLabURL() } - group := parallel.Collect[[]types.Finding](parallel.Limited(ctx, maxThreads)) - - secretsPatterns := rules.GetSecretsPatterns() - - for _, pattern := range secretsPatterns.Patterns { - group.Go(func(ctx context.Context) ([]types.Finding, error) { - findingsYml := []types.Finding{} - m, err := regexp.Compile(pattern.Pattern.Regex) - if err != nil { - log.Trace().Err(err).Str("name", pattern.Pattern.Name).Str("regex", pattern.Pattern.Regex).Msg("Failed compiling regex expression") - return findingsYml, nil - } - - hits := m.FindAllIndex(text, -1) - - for _, hit := range hits { - hitStr := extractHitWithSurroundingText(text, hit, 50) - hitStr = cleanHitLine(hitStr) - if len(hitStr) > 1024 { - hitStr = hitStr[0:1024] - } - - if hitStr != "" { - findingsYml = append(findingsYml, types.Finding{Pattern: pattern, Text: hitStr, Engine: EngineRules}) - } - } - - return findingsYml, nil - }) - } - - resultsYml, err := group.Wait() + confidenceFilter := rules.GetConfidenceFilter() + findingsCombined, err := detectYAML(ctx, text, maxThreads, rules.GetCompiledPatterns(), confidenceFilter) if err != nil && ctx.Err() == nil { log.Error().Stack().Err(err).Msg("Failed waiting for parallel hit detection") } @@ -105,8 +74,6 @@ func detectHits(ctx context.Context, text []byte, maxThreads int, enableSecretsV return types.DetectionResult{Error: err} } - findingsCombined := slices.Concat(resultsYml...) - findingsBetterleaks, err := detectBetterleaks(ctx, text, maxThreads, enableSecretsVerification, options) if err != nil && ctx.Err() == nil { log.Warn().Err(err).Msg("Betterleaks detection failed") @@ -134,7 +101,7 @@ func detectHits(ctx context.Context, text []byte, maxThreads int, enableSecretsV } confidence := "high" if result.Verified { - confidence = "high-verified" + confidence = "verified" } finding := types.Finding{Pattern: types.PatternElement{Pattern: types.PatternPattern{Name: result.DetectorName, Confidence: confidence}}, Text: string(secret), Engine: EngineGitLab} findingsTr = append(findingsTr, finding) @@ -142,32 +109,9 @@ func detectHits(ctx context.Context, text []byte, maxThreads int, enableSecretsV return findingsTr, nil }) - for _, detector := range defaults.DefaultDetectors() { + for _, detector := range rules.GetTruffleHogRules() { trGroup.Go(func(ctx context.Context) ([]types.Finding, error) { - findingsTr := []types.Finding{} - trHits, err := detector.FromData(ctx, enableSecretsVerification, text) - if err != nil { - log.Error().Msg("Truffelhog Detector Failed " + err.Error()) - return []types.Finding{}, err - } - - for _, result := range trHits { - secret := result.Raw - if len(result.RawV2) > 0 { - secret = result.RawV2 - } - finding := types.Finding{Pattern: types.PatternElement{Pattern: types.PatternPattern{Name: result.DetectorType.String(), Confidence: "high-verified"}}, Text: string(secret), Engine: EngineTruffleHog} - - if result.Verified { - findingsTr = append(findingsTr, finding) - } - - if !enableSecretsVerification { - finding.Pattern.Pattern.Confidence = "trufflehog-unverified" - findingsTr = append(findingsTr, finding) - } - } - return findingsTr, nil + return detectTruffleHog(ctx, detector, enableSecretsVerification, text), nil }) } @@ -181,7 +125,70 @@ func detectHits(ctx context.Context, text []byte, maxThreads int, enableSecretsV findingsTr := slices.Concat(resultsTr...) totalFindings := slices.Concat(findingsCombined, findingsBetterleaks, findingsTr) - return types.DetectionResult{Findings: deduplicateFindings(totalFindings), Error: nil} + return types.DetectionResult{Findings: deduplicateFindings(filterFindings(totalFindings, confidenceFilter)), Error: nil} +} + +func detectYAML(ctx context.Context, text []byte, maxThreads int, patterns []rules.CompiledPattern, confidenceFilter []string) ([]types.Finding, error) { + text = []byte(stripansi.Strip(string(text))) + group := parallel.Collect[[]types.Finding](parallel.Limited(ctx, maxThreads)) + for _, pattern := range patterns { + if len(confidenceFilter) > 0 && !slices.Contains(confidenceFilter, pattern.Pattern.Pattern.Confidence) { + continue + } + group.Go(func(ctx context.Context) ([]types.Finding, error) { + findings := []types.Finding{} + for _, hit := range pattern.FindAllIndex(text) { + hitStr := cleanHitLine(extractHitWithSurroundingText(text, hit, 50)) + if len(hitStr) > 1024 { + hitStr = hitStr[:1024] + } + if hitStr != "" { + findings = append(findings, types.Finding{Pattern: pattern.Pattern, Text: hitStr, Engine: EngineRules}) + } + } + return findings, nil + }) + } + results, err := group.Wait() + return slices.Concat(results...), err +} + +func detectTruffleHog(ctx context.Context, detector trdetectors.Detector, verify bool, text []byte) []types.Finding { + results, err := detector.FromData(ctx, verify, text) + if err != nil && ctx.Err() == nil { + // Detector errors may contain credentials; log the type, not the raw error. + log.Error().Str("detector", detector.Type().String()).Msg("TruffleHog detector failed") + } + findings := make([]types.Finding, 0, len(results)) + for _, result := range results { + if verify && !result.Verified { + continue + } + secret := result.Raw + if len(result.RawV2) > 0 { + secret = result.RawV2 + } + confidence := "trufflehog" + if result.Verified { + confidence = "verified" + } + findings = append(findings, types.Finding{ + Pattern: types.PatternElement{Pattern: types.PatternPattern{ + Name: result.DetectorType.String(), Confidence: confidence, + }}, + Text: string(secret), Engine: EngineTruffleHog, + }) + } + return findings +} + +func filterFindings(findings []types.Finding, filter []string) []types.Finding { + if len(filter) == 0 { + return findings + } + return slices.DeleteFunc(findings, func(finding types.Finding) bool { + return !slices.Contains(filter, finding.Pattern.Pattern.Confidence) + }) } func deduplicateFindings(totalFindings []types.Finding) []types.Finding { diff --git a/pkg/scanner/engine/engine_test.go b/pkg/scanner/engine/engine_test.go index c4dbc0b6..49c0fee9 100644 --- a/pkg/scanner/engine/engine_test.go +++ b/pkg/scanner/engine/engine_test.go @@ -508,7 +508,7 @@ func TestDetectBetterleaks_ValidatesGitLabOnPublicAndSelfHosted(t *testing.T) { if found == nil { t.Fatalf("validated Betterleaks GitLab PAT finding not found: %v", findings) } - if found.Text != token || found.Pattern.Pattern.Confidence != "high-verified" { + if found.Text != token || found.Pattern.Pattern.Confidence != "verified" { t.Fatalf("unexpected validated finding: %+v", *found) } }) @@ -598,18 +598,18 @@ func TestMapBetterleaksFindings(t *testing.T) { {RuleID: "revoked-token", Confidence: "high", Match: blreport.Match{Value: "revoked-value"}, Analysis: blreport.Analysis{Status: blreport.ValidationStatusRevoked}}, } - mapped := mapBetterleaksFindings(findings, nil) + mapped := mapBetterleaksFindings(findings) if len(mapped) != 2 { t.Fatalf("mapped %d findings, want 2", len(mapped)) } - if mapped[0].Pattern.Pattern.Name != "betterleaks/valid-token" || mapped[0].Text != "secret-value" || mapped[0].Pattern.Pattern.Confidence != "high-verified" || mapped[0].Engine != EngineBetterleaks { + if mapped[0].Pattern.Pattern.Name != "betterleaks/valid-token" || mapped[0].Text != "secret-value" || mapped[0].Pattern.Pattern.Confidence != "verified" || mapped[0].Engine != EngineBetterleaks { t.Fatalf("unexpected validated finding mapping: %+v", mapped[0]) } if mapped[1].Pattern.Pattern.Name != "betterleaks/unresolved-token" || mapped[1].Text != "other-value" || mapped[1].Pattern.Pattern.Confidence != "low" { t.Fatalf("unexpected unresolved finding mapping: %+v", mapped[1]) } - verifiedOnly := mapBetterleaksFindings(findings, []string{"high-verified"}) + verifiedOnly := filterFindings(mapBetterleaksFindings(findings), []string{"verified"}) if len(verifiedOnly) != 1 || verifiedOnly[0].Pattern.Pattern.Name != "betterleaks/valid-token" { t.Fatalf("confidence filtering returned unexpected findings: %+v", verifiedOnly) } diff --git a/pkg/scanner/engine/reporting_test.go b/pkg/scanner/engine/reporting_test.go new file mode 100644 index 00000000..c20076fd --- /dev/null +++ b/pkg/scanner/engine/reporting_test.go @@ -0,0 +1,200 @@ +package engine + +import ( + "bytes" + "context" + "errors" + "slices" + "strings" + "testing" + "time" + + "github.com/CompassSecurity/pipeleek/pkg/scanner/rules" + "github.com/CompassSecurity/pipeleek/pkg/scanner/types" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + trdetectors "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb" +) + +type fixtureDetector struct { + results []trdetectors.Result + err error + verify bool +} + +func (d *fixtureDetector) FromData(_ context.Context, verify bool, _ []byte) ([]trdetectors.Result, error) { + d.verify = verify + return d.results, d.err +} + +func (*fixtureDetector) Keywords() []string { return []string{"fixture"} } +func (*fixtureDetector) Type() detector_typepb.DetectorType { + return detector_typepb.DetectorType_Gitlab +} +func (*fixtureDetector) Description() string { return "Test fixture" } + +func TestTruffleHogReportingRespectsVerification(t *testing.T) { + inconclusive := trdetectors.Result{DetectorType: detector_typepb.DetectorType_Gitlab, Raw: []byte("inconclusive")} + inconclusive.SetVerificationError(errors.New("verification failed")) + for _, verify := range []bool{true, false} { + detector := &fixtureDetector{results: []trdetectors.Result{ + {DetectorType: detector_typepb.DetectorType_Gitlab, Raw: []byte("identifier"), RawV2: []byte("complete-credential"), Verified: true}, + {DetectorType: detector_typepb.DetectorType_Gitlab, Raw: []byte("candidate")}, + inconclusive, + }} + findings := detectTruffleHog(context.Background(), detector, verify, []byte("fixture")) + expected := 3 + if verify { + expected = 1 + } + require.Len(t, findings, expected) + assert.Equal(t, verify, detector.verify) + assert.Equal(t, "complete-credential", findings[0].Text) + assert.Equal(t, "verified", findings[0].Pattern.Pattern.Confidence) + if !verify { + assert.Equal(t, "candidate", findings[1].Text) + assert.Equal(t, "trufflehog", findings[1].Pattern.Pattern.Confidence) + assert.Equal(t, "trufflehog", findings[2].Pattern.Pattern.Confidence) + } + for _, finding := range findings { + assert.Equal(t, EngineTruffleHog, finding.Engine) + assert.Equal(t, "Gitlab", finding.Pattern.Pattern.Name) + } + } +} + +func TestTruffleHogPreservesPartialResultsWithoutLeakingErrors(t *testing.T) { + var output bytes.Buffer + original := log.Logger + log.Logger = zerolog.New(&output) + t.Cleanup(func() { log.Logger = original }) + result := trdetectors.Result{DetectorType: detector_typepb.DetectorType_Gitlab, Raw: []byte("candidate")} + result.SetVerificationError(errors.New("verification failed for sensitive-fixture"), "sensitive-fixture") + detector := &fixtureDetector{ + results: []trdetectors.Result{ + result, + {DetectorType: detector_typepb.DetectorType_Gitlab, Raw: []byte("verified-candidate"), Verified: true}, + }, + err: errors.New("detector error containing sensitive-fixture"), + } + findings := detectTruffleHog(context.Background(), detector, true, nil) + require.Len(t, findings, 1) + assert.Equal(t, "verified", findings[0].Pattern.Pattern.Confidence) + assert.Equal(t, "verified-candidate", findings[0].Text) + assert.Contains(t, output.String(), "TruffleHog detector failed") + assert.NotContains(t, output.String(), "verification was inconclusive") + assert.NotContains(t, output.String(), "sensitive-fixture") + + output.Reset() + detector.err = nil + detector.results = []trdetectors.Result{result} + findings = detectTruffleHog(context.Background(), detector, true, nil) + assert.Empty(t, findings) + assert.Empty(t, output.String(), "Verification errors alone must not emit diagnostic logs") + + findings = detectTruffleHog(context.Background(), detector, false, nil) + require.Len(t, findings, 1) + assert.Equal(t, "trufflehog", findings[0].Pattern.Pattern.Confidence) + assert.Empty(t, output.String(), "Verification errors alone must not emit diagnostic logs") +} + +func TestConfidenceFilteringAcrossEngines(t *testing.T) { + var findings []types.Finding + for _, engine := range []string{EngineRules, EngineBetterleaks, EngineGitLab, EngineTruffleHog} { + for _, confidence := range []string{"verified", "trufflehog", "high", "medium", "low", "custom-confidence"} { + findings = append(findings, types.Finding{ + Engine: engine, + Pattern: types.PatternElement{Pattern: types.PatternPattern{Confidence: confidence}}, + }) + } + } + for _, filter := range [][]string{nil, {"verified"}, {"trufflehog"}, {"high", "medium"}, {"custom-confidence"}, {"nonexistent"}} { + filtered := filterFindings(slices.Clone(findings), filter) + expected := 24 + if len(filter) > 0 { + expected = 4 * len(filter) + } + if slices.Contains(filter, "nonexistent") { + expected = 0 + } + require.Len(t, filtered, expected) + for _, finding := range filtered { + if len(filter) > 0 { + assert.Contains(t, filter, finding.Pattern.Pattern.Confidence) + } + } + } +} + +func TestDetectHitsConfidenceSelectionBeforeDeduplication(t *testing.T) { + t.Chdir(t.TempDir()) + t.Cleanup(func() { rules.InitRules(nil) }) + deduplicationMutex.Lock() + previous := findingsDeduplicationList + findingsDeduplicationList = nil + deduplicationMutex.Unlock() + t.Cleanup(func() { + deduplicationMutex.Lock() + findingsDeduplicationList = previous + deduplicationMutex.Unlock() + }) + + rules.InitRules([]string{"verified"}) + findings, err := DetectHits([]byte("GITLAB_USER_ID=19357"), 1, false, time.Minute) + require.NoError(t, err) + assert.Empty(t, findings) + + rules.InitRules([]string{"medium"}) + findings, err = DetectHits([]byte("GITLAB_USER_ID=19357"), 1, false, time.Minute) + require.NoError(t, err) + require.Len(t, findings, 1, "An excluded result must not enter the deduplication state") + assert.Equal(t, EngineRules, findings[0].Engine) + assert.Equal(t, "medium", findings[0].Pattern.Pattern.Confidence) +} + +func TestYAMLConfidenceFilterSkipsExcludedPatterns(t *testing.T) { + // A nil compiled regex would panic if an excluded pattern were evaluated. + excluded := rules.CompiledPattern{ + Pattern: types.PatternElement{Pattern: types.PatternPattern{ + Name: "Excluded", Confidence: "excluded-confidence", + }}, + } + patterns := append(rules.BuiltinPatterns(), excluded) + findings, err := detectYAML(context.Background(), []byte("GITLAB_USER_ID=19357"), 1, patterns, []string{"verified"}) + require.NoError(t, err) + assert.Empty(t, findings) + + findings, err = detectYAML(context.Background(), []byte("GITLAB_USER_ID=19357"), 1, patterns, []string{"medium"}) + require.NoError(t, err) + require.Len(t, findings, 1) + assert.Equal(t, "medium", findings[0].Pattern.Pattern.Confidence) + assert.Equal(t, EngineRules, findings[0].Engine) +} + +func TestYAMLConfidenceFilterIncludesAllWhenEmpty(t *testing.T) { + findings, err := detectYAML(context.Background(), []byte("GITLAB_USER_ID=19357"), 1, rules.BuiltinPatterns(), nil) + require.NoError(t, err) + require.Len(t, findings, 1) + assert.Equal(t, "medium", findings[0].Pattern.Pattern.Confidence) +} + +func TestYAMLColoredTokens(t *testing.T) { + token := "glpat-" + strings.Repeat("a", 20) + for _, text := range []string{ + "\x1b[32m" + token + "\x1b[0m", + "TOKEN=\x1b[32m" + token + "\x1b[0m", + "\x1b[32m" + token + "\x1b[0m " + token, + } { + findings, err := detectYAML(context.Background(), []byte(text), 1, rules.BuiltinPatterns(), []string{"high"}) + require.NoError(t, err) + require.Len(t, findings, strings.Count(text, token)) + for _, finding := range findings { + assert.Equal(t, "Gitlab - Personal Access Token v2", finding.Pattern.Pattern.Name) + assert.Contains(t, finding.Text, token) + assert.NotContains(t, finding.Text, "\x1b") + } + } +} diff --git a/pkg/scanner/rules/download.go b/pkg/scanner/rules/download.go new file mode 100644 index 00000000..d5d84fd4 --- /dev/null +++ b/pkg/scanner/rules/download.go @@ -0,0 +1,69 @@ +package rules + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + + "github.com/CompassSecurity/pipeleek/pkg/httpclient" +) + +const ExternalRulesURL = "https://raw.githubusercontent.com/mazen160/secrets-patterns-db/master/db/rules-stable.yml" + +func Download(ctx context.Context, output string, force bool) (int, error) { + client := httpclient.GetPipeleekStandardHTTPClient() + defer client.CloseIdleConnections() + return download(ctx, client, ExternalRulesURL, output, force) +} + +func download(ctx context.Context, client *http.Client, source, output string, force bool) (int, error) { + if !force { + if _, err := os.Lstat(output); err == nil { + return 0, fmt.Errorf("%s already exists; use --force to replace it", output) + } else if !os.IsNotExist(err) { + return 0, fmt.Errorf("checking output: %w", err) + } + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil) + if err != nil { + return 0, fmt.Errorf("creating rules request: %w", err) + } + response, err := client.Do(request) + if err != nil { + return 0, fmt.Errorf("downloading rules: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return 0, fmt.Errorf("downloading rules: unexpected HTTP status %d", response.StatusCode) + } + data, err := io.ReadAll(response.Body) + if err != nil { + return 0, fmt.Errorf("reading downloaded rules: %w", err) + } + patterns, err := parseRules(data, source) + if err != nil { + return 0, err + } + if len(patterns) == 0 { + return 0, fmt.Errorf("downloaded rules contain no patterns") + } + flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL + if force { + flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC + } + // #nosec G304 -- output is the explicit destination selected by the user through CLI/config. + file, err := os.OpenFile(output, flags, 0600) + if err != nil { + return 0, fmt.Errorf("opening rules output %s: %w", output, err) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + return 0, fmt.Errorf("writing rules: %w", err) + } + if err := file.Close(); err != nil { + return 0, fmt.Errorf("closing rules: %w", err) + } + return len(patterns), nil +} diff --git a/pkg/scanner/rules/download_test.go b/pkg/scanner/rules/download_test.go new file mode 100644 index 00000000..a3b39a2f --- /dev/null +++ b/pkg/scanner/rules/download_test.go @@ -0,0 +1,97 @@ +package rules + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const downloadFixture = "patterns:\n - pattern:\n name: Fixture\n regex: FIXTURE_[A-Z]+\n confidence: high\n" + +func TestDownload(t *testing.T) { + for _, tc := range []struct { + name string + body string + status int + existing bool + force bool + wantError bool + }{ + {name: "success", body: downloadFixture, status: 200}, + {name: "larger than former limit", body: "# " + strings.Repeat(" ", 16<<20) + "\n" + downloadFixture, status: 200}, + {name: "existing", body: downloadFixture, status: 200, existing: true, wantError: true}, + {name: "force", body: downloadFixture, status: 200, existing: true, force: true}, + {name: "http error", status: 404, wantError: true}, + {name: "http error preserves existing", status: 404, existing: true, force: true, wantError: true}, + {name: "yaml error", body: "[", status: 200, wantError: true}, + {name: "empty", body: "patterns: []", status: 200, wantError: true}, + {name: "invalid regex", body: "patterns: [{pattern: {name: test, regex: '[', confidence: high}}]", status: 200, existing: true, force: true, wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + dir := t.TempDir() + output := filepath.Join(dir, "rules.yml") + if tc.existing { + require.NoError(t, os.WriteFile(output, []byte("original"), 0600)) + } + + count, err := download(context.Background(), server.Client(), server.URL, output, tc.force) + if tc.wantError { + require.Error(t, err) + if tc.existing { + data, err := os.ReadFile(output) + require.NoError(t, err) + require.Equal(t, "original", string(data)) + } else { + _, err := os.Stat(output) + require.True(t, os.IsNotExist(err)) + } + } else { + require.NoError(t, err) + require.Equal(t, 1, count) + data, err := os.ReadFile(output) + require.NoError(t, err) + require.Equal(t, tc.body, string(data)) + } + entries, err := os.ReadDir(dir) + require.NoError(t, err) + for _, entry := range entries { + require.Equal(t, "rules.yml", entry.Name(), "No temporary files should remain") + } + }) + } +} + +func TestDownloadCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + output := filepath.Join(t.TempDir(), "rules.yml") + _, err := download(ctx, http.DefaultClient, ExternalRulesURL, output, false) + require.ErrorIs(t, err, context.Canceled) + _, err = os.Stat(output) + require.True(t, os.IsNotExist(err)) +} + +func TestDownloadDoesNotOverwriteConcurrentFile(t *testing.T) { + output := filepath.Join(t.TempDir(), "rules.yml") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, os.WriteFile(output, []byte("concurrent"), 0600)) + _, _ = w.Write([]byte(downloadFixture)) + })) + defer server.Close() + _, err := download(context.Background(), server.Client(), server.URL, output, false) + require.Error(t, err) + data, err := os.ReadFile(output) + require.NoError(t, err) + require.Equal(t, "concurrent", string(data)) +} diff --git a/pkg/scanner/rules/rules.go b/pkg/scanner/rules/rules.go index 89ae29cc..c23de421 100644 --- a/pkg/scanner/rules/rules.go +++ b/pkg/scanner/rules/rules.go @@ -1,149 +1,257 @@ package rules import ( + _ "embed" "errors" "fmt" "os" + "regexp" "slices" "strings" + "sync" - "github.com/CompassSecurity/pipeleek/pkg/httpclient" "github.com/CompassSecurity/pipeleek/pkg/scanner/types" "github.com/rs/zerolog/log" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" "github.com/trufflesecurity/trufflehog/v3/pkg/engine/defaults" "gopkg.in/yaml.v3" - "resty.dev/v3" ) -var ruleFile = "https://raw.githubusercontent.com/mazen160/secrets-patterns-db/master/db/rules-stable.yml" -var ruleFileName = "rules.yml" +const ruleFileName = "rules.yml" -var secretsPatterns = types.SecretsPatterns{} -var truffelhogRules []detectors.Detector -var activeConfidenceFilter []string +//go:embed rules.yaml +var builtinYAML []byte -func DownloadRules() { - if _, err := os.Stat(ruleFileName); errors.Is(err, os.ErrNotExist) { - log.Debug().Msg("No rules file found, downloading") - err := downloadFile(ruleFile, ruleFileName, httpclient.GetPipeleekHTTPClient("", nil, nil)) - if err != nil { - log.Fatal().Stack().Err(err).Msg("Failed downloading rules file") - os.Exit(1) +type CompiledPattern struct { + Pattern types.PatternElement + regex *regexp.Regexp + enforceTokenBoundaries bool +} + +func (p CompiledPattern) FindAllIndex(text []byte) [][]int { + hits := p.regex.FindAllIndex(text, -1) + if !p.enforceTokenBoundaries { + return hits + } + // ANSI CSI sequences around a token are formatting, not token characters. + return slices.DeleteFunc(hits, func(hit []int) bool { + start, end := hit[0], hit[1] + if previous, ok := previousVisibleByte(text, start); ok && isTokenByte(previous) && previous != '=' { + return true } + next, nextIndex, ok := nextVisibleByte(text, end) + if ok && isTokenByte(next) { + return true + } + // A dot followed by token characters is a format segment, not punctuation. + if ok && next == '.' { + afterDot, _, hasAfterDot := nextVisibleByte(text, nextIndex+1) + return hasAfterDot && isTokenByte(afterDot) + } + return false + }) +} + +func previousVisibleByte(text []byte, index int) (byte, bool) { + for index > 0 { + if start, ok := ansiCSIEndingAt(text, index); ok { + index = start + continue + } + return text[index-1], true } + return 0, false } -func downloadFile(url string, filepath string, client *resty.Client) error { - resp, err := client.R().Get(url) - if err != nil { - return err +func nextVisibleByte(text []byte, index int) (byte, int, bool) { + for index < len(text) { + if end, ok := ansiCSIStartingAt(text, index); ok { + index = end + continue + } + return text[index], index, true + } + return 0, 0, false +} + +func ansiCSIEndingAt(text []byte, end int) (int, bool) { + if end == 0 || text[end-1] < 0x40 || text[end-1] > 0x7e { + return 0, false + } + for start := end - 2; start >= 0; start-- { + if text[start] == '[' { + if start == 0 || text[start-1] != 0x1b { + return 0, false + } + for i := start + 2; i < end-1; i++ { + if text[i] < 0x20 || text[i] > 0x3f { + return 0, false + } + } + return start - 1, true + } + if text[start] < 0x20 || text[start] > 0x3f { + return 0, false + } } + return 0, false +} - if resp.StatusCode() != 200 { - return fmt.Errorf("unexpected status %d downloading rules file", resp.StatusCode()) +func ansiCSIStartingAt(text []byte, start int) (int, bool) { + if start+1 >= len(text) || text[start] != 0x1b || text[start+1] != '[' { + return 0, false } + for end := start + 2; end < len(text); end++ { + if text[end] >= 0x40 && text[end] <= 0x7e { + return end + 1, true + } + if text[end] < 0x20 || text[end] > 0x3f { + return 0, false + } + } + return 0, false +} - // #nosec G304 - Writing rules download at controlled internal temp path - return os.WriteFile(filepath, resp.Bytes(), 0600) +func isTokenByte(b byte) bool { + return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '-' || b == '_' || b == '=' } -func InitRules(confidenceFilter []string) { - activeConfidenceFilter = slices.Clone(confidenceFilter) - DownloadRules() +var loadBuiltins = sync.OnceValues(func() ([]CompiledPattern, error) { + return parseRules(builtinYAML, "embedded rules.yaml") +}) - if len(secretsPatterns.Patterns) == 0 { - log.Debug().Msg("Loading rules.yml from filesystem") - yamlFile, err := os.ReadFile(ruleFileName) - if err != nil { - log.Fatal().Stack().Err(err).Msg("Failed opening rules file") +func BuiltinPatterns() []CompiledPattern { + patterns, err := loadBuiltins() + if err != nil { + log.Fatal().Err(err).Msg("Invalid embedded rules") + } + return slices.Clone(patterns) +} + +var state = struct { + sync.RWMutex + patterns []CompiledPattern + detectors []detectors.Detector + filter []string +}{} + +func parseRules(data []byte, source string) ([]CompiledPattern, error) { + var definitions types.SecretsPatterns + if err := yaml.Unmarshal(data, &definitions); err != nil { + return nil, fmt.Errorf("%s: invalid YAML: %w", source, err) + } + patterns := make([]CompiledPattern, 0, len(definitions.Patterns)) + names := make(map[string]bool) + for _, element := range definitions.Patterns { + p := element.Pattern + if strings.TrimSpace(p.Name) == "" || strings.TrimSpace(p.Regex) == "" || strings.TrimSpace(p.Confidence) == "" { + return nil, fmt.Errorf("%s: rule %q requires a name, regex, and confidence", source, p.Name) + } + if names[p.Name] { + return nil, fmt.Errorf("%s: duplicate rule name %q", source, p.Name) } - err = yaml.Unmarshal(yamlFile, &secretsPatterns) + names[p.Name] = true + regex, err := regexp.Compile(p.Regex) if err != nil { - log.Fatal().Stack().Err(err).Msg("Failed unmarshalling rules file") + return nil, fmt.Errorf("%s: rule %q: invalid regex: %w", source, p.Name, err) } + patterns = append(patterns, CompiledPattern{ + Pattern: element, + regex: regex, + enforceTokenBoundaries: p.Token, + }) + } + return patterns, nil +} - patterns := AppendPipeleekRules(secretsPatterns.Patterns) - - if len(confidenceFilter) > 0 { - log.Debug().Str("filter", strings.Join(confidenceFilter, ",")).Msg("Applying confidence filter") - filterdPatterns := []types.PatternElement{} - for _, pattern := range patterns { - if slices.Contains(confidenceFilter, pattern.Pattern.Confidence) { - filterdPatterns = append(filterdPatterns, pattern) - } - } - secretsPatterns.Patterns = filterdPatterns +func loadRules(path string) ([]CompiledPattern, error) { + builtins, err := loadBuiltins() + if err != nil { + return nil, err + } + patterns := slices.Clone(builtins) + data, err := os.ReadFile(path) // #nosec G304 -- InitRules supplies the fixed local rules.yml path; alternate paths are used only by tests. + if errors.Is(err, os.ErrNotExist) { + return patterns, nil + } + if err != nil { + return nil, fmt.Errorf("opening %s: %w", path, err) + } + custom, err := parseRules(data, path) + if err != nil { + return nil, err + } + for _, pattern := range custom { + index := slices.IndexFunc(patterns, func(builtin CompiledPattern) bool { + return builtin.Pattern.Pattern.Name == pattern.Pattern.Pattern.Name + }) + if index >= 0 { + patterns[index] = pattern + } else { + patterns = append(patterns, pattern) + } + } + return patterns, nil +} - totalRules := len(secretsPatterns.Patterns) - if totalRules == 0 { - log.Info().Int("count", totalRules).Msg("Your confidence filter removed all rules, are you sure? TruffleHog Rules will still detect secrets. This equals --confidence high-verified") - } +func InitRules(confidenceFilter []string) { + patterns, err := loadRules(ruleFileName) + if err != nil { + log.Fatal().Err(err).Msg("Failed loading rules") + return + } + trufflehog := defaults.DefaultDetectors() + if len(trufflehog) == 0 { + log.Fatal().Msg("No TruffleHog detectors loaded") + return + } + state.Lock() + state.patterns = patterns + state.detectors = trufflehog + state.filter = slices.Clone(confidenceFilter) + state.Unlock() + log.Debug().Int("count", len(patterns)).Msg("Loaded embedded and local rules") + log.Debug().Int("count", len(trufflehog)).Msg("Loaded TruffleHog rules") + if len(confidenceFilter) > 0 { + log.Debug().Strs("filter", confidenceFilter).Msg("Applying confidence filter") + } +} - log.Debug().Int("count", totalRules).Msg("Loaded filtered rules") - } else { - secretsPatterns.Patterns = patterns - log.Debug().Int("count", len(secretsPatterns.Patterns)).Msg("Loaded rules.yml rules") +func AppendPipeleekRules(patterns []types.PatternElement) []types.PatternElement { + result := slices.Clone(patterns) + for _, builtin := range BuiltinPatterns() { + if !slices.ContainsFunc(result, func(p types.PatternElement) bool { + return p.Pattern.Name == builtin.Pattern.Pattern.Name + }) { + result = append(result, builtin.Pattern) } } + return result +} - truffelhogRules = defaults.DefaultDetectors() - if len(truffelhogRules) < 1 { - log.Fatal().Msg("No trufflehog rules have been loaded, this is a bug") - } else { - log.Debug().Int("count", len(truffelhogRules)).Msg("Loaded TruffleHog rules") - } -} - -func AppendPipeleekRules(rules []types.PatternElement) []types.PatternElement { - customRules := []types.PatternElement{} - customRules = append(customRules, types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - Predefined Environment Variable", Regex: `(GITLAB_USER_ID|KUBECONFIG|CI_SERVER_TLS_KEY_FILE|CI_REPOSITORY_URL|CI_REGISTRY_PASSWORD|DOCKER_AUTH_CONFIG)=.*`, Confidence: "medium"}}) - - // Built-in rules for GitLab token types to ensure detection regardless of - // TruffleHog verification (which only verifies against gitlab.com and - // therefore misses tokens for self-hosted GitLab instances). - customRules = append(customRules, - // https://github.com/trufflesecurity/trufflehog/blob/main/pkg/detectors/gitlab/v2/gitlab_v2.go - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - Personal Access Token v2", Regex: `glpat-[a-zA-Z0-9\-=_]{20,22}`, Confidence: "high"}}, - // https://github.com/trufflesecurity/trufflehog/blob/afd5336caad0f61da51750ffe39869974b27b0db/pkg/detectors/gitlab/v3/gitlab_v3.go#L34 - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - Personal Access Token v3", Regex: `\b(glpat-[a-zA-Z0-9\-=_]{27,300}.[0-9a-z]{2}.[a-z0-9]{9})\b`, Confidence: "high"}}, - // https://github.com/gitlabhq/gitlabhq/blob/master/app/models/ci/trigger.rb - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - Pipeline Trigger Token", Regex: `glptt-[a-zA-Z0-9\-=_]{20,}`, Confidence: "high"}}, - // https://github.com/gitlabhq/gitlabhq/blob/master/app/models/ci/runner.rb (CREATED_RUNNER_TOKEN_PREFIX) - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - Runner Authentication Token", Regex: `glrt-[a-zA-Z0-9\-=_]{20,}`, Confidence: "high"}}, - // https://github.com/gitlabhq/gitlabhq/blob/master/app/models/ci/runner.rb (REGISTRATION_RUNNER_TOKEN_PREFIX) - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - Runner Registration Token", Regex: `glrtr-[a-zA-Z0-9\-=_]{20,}`, Confidence: "high"}}, - // https://github.com/gitlabhq/gitlabhq/blob/master/app/models/deploy_token.rb - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - Deploy Token", Regex: `gldt-[a-zA-Z0-9\-=_]{20,}`, Confidence: "high"}}, - // https://github.com/gitlabhq/gitlabhq/blob/master/app/models/ci/build.rb - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - CI Build Token", Regex: `glcbt-[a-zA-Z0-9\-=_]{20,}`, Confidence: "high"}}, - // https://github.com/gitlabhq/gitlabhq/blob/master/spec/lib/authn/tokens/oauth_application_secret_spec.rb - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - OAuth Application Secret", Regex: `gloas-[a-zA-Z0-9\-=_]{20,}`, Confidence: "high"}}, - // https://docs.gitlab.com/security/token_overview/ - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - SCIM/OAuth Access Token", Regex: `glsoat-[a-zA-Z0-9\-=_]{20,}`, Confidence: "high"}}, - // https://docs.gitlab.com/security/token_overview/ - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - Feed Token", Regex: `glft-[a-zA-Z0-9\-=_]{20,}`, Confidence: "high"}}, - // https://docs.gitlab.com/security/token_overview/ - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - Incoming Mail Token", Regex: `glimt-[a-zA-Z0-9\-=_]{20,}`, Confidence: "high"}}, - // https://docs.gitlab.com/security/token_overview/ - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - Feature Flags Client Token", Regex: `glffct-[a-zA-Z0-9\-=_]{20,}`, Confidence: "high"}}, - // https://docs.gitlab.com/security/token_overview/ - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - Agent for Kubernetes Token", Regex: `glagent-[a-zA-Z0-9\-=_]{20,}`, Confidence: "high"}}, - // https://github.com/gitlabhq/gitlabhq/blob/master/app/models/concerns/runners_token_prefixable.rb - types.PatternElement{Pattern: types.PatternPattern{Name: "Gitlab - Runner Token (Legacy)", Regex: `GR1348941[a-zA-Z0-9\-=_]{20,}`, Confidence: "high"}}, - ) - - return slices.Concat(rules, customRules) +func GetCompiledPatterns() []CompiledPattern { + state.RLock() + defer state.RUnlock() + return slices.Clone(state.patterns) } func GetSecretsPatterns() types.SecretsPatterns { - return secretsPatterns + patterns := GetCompiledPatterns() + result := types.SecretsPatterns{Patterns: make([]types.PatternElement, 0, len(patterns))} + for _, pattern := range patterns { + result.Patterns = append(result.Patterns, pattern.Pattern) + } + return result } func GetTruffleHogRules() []detectors.Detector { - return truffelhogRules + state.RLock() + defer state.RUnlock() + return slices.Clone(state.detectors) } func GetConfidenceFilter() []string { - return slices.Clone(activeConfidenceFilter) + state.RLock() + defer state.RUnlock() + return slices.Clone(state.filter) } diff --git a/pkg/scanner/rules/rules.yaml b/pkg/scanner/rules/rules.yaml new file mode 100644 index 00000000..4d877a31 --- /dev/null +++ b/pkg/scanner/rules/rules.yaml @@ -0,0 +1,75 @@ +patterns: + - pattern: + name: Gitlab - Predefined Environment Variable + regex: '(GITLAB_USER_ID|KUBECONFIG|CI_SERVER_TLS_KEY_FILE|CI_REPOSITORY_URL|CI_REGISTRY_PASSWORD|DOCKER_AUTH_CONFIG)=.*' + confidence: medium + - pattern: + name: Gitlab - Personal Access Token v2 + regex: 'glpat-[a-zA-Z0-9\-=_]{20,22}' + confidence: high + token: true + - pattern: + name: Gitlab - Personal Access Token v3 + regex: 'glpat-[a-zA-Z0-9\-=_]{27,300}\.[0-9a-z]{2}\.[a-z0-9]{9}' + confidence: high + token: true + - pattern: + name: Gitlab - Pipeline Trigger Token + regex: 'glptt-[a-zA-Z0-9\-=_]{20,}' + confidence: high + token: true + - pattern: + name: Gitlab - Runner Authentication Token + regex: 'glrt-[a-zA-Z0-9\-=_]{20,}' + confidence: high + token: true + - pattern: + name: Gitlab - Runner Registration Token + regex: 'glrtr-[a-zA-Z0-9\-=_]{20,}' + confidence: high + token: true + - pattern: + name: Gitlab - Deploy Token + regex: 'gldt-[a-zA-Z0-9\-=_]{20,}' + confidence: high + token: true + - pattern: + name: Gitlab - CI Build Token + regex: 'glcbt-[a-zA-Z0-9\-=_]{20,}' + confidence: high + token: true + - pattern: + name: Gitlab - OAuth Application Secret + regex: 'gloas-[a-zA-Z0-9\-=_]{20,}' + confidence: high + token: true + - pattern: + name: Gitlab - SCIM/OAuth Access Token + regex: 'glsoat-[a-zA-Z0-9\-=_]{20,}' + confidence: high + token: true + - pattern: + name: Gitlab - Feed Token + regex: 'glft-[a-zA-Z0-9\-=_]{20,}' + confidence: high + token: true + - pattern: + name: Gitlab - Incoming Mail Token + regex: 'glimt-[a-zA-Z0-9\-=_]{20,}' + confidence: high + token: true + - pattern: + name: Gitlab - Feature Flags Client Token + regex: 'glffct-[a-zA-Z0-9\-=_]{20,}' + confidence: high + token: true + - pattern: + name: Gitlab - Agent for Kubernetes Token + regex: 'glagent-[a-zA-Z0-9\-=_]{20,}' + confidence: high + token: true + - pattern: + name: Gitlab - Runner Token (Legacy) + regex: 'GR1348941[a-zA-Z0-9\-=_]{20,}' + confidence: high + token: true diff --git a/pkg/scanner/rules/rules_test.go b/pkg/scanner/rules/rules_test.go index feafc658..348cebf7 100644 --- a/pkg/scanner/rules/rules_test.go +++ b/pkg/scanner/rules/rules_test.go @@ -1,353 +1,191 @@ package rules import ( - "net/http" - "net/http/httptest" "os" "path/filepath" + "strings" "testing" - "github.com/CompassSecurity/pipeleek/pkg/httpclient" "github.com/CompassSecurity/pipeleek/pkg/scanner/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestAppendPipeleekRules(t *testing.T) { - tests := []struct { - name string - inputRules []types.PatternElement - expectedCount int - }{ - { - name: "empty rules", - inputRules: []types.PatternElement{}, - expectedCount: 15, - }, - { - name: "with existing rules", - inputRules: []types.PatternElement{ - {Pattern: types.PatternPattern{Name: "Test Rule", Regex: "test", Confidence: "high"}}, - }, - expectedCount: 16, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := AppendPipeleekRules(tt.inputRules) - if len(result) != tt.expectedCount { - t.Errorf("Expected %d rules, got %d", tt.expectedCount, len(result)) - } - - found := false - for _, rule := range result { - if rule.Pattern.Name == "Gitlab - Predefined Environment Variable" { - found = true - if rule.Pattern.Confidence != "medium" { - t.Errorf("Expected confidence 'medium', got %q", rule.Pattern.Confidence) - } - break - } - } - if !found { - t.Error("Custom GitLab rule not found in appended rules") - } +func TestBuiltinRules(t *testing.T) { + builtins := BuiltinPatterns() + require.Len(t, builtins, 15) + assert.Equal(t, "medium", builtins[0].Pattern.Pattern.Confidence) + for _, assignment := range []string{ + "GITLAB_USER_ID=12345", "KUBECONFIG=/tmp/config", "CI_SERVER_TLS_KEY_FILE=/tmp/key", + "CI_REPOSITORY_URL=https://example.test/repo", "CI_REGISTRY_PASSWORD=secret", + "DOCKER_AUTH_CONFIG={}", "CI_REGISTRY_PASSWORD=", "CI_REGISTRY_PASSWORD=****", + } { + t.Run(assignment, func(t *testing.T) { + require.Len(t, builtins[0].FindAllIndex([]byte(assignment)), 1) }) } -} - -func TestGetSecretsPatterns(t *testing.T) { - patterns := GetSecretsPatterns() - t.Logf("Patterns count: %d", len(patterns.Patterns)) -} - -func TestGetTruffleHogRules(t *testing.T) { - rules := GetTruffleHogRules() - t.Logf("TruffleHog rules count: %d", len(rules)) -} - -func TestDownloadRules(t *testing.T) { - tmpDir := t.TempDir() - origWd, _ := os.Getwd() - defer func() { - _ = os.Chdir(origWd) - }() - - _ = os.Chdir(tmpDir) - - t.Run("rules file does not exist", func(t *testing.T) { - if _, err := os.Stat(ruleFileName); err == nil { - _ = os.Remove(ruleFileName) - } - - DownloadRules() - - if _, err := os.Stat(ruleFileName); os.IsNotExist(err) { - t.Error("Expected rules file to be downloaded") - } - }) - - t.Run("rules file already exists", func(t *testing.T) { - if _, err := os.Stat(ruleFileName); os.IsNotExist(err) { - _ = os.WriteFile(ruleFileName, []byte("dummy"), 0644) - } - - DownloadRules() - - if _, err := os.Stat(ruleFileName); os.IsNotExist(err) { - t.Error("Expected rules file to exist") - } - }) -} - -func TestInitRules(t *testing.T) { - tmpDir := t.TempDir() - origWd, _ := os.Getwd() - defer func() { - _ = os.Chdir(origWd) - secretsPatterns = types.SecretsPatterns{} - truffelhogRules = nil - }() - - _ = os.Chdir(tmpDir) - - // Create a minimal valid rules file - rulesYAML := `patterns: - - pattern: - name: Test Pattern - regex: "test" - confidence: high - - pattern: - name: Medium Pattern - regex: "medium" - confidence: medium - - pattern: - name: Low Pattern - regex: "low" - confidence: low -` - _ = os.WriteFile(ruleFileName, []byte(rulesYAML), 0644) - - t.Run("no filter", func(t *testing.T) { - secretsPatterns = types.SecretsPatterns{} - truffelhogRules = nil - - InitRules([]string{}) - - if len(secretsPatterns.Patterns) == 0 { - t.Error("Expected patterns to be loaded") - } - - if len(truffelhogRules) == 0 { - t.Error("Expected TruffleHog rules to be loaded") - } - - // Should include custom GitLab rule - found := false - for _, p := range secretsPatterns.Patterns { - if p.Pattern.Name == "Gitlab - Predefined Environment Variable" { - found = true - break - } - } - if !found { - t.Error("Expected custom GitLab rule to be appended") - } - }) - - t.Run("with confidence filter high", func(t *testing.T) { - secretsPatterns = types.SecretsPatterns{} - truffelhogRules = nil - - InitRules([]string{"high"}) - - // Should only have high confidence patterns - for _, p := range secretsPatterns.Patterns { - if p.Pattern.Confidence != "high" { - t.Errorf("Expected only high confidence patterns, got %q", p.Pattern.Confidence) - } - } - }) - - t.Run("with multiple confidence filters", func(t *testing.T) { - secretsPatterns = types.SecretsPatterns{} - truffelhogRules = nil - - InitRules([]string{"high", "medium"}) - - // Should have high and medium confidence patterns - for _, p := range secretsPatterns.Patterns { - if p.Pattern.Confidence != "high" && p.Pattern.Confidence != "medium" { - t.Errorf("Expected only high/medium confidence patterns, got %q", p.Pattern.Confidence) - } + for _, pattern := range builtins[1:] { + require.Equal(t, "high", pattern.Pattern.Pattern.Confidence) + assert.True(t, pattern.Pattern.Pattern.Token) + var token string + switch pattern.Pattern.Pattern.Name { + case "Gitlab - Personal Access Token v2": + token = "glpat-" + strings.Repeat("a", 20) + case "Gitlab - Personal Access Token v3": + token = "glpat-" + strings.Repeat("a", 27) + ".ab.123456789" + default: + token = strings.Split(pattern.Pattern.Pattern.Regex, "[")[0] + strings.Repeat("a", 20) } - }) - - t.Run("filter removes all rules", func(t *testing.T) { - secretsPatterns = types.SecretsPatterns{} - truffelhogRules = nil - - InitRules([]string{"nonexistent"}) - - // Should have zero patterns after filtering - if len(secretsPatterns.Patterns) != 0 { - t.Errorf("Expected 0 patterns after filtering, got %d", len(secretsPatterns.Patterns)) - } - }) - - t.Run("already initialized", func(t *testing.T) { - // First initialize patterns - secretsPatterns = types.SecretsPatterns{} - truffelhogRules = nil - InitRules([]string{}) - initialCount := len(secretsPatterns.Patterns) - - if initialCount == 0 { - t.Error("Expected patterns to be initialized") - } - - // Call InitRules again - should not reload since patterns exist - InitRules([]string{}) - - // Should not have changed - if len(secretsPatterns.Patterns) != initialCount { - t.Errorf("Expected patterns count %d to remain unchanged, got %d", initialCount, len(secretsPatterns.Patterns)) + t.Run(pattern.Pattern.Pattern.Name, func(t *testing.T) { + data := []byte("token='" + token + "'") + hits := pattern.FindAllIndex(data) + require.Len(t, hits, 1) + assert.Equal(t, token, string(data[hits[0][0]:hits[0][1]])) + }) + } + for _, text := range []string{"ordinary text", "arn:aws:iam::123456789012:role/test", "s3://bucket/path", "https://example.test", "/tmp/config"} { + for _, pattern := range builtins { + assert.Empty(t, pattern.FindAllIndex([]byte(text)), "%s on %q", pattern.Pattern.Pattern.Name, text) } - }) + } } -func TestGetSecretsPatterns_AfterInit(t *testing.T) { - tmpDir := t.TempDir() - origWd, _ := os.Getwd() - defer func() { - _ = os.Chdir(origWd) - secretsPatterns = types.SecretsPatterns{} - }() - - _ = os.Chdir(tmpDir) - - rulesYAML := `patterns: - - pattern: - name: Test Pattern - regex: "test" - confidence: high -` - _ = os.WriteFile(ruleFileName, []byte(rulesYAML), 0644) - - secretsPatterns = types.SecretsPatterns{} - InitRules([]string{}) - - patterns := GetSecretsPatterns() - - if len(patterns.Patterns) == 0 { - t.Error("Expected non-empty patterns") +func TestPATBoundaries(t *testing.T) { + v2, v3 := BuiltinPatterns()[1], BuiltinPatterns()[2] + pat := "glpat-" + strings.Repeat("a", 20) + for _, text := range []string{pat, "TOKEN=" + pat, "(" + pat + ")", pat + ".", pat + ",", " " + pat + " " + pat} { + assert.NotEmpty(t, v2.FindAllIndex([]byte(text)), text) + } + for _, text := range []string{ + "glpat-" + strings.Repeat("a", 19), "glpat-" + strings.Repeat("a", 23), + "x" + pat, pat + ".ab.123456789", + "glpat-" + strings.Repeat("a", 27) + "XabX123456789", + } { + assert.Empty(t, v2.FindAllIndex([]byte(text)), text) + assert.Empty(t, v3.FindAllIndex([]byte(text)), text) + } + for _, length := range []int{27, 100, 300} { + pat3 := "glpat-" + strings.Repeat("a", length) + ".ab.123456789" + assert.Empty(t, v2.FindAllIndex([]byte(pat3))) + hits := v3.FindAllIndex([]byte(pat3)) + require.Len(t, hits, 1) + assert.Equal(t, []int{0, len(pat3)}, hits[0]) + assert.Empty(t, v3.FindAllIndex([]byte(pat3+"x"))) } } -func TestGetTruffleHogRules_AfterInit(t *testing.T) { - tmpDir := t.TempDir() - origWd, _ := os.Getwd() - defer func() { - _ = os.Chdir(origWd) - secretsPatterns = types.SecretsPatterns{} - truffelhogRules = nil - }() - - _ = os.Chdir(tmpDir) - - rulesYAML := `patterns: - - pattern: - name: Test Pattern - regex: "test" - confidence: high -` - _ = os.WriteFile(ruleFileName, []byte(rulesYAML), 0644) - - secretsPatterns = types.SecretsPatterns{} - truffelhogRules = nil - InitRules([]string{}) - - rules := GetTruffleHogRules() - - if len(rules) == 0 { - t.Error("Expected non-empty TruffleHog rules") +func TestPATBoundariesWithANSIFormatting(t *testing.T) { + pattern := BuiltinPatterns()[1] + token := "glpat-" + strings.Repeat("a", 20) + for _, tc := range []struct { + name, text string + wantMatch bool + }{ + {name: "colored token", text: "\x1b[32m" + token + "\x1b[0m", wantMatch: true}, + {name: "colored token in assignment", text: "TOKEN=\x1b[32m" + token + "\x1b[0m", wantMatch: true}, + {name: "token preceded by a word", text: "x\x1b[32m" + token}, + {name: "colored token followed by token", text: "\x1b[32m" + token + "\x1b[0m " + token, wantMatch: true}, + {name: "colored token followed by word", text: token + "\x1b[0mX"}, + } { + t.Run(tc.name, func(t *testing.T) { + data := []byte(tc.text) + hits := pattern.FindAllIndex(data) + if !tc.wantMatch { + assert.Empty(t, hits) + return + } + require.NotEmpty(t, hits) + assert.Equal(t, token, string(data[hits[0][0]:hits[0][1]])) + }) } } -func TestDownloadFile_Success(t *testing.T) { - content := "rules file content from mock" - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(content)) - })) - defer srv.Close() - - tmpDir := t.TempDir() - destFile := filepath.Join(tmpDir, "rules.yml") - - client := httpclient.GetPipeleekHTTPClient("", nil, nil) - err := downloadFile(srv.URL, destFile, client) +func TestLoadRulesMissingFile(t *testing.T) { + dir := t.TempDir() + patterns, err := loadRules(filepath.Join(dir, ruleFileName)) require.NoError(t, err) - - data, err := os.ReadFile(destFile) + assert.Len(t, patterns, 15) + entries, err := os.ReadDir(dir) require.NoError(t, err) - assert.Equal(t, content, string(data)) -} - -func TestDownloadFile_HTTPError(t *testing.T) { - tmpDir := t.TempDir() - destFile := filepath.Join(tmpDir, "rules.yml") - - client := httpclient.GetPipeleekHTTPClient("", nil, nil) - client.SetRetryCount(0) - - err := downloadFile("http://127.0.0.1:0", destFile, client) - assert.Error(t, err) + assert.Empty(t, entries, "Loading embedded defaults must not write or download files") } -func TestDownloadFile_BadOutputPath(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("data")) - })) - defer srv.Close() - - client := httpclient.GetPipeleekHTTPClient("", nil, nil) - // Attempting to write to a path inside a non-existent directory should fail - err := downloadFile(srv.URL, "/nonexistent-dir/rules.yml", client) - assert.Error(t, err) +func TestLoadRulesCustomOverrides(t *testing.T) { + path := filepath.Join(t.TempDir(), ruleFileName) + data := []byte(`patterns: + - pattern: + name: Gitlab - Predefined Environment Variable + regex: CUSTOM_ONLY + confidence: custom-confidence + - pattern: + name: Additional + regex: EXTRA + confidence: high + token: true +`) + require.NoError(t, os.WriteFile(path, data, 0600)) + patterns, err := loadRules(path) + require.NoError(t, err) + require.Len(t, patterns, 16) + assert.Equal(t, "custom-confidence", patterns[0].Pattern.Pattern.Confidence) + assert.Empty(t, patterns[0].FindAllIndex([]byte("GITLAB_USER_ID=1"))) + assert.Len(t, patterns[0].FindAllIndex([]byte("CUSTOM_ONLY")), 1) + assert.Len(t, patterns[15].FindAllIndex([]byte("EXTRA")), 1) + assert.Empty(t, patterns[15].FindAllIndex([]byte("PREFIX_EXTRA"))) + unchanged, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, data, unchanged) + appended := AppendPipeleekRules([]types.PatternElement{patterns[0].Pattern}) + assert.Len(t, appended, 15) + assert.Equal(t, patterns[0].Pattern, appended[0]) } -func TestAppendPipeleekRules_GitLabTokenRules(t *testing.T) { - result := AppendPipeleekRules([]types.PatternElement{}) - - expectedTokenRules := []string{ - "Gitlab - Personal Access Token v2", - "Gitlab - Personal Access Token v3", - "Gitlab - Pipeline Trigger Token", - "Gitlab - Runner Authentication Token", - "Gitlab - Runner Registration Token", - "Gitlab - Deploy Token", - "Gitlab - CI Build Token", - "Gitlab - OAuth Application Secret", - "Gitlab - SCIM/OAuth Access Token", - "Gitlab - Feed Token", - "Gitlab - Incoming Mail Token", - "Gitlab - Feature Flags Client Token", - "Gitlab - Agent for Kubernetes Token", - "Gitlab - Runner Token (Legacy)", +func TestInvalidLocalRules(t *testing.T) { + for _, test := range []struct { + name, yaml, errorText string + }{ + {"YAML", "patterns: [", "invalid YAML"}, + {"regex", "patterns:\n- pattern: {name: Bad, regex: '[', confidence: high}", `rule "Bad": invalid regex`}, + {"missing name", "patterns:\n- pattern: {regex: ok, confidence: high}", "requires"}, + {"missing regex", "patterns:\n- pattern: {name: Bad, confidence: high}", "requires"}, + {"missing confidence", "patterns:\n- pattern: {name: Bad, regex: ok}", "requires"}, + {"duplicates", "patterns:\n- pattern: {name: Bad, regex: ok, confidence: high}\n- pattern: {name: Bad, regex: other, confidence: low}", `duplicate rule name "Bad"`}, + } { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), ruleFileName) + require.NoError(t, os.WriteFile(path, []byte(test.yaml), 0600)) + _, err := loadRules(path) + require.ErrorContains(t, err, test.errorText) + assert.ErrorContains(t, err, path) + }) } + _, err := loadRules(t.TempDir()) + require.ErrorContains(t, err, "opening") +} - for _, expectedName := range expectedTokenRules { - found := false - for _, rule := range result { - if rule.Pattern.Name == expectedName { - found = true - assert.Equal(t, "high", rule.Pattern.Confidence, "GitLab token rule %q should have high confidence", expectedName) - break - } - } - assert.True(t, found, "Expected GitLab token rule %q to be present", expectedName) +func TestInitRulesRepeatedFilters(t *testing.T) { + t.Chdir(t.TempDir()) + t.Cleanup(func() { + state.Lock() + state.patterns, state.detectors, state.filter = nil, nil, nil + state.Unlock() + }) + for _, filter := range [][]string{{"high"}, {"medium"}, {"verified"}, {}} { + InitRules(filter) + assert.Len(t, GetSecretsPatterns().Patterns, 15, "Filtering is performed on final findings, not cached definitions") + assert.NotEmpty(t, GetTruffleHogRules()) + assert.Equal(t, filter, GetConfidenceFilter()) } + filter := GetConfidenceFilter() + filter = append(filter, "changed") + assert.Empty(t, GetConfidenceFilter()) + assert.Equal(t, []string{"changed"}, filter) + assert.Len(t, AppendPipeleekRules(nil), 15) + require.NoError(t, os.WriteFile(ruleFileName, []byte("patterns:\n- pattern: {name: Reloaded, regex: RELOADED, confidence: custom}\n"), 0600)) + InitRules([]string{"custom"}) + assert.Len(t, GetCompiledPatterns(), 16) + assert.Equal(t, "Reloaded", GetSecretsPatterns().Patterns[15].Pattern.Name) + require.NoError(t, os.WriteFile(ruleFileName, []byte("patterns: []\n"), 0600)) + InitRules(nil) + assert.Len(t, GetCompiledPatterns(), 15) } diff --git a/pkg/scanner/scanner.go b/pkg/scanner/scanner.go index a48ba350..144b8ce1 100644 --- a/pkg/scanner/scanner.go +++ b/pkg/scanner/scanner.go @@ -15,7 +15,6 @@ type DetectionResult = types.DetectionResult type DetectionOptions = engine.DetectionOptions var InitRules = rules.InitRules -var DownloadRules = rules.DownloadRules var AppendPipeleekRules = rules.AppendPipeleekRules var DetectHits = engine.DetectHits diff --git a/pkg/scanner/scanner_test.go b/pkg/scanner/scanner_test.go index a98d4141..19560c2c 100644 --- a/pkg/scanner/scanner_test.go +++ b/pkg/scanner/scanner_test.go @@ -125,7 +125,6 @@ func TestDetectionResultTypeAlias(t *testing.T) { func TestExportedFunctionReferences(t *testing.T) { // Verify that function aliases are not nil assert.NotNil(t, InitRules, "InitRules should be exported") - assert.NotNil(t, DownloadRules, "DownloadRules should be exported") assert.NotNil(t, AppendPipeleekRules, "AppendPipeleekRules should be exported") assert.NotNil(t, DetectHits, "DetectHits should be exported") assert.NotNil(t, DetectFileHits, "DetectFileHits should be exported") diff --git a/pkg/scanner/types/types.go b/pkg/scanner/types/types.go index 6103e3f8..f18d3de4 100644 --- a/pkg/scanner/types/types.go +++ b/pkg/scanner/types/types.go @@ -12,6 +12,7 @@ type PatternPattern struct { Name string `json:"name"` Regex string `json:"regex"` Confidence string `json:"confidence"` + Token bool `json:"token,omitempty" yaml:"token,omitempty"` } type Finding struct { diff --git a/pkg/webui/server.go b/pkg/webui/server.go index 3425e54c..1d4909a5 100644 --- a/pkg/webui/server.go +++ b/pkg/webui/server.go @@ -214,13 +214,15 @@ var pageTemplate = strings.Join([]string{ " .badge.low { background: rgba(215,167,133,.18); color: #7d4d2d; }", " .badge.medium { background: rgba(198,154,42,.18); color: #72560d; }", " .badge.high { background: rgba(217,111,70,.18); color: #8d3d1f; }", - " .badge.high-verified { background: rgba(164,185,127,.24); color: var(--accent-dark); }", + " .badge.verified { background: rgba(164,185,127,.24); color: var(--accent-dark); }", + " .badge.trufflehog { background: rgba(95,99,104,.12); color: var(--muted); }", " .badge.unknown { background: rgba(95,99,104,.12); color: var(--muted); }", " .badge.all { background: rgba(95,99,104,.12); color: var(--muted); }", " .badge.type-log { background: rgba(95,99,104,.12); color: #3f454a; }", " .badge.type-archive { background: rgba(64,107,54,.12); color: var(--accent-dark); }", " .badge.type-nested-archive { background: rgba(64,107,54,.18); color: var(--accent-dark); }", " .badge.type-dotenv { background: rgba(123,136,180,.14); color: #405089; }", + " .badge.type-cicd-yaml { background: rgba(123,136,180,.14); color: #405089; }", " .secret { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; background: #f7f7f7; border: 1px solid var(--line); border-radius: 6px; padding: .45rem .5rem; display: block; width: 100%; min-width: 0; color: var(--ink); cursor: pointer; transition: border-color .15s ease, background-color .15s ease; }", " .secret:hover { border-color: rgba(64,107,54,.5); background: #f1f6eb; }", " .secret:active { transform: translateY(1px); }", @@ -281,7 +283,8 @@ var pageTemplate = strings.Join([]string{ " ", "
", " ", " ", @@ -308,11 +312,11 @@ var pageTemplate = strings.Join([]string{ " ", "