From 9bbb4125aa76aa7785d76d5356a6e9a1f3ae5ff3 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:09:53 +0000 Subject: [PATCH 1/5] feat: refactor scanner rules and add optional rule downloads Embed built-in rules, preserve local overrides, and make external rule downloads explicit. Standardize verified confidence labels and suppress unverified TruffleHog results when verification is enabled. Update UI, documentation, and regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/guides/gitlab.md | 5 +- docs/guides/scanning.md | 4 +- docs/introduction/configuration.md | 26 +- docs/introduction/secrets_verification.md | 87 +++- internal/cmd/root.go | 2 + internal/cmd/rules/rules.go | 50 ++ internal/cmd/rules/rules_test.go | 20 + pkg/config/gen/gen.go | 3 +- pkg/scan/result/levels_test.go | 40 ++ pkg/scanner/detectors/gitlab_patterns_test.go | 36 ++ pkg/scanner/detectors/gitlab_url_detector.go | 50 +- pkg/scanner/engine/betterleaks.go | 11 +- pkg/scanner/engine/engine.go | 86 ++-- pkg/scanner/engine/engine_test.go | 8 +- pkg/scanner/engine/reporting_test.go | 155 ++++++ pkg/scanner/rules/download.go | 80 ++++ pkg/scanner/rules/download_test.go | 97 ++++ pkg/scanner/rules/rules.go | 245 ++++++---- pkg/scanner/rules/rules.yaml | 61 +++ pkg/scanner/rules/rules_test.go | 451 +++++------------- pkg/scanner/scanner.go | 1 - pkg/scanner/scanner_test.go | 1 - pkg/webui/server.go | 18 +- pkg/webui/server_test.go | 47 +- tests/e2e/github/scan/local_rules_test.go | 143 ++++++ tests/e2e/rules/download_test.go | 98 ++++ 26 files changed, 1284 insertions(+), 541 deletions(-) create mode 100644 internal/cmd/rules/rules.go create mode 100644 internal/cmd/rules/rules_test.go create mode 100644 pkg/scan/result/levels_test.go create mode 100644 pkg/scanner/detectors/gitlab_patterns_test.go create mode 100644 pkg/scanner/engine/reporting_test.go create mode 100644 pkg/scanner/rules/download.go create mode 100644 pkg/scanner/rules/download_test.go create mode 100644 pkg/scanner/rules/rules.yaml create mode 100644 tests/e2e/github/scan/local_rules_test.go create mode 100644 tests/e2e/rules/download_test.go diff --git a/docs/guides/gitlab.md b/docs/guides/gitlab.md index a3f63217..2b309a7c 100644 --- a/docs/guides/gitlab.md +++ b/docs/guides/gitlab.md @@ -183,13 +183,12 @@ 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 embedded and local rules count=15 2024-09-26T13:47:10+02:00 debug Loaded filtered rules count=882 2024-09-26T13:47:10+02:00 info Fetching projects 2024-09-26T13:47:10+02:00 info Provided GitLab session cookie is valid diff --git a/docs/guides/scanning.md b/docs/guides/scanning.md index 5f214c4e..1b22dae8 100644 --- a/docs/guides/scanning.md +++ b/docs/guides/scanning.md @@ -29,7 +29,7 @@ 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. +As shown, Pipeleek can detect secrets in job logs and build artifacts using its embedded/custom YAML patterns, Betterleaks rules, and TruffleHog detectors. The example above uses custom patterns; the external pattern database is no longer downloaded. 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=verified`, TruffleHog, Betterleaks, or the GitLab detector confirmed the credential with its provider; enabling `--secrets-verification` can make outbound validation requests. With verification enabled, TruffleHog reports only successfully verified findings. With `--secrets-verification=false`, returned non-verified TruffleHog candidates are reported as `confidence=trufflehog`. Use `--confidence=verified` to show only successfully verified credentials across all engines. ### Live Findings Web UI @@ -45,7 +45,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..c5157d94 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -72,13 +72,37 @@ 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. +With verification enabled, TruffleHog reports only successfully verified findings as `verified`; non-verified candidates, including verification errors, are suppressed. With verification disabled, returned non-verified candidates use `trufflehog`. Confidence filtering applies to every engine. Migrate old filters from `high-verified` to `verified` and `trufflehog-unverified` to `trufflehog`; these old names are not aliases. + +The 15 Pipeleek built-in rules are embedded locally, including identifier/path rules. Optional working-directory `rules.yml` adds custom rules or overrides built-ins by exact name in the YAML engine. No rules are downloaded or written automatically; existing files remain unchanged. See [secret verification and custom rules](secrets_verification.md) for details. + +### Optional Rules Download + +`pipeleek rules download` explicitly downloads the Secrets Patterns Database stable +ruleset using Pipeleek's HTTP client (including global proxy, TLS, and timeout settings). +These additional regex rules may increase false positives and do not verify credentials. +Scans never download rules automatically. + +```yaml +rules: + download: + output: rules.yml # --output; PIPELEEK_RULES_DOWNLOAD_OUTPUT + force: false # --force; PIPELEEK_RULES_DOWNLOAD_FORCE +``` + +By default, existing files are not overwritten. Use `--force` to replace one. +`--output` changes the download destination only; scans still load `rules.yml` +from their working directory. + +### Per-command Overrides + Override per-command: ```yaml diff --git a/docs/introduction/secrets_verification.md b/docs/introduction/secrets_verification.md index 96877ec6..6b1a856b 100644 --- a/docs/introduction/secrets_verification.md +++ b/docs/introduction/secrets_verification.md @@ -6,7 +6,7 @@ keywords: - TruffleHog - credential validation - confidence levels - - high-verified + - verified - secret detection - credential testing - opsec @@ -18,27 +18,30 @@ Pipeleek uses [TruffleHog](https://github.com/trufflesecurity/trufflehog) and [B When Pipeleek scans logs or artifacts, it uses three detection sources: -1. **Pattern-based detection**: Custom YAML rules from `rules.yml` collected by [Secrets Patterns Database](https://github.com/mazen160/secrets-patterns-db) +1. **Pattern-based detection**: 15 embedded Pipeleek YAML rules plus optional custom rules from local `rules.yml`. Scans never download rules automatically. 2. **Betterleaks rules**: Embedded default rules with keyword, path, capture, and false-positive filtering 3. **TruffleHog detectors**: Specialized detectors with active 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: -- 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. +- With verification enabled, TruffleHog reports only successfully verified hits as `verified`. Other candidates, including those with verification errors, are suppressed. With verification disabled, returned non-verified candidates are reported as `trufflehog`. +- Betterleaks reports a validation result of `valid` as `verified`. Without verification, all Betterleaks detections are reported at their rule confidence (`high`, `medium`, or `low`); Betterleaks has no separate unverified confidence level. +- The GitLab URL-aware detector uses `verified` for successful verification against your configured instance and `high` for retained non-verified detections. Existing GitLab rejection behavior and Betterleaks invalid/revoked suppression are unchanged. + +Disabling verification exposes more TruffleHog candidates and may mean more false positives; `trufflehog` indicates a detection source, not a confidence ranking or a working credential. A failed verification request does not prove that a credential is invalid, but the candidate is suppressed when verification is enabled. ### Confidence Levels Pipeleek assigns confidence levels to all detected secrets: -| 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 | +| Level | Source | Description | Verified | +| ----- | ------ | ----------- | -------- | +| **verified** | TruffleHog, Betterleaks, GitLab detector | Successfully verified against a provider | Yes | +| **trufflehog** | TruffleHog | Non-verified detection reported when verification is disabled | No | +| **high** | Embedded/custom YAML, Betterleaks, GitLab detector | High confidence pattern match | No | +| **medium** | Embedded/custom YAML, Betterleaks | Medium confidence pattern match | No | +| **low** | Custom YAML, Betterleaks | Low confidence pattern match | No | +| **custom** | Custom YAML | User-defined confidence label | No | ### Disabling Verification @@ -47,36 +50,69 @@ For operational security (OpSec) or privacy, disable verification to prevent Tru Use the `--secrets-verification=false` flag: ```bash -pipeleek gl scan -u https://gitlab.com -t glpat-xxxxx --secrets-verification=false +pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --secrets-verification=false ``` ### Confidence Filtering -Results can be filtered by confidence, using the `--confidence` flag. +Results from every engine can be filtered using the `--confidence` flag. An empty filter reports all levels. Filtering does not disable provider verification. ```bash -pipeleek gl scan -u https://gitlab.com -t glpat-xxxxx --confidence=high-verified,high +# 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 ``` ## 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. +### Optional external rules download + +To explicitly install the Secrets Patterns Database stable ruleset from +`mazen160/secrets-patterns-db`, run: + +```bash +pipeleek rules download +``` + +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: + +```bash +pipeleek rules download --force +pipeleek rules download --output additional-rules.yml +``` + +`--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. + +### Local customization + +The built-in definitions live in `pkg/scanner/rules/rules.yaml` and are embedded in the binary. They include GitLab token patterns and the predefined-variable rule, which intentionally also matches identifiers and paths such as `GITLAB_USER_ID` and `KUBECONFIG`. These matches are indicators to review, not proof of a leaked credential. + +To add a rule, create `rules.yml` in your working directory. Pipeleek neither downloads nor creates this file. A rule with the same exact name as a built-in overrides that built-in in the YAML detection engine; other names add rules. Overrides do not alter the separate GitLab verifier or other engines. + +For example: ```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 ``` -You can create additional custom rules. +Each rule requires a nonempty name, valid Go regex, and nonempty confidence label. Duplicate names within one file, invalid YAML/regexes, and unreadable files fail initialization with an error. A missing file is normal and uses embedded defaults. Regexes are compiled during initialization rather than for each scanned item. > **💡Tip:** Test your regexes at [regex101.com](https://regex101.com/) (select Golang flavor). @@ -96,3 +132,10 @@ When you run Pipeleek, you'll see results for your custom rule and any built-in 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" ``` + +## Migration + +- Update scripts and confidence filters: `high-verified` is now `verified`; `trufflehog-unverified` is now `trufflehog`. Legacy filter names are not aliases. Custom YAML confidence labels remain user-defined. +- Existing local `rules.yml` files are preserved and loaded as custom rules. A previously downloaded Secrets Patterns Database will therefore still contribute its patterns. Back up and remove or curate that file if you want only the 15 embedded defaults. +- Automatic rule downloads and the old Go helpers (`rules.DownloadRules` and `scanner.DownloadRules`) have been removed. Use `pipeleek rules download` for an explicit external rules download. Go callers should initialize the embedded/local rules with `InitRules`. +- The live UI includes a `trufflehog` filter and count. These candidates are outside the verified/high severity ranking; the existing “any” notification threshold includes them. 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..b2c3454b --- /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. Scans never download rules automatically.", + 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..fc0d0fae 100644 --- a/pkg/config/gen/gen.go +++ b/pkg/config/gen/gen.go @@ -51,6 +51,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. @@ -152,7 +153,7 @@ func captureFlags(flagSet *pflag.FlagSet, keyPrefix []string, node *configNode, } flagSet.VisitAll(func(flag *pflag.Flag) { - if _, skip := rootFlagsToSkip[flag.Name]; skip { + if _, skip := rootFlagsToSkip[flag.Name]; skip && !(flag.Name == "output" && strings.Join(keyPrefix, ".") == "rules.download") { return } 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..84c2e5cb --- /dev/null +++ b/pkg/scanner/detectors/gitlab_patterns_test.go @@ -0,0 +1,36 @@ +package detectors + +import ( + "context" + "strings" + "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) + }) + } +} diff --git a/pkg/scanner/detectors/gitlab_url_detector.go b/pkg/scanner/detectors/gitlab_url_detector.go index 5cc22c72..d1933b0c 100644 --- a/pkg/scanner/detectors/gitlab_url_detector.go +++ b/pkg/scanner/detectors/gitlab_url_detector.go @@ -2,10 +2,10 @@ package detectors import ( "context" - "regexp" "sync" "github.com/CompassSecurity/pipeleek/pkg/gitlab/util" + "github.com/CompassSecurity/pipeleek/pkg/scanner/rules" "github.com/rs/zerolog/log" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" gitlab "gitlab.com/gitlab-org/api/client-go" @@ -20,7 +20,7 @@ var ( type gitlabPattern struct { name string - regex *regexp.Regexp + pattern rules.CompiledPattern strategy verificationStrategy } @@ -56,21 +56,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} @@ -96,13 +107,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..aa43e8ac 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" ) @@ -68,18 +67,10 @@ func detectHits(ctx context.Context, text []byte, maxThreads int, enableSecretsV } group := parallel.Collect[[]types.Finding](parallel.Limited(ctx, maxThreads)) - secretsPatterns := rules.GetSecretsPatterns() - - for _, pattern := range secretsPatterns.Patterns { + for _, pattern := range rules.GetCompiledPatterns() { 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) + hits := pattern.FindAllIndex(text) for _, hit := range hits { hitStr := extractHitWithSurroundingText(text, hit, 50) @@ -89,7 +80,7 @@ func detectHits(ctx context.Context, text []byte, maxThreads int, enableSecretsV } if hitStr != "" { - findingsYml = append(findingsYml, types.Finding{Pattern: pattern, Text: hitStr, Engine: EngineRules}) + findingsYml = append(findingsYml, types.Finding{Pattern: pattern.Pattern, Text: hitStr, Engine: EngineRules}) } } @@ -134,7 +125,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 +133,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 +149,45 @@ 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, rules.GetConfidenceFilter())), Error: nil} +} + +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..511e0825 --- /dev/null +++ b/pkg/scanner/engine/reporting_test.go @@ -0,0 +1,155 @@ +package engine + +import ( + "bytes" + "context" + "errors" + "slices" + "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) +} diff --git a/pkg/scanner/rules/download.go b/pkg/scanner/rules/download.go new file mode 100644 index 00000000..6b92d15f --- /dev/null +++ b/pkg/scanner/rules/download.go @@ -0,0 +1,80 @@ +package rules + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "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) + } + // Limit external input before parsing or writing it. + const maxSize = 16 << 20 + data, err := io.ReadAll(io.LimitReader(response.Body, maxSize+1)) + if err != nil { + return 0, fmt.Errorf("reading downloaded rules: %w", err) + } + if len(data) > maxSize { + return 0, fmt.Errorf("downloaded rules exceed %d bytes", maxSize) + } + patterns, err := parseRules(data, source) + if err != nil { + return 0, err + } + if len(patterns) == 0 { + return 0, fmt.Errorf("downloaded rules contain no patterns") + } + temp, err := os.CreateTemp(filepath.Dir(output), ".pipeleek-rules-*") + if err != nil { + return 0, fmt.Errorf("creating rules temporary file: %w", err) + } + defer os.Remove(temp.Name()) + if _, err := temp.Write(data); err != nil { + _ = temp.Close() + return 0, fmt.Errorf("writing rules: %w", err) + } + if err := temp.Close(); err != nil { + return 0, fmt.Errorf("closing rules: %w", err) + } + if force { + err = os.Rename(temp.Name(), output) + } else { + // Publish without replacing a file created while the download was running. + err = os.Link(temp.Name(), output) + } + if err != nil { + return 0, fmt.Errorf("saving rules to %s: %w", output, 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..7189de38 --- /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: "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: "oversized", body: strings.Repeat(" ", (16<<20)+1), 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..df5ed210 100644 --- a/pkg/scanner/rules/rules.go +++ b/pkg/scanner/rules/rules.go @@ -1,149 +1,188 @@ 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 + token bool } -func downloadFile(url string, filepath string, client *resty.Client) error { - resp, err := client.R().Get(url) - if err != nil { - return err - } - - if resp.StatusCode() != 200 { - return fmt.Errorf("unexpected status %d downloading rules file", resp.StatusCode()) +func (p CompiledPattern) FindAllIndex(text []byte) [][]int { + hits := p.regex.FindAllIndex(text, -1) + if !p.token { + return hits } + return slices.DeleteFunc(hits, func(hit []int) bool { + start, end := hit[0], hit[1] + if start > 0 && isTokenByte(text[start-1]) && text[start-1] != '=' { + return true + } + if end < len(text) && isTokenByte(text[end]) { + return true + } + // A dot followed by token characters is a format segment, not punctuation. + return end+1 < len(text) && text[end] == '.' && isTokenByte(text[end+1]) + }) +} - // #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, + token: source == "embedded rules.yaml" && p.Confidence == "high", + }) + } + 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 - - 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") - } - - log.Debug().Int("count", totalRules).Msg("Loaded filtered rules") +func loadRules(path string) ([]CompiledPattern, error) { + builtins, err := loadBuiltins() + if err != nil { + return nil, err + } + patterns := slices.Clone(builtins) + data, err := os.ReadFile(path) + 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 { - secretsPatterns.Patterns = patterns - log.Debug().Int("count", len(secretsPatterns.Patterns)).Msg("Loaded rules.yml rules") + patterns = append(patterns, pattern) } } + return patterns, nil +} - 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 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") } -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 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 +} + +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..006d524d --- /dev/null +++ b/pkg/scanner/rules/rules.yaml @@ -0,0 +1,61 @@ +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 + - 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 + - pattern: + name: Gitlab - Pipeline Trigger Token + regex: 'glptt-[a-zA-Z0-9\-=_]{20,}' + confidence: high + - pattern: + name: Gitlab - Runner Authentication Token + regex: 'glrt-[a-zA-Z0-9\-=_]{20,}' + confidence: high + - pattern: + name: Gitlab - Runner Registration Token + regex: 'glrtr-[a-zA-Z0-9\-=_]{20,}' + confidence: high + - pattern: + name: Gitlab - Deploy Token + regex: 'gldt-[a-zA-Z0-9\-=_]{20,}' + confidence: high + - pattern: + name: Gitlab - CI Build Token + regex: 'glcbt-[a-zA-Z0-9\-=_]{20,}' + confidence: high + - pattern: + name: Gitlab - OAuth Application Secret + regex: 'gloas-[a-zA-Z0-9\-=_]{20,}' + confidence: high + - pattern: + name: Gitlab - SCIM/OAuth Access Token + regex: 'glsoat-[a-zA-Z0-9\-=_]{20,}' + confidence: high + - pattern: + name: Gitlab - Feed Token + regex: 'glft-[a-zA-Z0-9\-=_]{20,}' + confidence: high + - pattern: + name: Gitlab - Incoming Mail Token + regex: 'glimt-[a-zA-Z0-9\-=_]{20,}' + confidence: high + - pattern: + name: Gitlab - Feature Flags Client Token + regex: 'glffct-[a-zA-Z0-9\-=_]{20,}' + confidence: high + - pattern: + name: Gitlab - Agent for Kubernetes Token + regex: 'glagent-[a-zA-Z0-9\-=_]{20,}' + confidence: high + - pattern: + name: Gitlab - Runner Token (Legacy) + regex: 'GR1348941[a-zA-Z0-9\-=_]{20,}' + confidence: high diff --git a/pkg/scanner/rules/rules_test.go b/pkg/scanner/rules/rules_test.go index feafc658..60d3c08f 100644 --- a/pkg/scanner/rules/rules_test.go +++ b/pkg/scanner/rules/rules_test.go @@ -1,353 +1,162 @@ 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) - } - } - }) - - 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)) + for _, pattern := range builtins[1:] { + require.Equal(t, "high", pattern.Pattern.Pattern.Confidence) + 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("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 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 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 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 +`) + 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) + 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/webui/server.go b/pkg/webui/server.go index 3425e54c..e2777c28 100644 --- a/pkg/webui/server.go +++ b/pkg/webui/server.go @@ -214,7 +214,8 @@ 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; }", @@ -281,7 +282,8 @@ var pageTemplate = strings.Join([]string{ " ", " ", "
", " ", - " ", + " ", "
", " ", " ", "
", "
", diff --git a/pkg/webui/server_test.go b/pkg/webui/server_test.go index 030ae35f..17e2690c 100644 --- a/pkg/webui/server_test.go +++ b/pkg/webui/server_test.go @@ -157,6 +157,9 @@ func TestHandleRootAllowsInlineScript(t *testing.T) { if !strings.Contains(body, "Live secret findings") { t.Fatalf("expected rendered page body, got %q", body) } + if !strings.Contains(body, `data-value="cicd-yaml"`) || !strings.Contains(body, ".badge.type-cicd-yaml") { + t.Fatal("missing CI/CD YAML source filter or badge") + } if !strings.Contains(body, `data-value="trufflehog"`) || !strings.Contains(body, ".badge.trufflehog") || !strings.Contains(body, "trufflehog: 0") || !strings.Contains(body, "['verified', 'high', 'medium', 'low', 'trufflehog']") { t.Fatal("expected a neutral TruffleHog candidate filter, badge and summary count") } diff --git a/tests/e2e/gitlab/cicd/scan/scan_test.go b/tests/e2e/gitlab/cicd/scan/scan_test.go index 2a773e3d..1a35f98c 100644 --- a/tests/e2e/gitlab/cicd/scan/scan_test.go +++ b/tests/e2e/gitlab/cicd/scan/scan_test.go @@ -20,14 +20,14 @@ func TestGLCicdScan_HappyPath(t *testing.T) { case "/api/v4/projects": w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode([]map[string]interface{}{ - {"id": 1, "name": "test-project", "path_with_namespace": "group/test-project"}, + {"id": 1, "name": "test-project", "path_with_namespace": "group/test-project", "web_url": "https://gitlab.example/group/test-project"}, }) case "/api/v4/projects/1/ci/lint": w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "valid": true, - "merged_yaml": "deploy:\n script:\n - echo AKIAIOSFODNN7EXAMPLE\n", + "merged_yaml": "deploy:\n script:\n - echo GITLAB_USER_ID=19357\n", "warnings": [], "errors": [] }`)) @@ -43,10 +43,16 @@ func TestGLCicdScan_HappyPath(t *testing.T) { "gl", "cicd", "scan", "--url", server.URL, "--token", "glpat-test-token-123", + "--secrets-verification=false", + "--confidence=medium", + "--json", }, nil, 10*time.Second) assert.Nil(t, exitErr, "Command should succeed") - assert.Contains(t, stdout, "AKIAIOSFODNN7EXAMPLE", "Should detect the secret embedded in the CI/CD YAML") + assert.Contains(t, stdout, "GITLAB_USER_ID=19357", "Should detect the indicator embedded in the CI/CD YAML") + assert.Contains(t, stdout, `"type":"cicd-yaml"`) + assert.Contains(t, stdout, `"url":"https://gitlab.example/group/test-project/-/blob/HEAD/.gitlab-ci.yml"`) + assert.NotContains(t, stdout, `"type":"log"`) requests := getRequests() for _, req := range requests { From ea820d1f7264bd07883774f12696b81343b60b50 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:03:16 +0000 Subject: [PATCH 3/5] fix: prefilter YAML rules and isolate E2E scan fixtures Skip excluded YAML patterns before scanning while preserving final cross-engine filtering. Make scan E2E tests independent of legacy downloaded rules and provider verification. Check host Renovate prerequisites and surface launch failures. Simplify verification documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/introduction/configuration.md | 2 +- docs/introduction/secrets_verification.md | 10 +--- pkg/scanner/engine/engine.go | 54 +++++++++---------- pkg/scanner/engine/reporting_test.go | 28 +++++++++- tests/e2e/bitbucket/scan/artifacts_test.go | 3 ++ tests/e2e/bitbucket/scan/basic_test.go | 1 + .../bitbucket/scan/unknown_archive_test.go | 3 ++ tests/e2e/devops/scan/scan_test.go | 2 + tests/e2e/github/scan/advanced_test.go | 2 +- tests/e2e/github/scan/artifacts_test.go | 2 + tests/e2e/github/scan/logs_test.go | 1 + tests/e2e/github/scan/unknown_archive_test.go | 1 + tests/e2e/gitlab/renovate/renovate_test.go | 5 +- tests/e2e/gitlab/scan/scan_test.go | 1 + tests/e2e/gitlab/snippets/snippets_test.go | 4 ++ tests/e2e/internal/testutil/scan_rules.go | 21 ++++++++ .../testutil/testdata/scan-rules.yaml | 22 ++++++++ 17 files changed, 123 insertions(+), 39 deletions(-) create mode 100644 tests/e2e/internal/testutil/scan_rules.go create mode 100644 tests/e2e/internal/testutil/testdata/scan-rules.yaml diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 3874dae8..2c20c162 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -79,7 +79,7 @@ common: `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. -See [verification and confidence filtering](secrets_verification.md#verification) for engine-specific reporting behavior and migration notes. +See [verification and confidence filtering](secrets_verification.md#verification) for engine-specific reporting behavior. ### Optional Rules Download diff --git a/docs/introduction/secrets_verification.md b/docs/introduction/secrets_verification.md index 882034bf..3bc0db24 100644 --- a/docs/introduction/secrets_verification.md +++ b/docs/introduction/secrets_verification.md @@ -19,7 +19,6 @@ Pipeleek scans CI/CD logs and artifacts using: - **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. -- **GitLab URL-aware detector:** checks supported tokens against your configured GitLab instance. ## Verification @@ -29,11 +28,12 @@ Verification is enabled by default. `--secrets-verification` controls provider c | ------ | -------------------- | --------------------- | | 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`) | -| GitLab detector | Verified credentials use `verified`; retained non-verified findings use `high` | Retained findings use `high` | | YAML rules | Rule confidence; no provider checks | Unchanged | 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. +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. + To skip provider checks: ```bash @@ -106,9 +106,3 @@ patterns: ``` Each rule needs a nonempty name, valid Go regex, and nonempty confidence label. Duplicate names, invalid YAML/regexes, and unreadable files fail initialization. - -## Migration - -- Update scripts and confidence filters: `high-verified` is now `verified`; `trufflehog-unverified` is now `trufflehog`. Legacy filter names are not aliases. Custom YAML confidence labels remain user-defined. -- Existing local `rules.yml` files are preserved and loaded as custom rules. A previously downloaded Secrets Patterns Database will therefore still contribute its patterns. Back up and remove or curate that file if you want only the 15 embedded defaults. -- Scans no longer download rules automatically. Use the optional download command above. diff --git a/pkg/scanner/engine/engine.go b/pkg/scanner/engine/engine.go index aa43e8ac..0679282c 100644 --- a/pkg/scanner/engine/engine.go +++ b/pkg/scanner/engine/engine.go @@ -65,30 +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)) - - for _, pattern := range rules.GetCompiledPatterns() { - group.Go(func(ctx context.Context) ([]types.Finding, error) { - findingsYml := []types.Finding{} - hits := pattern.FindAllIndex(text) - - 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.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") } @@ -96,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") @@ -149,7 +125,31 @@ 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(filterFindings(totalFindings, rules.GetConfidenceFilter())), 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) { + 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 { diff --git a/pkg/scanner/engine/reporting_test.go b/pkg/scanner/engine/reporting_test.go index 511e0825..987df87c 100644 --- a/pkg/scanner/engine/reporting_test.go +++ b/pkg/scanner/engine/reporting_test.go @@ -77,7 +77,7 @@ func TestTruffleHogPreservesPartialResultsWithoutLeakingErrors(t *testing.T) { result, {DetectorType: detector_typepb.DetectorType_Gitlab, Raw: []byte("verified-candidate"), Verified: true}, }, - err: errors.New("detector error containing sensitive-fixture"), + err: errors.New("detector error containing sensitive-fixture"), } findings := detectTruffleHog(context.Background(), detector, true, nil) require.Len(t, findings, 1) @@ -153,3 +153,29 @@ func TestDetectHitsConfidenceSelectionBeforeDeduplication(t *testing.T) { 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) +} diff --git a/tests/e2e/bitbucket/scan/artifacts_test.go b/tests/e2e/bitbucket/scan/artifacts_test.go index 36a256aa..e5b2fb50 100644 --- a/tests/e2e/bitbucket/scan/artifacts_test.go +++ b/tests/e2e/bitbucket/scan/artifacts_test.go @@ -159,6 +159,7 @@ SENDGRID_API_KEY=SG.1234567890abcdefghijklmnopqrstuvwxyz assert.Contains(t, output, "credentials.txt", "Should scan credentials file in small artifact") } func TestBitBucketScan_Artifacts_WithDotEnv(t *testing.T) { + testutil.UseTestRules(t) server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -442,6 +443,7 @@ ADMIN_PASSWORD=SuperSecretAdminPass123! } func TestBitBucketScan_Artifacts_MultipleFiles(t *testing.T) { + testutil.UseTestRules(t) server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -631,6 +633,7 @@ func TestBitBucketScan_Cookie_WithoutArtifacts(t *testing.T) { } func TestBitBucketScan_DownloadArtifacts(t *testing.T) { + testutil.UseTestRules(t) server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/tests/e2e/bitbucket/scan/basic_test.go b/tests/e2e/bitbucket/scan/basic_test.go index e629ef37..8c8a36b8 100644 --- a/tests/e2e/bitbucket/scan/basic_test.go +++ b/tests/e2e/bitbucket/scan/basic_test.go @@ -13,6 +13,7 @@ import ( ) func TestBitBucketScan_HappyPath(t *testing.T) { + testutil.UseTestRules(t) server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/tests/e2e/bitbucket/scan/unknown_archive_test.go b/tests/e2e/bitbucket/scan/unknown_archive_test.go index 93a7c11e..3367c823 100644 --- a/tests/e2e/bitbucket/scan/unknown_archive_test.go +++ b/tests/e2e/bitbucket/scan/unknown_archive_test.go @@ -16,6 +16,7 @@ import ( // TestBitBucketScan_UnknownArchive_BinaryWithSecrets tests that when encountering // an unknown archive file type, the scanner extracts printable strings and scans them func TestBitBucketScan_UnknownArchive_BinaryWithSecrets(t *testing.T) { + testutil.UseTestRules(t) // Create a binary file that is not a recognizable archive format // but contains embedded secrets (simulating a compiled binary or proprietary format) @@ -156,6 +157,7 @@ func TestBitBucketScan_UnknownArchive_BinaryWithSecrets(t *testing.T) { // TestBitBucketScan_UnknownArchive_ELFBinary tests string extraction from ELF binary format func TestBitBucketScan_UnknownArchive_ELFBinary(t *testing.T) { + testutil.UseTestRules(t) // Create an ELF-like binary with embedded secrets var elfBinary bytes.Buffer @@ -279,6 +281,7 @@ func TestBitBucketScan_UnknownArchive_ELFBinary(t *testing.T) { // TestBitBucketScan_UnknownArchive_MixedBinaryFormats tests handling of multiple unknown formats func TestBitBucketScan_UnknownArchive_MixedBinaryFormats(t *testing.T) { + testutil.UseTestRules(t) // Binary 1: Proprietary format with secret binary1 := []byte{ diff --git a/tests/e2e/devops/scan/scan_test.go b/tests/e2e/devops/scan/scan_test.go index 7c7ae006..94e8afa7 100644 --- a/tests/e2e/devops/scan/scan_test.go +++ b/tests/e2e/devops/scan/scan_test.go @@ -62,6 +62,7 @@ func TestAzureDevOpsScan_HappyPath(t *testing.T) { // TestAzureDevOpsScan_MissingToken tests missing required token func TestAzureDevOpsScan_WithLogs(t *testing.T) { + testutil.UseTestRules(t) server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -137,6 +138,7 @@ func TestAzureDevOpsScan_WithLogs(t *testing.T) { } func TestAzureDevOpsScan_Artifacts_WithDotEnv(t *testing.T) { + testutil.UseTestRules(t) // Create a zip with a .env file var zipBuf bytes.Buffer diff --git a/tests/e2e/github/scan/advanced_test.go b/tests/e2e/github/scan/advanced_test.go index 2ca4fe78..ea5c9155 100644 --- a/tests/e2e/github/scan/advanced_test.go +++ b/tests/e2e/github/scan/advanced_test.go @@ -204,8 +204,8 @@ func TestGitHubScan_RateLimit(t *testing.T) { } // TestGitHubScan_ConfidenceFilter tests multiple confidence levels -// SKIP: Test intermittently times out - needs investigation of zip handling in mock environment func TestGitHubScan_ConfidenceFilter(t *testing.T) { + testutil.UseTestRules(t) // Prepare zipped logs with secrets var logsZip bytes.Buffer diff --git a/tests/e2e/github/scan/artifacts_test.go b/tests/e2e/github/scan/artifacts_test.go index 38786cb3..00f4b711 100644 --- a/tests/e2e/github/scan/artifacts_test.go +++ b/tests/e2e/github/scan/artifacts_test.go @@ -13,6 +13,7 @@ import ( ) func TestGitHubScan_Artifacts_WithDotEnv(t *testing.T) { + testutil.UseTestRules(t) // Create artifact zip with a .env file var artifactZipBuf bytes.Buffer @@ -302,6 +303,7 @@ AWS_SECRET_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY // TestGitHubScan_Artifacts_NestedArchive tests nested zip handling func TestGitHubScan_Artifacts_NestedArchive(t *testing.T) { + testutil.UseTestRules(t) // Create inner zip with secret var innerZipBuf bytes.Buffer diff --git a/tests/e2e/github/scan/logs_test.go b/tests/e2e/github/scan/logs_test.go index 84a978e9..c3b9a296 100644 --- a/tests/e2e/github/scan/logs_test.go +++ b/tests/e2e/github/scan/logs_test.go @@ -112,6 +112,7 @@ func TestGitHubScan_HappyPath(t *testing.T) { // TestGitHubScan_MissingToken tests missing required flags func TestGitHubScan_WithLogs(t *testing.T) { + testutil.UseTestRules(t) // prepare zipped logs (single file inside ZIP) var logsZip bytes.Buffer diff --git a/tests/e2e/github/scan/unknown_archive_test.go b/tests/e2e/github/scan/unknown_archive_test.go index 117a1bfb..3897a935 100644 --- a/tests/e2e/github/scan/unknown_archive_test.go +++ b/tests/e2e/github/scan/unknown_archive_test.go @@ -17,6 +17,7 @@ import ( // TestGitHubScan_UnknownArchive_BinaryWithSecrets tests that when encountering // an unknown archive file type, the scanner extracts printable strings and scans them func TestGitHubScan_UnknownArchive_BinaryWithSecrets(t *testing.T) { + testutil.UseTestRules(t) // Create a binary file that is not a recognizable archive format // but contains embedded secrets (simulating a compiled binary or proprietary format) diff --git a/tests/e2e/gitlab/renovate/renovate_test.go b/tests/e2e/gitlab/renovate/renovate_test.go index ea9a506f..c0bd1c1d 100644 --- a/tests/e2e/gitlab/renovate/renovate_test.go +++ b/tests/e2e/gitlab/renovate/renovate_test.go @@ -573,7 +573,7 @@ func runRenovateProbeWithHostNpx(t *testing.T, repoDir, endpoint string) string ) output, err := cmd.CombinedOutput() if err != nil { - return string(output) + t.Fatalf("host Renovate probe failed: %v\n%s", err, output) } return string(output) } @@ -819,6 +819,9 @@ func tailForDiagnostics(value string, max int) string { func TestGLRenovateAutodiscovery_RenovateLatestPicksUpMavenWrapperExploit(t *testing.T) { skipRenovateContainerProbeIfUnavailable(t) + if _, err := exec.LookPath("npx"); err != nil { + t.Skipf("Skipping host Renovate probe because npx is unavailable: %v", err) + } filesSnapshot := captureAutodiscoveryRepoFiles(t) repoDir := materializeAutodiscoveryRepoFixture(t, filesSnapshot) cloneURL := startGitDaemonForRepo(t, repoDir) diff --git a/tests/e2e/gitlab/scan/scan_test.go b/tests/e2e/gitlab/scan/scan_test.go index c83d5831..4f06c6dd 100644 --- a/tests/e2e/gitlab/scan/scan_test.go +++ b/tests/e2e/gitlab/scan/scan_test.go @@ -254,6 +254,7 @@ func TestGitLabScan_FlagVariations(t *testing.T) { // TestGitLabScan_ScansCICDYaml verifies the scan command fetches and scans the // project's merged .gitlab-ci.yml for secrets via the CI lint API. func TestGitLabScan_ScansCICDYaml(t *testing.T) { + testutil.UseTestRules(t) server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/tests/e2e/gitlab/snippets/snippets_test.go b/tests/e2e/gitlab/snippets/snippets_test.go index 84df0ca3..51d4ec08 100644 --- a/tests/e2e/gitlab/snippets/snippets_test.go +++ b/tests/e2e/gitlab/snippets/snippets_test.go @@ -15,6 +15,7 @@ import ( const awsSnippetContent = "[default]\naws_access_key_id = AKIAT4GVSAXXEK64O6KD\naws_secret_access_key = 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\n" func TestGitLabSnippetsScan_PublicSnippets(t *testing.T) { + testutil.UseTestRules(t) server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -69,6 +70,7 @@ func TestGitLabSnippetsScan_PublicSnippets(t *testing.T) { } func TestGitLabSnippetsScan_MemberFilter(t *testing.T) { + testutil.UseTestRules(t) server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -131,6 +133,7 @@ func TestGitLabSnippetsScan_MemberFilter(t *testing.T) { } func TestGitLabSnippetsScan_ProjectFilter(t *testing.T) { + testutil.UseTestRules(t) server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -192,6 +195,7 @@ func TestGitLabSnippetsScan_ProjectFilter(t *testing.T) { } func TestGitLabSnippetsScan_GroupFilter(t *testing.T) { + testutil.UseTestRules(t) server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/tests/e2e/internal/testutil/scan_rules.go b/tests/e2e/internal/testutil/scan_rules.go new file mode 100644 index 00000000..616cdd8a --- /dev/null +++ b/tests/e2e/internal/testutil/scan_rules.go @@ -0,0 +1,21 @@ +package testutil + +import ( + _ "embed" + "os" + "testing" +) + +//go:embed testdata/scan-rules.yaml +var scanRules []byte + +// UseTestRules isolates transport/pipeline tests from downloaded rule databases +// and provider verification. Detection policy is tested separately. +func UseTestRules(t *testing.T) { + t.Helper() + t.Chdir(t.TempDir()) + t.Setenv("PIPELEEK_COMMON_SECRETS_VERIFICATION", "false") + if err := os.WriteFile("rules.yml", scanRules, 0600); err != nil { + t.Fatalf("write scan fixture rules: %v", err) + } +} diff --git a/tests/e2e/internal/testutil/testdata/scan-rules.yaml b/tests/e2e/internal/testutil/testdata/scan-rules.yaml new file mode 100644 index 00000000..5b622c01 --- /dev/null +++ b/tests/e2e/internal/testutil/testdata/scan-rules.yaml @@ -0,0 +1,22 @@ +# Deterministic fixture matchers, not production credential detectors. +patterns: + - pattern: + name: Password in URL + regex: '(postgres|postgresql|mysql)://[^\s"<>]+' + confidence: high + - pattern: + name: Github Personal Access Token + regex: 'ghp_[A-Za-z0-9]+' + confidence: high + - pattern: + name: Stripe Secret Key + regex: 'sk_(live|test)_[A-Za-z0-9]+' + confidence: high + - pattern: + name: AWS Fixture + regex: '(AKIA[A-Z0-9]{16}|AWS_SECRET_ACCESS_KEY[^\r\n]+)' + confidence: high + - pattern: + name: Assignment Fixture + regex: '(DATABASE_PASSWORD|GITLAB_TOKEN|JWT_SECRET|ENCRYPTION_KEY)=[^\r\n]+' + confidence: medium From 90cf6328d4e676a6682435180077272ce9975a86 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:22:11 +0000 Subject: [PATCH 4/5] fix: explain gosec false positives and print CI findings Annotate the CI YAML source label and fixed local rules-file read. Print gosec finding details before failing the CI check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gosec.yml | 2 ++ pkg/logging/hit.go | 2 +- pkg/scanner/rules/rules.go | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) 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/pkg/logging/hit.go b/pkg/logging/hit.go index 029135fb..fb1a3732 100644 --- a/pkg/logging/hit.go +++ b/pkg/logging/hit.go @@ -23,7 +23,7 @@ const ( SecretTypeNestedArchive SecretType = "nested-archive" // SecretTypeDotenv indicates a secret found in a dotenv file. SecretTypeDotenv SecretType = "dotenv" - SecretTypeCICDYaml SecretType = "cicd-yaml" + 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/scanner/rules/rules.go b/pkg/scanner/rules/rules.go index df5ed210..0a2474df 100644 --- a/pkg/scanner/rules/rules.go +++ b/pkg/scanner/rules/rules.go @@ -104,7 +104,7 @@ func loadRules(path string) ([]CompiledPattern, error) { return nil, err } patterns := slices.Clone(builtins) - data, err := os.ReadFile(path) + 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 } From 6db9cdf52d1bcf4b9e7455c7c09d746da3c3830d Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:30:37 +0000 Subject: [PATCH 5/5] Address rules engine PR review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/guides/gitlab.md | 3 +- docs/guides/scanning.md | 2 +- docs/introduction/configuration.md | 4 - docs/introduction/secrets_verification.md | 5 ++ internal/cmd/gitlab/scan/scan.go | 11 ++- internal/cmd/gitlab/scan/scan_test.go | 12 +++ internal/cmd/rules/rules.go | 2 +- pkg/config/gen/gen.go | 3 +- pkg/docs/generator.go | 2 +- pkg/docs/generator_test.go | 3 +- pkg/gitlab/scan/pipeline.go | 14 ++- pkg/gitlab/scan/pipeline_test.go | 39 ++++++++ pkg/scanner/detectors/gitlab_patterns_test.go | 40 +++++++++ pkg/scanner/detectors/gitlab_url_detector.go | 2 + pkg/scanner/engine/engine.go | 1 + pkg/scanner/engine/reporting_test.go | 19 ++++ pkg/scanner/rules/download.go | 33 +++---- pkg/scanner/rules/download_test.go | 2 +- pkg/scanner/rules/rules.go | 89 ++++++++++++++++--- pkg/scanner/rules/rules.yaml | 14 +++ pkg/scanner/rules/rules_test.go | 29 ++++++ pkg/scanner/types/types.go | 1 + tests/e2e/gitlab/scan/errors_test.go | 16 ++++ 23 files changed, 299 insertions(+), 47 deletions(-) diff --git a/docs/guides/gitlab.md b/docs/guides/gitlab.md index 2b309a7c..5b0d2724 100644 --- a/docs/guides/gitlab.md +++ b/docs/guides/gitlab.md @@ -189,7 +189,8 @@ $ pipeleek gl scan --token glpat-[redacted] --url https://gitlab.example.com -c 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 Loaded embedded and local rules count=15 -2024-09-26T13:47:10+02:00 debug Loaded filtered rules count=882 +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 c4ec8240..00320974 100644 --- a/docs/guides/scanning.md +++ b/docs/guides/scanning.md @@ -31,7 +31,7 @@ pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --cookie [redacted] - 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. Findings labeled `.gitlab-ci.yml` use `type=cicd-yaml` and link to that file on the default branch. The scanned content is GitLab's merged CI configuration, so a match may originate from an included file. +Job-log (`type=log`) and artifact (`type=archive`) findings link to the job. ### Live Findings Web UI diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 2c20c162..aa2acbda 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -77,10 +77,6 @@ common: 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. - -See [verification and confidence filtering](secrets_verification.md#verification) for engine-specific reporting behavior. - ### Optional Rules Download Settings for [`pipeleek rules download`](secrets_verification.md#optional-external-rules-download): diff --git a/docs/introduction/secrets_verification.md b/docs/introduction/secrets_verification.md index 3bc0db24..5434cd5c 100644 --- a/docs/introduction/secrets_verification.md +++ b/docs/introduction/secrets_verification.md @@ -40,6 +40,8 @@ To skip provider checks: 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. + ## Confidence and Filtering | Level | Meaning | @@ -88,6 +90,8 @@ pipeleek rules download --output additional-rules.yml `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`. ### Local customization @@ -106,3 +110,4 @@ patterns: ``` 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/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/rules/rules.go b/internal/cmd/rules/rules.go index b2c3454b..4af57f36 100644 --- a/internal/cmd/rules/rules.go +++ b/internal/cmd/rules/rules.go @@ -16,7 +16,7 @@ func NewRulesCmd() *cobra.Command { } 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. Scans never download rules automatically.", + 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)") diff --git a/pkg/config/gen/gen.go b/pkg/config/gen/gen.go index fc0d0fae..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{ @@ -153,7 +152,7 @@ func captureFlags(flagSet *pflag.FlagSet, keyPrefix []string, node *configNode, } flagSet.VisitAll(func(flag *pflag.Flag) { - if _, skip := rootFlagsToSkip[flag.Name]; skip && !(flag.Name == "output" && strings.Join(keyPrefix, ".") == "rules.download") { + if _, skip := rootFlagsToSkip[flag.Name]; skip { return } 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 103f40de..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: strings.TrimRight(project.WebURL, "/") + "/-/blob/HEAD/.gitlab-ci.yml", - 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 503cb0b8..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 { @@ -116,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/scanner/detectors/gitlab_patterns_test.go b/pkg/scanner/detectors/gitlab_patterns_test.go index 84c2e5cb..39c6510f 100644 --- a/pkg/scanner/detectors/gitlab_patterns_test.go +++ b/pkg/scanner/detectors/gitlab_patterns_test.go @@ -2,7 +2,10 @@ package detectors import ( "context" + "net/http" + "net/http/httptest" "strings" + "sync/atomic" "testing" "github.com/stretchr/testify/assert" @@ -27,6 +30,7 @@ func TestGitLabSharedPATFormats(t *testing.T) { 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)) @@ -34,3 +38,39 @@ func TestGitLabSharedPATFormats(t *testing.T) { }) } } + +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 d1933b0c..8ec77324 100644 --- a/pkg/scanner/detectors/gitlab_url_detector.go +++ b/pkg/scanner/detectors/gitlab_url_detector.go @@ -6,6 +6,7 @@ import ( "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" @@ -100,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 { diff --git a/pkg/scanner/engine/engine.go b/pkg/scanner/engine/engine.go index 0679282c..88182544 100644 --- a/pkg/scanner/engine/engine.go +++ b/pkg/scanner/engine/engine.go @@ -129,6 +129,7 @@ func detectHits(ctx context.Context, text []byte, maxThreads int, enableSecretsV } 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) { diff --git a/pkg/scanner/engine/reporting_test.go b/pkg/scanner/engine/reporting_test.go index 987df87c..c20076fd 100644 --- a/pkg/scanner/engine/reporting_test.go +++ b/pkg/scanner/engine/reporting_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "slices" + "strings" "testing" "time" @@ -179,3 +180,21 @@ func TestYAMLConfidenceFilterIncludesAllWhenEmpty(t *testing.T) { 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 index 6b92d15f..d5d84fd4 100644 --- a/pkg/scanner/rules/download.go +++ b/pkg/scanner/rules/download.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "os" - "path/filepath" "github.com/CompassSecurity/pipeleek/pkg/httpclient" ) @@ -39,15 +38,10 @@ func download(ctx context.Context, client *http.Client, source, output string, f if response.StatusCode != http.StatusOK { return 0, fmt.Errorf("downloading rules: unexpected HTTP status %d", response.StatusCode) } - // Limit external input before parsing or writing it. - const maxSize = 16 << 20 - data, err := io.ReadAll(io.LimitReader(response.Body, maxSize+1)) + data, err := io.ReadAll(response.Body) if err != nil { return 0, fmt.Errorf("reading downloaded rules: %w", err) } - if len(data) > maxSize { - return 0, fmt.Errorf("downloaded rules exceed %d bytes", maxSize) - } patterns, err := parseRules(data, source) if err != nil { return 0, err @@ -55,26 +49,21 @@ func download(ctx context.Context, client *http.Client, source, output string, f if len(patterns) == 0 { return 0, fmt.Errorf("downloaded rules contain no patterns") } - temp, err := os.CreateTemp(filepath.Dir(output), ".pipeleek-rules-*") + 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("creating rules temporary file: %w", err) + return 0, fmt.Errorf("opening rules output %s: %w", output, err) } - defer os.Remove(temp.Name()) - if _, err := temp.Write(data); err != nil { - _ = temp.Close() + if _, err := file.Write(data); err != nil { + _ = file.Close() return 0, fmt.Errorf("writing rules: %w", err) } - if err := temp.Close(); err != nil { + if err := file.Close(); err != nil { return 0, fmt.Errorf("closing rules: %w", err) } - if force { - err = os.Rename(temp.Name(), output) - } else { - // Publish without replacing a file created while the download was running. - err = os.Link(temp.Name(), output) - } - if err != nil { - return 0, fmt.Errorf("saving rules to %s: %w", output, err) - } return len(patterns), nil } diff --git a/pkg/scanner/rules/download_test.go b/pkg/scanner/rules/download_test.go index 7189de38..a3b39a2f 100644 --- a/pkg/scanner/rules/download_test.go +++ b/pkg/scanner/rules/download_test.go @@ -24,13 +24,13 @@ func TestDownload(t *testing.T) { 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: "oversized", body: strings.Repeat(" ", (16<<20)+1), 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) { diff --git a/pkg/scanner/rules/rules.go b/pkg/scanner/rules/rules.go index 0a2474df..c23de421 100644 --- a/pkg/scanner/rules/rules.go +++ b/pkg/scanner/rules/rules.go @@ -23,29 +23,95 @@ const ruleFileName = "rules.yml" var builtinYAML []byte type CompiledPattern struct { - Pattern types.PatternElement - regex *regexp.Regexp - token bool + Pattern types.PatternElement + regex *regexp.Regexp + enforceTokenBoundaries bool } func (p CompiledPattern) FindAllIndex(text []byte) [][]int { hits := p.regex.FindAllIndex(text, -1) - if !p.token { + 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 start > 0 && isTokenByte(text[start-1]) && text[start-1] != '=' { + if previous, ok := previousVisibleByte(text, start); ok && isTokenByte(previous) && previous != '=' { return true } - if end < len(text) && isTokenByte(text[end]) { + next, nextIndex, ok := nextVisibleByte(text, end) + if ok && isTokenByte(next) { return true } // A dot followed by token characters is a format segment, not punctuation. - return end+1 < len(text) && text[end] == '.' && isTokenByte(text[end+1]) + 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 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 +} + +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 +} + func isTokenByte(b byte) bool { return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '-' || b == '_' || b == '=' } @@ -90,9 +156,9 @@ func parseRules(data []byte, source string) ([]CompiledPattern, error) { return nil, fmt.Errorf("%s: rule %q: invalid regex: %w", source, p.Name, err) } patterns = append(patterns, CompiledPattern{ - Pattern: element, - regex: regex, - token: source == "embedded rules.yaml" && p.Confidence == "high", + Pattern: element, + regex: regex, + enforceTokenBoundaries: p.Token, }) } return patterns, nil @@ -146,6 +212,9 @@ func InitRules(confidenceFilter []string) { 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") + } } func AppendPipeleekRules(patterns []types.PatternElement) []types.PatternElement { diff --git a/pkg/scanner/rules/rules.yaml b/pkg/scanner/rules/rules.yaml index 006d524d..4d877a31 100644 --- a/pkg/scanner/rules/rules.yaml +++ b/pkg/scanner/rules/rules.yaml @@ -7,55 +7,69 @@ patterns: 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 60d3c08f..348cebf7 100644 --- a/pkg/scanner/rules/rules_test.go +++ b/pkg/scanner/rules/rules_test.go @@ -26,6 +26,7 @@ func TestBuiltinRules(t *testing.T) { } 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": @@ -73,6 +74,32 @@ func TestPATBoundaries(t *testing.T) { } } +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 TestLoadRulesMissingFile(t *testing.T) { dir := t.TempDir() patterns, err := loadRules(filepath.Join(dir, ruleFileName)) @@ -94,6 +121,7 @@ func TestLoadRulesCustomOverrides(t *testing.T) { name: Additional regex: EXTRA confidence: high + token: true `) require.NoError(t, os.WriteFile(path, data, 0600)) patterns, err := loadRules(path) @@ -103,6 +131,7 @@ func TestLoadRulesCustomOverrides(t *testing.T) { 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) 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/tests/e2e/gitlab/scan/errors_test.go b/tests/e2e/gitlab/scan/errors_test.go index 6a9d31d1..608221be 100644 --- a/tests/e2e/gitlab/scan/errors_test.go +++ b/tests/e2e/gitlab/scan/errors_test.go @@ -61,6 +61,22 @@ func TestGitLabScan_JobStatusSkipsProjectErrors(t *testing.T) { } } +func TestGitLabScan_RejectsSpaceSeparatedBooleanValue(t *testing.T) { + server, requests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("Invalid arguments must not trigger API requests: %s", r.URL) + w.WriteHeader(http.StatusInternalServerError) + }) + defer cleanup() + stdout, stderr, err := testutil.RunCLI(t, []string{ + "gl", "scan", "--url", server.URL, "--token", "fixture-token", + "--secrets-verification", "false", + }, nil, 10*time.Second) + require.Error(t, err) + assert.Contains(t, stdout+stderr, "--secrets-verification=false") + assert.NotContains(t, stdout+stderr, "Setting up queue") + assert.Empty(t, requests(), "Argument validation must happen before any provider checks") +} + func TestGitLabScan_PipelineSourceSkipsDeletedResources(t *testing.T) { for _, resource := range []string{"project", "pipeline"} { t.Run(resource, func(t *testing.T) {