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{
" ",
"
",
"
all
",
- "
high-verified
",
+ "
verified
",
+ "
trufflehog
",
"
high
",
"
medium
",
"
low
",
@@ -308,11 +310,11 @@ var pageTemplate = strings.Join([]string{
"
",
" ",
"
",
- "
",
+ "
",
"
",
"
",
"
",
- "
high-verified
",
+ "
verified
",
"
high and above
",
"
medium and above
",
"
low and above
",
@@ -359,9 +361,9 @@ var pageTemplate = strings.Join([]string{
" const normalize = function(value) { return (value || '').toLowerCase(); };",
" function updateNotificationStatus() { if (!('Notification' in window)) { enableNotificationsButton.hidden = true; notificationStatus.textContent = 'Browser notifications are not supported.'; return; } if (Notification.permission === 'granted') { enableNotificationsButton.hidden = true; notificationStatus.textContent = notificationThreshold.value === 'none' ? 'Notifications are off.' : 'Browser notifications enabled.'; return; } if (Notification.permission === 'denied') { enableNotificationsButton.hidden = true; notificationStatus.textContent = 'Notifications are blocked in browser settings.'; return; } enableNotificationsButton.hidden = notificationThreshold.value === 'none'; notificationStatus.textContent = notificationThreshold.value === 'none' ? 'Notifications are off.' : 'Allow notifications for matching findings.'; }",
" function requestNotificationPermission() { if (!('Notification' in window)) { updateNotificationStatus(); return; } try { Notification.requestPermission().then(updateNotificationStatus).catch(updateNotificationStatus); } catch (_) { updateNotificationStatus(); } }",
- " function meetsNotificationThreshold(confidence) { var threshold = notificationThreshold.value; if (threshold === 'none') return false; if (threshold === 'any') return true; var levels = ['high-verified', 'high', 'medium', 'low']; var findingLevel = levels.indexOf(normalize(confidence)); return findingLevel >= 0 && findingLevel <= levels.indexOf(threshold); }",
+ " function meetsNotificationThreshold(confidence) { var threshold = notificationThreshold.value; if (threshold === 'none') return false; if (threshold === 'any') return true; var levels = ['verified', 'high', 'medium', 'low']; var findingLevel = levels.indexOf(normalize(confidence)); return findingLevel >= 0 && findingLevel <= levels.indexOf(threshold); }",
" function closeActiveNotifications() { activeNotifications.forEach(function(notification) { notification.close(); }); activeNotifications = []; }",
- " function notifyFinding(item) { var findingTime = Number(item.timestampMillis); if (!state.notificationsReady || (findingTime > 0 && findingTime < notificationsSince) || !('Notification' in window) || Notification.permission !== 'granted' || !meetsNotificationThreshold(item.confidence)) return; try { var title = item.confidence === 'high-verified' ? 'Verified credential found' : 'Credential finding detected'; var notification = new Notification(title, { body: 'Confidence: ' + (item.confidence || 'unknown') + '. Open Pipeleek to review.' }); activeNotifications.push(notification); notification.onclose = function() { activeNotifications = activeNotifications.filter(function(openNotification) { return openNotification !== notification; }); }; notification.onclick = function() { window.focus(); notification.close(); }; } catch (_) {} }",
+ " function notifyFinding(item) { var findingTime = Number(item.timestampMillis); if (!state.notificationsReady || (findingTime > 0 && findingTime < notificationsSince) || !('Notification' in window) || Notification.permission !== 'granted' || !meetsNotificationThreshold(item.confidence)) return; try { var title = item.confidence === 'verified' ? 'Verified credential found' : 'Credential finding detected'; var notification = new Notification(title, { body: 'Confidence: ' + (item.confidence || 'unknown') + '. Open Pipeleek to review.' }); activeNotifications.push(notification); notification.onclose = function() { activeNotifications = activeNotifications.filter(function(openNotification) { return openNotification !== notification; }); }; notification.onclick = function() { window.focus(); notification.close(); }; } catch (_) {} }",
" function severityClass(value) { return String(value || 'unknown').toLowerCase().replace(/\\s+/g, '-'); }",
" function typeClass(value) { return String(value || 'log').toLowerCase().replace(/\\s+/g, '-'); }",
" function showToast(message) { toast.textContent = message; toast.classList.add('visible'); clearTimeout(showToast.timeout); showToast.timeout = setTimeout(function() { toast.classList.remove('visible'); }, 1200); }",
@@ -372,10 +374,10 @@ var pageTemplate = strings.Join([]string{
" return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });",
" }",
" function renderSummary() {",
- " var counts = { low: 0, medium: 0, high: 0, 'high-verified': 0 };",
+ " var counts = { low: 0, medium: 0, high: 0, verified: 0, trufflehog: 0 };",
" state.all.forEach(function(item) { var key = normalize(item.confidence || 'unknown'); if (key in counts) counts[key] += 1; });",
" summaryCards.replaceChildren();",
- " ['high-verified', 'high', 'medium', 'low'].forEach(function(key) { var metric = document.createElement('div'); metric.className = 'metric'; var label = document.createElement('label'); label.textContent = key.charAt(0).toUpperCase() + key.slice(1); var count = document.createElement('strong'); count.textContent = String(counts[key] || 0); metric.append(label, count); summaryCards.appendChild(metric); });",
+ " ['verified', 'high', 'medium', 'low', 'trufflehog'].forEach(function(key) { var metric = document.createElement('div'); metric.className = 'metric'; var label = document.createElement('label'); label.textContent = key.charAt(0).toUpperCase() + key.slice(1); if (key === 'trufflehog') label.title = 'TruffleHog detection without successful verification'; var count = document.createElement('strong'); count.textContent = String(counts[key] || 0); metric.append(label, count); summaryCards.appendChild(metric); });",
" }",
" function filteredFindings() {",
" var query = normalize(searchFilter.value);",
diff --git a/pkg/webui/server_test.go b/pkg/webui/server_test.go
index 5d6e6491..030ae35f 100644
--- a/pkg/webui/server_test.go
+++ b/pkg/webui/server_test.go
@@ -51,6 +51,30 @@ func TestHandleHitAssignsUniqueIDs(t *testing.T) {
}
}
+func TestHandleHitPreservesDetectionLevels(t *testing.T) {
+ for _, confidence := range []string{"verified", "trufflehog"} {
+ s := &Server{clients: make(map[chan string]struct{})}
+ s.handleHit(logging.HitRecord{
+ Time: time.Now(), Confidence: confidence, Value: "fixture",
+ Fields: map[string]interface{}{"engine": "trufflehog"},
+ })
+ if len(s.findings) != 1 || s.findings[0].Confidence != confidence || s.findings[0].Details["engine"] != "trufflehog" {
+ t.Fatal("expected the live finding to preserve its detection level and engine")
+ }
+ data, err := json.Marshal(s.findings[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ var decoded Finding
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatal(err)
+ }
+ if decoded.Confidence != confidence || decoded.Details["engine"] != "trufflehog" {
+ t.Fatal("expected SSE JSON to preserve detection metadata")
+ }
+ }
+}
+
func TestCompletedEventsReplayFindingsAndDone(t *testing.T) {
startedAt := time.Date(2026, time.October, 1, 12, 0, 0, 0, time.UTC)
s := &Server{token: "abc", clients: make(map[chan string]struct{}), startedAt: startedAt}
@@ -133,14 +157,20 @@ 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="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")
+ }
+ if !strings.Contains(body, "var levels = ['verified', 'high', 'medium', 'low']") || strings.Contains(body, "high-verified") || strings.Contains(body, "trufflehog-unverified") {
+ t.Fatal("expected migrated notification ranking without ranking TruffleHog candidates as verified")
+ }
if !strings.Contains(body, "No findings yet. Results will appear here as the scan runs.") || !strings.Contains(body, "No findings were detected.") || !strings.Contains(body, "No findings match the current filters.") {
t.Fatal("expected distinct empty states for an active scan, a completed scan, and filtered results")
}
if !strings.Contains(body, "id=\"elapsed-time\"") || !strings.Contains(body, "var elapsedBase = ") || !strings.Contains(body, "window.setInterval(updateElapsedTime, 1000)") {
t.Fatal("expected elapsed timer element, server-provided baseline, and periodic updates")
}
- if !strings.Contains(body, `id="notification-threshold-picker"`) || !strings.Contains(body, `aria-multiselectable="false"`) || !strings.Contains(body, `data-value="high-verified" aria-selected="true"`) || !strings.Contains(body, `data-value="none" aria-selected="false"`) || !strings.Contains(body, `class="badge high-verified">high-verified`) || !strings.Contains(body, `id="enable-notifications"`) {
- t.Fatal("expected single-select badge picker with high-verified default, None option, and permission fallback control")
+ if !strings.Contains(body, `id="notification-threshold-picker"`) || !strings.Contains(body, `aria-multiselectable="false"`) || !strings.Contains(body, `data-value="verified" aria-selected="true"`) || !strings.Contains(body, `data-value="none" aria-selected="false"`) || !strings.Contains(body, `class="badge verified">verified`) || !strings.Contains(body, `id="enable-notifications"`) {
+ t.Fatal("expected single-select badge picker with verified default, None option, and permission fallback control")
}
if strings.Index(body, `id="notification-status"`) < strings.Index(body, `id="notification-threshold-picker"`) {
t.Fatal("expected notification status to appear after its threshold picker")
@@ -151,8 +181,8 @@ func TestHandleRootAllowsInlineScript(t *testing.T) {
if !strings.Contains(body, "notificationsSince = Date.now(); closeActiveNotifications()") || !strings.Contains(body, "Number(item.timestampMillis)") || !strings.Contains(body, "findingTime < notificationsSince") || !strings.Contains(body, "function closeActiveNotifications()") {
t.Fatal("expected threshold changes to close and suppress already-queued notifications")
}
- if !strings.Contains(body, `id="notification-threshold" type="hidden" value="high-verified"`) || strings.Contains(body, "document.cookie") || strings.Contains(body, "localStorage") {
- t.Fatal("expected the notification threshold to reset to high-verified for each new WebUI page")
+ if !strings.Contains(body, `id="notification-threshold" type="hidden" value="verified"`) || strings.Contains(body, "document.cookie") || strings.Contains(body, "localStorage") {
+ t.Fatal("expected the notification threshold to reset to verified for each new WebUI page")
}
if strings.Contains(body, "tableBody.innerHTML") || !strings.Contains(body, "function appendDetailValue") || !strings.Contains(body, "parsed.protocol === 'http:' || parsed.protocol === 'https:'") {
t.Fatal("expected finding values to render via DOM text APIs with HTTP(S)-only links")
@@ -169,7 +199,7 @@ func TestHandleRootAllowsInlineScript(t *testing.T) {
if !strings.Contains(body, ".filters { display: flex; flex-wrap: wrap; gap: .75rem; align-items: flex-start; }") || !strings.Contains(body, ".filters > .control-btn { align-self: flex-start; margin-top: 1.5rem; }") {
t.Fatal("expected filter labels to align at the top while action buttons align with the controls")
}
- if strings.Count(body, `aria-multiselectable="true"`) != 2 || !strings.Contains(body, `class="badge high-verified">high-verified`) {
+ if strings.Count(body, `aria-multiselectable="true"`) != 2 || !strings.Contains(body, `class="badge verified">verified`) {
t.Fatal("expected severity and type filters to render badge-style multi-select options")
}
if !strings.Contains(body, `id="toggle-full-width"`) || !strings.Contains(body, "main.main-wide") {
@@ -305,7 +335,7 @@ func TestExportCSVPreservesSpecialValues(t *testing.T) {
{
Time: "2026-10-01T12:34:56Z",
Type: "log",
- Confidence: "high",
+ Confidence: "trufflehog",
RuleName: `rule, with "quotes"`,
Value: "first line,\r\nsecond line \"quoted\" 雪",
Details: map[string]string{
@@ -316,7 +346,7 @@ func TestExportCSVPreservesSpecialValues(t *testing.T) {
{
Time: "2026-10-01T12:35:00Z",
Type: "dotenv",
- Confidence: "low",
+ Confidence: "verified",
RuleName: "formula value",
Value: "\t=1+1",
},
@@ -345,6 +375,9 @@ func TestExportCSVPreservesSpecialValues(t *testing.T) {
if len(records) != 3 {
t.Fatalf("expected header and two findings, got %d records", len(records))
}
+ if records[1][0] != "trufflehog" || records[2][0] != "verified" {
+ t.Fatal("expected CSV export to preserve the detection levels")
+ }
if got, want := records[1][1], findings[0].RuleName; got != want {
t.Errorf("rule name mismatch: got %q, want %q", got, want)
}
diff --git a/tests/e2e/github/scan/local_rules_test.go b/tests/e2e/github/scan/local_rules_test.go
new file mode 100644
index 00000000..8b60bb7f
--- /dev/null
+++ b/tests/e2e/github/scan/local_rules_test.go
@@ -0,0 +1,143 @@
+package e2e
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/CompassSecurity/pipeleek/tests/e2e/internal/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGitHubScan_LocalRulesAndLevels(t *testing.T) {
+ require.True(t, filepath.IsAbs(os.Getenv("PIPELEEK_BINARY")), "Use an absolute PIPELEEK_BINARY for isolated working-directory tests")
+ dir := t.TempDir()
+ t.Chdir(dir)
+ archive := func(name, content string) []byte {
+ var buffer bytes.Buffer
+ writer := zip.NewWriter(&buffer)
+ file, err := writer.Create(name)
+ require.NoError(t, err)
+ _, err = file.Write([]byte(content))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+ return buffer.Bytes()
+ }
+ logs := archive("job.log", "GITLAB_USER_ID=19357\nGITLAB_TOKEN=glpat-abcdefghijklmnopqrst\nPIPELEEK_LOG_FIXTURE\n")
+ artifact := archive("settings.txt", "KUBECONFIG=/tmp/local-config\nPIPELEEK_ARTIFACT_FIXTURE\n")
+ server, requests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v3/user/repos":
+ testutil.JSON(w, http.StatusOK, []map[string]interface{}{{
+ "id": 1, "name": "test-repo", "full_name": "user/test-repo",
+ "html_url": "https://example.test/user/test-repo",
+ "owner": map[string]interface{}{"login": "user"},
+ }})
+ case "/api/v3/repos/user/test-repo/actions/runs":
+ testutil.JSON(w, http.StatusOK, map[string]interface{}{
+ "total_count": 1,
+ "workflow_runs": []map[string]interface{}{{
+ "id": 100, "name": "Local rules", "status": "completed", "display_title": "Local rules",
+ "html_url": "https://example.test/runs/100",
+ "repository": map[string]interface{}{"name": "test-repo", "owner": map[string]interface{}{"login": "user"}},
+ }},
+ })
+ case "/api/v3/repos/user/test-repo/actions/runs/100/jobs":
+ testutil.JSON(w, http.StatusOK, map[string]interface{}{"jobs": []map[string]interface{}{{"id": 1000, "name": "fixture-job"}}, "total_count": 1})
+ case "/api/v3/repos/user/test-repo/actions/runs/100/logs":
+ w.Header().Set("Location", "http://"+r.Host+"/download/logs.zip")
+ w.WriteHeader(http.StatusFound)
+ case "/download/logs.zip":
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(logs)
+ case "/api/v3/repos/user/test-repo/actions/runs/100/artifacts":
+ testutil.JSON(w, http.StatusOK, map[string]interface{}{
+ "artifacts": []map[string]interface{}{{
+ "id": 1, "name": "fixture-output",
+ "archive_download_url": "http://" + r.Host + "/api/v3/repos/user/test-repo/actions/artifacts/1/zip",
+ }}, "total_count": 1,
+ })
+ case "/api/v3/repos/user/test-repo/actions/artifacts/1/zip":
+ w.Header().Set("Location", "http://"+r.Host+"/download/artifact.zip")
+ w.WriteHeader(http.StatusFound)
+ case "/download/artifact.zip":
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(artifact)
+ default:
+ testutil.JSON(w, http.StatusOK, map[string]interface{}{})
+ }
+ })
+ defer cleanup()
+
+ run := func(confidence string) []map[string]interface{} {
+ t.Helper()
+ args := []string{"gh", "scan", "--url", server.URL, "--token", "fixture-token", "--owned", "--artifacts", "--secrets-verification=false", "--json", "--verbose"}
+ if confidence != "" {
+ args = append(args, "--confidence="+confidence)
+ }
+ stdout, stderr, err := testutil.RunCLI(t, args, nil, time.Minute)
+ require.NoError(t, err, stdout+stderr)
+ var findings []map[string]interface{}
+ for _, line := range strings.Split(stdout+"\n"+stderr, "\n") {
+ var record map[string]interface{}
+ if json.Unmarshal([]byte(line), &record) == nil && record["message"] == "SECRET" {
+ findings = append(findings, record)
+ }
+ }
+ return findings
+ }
+ has := func(findings []map[string]interface{}, confidence, value string) bool {
+ for _, finding := range findings {
+ text, ok := finding["value"].(string)
+ if ok && finding["confidence"] == confidence && strings.Contains(text, value) {
+ return true
+ }
+ }
+ return false
+ }
+ findings := run("")
+ assert.True(t, has(findings, "medium", "GITLAB_USER_ID=19357"), "Retain the identifier rule without a local rules file")
+ assert.True(t, has(findings, "medium", "KUBECONFIG=/tmp/local-config"), "Retain path matching in artifacts")
+ assert.True(t, has(findings, "trufflehog", "glpat-abcdefghijklmnopqrst"), "Offline TruffleHog candidates use the new level")
+ _, err := os.Stat(filepath.Join(dir, "rules.yml"))
+ assert.True(t, os.IsNotExist(err), "Scanning must not create or download rules")
+ assert.Empty(t, run("verified"), "Offline fixtures must not become verified")
+ candidates := run("trufflehog")
+ require.NotEmpty(t, candidates)
+ for _, finding := range candidates {
+ assert.Equal(t, "trufflehog", finding["confidence"])
+ assert.Equal(t, "trufflehog", finding["engine"])
+ }
+
+ custom := []byte(`patterns:
+ - pattern:
+ name: Gitlab - Predefined Environment Variable
+ regex: PIPELEEK_(LOG|ARTIFACT)_FIXTURE
+ confidence: custom-confidence
+`)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "rules.yml"), custom, 0600))
+ findings = run("custom-confidence,medium")
+ assert.True(t, has(findings, "custom-confidence", "PIPELEEK_LOG_FIXTURE"))
+ assert.True(t, has(findings, "custom-confidence", "PIPELEEK_ARTIFACT_FIXTURE"))
+ for _, finding := range findings {
+ assert.Equal(t, "custom-confidence", finding["confidence"], "The overridden variable rule must not emit medium findings")
+ }
+ unchanged, err := os.ReadFile(filepath.Join(dir, "rules.yml"))
+ require.NoError(t, err)
+ assert.Equal(t, custom, unchanged)
+ assert.NotEmpty(t, requests())
+
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "rules.yml"), []byte("patterns:\n- pattern: {name: InvalidFixture, regex: '[', confidence: high}\n"), 0600))
+ stdout, stderr, err := testutil.RunCLI(t, []string{"gh", "scan", "--url", server.URL, "--token", "fixture-token", "--secrets-verification=false"}, nil, time.Minute)
+ require.Error(t, err)
+ assert.Contains(t, stdout+stderr, "InvalidFixture")
+ assert.Contains(t, stdout+stderr, "invalid regex")
+ assert.Contains(t, stdout+stderr, "Failed loading rules")
+}
diff --git a/tests/e2e/rules/download_test.go b/tests/e2e/rules/download_test.go
new file mode 100644
index 00000000..0131a1ae
--- /dev/null
+++ b/tests/e2e/rules/download_test.go
@@ -0,0 +1,98 @@
+package rules
+
+import (
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/CompassSecurity/pipeleek/tests/e2e/internal/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+func TestRulesDownload(t *testing.T) {
+ require.True(t, filepath.IsAbs(os.Getenv("PIPELEEK_BINARY")))
+ t.Chdir(t.TempDir())
+ const fixture = "patterns:\n - pattern:\n name: Fixture\n regex: FIXTURE_[A-Z]+\n confidence: high\n"
+ backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/mazen160/secrets-patterns-db/master/db/rules-stable.yml", r.URL.Path)
+ _, _ = w.Write([]byte(fixture))
+ }))
+ defer backend.Close()
+ proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !assert.Equal(t, http.MethodConnect, r.Method) || !assert.Equal(t, "raw.githubusercontent.com:443", r.Host) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ upstream, err := net.DialTimeout("tcp", backend.Listener.Addr().String(), 5*time.Second)
+ if !assert.NoError(t, err) {
+ w.WriteHeader(http.StatusBadGateway)
+ return
+ }
+ defer upstream.Close()
+ conn, buffer, err := w.(http.Hijacker).Hijack()
+ if !assert.NoError(t, err) {
+ return
+ }
+ defer conn.Close()
+ _, err = conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
+ if !assert.NoError(t, err) {
+ return
+ }
+ go func() { _, _ = io.Copy(upstream, buffer) }()
+ _, _ = io.Copy(conn, upstream)
+ }))
+ defer proxy.Close()
+ run := func(extra ...string) (string, error) {
+ t.Helper()
+ args := append([]string{"rules", "download", "--proxy", proxy.URL, "--http-timeout", "10s"}, extra...)
+ stdout, stderr, err := testutil.RunCLI(t, args, nil, 30*time.Second)
+ return stdout + stderr, err
+ }
+ output, err := run()
+ require.NoError(t, err, output)
+ assert.Contains(t, output, "Downloaded optional rules")
+ data, err := os.ReadFile("rules.yml")
+ require.NoError(t, err)
+ require.Equal(t, fixture, string(data))
+
+ require.NoError(t, os.WriteFile("rules.yml", []byte("original"), 0600))
+ output, err = run()
+ require.Error(t, err, output)
+ assert.Contains(t, output, "--force")
+ data, err = os.ReadFile("rules.yml")
+ require.NoError(t, err)
+ require.Equal(t, "original", string(data))
+
+ output, err = run("--force")
+ require.NoError(t, err, output)
+ data, err = os.ReadFile("rules.yml")
+ require.NoError(t, err)
+ require.Equal(t, fixture, string(data))
+
+ output, err = run("--output", "custom.yml")
+ require.NoError(t, err, output)
+ data, err = os.ReadFile("custom.yml")
+ require.NoError(t, err)
+ require.Equal(t, fixture, string(data))
+
+ stdout, stderr, err := testutil.RunCLI(t, []string{"config", "gen"}, nil, 30*time.Second)
+ require.NoError(t, err, stderr)
+ var generated struct {
+ Rules struct {
+ Download struct {
+ Output string
+ Force bool
+ }
+ }
+ }
+ require.NoError(t, yaml.Unmarshal([]byte(stdout), &generated))
+ require.Equal(t, "rules.yml", generated.Rules.Download.Output)
+ require.False(t, generated.Rules.Download.Force)
+}
From 93093ffaae08b4672eb176676b50781e8cff02f8 Mon Sep 17 00:00:00 2001
From: frjcomp <107982661+frjcomp@users.noreply.github.com>
Date: Mon, 19 Oct 2026 08:27:23 +0000
Subject: [PATCH 2/5] fix: identify CI YAML findings and streamline scanner
docs
Report merged GitLab CI configuration findings as cicd-yaml and link to the CI YAML file. Add the source type to the web UI and regression tests. Consolidate rules documentation and update ELK verified-confidence queries.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
docs/guides/scanning.md | 4 +-
docs/introduction/configuration.md | 15 +---
docs/introduction/secrets_verification.md | 95 ++++++++---------------
examples/compose-elk/README.md | 2 +-
examples/compose-elk/setup-kibana.sh | 14 ++--
pkg/gitlab/scan/pipeline.go | 2 +-
pkg/gitlab/scan/pipeline_test.go | 6 +-
pkg/gitlab/scan/queue.go | 1 +
pkg/logging/hit.go | 3 +-
pkg/webui/server.go | 2 +
pkg/webui/server_test.go | 3 +
tests/e2e/gitlab/cicd/scan/scan_test.go | 12 ++-
12 files changed, 70 insertions(+), 89 deletions(-)
diff --git a/docs/guides/scanning.md b/docs/guides/scanning.md
index 1b22dae8..c4ec8240 100644
--- a/docs/guides/scanning.md
+++ b/docs/guides/scanning.md
@@ -29,7 +29,9 @@ pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --cookie [redacted] -
2025-09-30T09:53:37Z hit SECRET confidence=high type=artifact file=an_artifact.txt jobName=artifact-job ruleName="Generic - 1719" url=gitlab.com/testgroup/project/-/jobs/11484162833 value="datadog_api_key=secret_artifact_value "
```
-As shown, Pipeleek can detect secrets in job logs and build artifacts using its 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.
+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.
### Live Findings Web UI
diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md
index c5157d94..3874dae8 100644
--- a/docs/introduction/configuration.md
+++ b/docs/introduction/configuration.md
@@ -79,16 +79,11 @@ 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.
-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.
+See [verification and confidence filtering](secrets_verification.md#verification) for engine-specific reporting behavior and migration notes.
### 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.
+Settings for [`pipeleek rules download`](secrets_verification.md#optional-external-rules-download):
```yaml
rules:
@@ -97,14 +92,8 @@ rules:
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
gitlab:
scan:
diff --git a/docs/introduction/secrets_verification.md b/docs/introduction/secrets_verification.md
index 6b1a856b..882034bf 100644
--- a/docs/introduction/secrets_verification.md
+++ b/docs/introduction/secrets_verification.md
@@ -1,6 +1,6 @@
---
-title: Secret Verification with TruffleHog
-description: Learn how Pipeleek uses TruffleHog to automatically verify detected secrets, understand confidence levels, and how to disable verification for operational security.
+title: Secret Detection, Verification, and Rules
+description: Understand Pipeleek's detection engines, confidence levels, custom rules, and optional rule downloads.
keywords:
- secret verification
- TruffleHog
@@ -12,50 +12,44 @@ keywords:
- opsec
---
-Pipeleek uses [TruffleHog](https://github.com/trufflesecurity/trufflehog) and [Betterleaks](https://github.com/betterleaks/betterleaks) to detect secrets in CI/CD logs and artifacts. When verification is enabled, both engines can check supported credentials against their providers.
+# Secret Detection, Verification, and Rules
-### How It Works
+Pipeleek scans CI/CD logs and artifacts using:
-When Pipeleek scans logs or artifacts, it uses three detection sources:
+- **YAML rules:** 15 embedded Pipeleek rules plus optional working-directory `rules.yml`.
+- **[Betterleaks](https://github.com/betterleaks/betterleaks):** embedded rules with false-positive filtering.
+- **[TruffleHog](https://github.com/trufflesecurity/trufflehog):** specialized credential detectors.
+- **GitLab URL-aware detector:** checks supported tokens against your configured GitLab instance.
-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
+## Verification
-The `--secrets-verification` option controls provider verification for both TruffleHog and Betterleaks. With verification enabled, either engine may make outbound requests to credential providers for supported credentials. Disable verification to scan without those provider checks; findings are still reported, but their confidence depends on the detection source:
+Verification is enabled by default. `--secrets-verification` controls provider checks for TruffleHog, Betterleaks, and the GitLab detector. These checks can send detected credentials to provider APIs.
-- 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.
+| Source | Verification enabled | Verification disabled |
+| ------ | -------------------- | --------------------- |
+| TruffleHog | Only successfully verified findings (`verified`) | Returned candidates (`trufflehog`) |
+| Betterleaks | Valid credentials use `verified`; invalid/revoked results are suppressed; other retained findings use rule confidence | Rule confidence (`high`, `medium`, or `low`) |
+| GitLab detector | Verified credentials use `verified`; retained non-verified findings use `high` | Retained findings use `high` |
+| YAML rules | Rule confidence; no provider checks | 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.
+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.
-### Confidence Levels
-
-Pipeleek assigns confidence levels to all detected secrets:
-
-| 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
-
-For operational security (OpSec) or privacy, disable verification to prevent TruffleHog and Betterleaks from sending credential checks to provider APIs.
-
-Use the `--secrets-verification=false` flag:
+To skip provider checks:
```bash
pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --secrets-verification=false
```
-### Confidence Filtering
+## Confidence and Filtering
-Results from every engine can be filtered using the `--confidence` flag. An empty filter reports all levels. Filtering does not disable provider verification.
+| Level | Meaning |
+| ----- | ------- |
+| `verified` | Successfully verified credential |
+| `trufflehog` | Non-verified TruffleHog candidate; not a confidence ranking |
+| `high`, `medium`, `low` | Pattern confidence, not proof that a credential works |
+| User-defined label | Confidence assigned by a custom YAML rule |
+
+`--confidence` filters results from every engine. An empty filter includes all levels reported under the verification policy above. Filtering does not disable provider checks.
```bash
# Higher-confidence findings, including verified credentials
@@ -72,8 +66,7 @@ pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --secrets-verificatio
### Optional external rules download
-To explicitly install the Secrets Patterns Database stable ruleset from
-`mazen160/secrets-patterns-db`, run:
+To install the [Secrets Patterns Database](https://github.com/mazen160/secrets-patterns-db) stable ruleset:
```bash
pipeleek rules download
@@ -98,44 +91,24 @@ 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.
+Built-in rules include GitLab token patterns and predefined variables such as `GITLAB_USER_ID` and `KUBECONFIG`. Identifier/path matches are indicators to review, not proof of a leaked credential.
-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.
+Create `rules.yml` in your working directory to add rules. An exact-name match overrides a built-in in the YAML engine; other names add rules. Overrides do not affect the separate GitLab detector or other engines. Without this file, scans use the embedded defaults.
-For example:
-
-```yaml
-patterns:
- - pattern:
- name: AWS API Key
- regex: AKIA[0-9A-Z]{16}
- confidence: high
-```
-
-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).
-
-A simple example that detects strings that follow the Regex pattern `PIPELEEK_.*` and that are logged with a custom confidence:
+For example, to report `PIPELEEK_` markers with a custom confidence:
```yaml
patterns:
- pattern:
name: Pipeleek Custom Rule
- regex: PIPELEEK_.*
+ regex: PIPELEEK_[A-Z0-9_]+
confidence: custom-confidence
```
-When you run Pipeleek, you'll see results for your custom rule and any built-in rules:
-
-```bash
-pipeleek gl scan -u https://gitlab.com -t glpat-[redacted] --secrets-verification=false --verbose
-2025-09-30T11:39:08Z hit SECRET confidence=custom-confidence type=log jobName=build-job-hidden ruleName="Pipeleek Custom Rule" url=gitlab.com/testgroup/project/-/jobs/11547853360 value="PIPELEEK_HIT=secret"
-```
+Each rule needs a nonempty name, valid Go regex, and nonempty confidence label. Duplicate names, invalid YAML/regexes, and unreadable files fail initialization.
## 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.
+- Scans no longer download rules automatically. Use the optional download command above.
diff --git a/examples/compose-elk/README.md b/examples/compose-elk/README.md
index a4be3455..db3e746d 100644
--- a/examples/compose-elk/README.md
+++ b/examples/compose-elk/README.md
@@ -80,7 +80,7 @@ Once you've run `bash start-elk.sh`, the Kibana data view is automatically confi
3. You will see:
- `Pipeleek - High Hits` (count of `scan.confidence: high` hits)
-- `Pipeleek - High-Verified Hits` (count of `scan.confidence: high-verified` hits)
+- `Pipeleek - Verified Hits` (count of `scan.confidence: verified` hits)
- `Pipeleek - Log Intake Over Time` (bar chart over the last 4 hours with fixed 10-minute buckets)
- `Pipeleek - Hits by Asset Type` (pie chart, for example hit logs vs hit artifacts)
- `Pipeleek - Hits by Confidence` (pie chart of confidence ratings)
diff --git a/examples/compose-elk/setup-kibana.sh b/examples/compose-elk/setup-kibana.sh
index 130c8d51..b9129edb 100755
--- a/examples/compose-elk/setup-kibana.sh
+++ b/examples/compose-elk/setup-kibana.sh
@@ -210,12 +210,12 @@ high_metric_payload='{
}'
upsert_saved_object "visualization" "pipeleek-vis-high-count" "$high_metric_payload"
-echo "Creating metric visualization for high-verified hits..."
+echo "Creating metric visualization for verified hits..."
high_verified_metric_payload='{
"attributes": {
- "title": "Pipeleek - High-Verified Hits",
- "description": "Count of hit events with high-verified confidence",
- "visState": "{\"title\":\"Pipeleek - High-Verified Hits\",\"type\":\"metrics\",\"params\":{\"id\":\"pipeleek-high-verified-panel\",\"type\":\"metric\",\"series\":[{\"id\":\"pipeleek-high-verified-series\",\"split_mode\":\"everything\",\"split_color_mode\":\"value\",\"metrics\":[{\"id\":\"pipeleek-high-verified-count\",\"type\":\"count\"}],\"label\":\"High-Verified Hits\",\"color\":\"#D4821E\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":\"1\",\"point_size\":\"1\",\"fill\":\"0.5\",\"value_template\":\"{{value}}\",\"color_rules\":[{\"id\":\"pipeleek-high-verified-color-rule\",\"operator\":\"gte\",\"value\":0,\"color\":\"#D4821E\"}],\"background_color_rules\":[{\"id\":\"pipeleek-high-verified-bg-rule\",\"operator\":\"gte\",\"value\":0,\"color\":\"#D4821E\"}],\"gauge_color_rules\":[{\"id\":\"pipeleek-high-verified-gauge-rule\",\"operator\":\"gte\",\"value\":0,\"color\":\"#D4821E\"}]}],\"time_field\":\"@timestamp\",\"index_pattern\":\"pipeleek-logs-*\",\"default_index_pattern\":\"pipeleek-logs-*\",\"default_timefield\":\"@timestamp\",\"filter\":{\"language\":\"kuery\",\"query\":\"level:hit and confidence:\\\"high-verified\\\"\"},\"interval\":\"auto\",\"time_range_mode\":\"entire_time_range\",\"isModelInvalid\":false}}",
+ "title": "Pipeleek - Verified Hits",
+ "description": "Count of hit events with verified confidence",
+ "visState": "{\"title\":\"Pipeleek - Verified Hits\",\"type\":\"metrics\",\"params\":{\"id\":\"pipeleek-high-verified-panel\",\"type\":\"metric\",\"series\":[{\"id\":\"pipeleek-high-verified-series\",\"split_mode\":\"everything\",\"split_color_mode\":\"value\",\"metrics\":[{\"id\":\"pipeleek-high-verified-count\",\"type\":\"count\"}],\"label\":\"Verified Hits\",\"color\":\"#D4821E\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":\"1\",\"point_size\":\"1\",\"fill\":\"0.5\",\"value_template\":\"{{value}}\",\"color_rules\":[{\"id\":\"pipeleek-high-verified-color-rule\",\"operator\":\"gte\",\"value\":0,\"color\":\"#D4821E\"}],\"background_color_rules\":[{\"id\":\"pipeleek-high-verified-bg-rule\",\"operator\":\"gte\",\"value\":0,\"color\":\"#D4821E\"}],\"gauge_color_rules\":[{\"id\":\"pipeleek-high-verified-gauge-rule\",\"operator\":\"gte\",\"value\":0,\"color\":\"#D4821E\"}]}],\"time_field\":\"@timestamp\",\"index_pattern\":\"pipeleek-logs-*\",\"default_index_pattern\":\"pipeleek-logs-*\",\"default_timefield\":\"@timestamp\",\"filter\":{\"language\":\"kuery\",\"query\":\"level:hit and confidence:\\\"verified\\\"\"},\"interval\":\"auto\",\"time_range_mode\":\"entire_time_range\",\"isModelInvalid\":false}}",
"uiStateJSON": "{}",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}"
@@ -272,7 +272,7 @@ ratings_pie_payload='{
"title": "Pipeleek - Hits by Confidence",
"description": "Hit count grouped by confidence rating",
"visState": "{\"title\":\"Pipeleek - Hits by Confidence\",\"type\":\"pie\",\"params\":{\"addLegend\":true,\"addTooltip\":true,\"isDonut\":true,\"legendPosition\":\"right\",\"labels\":{\"show\":true,\"values\":true}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"confidence\",\"size\":10,\"order\":\"desc\",\"orderBy\":\"1\",\"missingBucket\":true,\"missingBucketLabel\":\"unknown\"}}]}",
- "uiStateJSON": "{\"vis\":{\"colors\":{\"high-verified\":\"#D4821E\",\"high\":\"#2A4A25\",\"medium\":\"#B9CF7E\",\"low\":\"#8B6BA8\",\"warning\":\"#C4A035\",\"informational\":\"#B9CF7E\",\"unknown\":\"#4A8FA8\"}}}",
+ "uiStateJSON": "{\"vis\":{\"colors\":{\"verified\":\"#D4821E\",\"high\":\"#2A4A25\",\"medium\":\"#B9CF7E\",\"low\":\"#8B6BA8\",\"warning\":\"#C4A035\",\"informational\":\"#B9CF7E\",\"unknown\":\"#4A8FA8\"}}}",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"level:hit\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"
}
@@ -293,7 +293,7 @@ confidence_timeseries_payload='{
"title": "Pipeleek - Hit Confidence Over Time",
"description": "Hit count over time split by confidence",
"visState": "{\"title\":\"Pipeleek - Hit Confidence Over Time\",\"type\":\"histogram\",\"params\":{\"addLegend\":true,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true},\"title\":{\"text\":\"Time\"}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true},\"title\":{\"text\":\"Hit Count\"}}],\"seriesParams\":[{\"show\":true,\"type\":\"line\",\"mode\":\"normal\",\"data\":{\"id\":\"1\",\"label\":\"Hit Count\"},\"valueAxis\":\"ValueAxis-1\",\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"lineWidth\":2}],\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"times\":[],\"addTimeMarker\":false,\"dimensions\":{\"x\":{\"accessor\":\"2\",\"format\":\"date\",\"params\":{}},\"y\":[{\"accessor\":\"1\",\"format\":{\"id\":\"number\"}}]}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"useNormalizedEsInterval\":false,\"min_doc_count\":1,\"extended_bounds\":{},\"interval\":\"10m\",\"drop_partials\":false,\"time_zone\":\"UTC\"}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"confidence\",\"size\":10,\"order\":\"desc\",\"orderBy\":\"1\",\"missingBucket\":true,\"missingBucketLabel\":\"unknown\"}}]}",
- "uiStateJSON": "{\"vis\":{\"colors\":{\"high-verified\":\"#D4821E\",\"high\":\"#2A4A25\",\"medium\":\"#B9CF7E\",\"low\":\"#8B6BA8\",\"warning\":\"#C4A035\",\"informational\":\"#B9CF7E\",\"unknown\":\"#4A8FA8\"}}}",
+ "uiStateJSON": "{\"vis\":{\"colors\":{\"verified\":\"#D4821E\",\"high\":\"#2A4A25\",\"medium\":\"#B9CF7E\",\"low\":\"#8B6BA8\",\"warning\":\"#C4A035\",\"informational\":\"#B9CF7E\",\"unknown\":\"#4A8FA8\"}}}",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"level:hit\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"
}
@@ -312,7 +312,7 @@ echo "Creating prebuilt dashboard..."
dashboard_payload='{
"attributes": {
"title": "Pipeleek - Hit Monitoring",
- "description": "High/high-verified counters, log intake over time, confidence trend over time, pie charts for hit distributions, latest 100 scan entries, and latest 100 raw log entries. Use dashboard filters for scan.platform and scan.confidence.",
+ "description": "High/verified counters, log intake over time, confidence trend over time, pie charts for hit distributions, latest 100 scan entries, and latest 100 raw log entries. Use dashboard filters for scan.platform and scan.confidence.",
"hits": 0,
"optionsJSON": "{\"useMargins\":true,\"hidePanelTitles\":false}",
"panelsJSON": "[{\"type\":\"visualization\",\"panelIndex\":\"1\",\"gridData\":{\"x\":0,\"y\":0,\"w\":12,\"h\":8,\"i\":\"1\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_1\",\"version\":\"8.13.4\"},{\"type\":\"visualization\",\"panelIndex\":\"2\",\"gridData\":{\"x\":12,\"y\":0,\"w\":12,\"h\":8,\"i\":\"2\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_2\",\"version\":\"8.13.4\"},{\"type\":\"visualization\",\"panelIndex\":\"3\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":8,\"i\":\"3\"},\"embeddableConfig\":{\"timeRange\":{\"from\":\"now-4h\",\"to\":\"now\"}},\"panelRefName\":\"panel_3\",\"version\":\"8.13.4\"},{\"type\":\"visualization\",\"panelIndex\":\"4\",\"gridData\":{\"x\":0,\"y\":8,\"w\":24,\"h\":12,\"i\":\"4\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_4\",\"version\":\"8.13.4\"},{\"type\":\"visualization\",\"panelIndex\":\"5\",\"gridData\":{\"x\":24,\"y\":8,\"w\":24,\"h\":12,\"i\":\"5\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_5\",\"version\":\"8.13.4\"},{\"type\":\"visualization\",\"panelIndex\":\"8\",\"gridData\":{\"x\":0,\"y\":20,\"w\":48,\"h\":10,\"i\":\"8\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_8\",\"version\":\"8.13.4\"},{\"type\":\"search\",\"panelIndex\":\"6\",\"gridData\":{\"x\":0,\"y\":30,\"w\":48,\"h\":12,\"i\":\"6\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_6\",\"version\":\"8.13.4\"},{\"type\":\"search\",\"panelIndex\":\"7\",\"gridData\":{\"x\":0,\"y\":42,\"w\":48,\"h\":12,\"i\":\"7\"},\"embeddableConfig\":{},\"panelRefName\":\"panel_7\",\"version\":\"8.13.4\"}]",
diff --git a/pkg/gitlab/scan/pipeline.go b/pkg/gitlab/scan/pipeline.go
index cdc9b1ba..103f40de 100644
--- a/pkg/gitlab/scan/pipeline.go
+++ b/pkg/gitlab/scan/pipeline.go
@@ -244,7 +244,7 @@ func getAllJobs(git *gitlab.Client, project *gitlab.Project, options *ScanOption
enqueueItem(globQueue, QueueItemCICDYaml, QueueMeta{
ProjectId: int(project.ID),
- JobWebUrl: project.WebURL,
+ JobWebUrl: strings.TrimRight(project.WebURL, "/") + "/-/blob/HEAD/.gitlab-ci.yml",
JobName: ".gitlab-ci.yml",
ProjectPathWithNamespace: project.PathWithNamespace,
}, waitGroup)
diff --git a/pkg/gitlab/scan/pipeline_test.go b/pkg/gitlab/scan/pipeline_test.go
index dfb93001..503cb0b8 100644
--- a/pkg/gitlab/scan/pipeline_test.go
+++ b/pkg/gitlab/scan/pipeline_test.go
@@ -78,7 +78,10 @@ func TestGetAllJobs_StatusScope(t *testing.T) {
globQueue, waitGroup = oldQueue, oldWG
}()
- getAllJobs(client, &gitlab.Project{ID: 1, PathWithNamespace: "group/project"}, opts)
+ getAllJobs(client, &gitlab.Project{
+ ID: 1, PathWithNamespace: "group/project",
+ WebURL: "https://gitlab.example/group/project/",
+ }, opts)
assert.Equal(t, int32(2), jobPages.Load(), "job-limit should stop pagination after two matching jobs")
wantItems := 4
if authenticated {
@@ -98,6 +101,7 @@ func TestGetAllJobs_StatusScope(t *testing.T) {
artifacts = append(artifacts, item.Meta.JobId)
case QueueItemCICDYaml:
yamlItems++
+ assert.Equal(t, "https://gitlab.example/group/project/-/blob/HEAD/.gitlab-ci.yml", item.Meta.JobWebUrl)
}
waitGroup.Done()
case <-time.After(2 * time.Second):
diff --git a/pkg/gitlab/scan/queue.go b/pkg/gitlab/scan/queue.go
index 14ac3112..d294de68 100644
--- a/pkg/gitlab/scan/queue.go
+++ b/pkg/gitlab/scan/queue.go
@@ -240,6 +240,7 @@ func analyzeCICDYaml(git *gitlab.Client, item QueueItem, options *ScanOptions) {
result.ReportFindings(logResult.Findings, result.ReportOptions{
LocationURL: item.Meta.JobWebUrl,
JobName: item.Meta.JobName,
+ Type: logging.SecretTypeCICDYaml,
})
}
diff --git a/pkg/logging/hit.go b/pkg/logging/hit.go
index 5cb26538..029135fb 100644
--- a/pkg/logging/hit.go
+++ b/pkg/logging/hit.go
@@ -22,7 +22,8 @@ const (
// NestedArchive indicates a secret found in a nested archive.
SecretTypeNestedArchive SecretType = "nested-archive"
// SecretTypeDotenv indicates a secret found in a dotenv file.
- SecretTypeDotenv SecretType = "dotenv"
+ SecretTypeDotenv SecretType = "dotenv"
+ SecretTypeCICDYaml SecretType = "cicd-yaml"
)
// HitLevel defines a custom log level for security finding hits.
diff --git a/pkg/webui/server.go b/pkg/webui/server.go
index e2777c28..1d4909a5 100644
--- a/pkg/webui/server.go
+++ b/pkg/webui/server.go
@@ -222,6 +222,7 @@ var pageTemplate = strings.Join([]string{
" .badge.type-archive { background: rgba(64,107,54,.12); color: var(--accent-dark); }",
" .badge.type-nested-archive { background: rgba(64,107,54,.18); color: var(--accent-dark); }",
" .badge.type-dotenv { background: rgba(123,136,180,.14); color: #405089; }",
+ " .badge.type-cicd-yaml { background: rgba(123,136,180,.14); color: #405089; }",
" .secret { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; background: #f7f7f7; border: 1px solid var(--line); border-radius: 6px; padding: .45rem .5rem; display: block; width: 100%; min-width: 0; color: var(--ink); cursor: pointer; transition: border-color .15s ease, background-color .15s ease; }",
" .secret:hover { border-color: rgba(64,107,54,.5); background: #f1f6eb; }",
" .secret:active { transform: translateY(1px); }",
@@ -301,6 +302,7 @@ var pageTemplate = strings.Join([]string{
"
archive
",
"
nested-archive
",
"
dotenv
",
+ "
cicd-yaml
",
"
",
"
",
"
",
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) {