From 91e8474bafffaa73903270da5f8b3fb92de04445 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:26:15 +0000 Subject: [PATCH 01/11] feat(circle): add CircleCI scanner and improve debug progress logs --- Makefile | 15 +- cmd/pipeleek-circle/main.go | 28 ++ docs/introduction/configuration.md | 22 + go.mod | 78 ++- go.sum | 156 ++++++ internal/cmd/circle/circle.go | 18 + internal/cmd/circle/circle_test.go | 30 ++ internal/cmd/circle/scan/scan.go | 169 +++++++ internal/cmd/circle/scan/scan_test.go | 41 ++ internal/cmd/root.go | 3 + pipeleek.example.yaml | 23 + pkg/circle/scan/normalize.go | 169 +++++++ pkg/circle/scan/scanner.go | 680 ++++++++++++++++++++++++++ pkg/circle/scan/scanner_test.go | 165 +++++++ pkg/circle/scan/transport.go | 408 ++++++++++++++++ pkg/circle/scan/transport_test.go | 108 ++++ tests/e2e/circle/scan/scan_test.go | 136 ++++++ 17 files changed, 2236 insertions(+), 13 deletions(-) create mode 100644 cmd/pipeleek-circle/main.go create mode 100644 internal/cmd/circle/circle.go create mode 100644 internal/cmd/circle/circle_test.go create mode 100644 internal/cmd/circle/scan/scan.go create mode 100644 internal/cmd/circle/scan/scan_test.go create mode 100644 pkg/circle/scan/normalize.go create mode 100644 pkg/circle/scan/scanner.go create mode 100644 pkg/circle/scan/scanner_test.go create mode 100644 pkg/circle/scan/transport.go create mode 100644 pkg/circle/scan/transport_test.go create mode 100644 tests/e2e/circle/scan/scan_test.go diff --git a/Makefile b/Makefile index 7bb0439a..81a603a6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build build-all build-gitlab build-github build-bitbucket build-devops build-gitea test test-unit test-e2e lint clean coverage coverage-html serve-docs +.PHONY: help build build-all build-gitlab build-github build-bitbucket build-devops build-gitea build-circle test test-unit test-e2e lint clean coverage coverage-html serve-docs # Default target help: @@ -12,6 +12,7 @@ help: @echo " make build-bitbucket - Build BitBucket-specific binary" @echo " make build-devops - Build Azure DevOps-specific binary" @echo " make build-gitea - Build Gitea-specific binary" + @echo " make build-circle - Build CircleCI-specific binary" @echo " make test - Run all tests (unit + e2e)" @echo " make test-unit - Run unit tests only" @echo " make test-e2e - Run e2e tests (builds binary first)" @@ -54,8 +55,13 @@ build-gitea: @echo "Building pipeleek-gitea..." CGO_ENABLED=0 go build $(GO_BUILD_FLAGS) -o pipeleek-gitea ./cmd/pipeleek-gitea +# Build CircleCI-specific binary +build-circle: + @echo "Building pipeleek-circle..." + CGO_ENABLED=0 go build $(GO_BUILD_FLAGS) -o pipeleek-circle ./cmd/pipeleek-circle + # Build all binaries -build-all: build build-gitlab build-github build-bitbucket build-devops build-gitea +build-all: build build-gitlab build-github build-bitbucket build-devops build-gitea build-circle @echo "All binaries built successfully" # Run all tests @@ -92,6 +98,10 @@ test-e2e-gitea: build @echo "Running Gitea e2e tests..." PIPELEEK_BINARY=$$(pwd)/pipeleek go test ./tests/e2e/gitea/... -tags=e2e -v +test-e2e-circle: build + @echo "Running CircleCI e2e tests..." + PIPELEEK_BINARY=$$(pwd)/pipeleek go test ./tests/e2e/circle/... -tags=e2e -v + # Generate test coverage report coverage: @echo "Generating coverage report..." @@ -139,4 +149,5 @@ clean: rm -f pipeleek-bitbucket pipeleek-bitbucket.exe rm -f pipeleek-devops pipeleek-devops.exe rm -f pipeleek-gitea pipeleek-gitea.exe + rm -f pipeleek-circle pipeleek-circle.exe go clean -cache -testcache diff --git a/cmd/pipeleek-circle/main.go b/cmd/pipeleek-circle/main.go new file mode 100644 index 00000000..f41aa16f --- /dev/null +++ b/cmd/pipeleek-circle/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "github.com/CompassSecurity/pipeleek/internal/cmd/circle" + "github.com/CompassSecurity/pipeleek/internal/cmd/common" + "github.com/spf13/cobra" +) + +func main() { + common.Run(newRootCmd()) +} + +func newRootCmd() *cobra.Command { + circleCmd := circle.NewCircleRootCmd() + circleCmd.Use = "pipeleek-circle" + circleCmd.Short = "Scan CircleCI logs and artifacts for secrets" + circleCmd.Long = `Pipeleek-Circle scans CircleCI pipelines, logs, test results, and artifacts to detect leaked secrets.` + circleCmd.Version = common.Version + circleCmd.GroupID = "" + + common.SetupPersistentPreRun(circleCmd) + common.AddCommonFlags(circleCmd) + + circleCmd.SetVersionTemplate(`{{.Version}} +`) + + return circleCmd +} diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index b15cc4cc..39ba5e5e 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -200,6 +200,28 @@ jenkins: max_builds: 25 # jenkins scan --max-builds ``` +### CircleCI + +```yaml +circle: + url: https://circleci.com + token: circleci-token + + scan: + project: [my-org/my-repo] # circle scan --project (optional if org is set) + vcs: github # circle scan --vcs + org: my-org # circle scan --org (also enables org-wide discovery when project is omitted) + branch: main # circle scan --branch + status: [success, failed] # circle scan --status + workflow: [build, deploy] # circle scan --workflow + job: [unit-tests, release] # circle scan --job + since: 2026-01-01T00:00:00Z # circle scan --since (RFC3339) + until: 2026-01-31T23:59:59Z # circle scan --until (RFC3339) + max_pipelines: 50 # circle scan --max-pipelines + tests: true # circle scan --tests + insights: true # circle scan --insights +``` + ### Common Settings Scan commands inherit from `common`: diff --git a/go.mod b/go.mod index 9d065746..3386353f 100644 --- a/go.mod +++ b/go.mod @@ -35,17 +35,26 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/secretmanager v1.16.0 // indirect - dario.cat/mergo v1.0.0 // indirect + dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.1 // indirect github.com/42wim/httpsig v1.2.4 // indirect + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/BobuSumisu/aho-corasick v1.0.3 // indirect + github.com/CircleCI-Public/circle-policy-agent v0.0.779 // indirect + github.com/CircleCI-Public/circleci-cli v0.1.34950 // indirect + github.com/CircleCI-Public/circleci-config v0.0.0-20231003143420-842d4b0025ef // indirect + github.com/Masterminds/semver v1.5.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/TheZeroSlave/zapsentry v1.23.0 // indirect github.com/Unpackerr/iso9660 v0.0.3 // indirect + github.com/a8m/envsubst v1.4.2 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2 v1.39.0 // indirect github.com/aws/aws-sdk-go-v2/config v1.31.7 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.11 // indirect @@ -60,14 +69,24 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.3 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.38.3 // indirect github.com/aws/smithy-go v1.23.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/sevenzip v1.6.1 // indirect github.com/bodgit/windows v1.0.1 // indirect + github.com/briandowns/spinner v1.23.0 // indirect github.com/cavaliergopher/cpio v1.0.1 // indirect github.com/cavaliergopher/rpm v1.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbles v0.18.0 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/couchbase/gocb/v2 v2.11.0 // indirect @@ -82,6 +101,8 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/erikgeiser/promptkit v0.9.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -91,22 +112,26 @@ require ( github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.8.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-redis/redis v6.15.9+incompatible // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/golang-jwt/jwt/v5 v5.2.3 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/snappy v1.0.0 // indirect + github.com/google/go-github/v30 v30.1.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.16.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -114,14 +139,17 @@ require ( github.com/hashicorp/go-version v1.8.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/headzoo/ut v0.0.0-20181013193318-a13b5a7a02ca // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect github.com/icza/bitio v1.1.0 // indirect + github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jlaffaye/ftp v0.2.0 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/klauspost/compress v1.18.4 // indirect - github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect @@ -129,44 +157,63 @@ require ( github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/lib/pq v1.10.9 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mariduv/ldap-verify v0.0.2 // indirect github.com/marusama/semaphore/v2 v2.5.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.17 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mewkiz/flac v1.0.13 // indirect github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/microsoft/go-mssqldb v1.8.2 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/montanaflynn/stats v0.7.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nwaples/rardecode/v2 v2.2.2 // indirect github.com/nxadm/tail v1.4.11 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/open-policy-agent/opa v1.4.0 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/peterebden/ar v0.0.0-20241106141004-20dc11b778e8 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect - github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.20.5 // indirect + github.com/prometheus/client_golang v1.21.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/rabbitmq/amqp091-go v1.10.0 // indirect + github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect + github.com/rhysd/go-github-selfupdate v1.2.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect + github.com/segmentio/analytics-go v3.1.0+incompatible // indirect github.com/segmentio/asm v1.2.1 // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/segmentio/backo-go v1.0.1 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/sshaman1101/dcompress v0.0.0-20200109162717-50436a6332de // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tchap/go-patricia/v2 v2.3.2 // indirect + github.com/tcnksm/go-gitconfig v0.1.2 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/therootcompany/xz v1.0.1 // indirect github.com/tidwall/match v1.1.1 // indirect @@ -178,20 +225,27 @@ require ( github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect + github.com/yashtewari/glob-intersection v0.2.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect golang.org/x/crypto v0.49.0 // indirect + golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect golang.org/x/sync v0.20.0 // indirect golift.io/udf v0.0.1 // indirect google.golang.org/api v0.259.0 // indirect @@ -200,6 +254,8 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/grpc v1.79.3 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 114547a7..f7c7d7b5 100644 --- a/go.sum +++ b/go.sum @@ -17,10 +17,14 @@ code.gitea.io/sdk/gitea v0.24.1 h1:hpaqcdGcBmfMpV7JSbBJVwE99qo+WqGreJYKrDKEyW8= code.gitea.io/sdk/gitea v0.24.1/go.mod h1:5/77BL3sHneCMEiZaMT9lfTvnnibsYxyO48mceCF3qA= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg= @@ -40,6 +44,12 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2/go.mod h1:wP83 github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8a+4nPE9g= github.com/BobuSumisu/aho-corasick v1.0.3/go.mod h1:hm4jLcvZKI2vRF2WDU1N4p/jpWtpOzp3nLmi9AzX/XE= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/CircleCI-Public/circle-policy-agent v0.0.779 h1:yjKIhwYyd6DQL4UH37Ln5Myx/zMX7H8ussp28unZjnE= +github.com/CircleCI-Public/circle-policy-agent v0.0.779/go.mod h1:bYS06KcciyAEH13ggXZhOdl8hYS4d/iSCGgX5b03B1U= +github.com/CircleCI-Public/circleci-cli v0.1.34950 h1:W3ZyGfyKL35PPaJcCShW5H6t+GGS0HzBjkOlKG6LmU0= +github.com/CircleCI-Public/circleci-cli v0.1.34950/go.mod h1:uUr25Nqz9pq9QoFYimDGsWpG5D8Q3+FDldKTiWdHRmA= +github.com/CircleCI-Public/circleci-config v0.0.0-20231003143420-842d4b0025ef h1:Bhr3xH8/XV0CF8wHFxWgBkmDRTQIW5O1MiuL3n1AEug= +github.com/CircleCI-Public/circleci-config v0.0.0-20231003143420-842d4b0025ef/go.mod h1:2AyBtY8aKfifpEw4OBC+8jrVeoPEkpYTzBojhZ5pEtE= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= @@ -48,29 +58,42 @@ github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzX github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= github.com/MarvinJWendt/testza v0.4.2 h1:Vbw9GkSB5erJI2BPnBL9SVGV9myE+XmUSFahBGUhW2Q= github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= github.com/TheZeroSlave/zapsentry v1.23.0 h1:TKyzfEL7LRlRr+7AvkukVLZ+jZPC++ebCUv7ZJHl1AU= github.com/TheZeroSlave/zapsentry v1.23.0/go.mod h1:3DRFLu4gIpnCTD4V9HMCBSaqYP8gYU7mZickrs2/rIY= github.com/Unpackerr/iso9660 v0.0.3 h1:WXXFIcmDLhnsKhXjPg2moUmHxhoUmIX7FLxrtqHJ7yQ= github.com/Unpackerr/iso9660 v0.0.3/go.mod h1:4Py6ZWQ+sUVo4BmmzZaFgOLcS3to5BMvH39TlOYNxhA= +github.com/a8m/envsubst v1.4.2 h1:4yWIHXOLEJHQEFd4UjrWDrYeYlV7ncFWJOCBRLOZHQg= +github.com/a8m/envsubst v1.4.2/go.mod h1:MVUTQNGQ3tsjOOtKCNd+fl8RzhsXcDvvAEzkhGtlsbY= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atomicgo/cursor v0.0.1 h1:xdogsqa6YYlLfM+GyClC/Lchf7aiMerFiZQn7soTOoU= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.39.0 h1:xm5WV/2L4emMRmMjHFykqiA4M/ra0DJVSWUkDyBjbg4= github.com/aws/aws-sdk-go-v2 v1.39.0/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2/config v1.31.7 h1:zS1O6hr6t0nZdBCMFc/c9OyZFyLhXhf/B2IZ9Y0lRQE= @@ -99,9 +122,14 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.3 h1:yEiZ0ztgji2GsCb/6uQSITXcGdtm github.com/aws/aws-sdk-go-v2/service/sts v1.38.3/go.mod h1:Z+Gd23v97pX9zK97+tX4ppAgqCt3Z2dIXB02CtBncK8= github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bndr/gojenkins v1.2.0 h1:iomz/HKK5HlmQ1a65Qc3ejd6i1z6MtcyI85GZYetMxI= @@ -112,6 +140,8 @@ github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4 github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= +github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= +github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE= github.com/brianvoe/gofakeit/v7 v7.6.0 h1:M3RUb5CuS2IZmF/cP+O+NdLxJEuDAZxNQBwPbbqR6h4= github.com/brianvoe/gofakeit/v7 v7.6.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM= @@ -123,6 +153,20 @@ github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= @@ -158,6 +202,7 @@ github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -191,12 +236,17 @@ github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9O github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/erikgeiser/promptkit v0.9.0 h1:3qL1mS/ntCrXdb8sTP/ka82CJ9kEQaGuYXNrYJkWYBc= +github.com/erikgeiser/promptkit v0.9.0/go.mod h1:pU9dtogSe3Jlc2AY77EP7R4WFP/vgD4v+iImC83KsCo= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= @@ -218,6 +268,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5Jk= github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -238,6 +290,8 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -263,11 +317,15 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= +github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE= github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -281,6 +339,8 @@ github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4 github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0 h1:1Opow3+BWDwqor78DcJkJCIwnkviFi+rrOANki9BUFw= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls= @@ -310,10 +370,16 @@ github.com/headzoo/surf v1.0.1 h1:wk3+LT8gjnCxEwfBJl6MhaNg154En5KjgmgzAG9uMS0= github.com/headzoo/surf v1.0.1/go.mod h1:/bct0m/iMNEqpn520y01yoaWxsAEigGFPnvyR1ewR5M= github.com/headzoo/ut v0.0.0-20181013193318-a13b5a7a02ca h1:utFgFwgxaqx5OthzE3DSGrtOq7rox5r2sxZ2wbfTuK0= github.com/headzoo/ut v0.0.0-20181013193318-a13b5a7a02ca/go.mod h1:8926sG02TCOX4RFRzIMFIzRw4xuc/TwO2gtN7teMJZ4= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -324,8 +390,12 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= +github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= @@ -335,6 +405,8 @@ github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuOb github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -363,6 +435,8 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= @@ -373,13 +447,20 @@ github.com/mariduv/ldap-verify v0.0.2 h1:NBdDTYyWDr71CONVcizasqL/AA9tQ2RNgLhTgny github.com/mariduv/ldap-verify v0.0.2/go.mod h1:d/7+kkMBGDs9LPZ/7hmduYqtOkRIJcgpa8dL+9CsveE= github.com/marusama/semaphore/v2 v2.5.0 h1:o/1QJD9DBYOWRnDhPwDVAXQn6mQYD0gZaS1Tpx6DJGM= github.com/marusama/semaphore/v2 v2.5.0/go.mod h1:z9nMiNUekt/LTpTUQdpp+4sJeYqUGpwMHfW0Z8V8fnQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -391,8 +472,13 @@ github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HN github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI= github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= @@ -411,6 +497,14 @@ github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8 github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nsqio/go-diskqueue v1.1.0 h1:r0dJ0DMXT3+2mOq+79cvCjnhoBxyGC2S9O+OjQrpe4Q= @@ -419,10 +513,16 @@ github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3 github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/open-policy-agent/opa v1.4.0 h1:IGO3xt5HhQKQq2axfa9memIFx5lCyaBlG+fXcgHpd3A= +github.com/open-policy-agent/opa v1.4.0/go.mod h1:DNzZPKqKh4U0n0ANxcCVlw8lCSv2c+h5G/3QvSYdWZ8= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -430,6 +530,8 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= @@ -442,6 +544,8 @@ github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4 github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= +github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -456,11 +560,15 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= +github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= @@ -473,8 +581,13 @@ github.com/pterm/pterm v0.12.40 h1:LvQE43RYegVH+y5sCDcqjlbsRu0DlAecEn9FDfs9ePs= github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rhysd/actionlint v1.7.11 h1:m+aSuCpCIClS8X02xMG4Z8s87fCHPsAtYkAoWGQZgEE= github.com/rhysd/actionlint v1.7.11/go.mod h1:8n50YougV9+50niD7oxgDTZ1KbN/ZnKiQ2xpLFeVhsI= +github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag= +github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -493,11 +606,18 @@ github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDc github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= +github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= +github.com/segmentio/analytics-go v3.1.0+incompatible h1:IyiOfUgQFVHvsykKKbdI7ZsH374uv3/DfZUo9+G0Z80= +github.com/segmentio/analytics-go v3.1.0+incompatible/go.mod h1:C7CYBtQWk4vRk2RyLu0qOcbHJ18E3F1HV2C/8JvKN48= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/backo-go v1.0.1 h1:68RQccglxZeyURy93ASB/2kc9QudzgIDexJ927N++y4= +github.com/segmentio/backo-go v1.0.1/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= @@ -541,6 +661,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tchap/go-patricia/v2 v2.3.2 h1:xTHFutuitO2zqKAQ5rCROYgUb7Or/+IC3fts9/Yc7nM= +github.com/tchap/go-patricia/v2 v2.3.2/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw= +github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE= github.com/testcontainers/testcontainers-go v0.34.0 h1:5fbgF0vIN5u+nD3IWabQwRybuB4GY8G2HHgCkbMzMHo= github.com/testcontainers/testcontainers-go v0.34.0/go.mod h1:6P/kMkQe8yqPHfPWNulFGdFHTD8HB2vLq/231xY2iPQ= github.com/testcontainers/testcontainers-go/modules/mongodb v0.34.0 h1:o3bgcECyBFfMwqexCH/6vIJ8XzbCffCP/Euesu33rgY= @@ -570,6 +694,7 @@ github.com/trufflesecurity/trufflehog/v3 v3.94.1 h1:MjY+oWN1oqoIaerGMWM/i/ZqeCRr github.com/trufflesecurity/trufflehog/v3 v3.94.1/go.mod h1:tMrggJ/W7O5L60WYdVsexBOqIZ4k3X9VMnAu6tmnyBQ= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= @@ -588,11 +713,19 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g= +github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/yosuke-furukawa/json5 v0.1.1 h1:0F9mNwTvOuDNH243hoPqvf+dxa5QsKnZzU20uNsh3ZI= github.com/yosuke-furukawa/json5 v0.1.1/go.mod h1:sw49aWDqNdRJ6DYUtIQiaA3xyj2IL9tjeNYmX2ixwcU= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= @@ -616,14 +749,22 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6h go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -645,6 +786,7 @@ go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSy golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -670,6 +812,7 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -688,6 +831,7 @@ golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -704,7 +848,9 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -714,6 +860,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -733,6 +880,7 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -747,9 +895,11 @@ golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= @@ -786,6 +936,7 @@ gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= @@ -810,15 +961,18 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -828,3 +982,5 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU= resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/internal/cmd/circle/circle.go b/internal/cmd/circle/circle.go new file mode 100644 index 00000000..289cb241 --- /dev/null +++ b/internal/cmd/circle/circle.go @@ -0,0 +1,18 @@ +package circle + +import ( + "github.com/CompassSecurity/pipeleek/internal/cmd/circle/scan" + "github.com/spf13/cobra" +) + +func NewCircleRootCmd() *cobra.Command { + circleCmd := &cobra.Command{ + Use: "circle [command]", + Short: "CircleCI related commands", + GroupID: "CircleCI", + } + + circleCmd.AddCommand(scan.NewScanCmd()) + + return circleCmd +} diff --git a/internal/cmd/circle/circle_test.go b/internal/cmd/circle/circle_test.go new file mode 100644 index 00000000..18e4695b --- /dev/null +++ b/internal/cmd/circle/circle_test.go @@ -0,0 +1,30 @@ +package circle + +import "testing" + +func TestNewCircleRootCmd(t *testing.T) { + cmd := NewCircleRootCmd() + if cmd == nil { + t.Fatal("Expected non-nil command") + } + + if cmd.Use != "circle [command]" { + t.Errorf("Expected Use to be 'circle [command]', got %q", cmd.Use) + } + + if cmd.Short == "" { + t.Error("Expected non-empty Short description") + } + + if cmd.GroupID != "CircleCI" { + t.Errorf("Expected GroupID 'CircleCI', got %q", cmd.GroupID) + } + + if len(cmd.Commands()) != 1 { + t.Errorf("Expected 1 subcommand, got %d", len(cmd.Commands())) + } + + if len(cmd.Commands()) == 1 && cmd.Commands()[0].Use != "scan" { + t.Errorf("Expected subcommand 'scan', got %q", cmd.Commands()[0].Use) + } +} diff --git a/internal/cmd/circle/scan/scan.go b/internal/cmd/circle/scan/scan.go new file mode 100644 index 00000000..6c1d49e4 --- /dev/null +++ b/internal/cmd/circle/scan/scan.go @@ -0,0 +1,169 @@ +package scan + +import ( + "time" + + "github.com/CompassSecurity/pipeleek/internal/cmd/flags" + circlescan "github.com/CompassSecurity/pipeleek/pkg/circle/scan" + "github.com/CompassSecurity/pipeleek/pkg/config" + "github.com/CompassSecurity/pipeleek/pkg/logging" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +type CircleScanOptions struct { + config.CommonScanOptions + Token string + CircleURL string + Organization string + Projects []string + VCS string + Branch string + Statuses []string + Workflows []string + Jobs []string + Since string + Until string + MaxPipelines int + IncludeTests bool + Insights bool +} + +var options = CircleScanOptions{ + CommonScanOptions: config.DefaultCommonScanOptions(), +} + +var maxArtifactSize string + +func NewScanCmd() *cobra.Command { + scanCmd := &cobra.Command{ + Use: "scan", + Short: "Scan CircleCI logs and artifacts", + Long: `Scan CircleCI pipelines, workflows, jobs, logs, test results, and optional artifacts for secrets.`, + Example: ` +# Scan explicit project(s) +pipeleek circle scan --token --project org/repo + +# Restrict by branch and statuses +pipeleek circle scan --token --project org/repo --branch main --status success --status failed + +# Include artifacts and tests with time window +pipeleek circle scan --token --project org/repo --artifacts --since 2026-01-01T00:00:00Z --until 2026-01-31T23:59:59Z + `, + Run: Scan, + } + + flags.AddCommonScanFlagsNoOwned(scanCmd, &options.CommonScanOptions, &maxArtifactSize) + scanCmd.Flags().StringVarP(&options.Token, "token", "t", "", "CircleCI API token") + scanCmd.Flags().StringVarP(&options.CircleURL, "circle", "c", "https://circleci.com", "CircleCI base URL") + scanCmd.Flags().StringVarP(&options.Organization, "org", "", "", "CircleCI organization slug (used to filter projects)") + scanCmd.Flags().StringSliceVarP(&options.Projects, "project", "p", []string{}, "Project selector. Format: org/repo or vcs/org/repo") + scanCmd.Flags().StringVarP(&options.VCS, "vcs", "", "github", "VCS provider for project selectors without prefix (github or bitbucket)") + scanCmd.Flags().StringVarP(&options.Branch, "branch", "b", "", "Filter pipelines by branch") + scanCmd.Flags().StringSliceVarP(&options.Statuses, "status", "", []string{}, "Filter by pipeline/workflow/job status") + scanCmd.Flags().StringSliceVarP(&options.Workflows, "workflow", "", []string{}, "Filter by workflow name") + scanCmd.Flags().StringSliceVarP(&options.Jobs, "job", "", []string{}, "Filter by job name") + scanCmd.Flags().StringVarP(&options.Since, "since", "", "", "Include items created after this RFC3339 timestamp") + scanCmd.Flags().StringVarP(&options.Until, "until", "", "", "Include items created before this RFC3339 timestamp") + scanCmd.Flags().IntVarP(&options.MaxPipelines, "max-pipelines", "", 0, "Maximum number of pipelines to scan per project (0 = no limit)") + scanCmd.Flags().BoolVarP(&options.IncludeTests, "tests", "", true, "Scan CircleCI test results per job") + scanCmd.Flags().BoolVarP(&options.Insights, "insights", "", true, "Scan CircleCI workflow insights endpoints") + + return scanCmd +} + +func Scan(cmd *cobra.Command, args []string) { + if err := config.AutoBindFlags(cmd, map[string]string{ + "circle": "circle.url", + "token": "circle.token", + "org": "circle.org", + "project": "circle.scan.project", + "vcs": "circle.scan.vcs", + "branch": "circle.scan.branch", + "status": "circle.scan.status", + "workflow": "circle.scan.workflow", + "job": "circle.scan.job", + "since": "circle.scan.since", + "until": "circle.scan.until", + "max-pipelines": "circle.scan.max_pipelines", + "tests": "circle.scan.tests", + "insights": "circle.scan.insights", + "threads": "common.threads", + "truffle-hog-verification": "common.trufflehog_verification", + "max-artifact-size": "common.max_artifact_size", + "confidence": "common.confidence_filter", + "hit-timeout": "common.hit_timeout", + }); err != nil { + log.Fatal().Err(err).Msg("Failed to bind command flags to configuration keys") + } + + if err := config.RequireConfigKeys("circle.token"); err != nil { + log.Fatal().Err(err).Msg("required configuration missing") + } + + options.Token = config.GetString("circle.token") + options.CircleURL = config.GetString("circle.url") + options.Organization = config.GetString("circle.org") + options.Projects = config.GetStringSlice("circle.scan.project") + options.VCS = config.GetString("circle.scan.vcs") + options.Branch = config.GetString("circle.scan.branch") + options.Statuses = config.GetStringSlice("circle.scan.status") + options.Workflows = config.GetStringSlice("circle.scan.workflow") + options.Jobs = config.GetStringSlice("circle.scan.job") + options.Since = config.GetString("circle.scan.since") + options.Until = config.GetString("circle.scan.until") + options.MaxPipelines = config.GetInt("circle.scan.max_pipelines") + options.IncludeTests = config.GetBool("circle.scan.tests") + options.Insights = config.GetBool("circle.scan.insights") + options.MaxScanGoRoutines = config.GetInt("common.threads") + options.TruffleHogVerification = config.GetBool("common.trufflehog_verification") + options.ConfidenceFilter = config.GetStringSlice("common.confidence_filter") + maxArtifactSize = config.GetString("common.max_artifact_size") + if hitTimeoutSeconds := config.GetInt("common.hit_timeout"); hitTimeoutSeconds > 0 { + options.HitTimeout = time.Duration(hitTimeoutSeconds) * time.Second + } + + if err := config.ValidateURL(options.CircleURL, "CircleCI URL"); err != nil { + log.Fatal().Err(err).Msg("Invalid CircleCI URL") + } + if err := config.ValidateToken(options.Token, "CircleCI API token"); err != nil { + log.Fatal().Err(err).Msg("Invalid CircleCI API token") + } + if err := config.ValidateThreadCount(options.MaxScanGoRoutines); err != nil { + log.Fatal().Err(err).Msg("Invalid thread count") + } + + scanOpts, err := circlescan.InitializeOptions(circlescan.InitializeOptionsInput{ + Token: options.Token, + CircleURL: options.CircleURL, + Organization: options.Organization, + Projects: options.Projects, + VCS: options.VCS, + Branch: options.Branch, + Statuses: options.Statuses, + WorkflowNames: options.Workflows, + JobNames: options.Jobs, + Since: options.Since, + Until: options.Until, + MaxPipelines: options.MaxPipelines, + IncludeTests: options.IncludeTests, + IncludeInsights: options.Insights, + Artifacts: options.Artifacts, + MaxArtifactSize: maxArtifactSize, + ConfidenceFilter: options.ConfidenceFilter, + MaxScanGoRoutines: options.MaxScanGoRoutines, + TruffleHogVerification: options.TruffleHogVerification, + HitTimeout: options.HitTimeout, + }) + if err != nil { + log.Fatal().Err(err).Msg("Failed initializing CircleCI scan options") + } + + scanner := circlescan.NewScanner(scanOpts) + logging.RegisterStatusHook(func() *zerolog.Event { return scanner.Status() }) + + if err := scanner.Scan(); err != nil { + log.Fatal().Err(err).Msg("Scan failed") + } +} diff --git a/internal/cmd/circle/scan/scan_test.go b/internal/cmd/circle/scan/scan_test.go new file mode 100644 index 00000000..01cd97fc --- /dev/null +++ b/internal/cmd/circle/scan/scan_test.go @@ -0,0 +1,41 @@ +package scan + +import "testing" + +func TestNewScanCmd(t *testing.T) { + cmd := NewScanCmd() + if cmd == nil { + t.Fatal("expected non-nil command") + } + + if cmd.Use != "scan" { + t.Fatalf("expected use 'scan', got %q", cmd.Use) + } + + flags := cmd.Flags() + for _, name := range []string{ + "token", + "circle", + "org", + "project", + "vcs", + "branch", + "status", + "workflow", + "job", + "since", + "until", + "max-pipelines", + "tests", + "insights", + "threads", + "truffle-hog-verification", + "confidence", + "artifacts", + "max-artifact-size", + } { + if flags.Lookup(name) == nil { + t.Errorf("expected flag %q to exist", name) + } + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 93898103..68955778 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -8,6 +8,7 @@ import ( "time" "github.com/CompassSecurity/pipeleek/internal/cmd/bitbucket" + "github.com/CompassSecurity/pipeleek/internal/cmd/circle" "github.com/CompassSecurity/pipeleek/internal/cmd/devops" "github.com/CompassSecurity/pipeleek/internal/cmd/docs" "github.com/CompassSecurity/pipeleek/internal/cmd/gitea" @@ -76,6 +77,7 @@ func init() { rootCmd.AddCommand(devops.NewAzureDevOpsRootCmd()) rootCmd.AddCommand(gitea.NewGiteaRootCmd()) rootCmd.AddCommand(jenkins.NewJenkinsRootCmd()) + rootCmd.AddCommand(circle.NewCircleRootCmd()) rootCmd.AddCommand(docs.NewDocsCmd(rootCmd)) 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") @@ -96,6 +98,7 @@ func init() { rootCmd.AddGroup(&cobra.Group{ID: "AzureDevOps", Title: "Azure DevOps Commands"}) rootCmd.AddGroup(&cobra.Group{ID: "Gitea", Title: "Gitea Commands"}) rootCmd.AddGroup(&cobra.Group{ID: "Jenkins", Title: "Jenkins Commands"}) + rootCmd.AddGroup(&cobra.Group{ID: "CircleCI", Title: "CircleCI Commands"}) } type CustomWriter struct { diff --git a/pipeleek.example.yaml b/pipeleek.example.yaml index d663de5a..6df71c03 100644 --- a/pipeleek.example.yaml +++ b/pipeleek.example.yaml @@ -200,3 +200,26 @@ jenkins: job: "team-a/service-a" # Optional: scan a single job path max_builds: 25 # Maximum builds to scan per job (0 = all) # Inherits common.* settings + +#------------------------------------------------------------------------------ +# CircleCI Platform Configuration +#------------------------------------------------------------------------------ +circle: + url: https://circleci.com + token: circleci_token_REPLACE_ME + + # scan - Scan CircleCI pipelines, logs, test results and optional artifacts + scan: + project: ["example-org/example-repo"] # Optional project selector(s): org/repo or vcs/org/repo + vcs: "github" # Default VCS used when project entries omit prefix + org: "example-org" # Optional org filter; if project is omitted this discovers org projects + branch: "main" # Optional branch filter + status: ["success", "failed"] # Optional pipeline/workflow/job status filter + workflow: ["build", "deploy"] # Optional workflow name filter + job: ["unit-tests", "release"] # Optional job name filter + since: "2026-01-01T00:00:00Z" # Optional RFC3339 start timestamp + until: "2026-01-31T23:59:59Z" # Optional RFC3339 end timestamp + max_pipelines: 50 # Maximum number of pipelines to scan per project (0 = no limit) + tests: true # Scan job test results + insights: true # Scan workflow insights endpoints + # Inherits common.* settings diff --git a/pkg/circle/scan/normalize.go b/pkg/circle/scan/normalize.go new file mode 100644 index 00000000..269a43c3 --- /dev/null +++ b/pkg/circle/scan/normalize.go @@ -0,0 +1,169 @@ +package scan + +import ( + "fmt" + "strings" +) + +func normalizeProjectSlug(value, defaultVCS string) (string, error) { + parts := strings.Split(strings.Trim(value, " /"), "/") + if len(parts) == 2 { + return fmt.Sprintf("%s/%s/%s", defaultVCS, parts[0], parts[1]), nil + } + if len(parts) == 3 { + return strings.Join(parts, "/"), nil + } + return "", fmt.Errorf("invalid project selector %q (expected org/repo or vcs/org/repo)", value) +} + +func belongsToOrg(projectSlug, org string) bool { + parts := strings.Split(projectSlug, "/") + return len(parts) >= 3 && strings.EqualFold(parts[1], org) +} + +func normalizedOrgName(org string) string { + trimmed := strings.Trim(strings.TrimSpace(org), "/") + if trimmed == "" { + return "" + } + parts := strings.Split(trimmed, "/") + if len(parts) >= 2 { + return parts[len(parts)-1] + } + return trimmed +} + +func toFilterSet(values []string) map[string]struct{} { + out := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(strings.ToLower(value)) + if trimmed == "" { + continue + } + out[trimmed] = struct{}{} + } + return out +} + +func matchesFilter(filter map[string]struct{}, value string) bool { + if len(filter) == 0 { + return true + } + _, ok := filter[strings.ToLower(strings.TrimSpace(value))] + return ok +} + +func vcsFromURL(raw string) string { + value := strings.ToLower(raw) + switch { + case strings.Contains(value, "bitbucket"): + return "bitbucket" + case strings.Contains(value, "github"): + return "github" + default: + return "" + } +} + +func normalizeVCSName(vcs string) string { + switch strings.ToLower(strings.TrimSpace(vcs)) { + case "github", "gh": + return "github" + case "bitbucket", "bb": + return "bitbucket" + case "circleci": + return "circleci" + default: + return strings.ToLower(strings.TrimSpace(vcs)) + } +} + +func projectSlugFromV1(item v1ProjectItem, defaultVCS string) (string, bool) { + vcs := normalizeVCSName(item.VCSType) + if vcs == "circleci" { + if slug, ok := circleciUUIDSlug(item.VCSURL); ok { + return slug, true + } + } + + org := strings.TrimSpace(item.Username) + repo := strings.TrimSpace(item.Reponame) + if org == "" || repo == "" { + return "", false + } + + if vcs == "" { + vcs = normalizeVCSName(vcsFromURL(item.VCSURL)) + } + if vcs == "" { + vcs = normalizeVCSName(defaultVCS) + } + if vcs == "" { + vcs = "github" + } + + normalized, err := normalizeProjectSlug(fmt.Sprintf("%s/%s/%s", vcs, org, repo), defaultVCS) + if err != nil { + return "", false + } + + return normalized, true +} + +func circleciUUIDSlug(raw string) (string, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", false + } + trimmed = strings.TrimPrefix(trimmed, "https://") + trimmed = strings.TrimPrefix(trimmed, "http://") + trimmed = strings.TrimPrefix(trimmed, "//") + trimmed = strings.TrimPrefix(trimmed, "circleci.com/") + trimmed = strings.Trim(trimmed, "/") + + parts := strings.Split(trimmed, "/") + if len(parts) < 2 { + return "", false + } + + orgID := strings.TrimSpace(parts[0]) + projectID := strings.TrimSpace(parts[1]) + if orgID == "" || projectID == "" { + return "", false + } + + return fmt.Sprintf("circleci/%s/%s", orgID, projectID), true +} + +func vcsSlugCandidates(vcs string) []string { + v := strings.ToLower(strings.TrimSpace(vcs)) + switch v { + case "gh", "github": + return []string{"github", "gh"} + case "bb", "bitbucket": + return []string{"bitbucket", "bb"} + case "gitlab", "gl": + return []string{"gitlab", "gl"} + case "": + return []string{"github", "gh", "bitbucket", "bb"} + default: + return []string{v} + } +} + +func uniqueStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + key := strings.TrimSpace(value) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + return out +} diff --git a/pkg/circle/scan/scanner.go b/pkg/circle/scan/scanner.go new file mode 100644 index 00000000..91f37fb6 --- /dev/null +++ b/pkg/circle/scan/scanner.go @@ -0,0 +1,680 @@ +package scan + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/CompassSecurity/pipeleek/pkg/format" + "github.com/CompassSecurity/pipeleek/pkg/logging" + "github.com/CompassSecurity/pipeleek/pkg/scan/logline" + "github.com/CompassSecurity/pipeleek/pkg/scan/result" + "github.com/CompassSecurity/pipeleek/pkg/scan/runner" + pkgscanner "github.com/CompassSecurity/pipeleek/pkg/scanner" + "github.com/h2non/filetype" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type InitializeOptionsInput struct { + Token string + CircleURL string + Organization string + Projects []string + VCS string + Branch string + Statuses []string + WorkflowNames []string + JobNames []string + Since string + Until string + MaxPipelines int + IncludeTests bool + IncludeInsights bool + Artifacts bool + MaxArtifactSize string + ConfidenceFilter []string + MaxScanGoRoutines int + TruffleHogVerification bool + HitTimeout time.Duration +} + +type ScanOptions struct { + Token string + CircleURL string + Organization string + Projects []string + Branch string + Statuses map[string]struct{} + WorkflowNames map[string]struct{} + JobNames map[string]struct{} + Since *time.Time + Until *time.Time + MaxPipelines int + IncludeTests bool + IncludeInsights bool + Artifacts bool + MaxArtifactSize int64 + ConfidenceFilter []string + MaxScanGoRoutines int + TruffleHogVerification bool + HitTimeout time.Duration + Context context.Context + APIClient CircleClient + HTTPClient *http.Client +} + +type Scanner interface { + pkgscanner.BaseScanner + Status() *zerolog.Event +} + +type circleScanner struct { + options ScanOptions + + pipelinesScanned atomic.Int64 + jobsScanned atomic.Int64 + artifactsScanned atomic.Int64 + currentProject string + mu sync.RWMutex +} + +var _ pkgscanner.BaseScanner = (*circleScanner)(nil) + +func NewScanner(opts ScanOptions) Scanner { + return &circleScanner{options: opts} +} + +func (s *circleScanner) Status() *zerolog.Event { + s.mu.RLock() + project := s.currentProject + s.mu.RUnlock() + + return log.Info(). + Int64("pipelinesScanned", s.pipelinesScanned.Load()). + Int64("jobsScanned", s.jobsScanned.Load()). + Int64("artifactsScanned", s.artifactsScanned.Load()). + Str("currentProject", project) +} + +func (s *circleScanner) Scan() error { + runner.InitScanner(s.options.ConfidenceFilter) + + for _, project := range s.options.Projects { + s.mu.Lock() + s.currentProject = project + s.mu.Unlock() + + log.Info().Str("project", project).Msg("Scanning CircleCI project") + if err := s.scanProject(project); err != nil { + log.Warn().Err(err).Str("project", project).Msg("Project scan failed, continuing") + } + } + + log.Info().Msg("Scan Finished, Bye Bye 🏳️‍🌈🔥") + return nil +} + +func (s *circleScanner) scanProject(project string) error { + var pageToken string + scanned := 0 + + for { + log.Debug(). + Str("project", project). + Str("pageToken", pageToken). + Int("pipelinesScannedForProject", scanned). + Msg("Fetching pipeline page") + + pipelines, nextToken, err := s.options.APIClient.ListPipelines(s.options.Context, project, s.options.Branch, pageToken) + if err != nil { + return err + } + + log.Debug(). + Str("project", project). + Int("pipelinesReturned", len(pipelines)). + Str("nextPageToken", nextToken). + Msg("Fetched pipeline page") + + for _, pipeline := range pipelines { + if s.options.MaxPipelines > 0 && scanned >= s.options.MaxPipelines { + log.Debug(). + Str("project", project). + Int("maxPipelines", s.options.MaxPipelines). + Int("pipelinesScannedForProject", scanned). + Msg("Reached max pipeline limit for project") + return nil + } + + if !s.inTimeWindow(parseRFC3339Ptr(pipeline.CreatedAt)) { + continue + } + + if !matchesFilter(s.options.Statuses, pipeline.State) { + continue + } + + s.pipelinesScanned.Add(1) + scanned++ + + log.Debug(). + Str("project", project). + Str("pipelineID", pipeline.ID). + Str("pipelineState", pipeline.State). + Msg("Scanning pipeline") + + if err := s.scanPipeline(project, pipeline); err != nil { + log.Warn().Err(err).Str("project", project).Str("pipeline", pipeline.ID).Msg("Pipeline scan failed, continuing") + } + } + + if nextToken == "" { + break + } + pageToken = nextToken + } + + if s.options.IncludeInsights { + if err := s.scanProjectInsights(project); err != nil { + log.Debug().Err(err).Str("project", project).Msg("Failed scanning project insights") + } + } + + return nil +} + +func (s *circleScanner) scanProjectInsights(project string) error { + log.Debug().Str("project", project).Msg("Scanning project insights") + + workflows, err := s.options.APIClient.ListProjectInsightsWorkflows(s.options.Context, project, s.options.Branch) + if err != nil { + return err + } + + log.Debug(). + Str("project", project). + Int("insightWorkflows", len(workflows)). + Msg("Fetched project insights workflows") + + for _, workflowName := range workflows { + if !matchesFilter(s.options.WorkflowNames, workflowName) { + continue + } + + details, err := s.options.APIClient.GetProjectInsightsWorkflow(s.options.Context, project, workflowName, s.options.Branch) + if err != nil { + continue + } + + payload, err := json.Marshal(details) + if err != nil { + continue + } + + findings, err := pkgscanner.DetectHits(payload, s.options.MaxScanGoRoutines, s.options.TruffleHogVerification, s.options.HitTimeout) + if err != nil { + continue + } + + result.ReportFindings(findings, result.ReportOptions{ + LocationURL: strings.TrimRight(s.options.CircleURL, "/") + "/pipelines/" + project, + JobName: workflowName, + BuildName: "insights", + Type: logging.SecretTypeLog, + }) + } + + return nil +} + +func (s *circleScanner) scanPipeline(project string, pipeline pipelineItem) error { + workflows, err := s.options.APIClient.ListPipelineWorkflows(s.options.Context, pipeline.ID) + if err != nil { + return err + } + + log.Debug(). + Str("project", project). + Str("pipelineID", pipeline.ID). + Int("workflowsReturned", len(workflows)). + Msg("Fetched pipeline workflows") + + for _, wf := range workflows { + if !matchesFilter(s.options.WorkflowNames, wf.Name) { + continue + } + if !matchesFilter(s.options.Statuses, wf.Status) { + continue + } + if !s.inTimeWindow(parseRFC3339Ptr(wf.CreatedAt)) { + continue + } + + log.Debug(). + Str("project", project). + Str("pipelineID", pipeline.ID). + Str("workflowID", wf.ID). + Str("workflowName", wf.Name). + Str("workflowStatus", wf.Status). + Msg("Scanning workflow") + + if err := s.scanWorkflow(project, pipeline, wf); err != nil { + log.Warn().Err(err).Str("project", project).Str("workflow", wf.ID).Msg("Workflow scan failed, continuing") + } + } + + return nil +} + +func (s *circleScanner) scanWorkflow(project string, pipeline pipelineItem, workflow workflowItem) error { + jobs, err := s.options.APIClient.ListWorkflowJobs(s.options.Context, workflow.ID) + if err != nil { + return err + } + + log.Debug(). + Str("project", project). + Str("pipelineID", pipeline.ID). + Str("workflowID", workflow.ID). + Int("jobsReturned", len(jobs)). + Msg("Fetched workflow jobs") + + for _, job := range jobs { + if !matchesFilter(s.options.JobNames, job.Name) { + continue + } + if !matchesFilter(s.options.Statuses, job.Status) { + continue + } + + log.Debug(). + Str("project", project). + Str("workflowID", workflow.ID). + Int("jobNumber", job.JobNumber). + Str("jobName", job.Name). + Str("jobStatus", job.Status). + Msg("Scanning job") + + s.jobsScanned.Add(1) + if err := s.scanJob(project, pipeline, workflow, job); err != nil { + log.Warn().Err(err).Str("project", project).Int("jobNumber", job.JobNumber).Msg("Job scan failed, continuing") + } + } + + return nil +} + +func (s *circleScanner) scanJob(project string, pipeline pipelineItem, workflow workflowItem, job workflowJobItem) error { + jobDetails, err := s.options.APIClient.GetProjectJob(s.options.Context, project, job.JobNumber) + if err != nil { + return err + } + if len(jobDetails.Steps) == 0 { + legacyDetails, legacyErr := s.options.APIClient.GetProjectJobV1(s.options.Context, project, job.JobNumber) + if legacyErr == nil && len(legacyDetails.Steps) > 0 { + if strings.TrimSpace(jobDetails.Name) == "" { + jobDetails.Name = legacyDetails.Name + } + if strings.TrimSpace(jobDetails.WebURL) == "" { + jobDetails.WebURL = legacyDetails.WebURL + } + jobDetails.Steps = legacyDetails.Steps + } + } + + log.Debug(). + Str("project", project). + Str("workflowID", workflow.ID). + Int("jobNumber", job.JobNumber). + Int("steps", len(jobDetails.Steps)). + Msg("Fetched job details") + + locationURL := circleAppWorkflowURL(workflow.ID) + + if err := s.scanJobLogs(project, workflow, jobDetails, locationURL); err != nil { + log.Debug().Err(err).Str("project", project).Int("job", job.JobNumber).Msg("Failed scanning job logs") + } + + if s.options.IncludeTests { + if err := s.scanJobTests(project, workflow, job, jobDetails, locationURL); err != nil { + log.Debug().Err(err).Str("project", project).Int("job", job.JobNumber).Msg("Failed scanning job tests") + } + } + + if s.options.Artifacts { + if err := s.scanJobArtifacts(project, workflow, job, jobDetails, locationURL); err != nil { + log.Debug().Err(err).Str("project", project).Int("job", job.JobNumber).Msg("Failed scanning job artifacts") + } + } + + _ = pipeline + return nil +} + +func (s *circleScanner) scanJobLogs(project string, workflow workflowItem, details projectJobResponse, locationURL string) error { + log.Debug(). + Str("project", project). + Str("workflowID", workflow.ID). + Str("jobName", details.Name). + Int("steps", len(details.Steps)). + Msg("Scanning job logs") + + for _, step := range details.Steps { + for _, action := range step.Actions { + if action.OutputURL == "" { + continue + } + + logBytes, err := s.options.APIClient.DownloadWithAuth(s.options.Context, action.OutputURL) + if err != nil { + continue + } + if len(logBytes) == 0 { + continue + } + + processed := flattenLogOutput(logBytes) + logResult, err := logline.ProcessLogs(processed, logline.ProcessOptions{ + MaxGoRoutines: s.options.MaxScanGoRoutines, + VerifyCredentials: s.options.TruffleHogVerification, + HitTimeout: s.options.HitTimeout, + }) + if err != nil { + continue + } + + if len(logResult.Findings) > 0 { + log.Debug(). + Str("project", project). + Str("workflowID", workflow.ID). + Str("jobName", details.Name). + Int("findings", len(logResult.Findings)). + Msg("Detected findings in job log output") + } + + result.ReportFindings(logResult.Findings, result.ReportOptions{ + LocationURL: locationURL, + JobName: workflow.Name, + BuildName: details.Name, + Type: logging.SecretTypeLog, + }) + } + } + + _ = project + return nil +} + +func (s *circleScanner) scanJobTests(project string, workflow workflowItem, job workflowJobItem, details projectJobResponse, locationURL string) error { + tests, err := s.options.APIClient.ListJobTests(s.options.Context, project, job.JobNumber) + if err != nil { + return err + } + + log.Debug(). + Str("project", project). + Str("workflowID", workflow.ID). + Int("jobNumber", job.JobNumber). + Int("testsReturned", len(tests)). + Msg("Fetched job tests") + + if len(tests) == 0 { + return nil + } + + payload, err := json.Marshal(tests) + if err != nil { + return err + } + + findings, err := pkgscanner.DetectHits(payload, s.options.MaxScanGoRoutines, s.options.TruffleHogVerification, s.options.HitTimeout) + if err != nil { + return err + } + + if len(findings) > 0 { + log.Debug(). + Str("project", project). + Str("workflowID", workflow.ID). + Int("jobNumber", job.JobNumber). + Int("findings", len(findings)). + Msg("Detected findings in job tests") + } + + result.ReportFindings(findings, result.ReportOptions{ + LocationURL: locationURL, + JobName: workflow.Name, + BuildName: fmt.Sprintf("%s tests", details.Name), + Type: logging.SecretTypeLog, + }) + + return nil +} + +func (s *circleScanner) scanJobArtifacts(project string, workflow workflowItem, job workflowJobItem, details projectJobResponse, locationURL string) error { + artifacts, err := s.options.APIClient.ListJobArtifacts(s.options.Context, project, job.JobNumber) + if err != nil { + return err + } + + log.Debug(). + Str("project", project). + Str("workflowID", workflow.ID). + Int("jobNumber", job.JobNumber). + Int("artifactsReturned", len(artifacts)). + Msg("Fetched job artifacts") + + for _, artifact := range artifacts { + if artifact.URL == "" || artifact.Path == "" { + continue + } + + content, err := s.options.APIClient.DownloadWithAuth(s.options.Context, artifact.URL) + if err != nil { + continue + } + + if int64(len(content)) > s.options.MaxArtifactSize { + log.Debug(). + Str("project", project). + Str("workflowID", workflow.ID). + Int("jobNumber", job.JobNumber). + Str("artifact", artifact.Path). + Int("bytes", len(content)). + Int64("maxBytes", s.options.MaxArtifactSize). + Msg("Skipped large artifact") + continue + } + + s.artifactsScanned.Add(1) + if filetype.IsArchive(content) { + pkgscanner.HandleArchiveArtifact(artifact.Path, content, locationURL, details.Name, s.options.TruffleHogVerification, s.options.HitTimeout) + continue + } + + pkgscanner.DetectFileHits(content, locationURL, details.Name, artifact.Path, workflow.Name, s.options.TruffleHogVerification, s.options.HitTimeout) + } + + return nil +} + +func (s *circleScanner) inTimeWindow(value *time.Time) bool { + if value == nil { + return true + } + if s.options.Since != nil && value.Before(*s.options.Since) { + return false + } + if s.options.Until != nil && value.After(*s.options.Until) { + return false + } + return true +} + +func InitializeOptions(input InitializeOptionsInput) (ScanOptions, error) { + orgName := normalizedOrgName(input.Organization) + + if input.CircleURL == "" { + input.CircleURL = "https://circleci.com" + } + if input.VCS == "" { + input.VCS = "github" + } + + maxArtifactBytes, err := format.ParseHumanSize(input.MaxArtifactSize) + if err != nil { + return ScanOptions{}, err + } + + since, err := parseOptionalRFC3339(input.Since) + if err != nil { + return ScanOptions{}, fmt.Errorf("invalid --since value: %w", err) + } + until, err := parseOptionalRFC3339(input.Until) + if err != nil { + return ScanOptions{}, fmt.Errorf("invalid --until value: %w", err) + } + if since != nil && until != nil && since.After(*until) { + return ScanOptions{}, fmt.Errorf("--since must be before --until") + } + + projects := make([]string, 0, len(input.Projects)) + for _, p := range input.Projects { + normalized, err := normalizeProjectSlug(p, input.VCS) + if err != nil { + return ScanOptions{}, err + } + if orgName != "" && !belongsToOrg(normalized, orgName) { + continue + } + projects = append(projects, normalized) + } + + baseURL, err := url.Parse(strings.TrimRight(input.CircleURL, "/") + "/api/v2/") + if err != nil { + return ScanOptions{}, err + } + + httpClient := &http.Client{Timeout: 45 * time.Second} + apiClient := newCircleAPIClient(baseURL, input.Token, httpClient) + + if len(projects) == 0 { + if strings.TrimSpace(input.Organization) != "" { + resolved, err := apiClient.ListOrganizationProjects(context.Background(), input.Organization, input.VCS) + if err != nil { + fallbackProjects, fallbackErr := apiClient.ListAccessibleProjectsV1(context.Background(), input.VCS, orgName) + if fallbackErr != nil { + return ScanOptions{}, err + } + projects = uniqueStrings(append(projects, fallbackProjects...)) + } else { + projects = resolved + } + } else { + resolved, err := apiClient.ListAccessibleProjectsV1(context.Background(), input.VCS, "") + if err != nil { + return ScanOptions{}, fmt.Errorf("provide --project or --org, or ensure token can list accessible projects: %w", err) + } + projects = resolved + } + } + + if len(projects) == 0 { + return ScanOptions{}, fmt.Errorf("no project remains after applying organization filter") + } + + return ScanOptions{ + Token: input.Token, + CircleURL: input.CircleURL, + Organization: input.Organization, + Projects: projects, + Branch: input.Branch, + Statuses: toFilterSet(input.Statuses), + WorkflowNames: toFilterSet(input.WorkflowNames), + JobNames: toFilterSet(input.JobNames), + Since: since, + Until: until, + MaxPipelines: input.MaxPipelines, + IncludeTests: input.IncludeTests, + IncludeInsights: input.IncludeInsights, + Artifacts: input.Artifacts, + MaxArtifactSize: maxArtifactBytes, + ConfidenceFilter: input.ConfidenceFilter, + MaxScanGoRoutines: input.MaxScanGoRoutines, + TruffleHogVerification: input.TruffleHogVerification, + HitTimeout: input.HitTimeout, + Context: context.Background(), + APIClient: apiClient, + HTTPClient: httpClient, + }, nil +} + +func parseOptionalRFC3339(value string) (*time.Time, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil, err + } + return &t, nil +} + +func parseRFC3339Ptr(value string) *time.Time { + if value == "" { + return nil + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil + } + return &t +} + +func flattenLogOutput(raw []byte) []byte { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" { + return raw + } + + if strings.HasPrefix(trimmed, "[") { + var entries []map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), &entries); err == nil && len(entries) > 0 { + var b strings.Builder + for _, entry := range entries { + if msg, ok := entry["message"].(string); ok && msg != "" { + b.WriteString(msg) + b.WriteByte('\n') + } + } + if b.Len() > 0 { + return []byte(b.String()) + } + } + } + + if strings.HasPrefix(trimmed, "{") { + var entry map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), &entry); err == nil { + if msg, ok := entry["message"].(string); ok && msg != "" { + return []byte(msg) + } + } + } + + return []byte(trimmed) +} + +func circleAppWorkflowURL(workflowID string) string { + if strings.TrimSpace(workflowID) == "" { + return "https://app.circleci.com/pipelines" + } + return fmt.Sprintf("https://app.circleci.com/pipelines/workflows/%s", workflowID) +} diff --git a/pkg/circle/scan/scanner_test.go b/pkg/circle/scan/scanner_test.go new file mode 100644 index 00000000..7f5de508 --- /dev/null +++ b/pkg/circle/scan/scanner_test.go @@ -0,0 +1,165 @@ +package scan + +import "testing" + +func TestNormalizeProjectSlug(t *testing.T) { + tests := []struct { + name string + in string + vcs string + want string + wantError bool + }{ + {name: "org/repo", in: "org/repo", vcs: "github", want: "github/org/repo"}, + {name: "vcs/org/repo", in: "bitbucket/org/repo", vcs: "github", want: "bitbucket/org/repo"}, + {name: "invalid", in: "org", vcs: "github", wantError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeProjectSlug(tt.in, tt.vcs) + if tt.wantError { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestBelongsToOrg(t *testing.T) { + if !belongsToOrg("github/my-org/my-repo", "my-org") { + t.Fatal("expected project to belong to org") + } + if belongsToOrg("github/other-org/my-repo", "my-org") { + t.Fatal("expected project to not belong to org") + } +} + +func TestNormalizedOrgName(t *testing.T) { + tests := []struct { + in string + want string + }{ + {in: "my-org", want: "my-org"}, + {in: "github/my-org", want: "my-org"}, + {in: "gh/my-org", want: "my-org"}, + {in: "", want: ""}, + } + + for _, tt := range tests { + if got := normalizedOrgName(tt.in); got != tt.want { + t.Fatalf("normalizedOrgName(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestVCSFromURL(t *testing.T) { + tests := []struct { + in string + want string + }{ + {in: "https://github.com/example/repo", want: "github"}, + {in: "https://bitbucket.org/example/repo", want: "bitbucket"}, + {in: "https://example.com/example/repo", want: ""}, + } + + for _, tt := range tests { + if got := vcsFromURL(tt.in); got != tt.want { + t.Fatalf("vcsFromURL(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestNormalizeVCSName(t *testing.T) { + tests := []struct { + in string + want string + }{ + {in: "github", want: "github"}, + {in: "gh", want: "github"}, + {in: "circleci", want: "circleci"}, + {in: "bb", want: "bitbucket"}, + {in: "bitbucket", want: "bitbucket"}, + } + + for _, tt := range tests { + if got := normalizeVCSName(tt.in); got != tt.want { + t.Fatalf("normalizeVCSName(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestCircleciUUIDSlug(t *testing.T) { + slug, ok := circleciUUIDSlug("//circleci.com/3901667c-bcfd-4296-8bda-c5c6e35ab886/4856fff8-1113-43d7-a091-4f7950757db9") + if !ok { + t.Fatal("expected slug extraction to succeed") + } + + want := "circleci/3901667c-bcfd-4296-8bda-c5c6e35ab886/4856fff8-1113-43d7-a091-4f7950757db9" + if slug != want { + t.Fatalf("expected %q, got %q", want, slug) + } +} + +func TestProjectSlugFromV1(t *testing.T) { + item := v1ProjectItem{ + Username: "pipeleek", + Reponame: "pipeleek-secrets-demo", + VCSURL: "//circleci.com/3901667c-bcfd-4296-8bda-c5c6e35ab886/4856fff8-1113-43d7-a091-4f7950757db9", + VCSType: "circleci", + } + + slug, ok := projectSlugFromV1(item, "github") + if !ok { + t.Fatal("expected project slug conversion to succeed") + } + + want := "circleci/3901667c-bcfd-4296-8bda-c5c6e35ab886/4856fff8-1113-43d7-a091-4f7950757db9" + if slug != want { + t.Fatalf("expected %q, got %q", want, slug) + } +} + +func TestCircleAppWorkflowURL(t *testing.T) { + if got := circleAppWorkflowURL(""); got != "https://app.circleci.com/pipelines" { + t.Fatalf("unexpected fallback url: %s", got) + } + + if got := circleAppWorkflowURL("wf-123"); got != "https://app.circleci.com/pipelines/workflows/wf-123" { + t.Fatalf("unexpected workflow url: %s", got) + } +} + +func TestFlattenLogOutput(t *testing.T) { + t.Run("json array", func(t *testing.T) { + raw := []byte(`[{"message":"line1"},{"message":"line2"}]`) + got := string(flattenLogOutput(raw)) + if got != "line1\nline2\n" { + t.Fatalf("unexpected flattened output: %q", got) + } + }) + + t.Run("json object", func(t *testing.T) { + raw := []byte(`{"message":"single-line"}`) + got := string(flattenLogOutput(raw)) + if got != "single-line" { + t.Fatalf("unexpected flattened output: %q", got) + } + }) + + t.Run("plain text", func(t *testing.T) { + raw := []byte(" hello secrets \n") + got := string(flattenLogOutput(raw)) + if got != "hello secrets" { + t.Fatalf("unexpected flattened output: %q", got) + } + }) +} diff --git a/pkg/circle/scan/transport.go b/pkg/circle/scan/transport.go new file mode 100644 index 00000000..757e400c --- /dev/null +++ b/pkg/circle/scan/transport.go @@ -0,0 +1,408 @@ +package scan + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/CircleCI-Public/circleci-cli/api/rest" + "github.com/rs/zerolog/log" +) + +type CircleClient interface { + ListOrganizationProjects(ctx context.Context, orgSlug, defaultVCS string) ([]string, error) + ListAccessibleProjectsV1(ctx context.Context, defaultVCS, orgFilter string) ([]string, error) + ListPipelines(ctx context.Context, projectSlug, branch, pageToken string) ([]pipelineItem, string, error) + ListPipelineWorkflows(ctx context.Context, pipelineID string) ([]workflowItem, error) + ListWorkflowJobs(ctx context.Context, workflowID string) ([]workflowJobItem, error) + GetProjectJob(ctx context.Context, projectSlug string, jobNumber int) (projectJobResponse, error) + GetProjectJobV1(ctx context.Context, projectSlug string, jobNumber int) (projectJobResponse, error) + ListJobArtifacts(ctx context.Context, projectSlug string, jobNumber int) ([]jobArtifactItem, error) + ListJobTests(ctx context.Context, projectSlug string, jobNumber int) ([]jobTestItem, error) + ListProjectInsightsWorkflows(ctx context.Context, projectSlug, branch string) ([]string, error) + GetProjectInsightsWorkflow(ctx context.Context, projectSlug, workflowName, branch string) (map[string]interface{}, error) + DownloadWithAuth(ctx context.Context, rawURL string) ([]byte, error) +} + +type circleAPIClient struct { + restClient *rest.Client + httpClient *http.Client + token string +} + +func newCircleAPIClient(baseURL *url.URL, token string, httpClient *http.Client) *circleAPIClient { + return &circleAPIClient{ + restClient: rest.New(baseURL, token, httpClient), + httpClient: httpClient, + token: token, + } +} + +type pipelineListResponse struct { + Items []pipelineItem `json:"items"` + NextPageToken string `json:"next_page_token"` +} + +type orgProjectListResponse struct { + Items []struct { + Slug string `json:"slug"` + } `json:"items"` + NextPageToken string `json:"next_page_token"` +} + +type v1ProjectItem struct { + Username string `json:"username"` + Reponame string `json:"reponame"` + VCSURL string `json:"vcs_url"` + VCSType string `json:"vcs_type"` +} + +type pipelineItem struct { + ID string `json:"id"` + State string `json:"state"` + CreatedAt string `json:"created_at"` +} + +type workflowListResponse struct { + Items []workflowItem `json:"items"` +} + +type workflowItem struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` +} + +type workflowJobListResponse struct { + Items []workflowJobItem `json:"items"` +} + +type workflowJobItem struct { + JobNumber int `json:"job_number"` + Name string `json:"name"` + Status string `json:"status"` +} + +type projectJobResponse struct { + Name string `json:"name"` + WebURL string `json:"web_url"` + Steps []struct { + Actions []struct { + OutputURL string `json:"output_url"` + } `json:"actions"` + } `json:"steps"` +} + +type jobArtifactsResponse struct { + Items []jobArtifactItem `json:"items"` +} + +type jobArtifactItem struct { + Path string `json:"path"` + URL string `json:"url"` +} + +type jobTestsResponse struct { + Items []jobTestItem `json:"items"` +} + +type jobTestItem struct { + Name string `json:"name"` + Result string `json:"result"` + Message string `json:"message"` + File string `json:"file"` +} + +func (c *circleAPIClient) ListPipelines(ctx context.Context, projectSlug, branch, pageToken string) ([]pipelineItem, string, error) { + q := url.Values{} + if branch != "" { + q.Set("branch", branch) + } + if pageToken != "" { + q.Set("page-token", pageToken) + } + + var out pipelineListResponse + if err := c.getJSON(ctx, fmt.Sprintf("project/%s/pipeline", projectSlug), q, &out); err != nil { + return nil, "", err + } + return out.Items, out.NextPageToken, nil +} + +func (c *circleAPIClient) ListOrganizationProjects(ctx context.Context, orgSlug, defaultVCS string) ([]string, error) { + candidates := []string{orgSlug} + if !strings.Contains(orgSlug, "/") { + for _, vcsSlug := range vcsSlugCandidates(defaultVCS) { + candidates = append(candidates, fmt.Sprintf("%s/%s", vcsSlug, orgSlug)) + } + } + candidates = uniqueStrings(candidates) + + var lastErr error + for _, candidate := range candidates { + var out []string + var pageToken string + + for { + q := url.Values{} + if pageToken != "" { + q.Set("page-token", pageToken) + } + + var resp orgProjectListResponse + if err := c.getJSON(ctx, fmt.Sprintf("organization/%s/project", candidate), q, &resp); err != nil { + lastErr = err + out = nil + break + } + + for _, item := range resp.Items { + slug := strings.TrimSpace(item.Slug) + if slug == "" { + continue + } + if !strings.Contains(slug, "/") { + continue + } + if len(strings.Split(slug, "/")) == 2 { + slug = fmt.Sprintf("%s/%s", defaultVCS, slug) + } + out = append(out, slug) + } + + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + if len(out) > 0 { + return out, nil + } + } + + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("organization %q has no accessible projects", orgSlug) +} + +func (c *circleAPIClient) ListAccessibleProjectsV1(ctx context.Context, defaultVCS, orgFilter string) ([]string, error) { + requestURL, err := c.restClient.BaseURL.Parse("../v1.1/projects") + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Circle-Token", c.token) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("v1 project discovery failed: %s", resp.Status) + } + + var items []v1ProjectItem + if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { + return nil, err + } + + projects := make([]string, 0, len(items)) + normalizedFilter := strings.ToLower(strings.TrimSpace(orgFilter)) + for _, item := range items { + log.Debug(). + Str("username", strings.TrimSpace(item.Username)). + Str("reponame", strings.TrimSpace(item.Reponame)). + Str("vcsType", strings.TrimSpace(item.VCSType)). + Str("vcsURL", strings.TrimSpace(item.VCSURL)). + Msg("Discovered project from CircleCI v1 API") + + if normalizedFilter != "" && strings.ToLower(strings.TrimSpace(item.Username)) != normalizedFilter { + log.Debug(). + Str("username", strings.TrimSpace(item.Username)). + Str("orgFilter", orgFilter). + Msg("Skipped discovered project due to org filter mismatch") + continue + } + + slug, ok := projectSlugFromV1(item, defaultVCS) + if !ok { + log.Debug(). + Str("username", strings.TrimSpace(item.Username)). + Str("reponame", strings.TrimSpace(item.Reponame)). + Str("vcsType", strings.TrimSpace(item.VCSType)). + Msg("Skipped discovered project because slug normalization failed") + continue + } + + log.Debug(). + Str("slug", slug). + Msg("Normalized discovered project to scan slug") + projects = append(projects, slug) + } + + projects = uniqueStrings(projects) + if len(projects) == 0 { + return nil, fmt.Errorf("no accessible projects returned by v1 discovery") + } + + return projects, nil +} + +func (c *circleAPIClient) ListPipelineWorkflows(ctx context.Context, pipelineID string) ([]workflowItem, error) { + var out workflowListResponse + if err := c.getJSON(ctx, fmt.Sprintf("pipeline/%s/workflow", pipelineID), nil, &out); err != nil { + return nil, err + } + return out.Items, nil +} + +func (c *circleAPIClient) ListWorkflowJobs(ctx context.Context, workflowID string) ([]workflowJobItem, error) { + var out workflowJobListResponse + if err := c.getJSON(ctx, fmt.Sprintf("workflow/%s/job", workflowID), nil, &out); err != nil { + return nil, err + } + return out.Items, nil +} + +func (c *circleAPIClient) GetProjectJob(ctx context.Context, projectSlug string, jobNumber int) (projectJobResponse, error) { + var out projectJobResponse + err := c.getJSON(ctx, fmt.Sprintf("project/%s/job/%s", projectSlug, strconv.Itoa(jobNumber)), nil, &out) + return out, err +} + +func (c *circleAPIClient) GetProjectJobV1(ctx context.Context, projectSlug string, jobNumber int) (projectJobResponse, error) { + requestURL, err := c.restClient.BaseURL.Parse(fmt.Sprintf("../v1.1/project/%s/%d", projectSlug, jobNumber)) + if err != nil { + return projectJobResponse{}, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil) + if err != nil { + return projectJobResponse{}, err + } + req.Header.Set("Circle-Token", c.token) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return projectJobResponse{}, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return projectJobResponse{}, fmt.Errorf("v1 job details failed: %s", resp.Status) + } + + var out projectJobResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return projectJobResponse{}, err + } + + return out, nil +} + +func (c *circleAPIClient) ListJobArtifacts(ctx context.Context, projectSlug string, jobNumber int) ([]jobArtifactItem, error) { + var out jobArtifactsResponse + if err := c.getJSON(ctx, fmt.Sprintf("project/%s/%s/artifacts", projectSlug, strconv.Itoa(jobNumber)), nil, &out); err != nil { + return nil, err + } + return out.Items, nil +} + +func (c *circleAPIClient) ListJobTests(ctx context.Context, projectSlug string, jobNumber int) ([]jobTestItem, error) { + var out jobTestsResponse + if err := c.getJSON(ctx, fmt.Sprintf("project/%s/%s/tests", projectSlug, strconv.Itoa(jobNumber)), nil, &out); err != nil { + return nil, err + } + return out.Items, nil +} + +func (c *circleAPIClient) ListProjectInsightsWorkflows(ctx context.Context, projectSlug, branch string) ([]string, error) { + q := url.Values{} + if branch != "" { + q.Set("branch", branch) + } + + var resp struct { + Items []struct { + Name string `json:"name"` + } `json:"items"` + } + if err := c.getJSON(ctx, fmt.Sprintf("insights/%s/workflows", projectSlug), q, &resp); err != nil { + return nil, err + } + + out := make([]string, 0, len(resp.Items)) + for _, item := range resp.Items { + if strings.TrimSpace(item.Name) != "" { + out = append(out, item.Name) + } + } + + return out, nil +} + +func (c *circleAPIClient) GetProjectInsightsWorkflow(ctx context.Context, projectSlug, workflowName, branch string) (map[string]interface{}, error) { + q := url.Values{} + if branch != "" { + q.Set("branch", branch) + } + + var resp map[string]interface{} + if err := c.getJSON(ctx, fmt.Sprintf("insights/%s/workflows/%s", projectSlug, url.PathEscape(workflowName)), q, &resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (c *circleAPIClient) DownloadWithAuth(ctx context.Context, rawURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Circle-Token", c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("download failed: %s", resp.Status) + } + + return io.ReadAll(resp.Body) +} + +func (c *circleAPIClient) getJSON(ctx context.Context, path string, query url.Values, out interface{}) error { + u := &url.URL{Path: path} + if query != nil { + u.RawQuery = query.Encode() + } + + req, err := c.restClient.NewRequest(http.MethodGet, u, nil) + if err != nil { + return err + } + req = req.WithContext(ctx) + + _, err = c.restClient.DoRequest(req, out) + return err +} diff --git a/pkg/circle/scan/transport_test.go b/pkg/circle/scan/transport_test.go new file mode 100644 index 00000000..b42adb4c --- /dev/null +++ b/pkg/circle/scan/transport_test.go @@ -0,0 +1,108 @@ +package scan + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" +) + +func TestListOrganizationProjectsCandidateFallback(t *testing.T) { + var ( + mu sync.Mutex + requests []string + ) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.URL.Path) + mu.Unlock() + + switch r.URL.Path { + case "/api/v2/organization/my-org/project": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + case "/api/v2/organization/github/my-org/project": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[{"slug":"my-org/repo-a"},{"slug":"bitbucket/other/repo-b"}],"next_page_token":""}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + baseURL, err := url.Parse(ts.URL + "/api/v2/") + if err != nil { + t.Fatalf("failed to parse base url: %v", err) + } + + client := newCircleAPIClient(baseURL, "token", ts.Client()) + projects, err := client.ListOrganizationProjects(context.Background(), "my-org", "github") + if err != nil { + t.Fatalf("expected fallback candidate lookup to succeed, got error: %v", err) + } + + if len(projects) != 2 { + t.Fatalf("expected 2 projects, got %d: %#v", len(projects), projects) + } + if projects[0] != "github/my-org/repo-a" { + t.Fatalf("unexpected first project: %q", projects[0]) + } + if projects[1] != "bitbucket/other/repo-b" { + t.Fatalf("unexpected second project: %q", projects[1]) + } + + mu.Lock() + defer mu.Unlock() + if len(requests) < 2 { + t.Fatalf("expected at least 2 requests, got %d", len(requests)) + } + if requests[0] != "/api/v2/organization/my-org/project" { + t.Fatalf("expected first candidate request path, got %q", requests[0]) + } + if requests[1] != "/api/v2/organization/github/my-org/project" { + t.Fatalf("expected second candidate request path, got %q", requests[1]) + } +} + +func TestListAccessibleProjectsV1FiltersAndNormalizes(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1.1/projects" { + w.WriteHeader(http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + payload := []v1ProjectItem{ + {Username: "team", Reponame: "repo-a", VCSType: "github", VCSURL: "https://github.com/team/repo-a"}, + {Username: "other", Reponame: "repo-z", VCSType: "github", VCSURL: "https://github.com/other/repo-z"}, + {Username: "team", Reponame: "ignored", VCSType: "circleci", VCSURL: "//circleci.com/org-uuid/proj-uuid"}, + } + _ = json.NewEncoder(w).Encode(payload) + })) + defer ts.Close() + + baseURL, err := url.Parse(ts.URL + "/api/v2/") + if err != nil { + t.Fatalf("failed to parse base url: %v", err) + } + + client := newCircleAPIClient(baseURL, "token", ts.Client()) + projects, err := client.ListAccessibleProjectsV1(context.Background(), "github", "team") + if err != nil { + t.Fatalf("expected v1 discovery to succeed, got error: %v", err) + } + + if len(projects) != 2 { + t.Fatalf("expected 2 filtered projects, got %d: %#v", len(projects), projects) + } + if projects[0] != "github/team/repo-a" { + t.Fatalf("unexpected first project: %q", projects[0]) + } + if projects[1] != "circleci/org-uuid/proj-uuid" { + t.Fatalf("unexpected second project: %q", projects[1]) + } +} diff --git a/tests/e2e/circle/scan/scan_test.go b/tests/e2e/circle/scan/scan_test.go new file mode 100644 index 00000000..211d8431 --- /dev/null +++ b/tests/e2e/circle/scan/scan_test.go @@ -0,0 +1,136 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/CompassSecurity/pipeleek/tests/e2e/internal/testutil" + "github.com/stretchr/testify/assert" +) + +func TestCircleScan_ProjectHappyPath(t *testing.T) { + server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + baseURL := "http://" + r.Host + + switch { + case r.URL.Path == "/api/v2/project/github/example-org/example-repo/pipeline": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []map[string]interface{}{{ + "id": "pipeline-1", + "state": "created", + "created_at": "2026-01-10T10:00:00Z", + }}, + "next_page_token": "", + }) + case r.URL.Path == "/api/v2/pipeline/pipeline-1/workflow": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []map[string]interface{}{{ + "id": "wf-1", + "name": "build", + "status": "success", + "created_at": "2026-01-10T10:05:00Z", + }}, + }) + case r.URL.Path == "/api/v2/workflow/wf-1/job": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []map[string]interface{}{{ + "job_number": 101, + "name": "unit-tests", + "status": "success", + }}, + }) + case r.URL.Path == "/api/v2/project/github/example-org/example-repo/job/101": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "name": "unit-tests", + "web_url": fmt.Sprintf("%s/job/101", baseURL), + "steps": []map[string]interface{}{{ + "actions": []map[string]interface{}{{ + "output_url": fmt.Sprintf("%s/log/101", baseURL), + }}, + }}, + }) + case r.URL.Path == "/log/101": + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("build started\nall good\n")) + case r.URL.Path == "/api/v2/project/github/example-org/example-repo/101/tests": + _ = json.NewEncoder(w).Encode(map[string]interface{}{"items": []interface{}{}}) + case r.URL.Path == "/api/v2/insights/github/example-org/example-repo/workflows": + _ = json.NewEncoder(w).Encode(map[string]interface{}{"items": []map[string]interface{}{{"name": "build"}}}) + case r.URL.Path == "/api/v2/insights/github/example-org/example-repo/workflows/build": + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success_rate": 1.0}) + default: + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{}) + } + }) + defer cleanup() + + stdout, stderr, exitErr := testutil.RunCLI(t, []string{ + "circle", "scan", + "--circle", server.URL, + "--token", "circle-token", + "--project", "example-org/example-repo", + "--max-pipelines", "1", + "--tests", "false", + "--insights", "false", + }, nil, 20*time.Second) + + assert.Nil(t, exitErr, "circle scan should succeed") + requests := getRequests() + assert.True(t, len(requests) >= 5, "expected multiple CircleCI API requests") + + joined := stdout + stderr + t.Logf("Output:\n%s", joined) +} + +func TestCircleScan_OrgDiscovery(t *testing.T) { + server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.URL.Path == "/api/v2/organization/example-org/project": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []map[string]interface{}{{ + "slug": "github/example-org/example-repo", + }}, + "next_page_token": "", + }) + case r.URL.Path == "/api/v2/project/github/example-org/example-repo/pipeline": + _ = json.NewEncoder(w).Encode(map[string]interface{}{"items": []interface{}{}, "next_page_token": ""}) + case strings.HasPrefix(r.URL.Path, "/api/v2/insights/github/example-org/example-repo/workflows"): + _ = json.NewEncoder(w).Encode(map[string]interface{}{"items": []interface{}{}}) + default: + _ = json.NewEncoder(w).Encode(map[string]interface{}{}) + } + }) + defer cleanup() + + _, _, exitErr := testutil.RunCLI(t, []string{ + "circle", "scan", + "--circle", server.URL, + "--token", "circle-token", + "--org", "example-org", + "--max-pipelines", "1", + "--tests", "false", + "--insights", "false", + }, nil, 15*time.Second) + + assert.Nil(t, exitErr, "circle scan should support org discovery without --project") + + requests := getRequests() + sawOrgProjects := false + for _, req := range requests { + if req.Path == "/api/v2/organization/example-org/project" { + sawOrgProjects = true + break + } + } + assert.True(t, sawOrgProjects, "expected org project discovery request") +} From fad73d67f21a23cb70b8a81fa701b50c17efe564 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:15:46 +0000 Subject: [PATCH 02/11] fix(circle): address Copilot PR review comments --- internal/cmd/circle/scan/scan.go | 4 ++-- pkg/circle/scan/scanner.go | 8 +++----- pkg/circle/scan/transport_test.go | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/internal/cmd/circle/scan/scan.go b/internal/cmd/circle/scan/scan.go index 6c1d49e4..d744650b 100644 --- a/internal/cmd/circle/scan/scan.go +++ b/internal/cmd/circle/scan/scan.go @@ -77,7 +77,7 @@ func Scan(cmd *cobra.Command, args []string) { if err := config.AutoBindFlags(cmd, map[string]string{ "circle": "circle.url", "token": "circle.token", - "org": "circle.org", + "org": "circle.scan.org", "project": "circle.scan.project", "vcs": "circle.scan.vcs", "branch": "circle.scan.branch", @@ -104,7 +104,7 @@ func Scan(cmd *cobra.Command, args []string) { options.Token = config.GetString("circle.token") options.CircleURL = config.GetString("circle.url") - options.Organization = config.GetString("circle.org") + options.Organization = config.GetString("circle.scan.org") options.Projects = config.GetStringSlice("circle.scan.project") options.VCS = config.GetString("circle.scan.vcs") options.Branch = config.GetString("circle.scan.branch") diff --git a/pkg/circle/scan/scanner.go b/pkg/circle/scan/scanner.go index 91f37fb6..4cda44a5 100644 --- a/pkg/circle/scan/scanner.go +++ b/pkg/circle/scan/scanner.go @@ -303,7 +303,7 @@ func (s *circleScanner) scanWorkflow(project string, pipeline pipelineItem, work Msg("Scanning job") s.jobsScanned.Add(1) - if err := s.scanJob(project, pipeline, workflow, job); err != nil { + if err := s.scanJob(project, workflow, job); err != nil { log.Warn().Err(err).Str("project", project).Int("jobNumber", job.JobNumber).Msg("Job scan failed, continuing") } } @@ -311,7 +311,7 @@ func (s *circleScanner) scanWorkflow(project string, pipeline pipelineItem, work return nil } -func (s *circleScanner) scanJob(project string, pipeline pipelineItem, workflow workflowItem, job workflowJobItem) error { +func (s *circleScanner) scanJob(project string, workflow workflowItem, job workflowJobItem) error { jobDetails, err := s.options.APIClient.GetProjectJob(s.options.Context, project, job.JobNumber) if err != nil { return err @@ -354,7 +354,6 @@ func (s *circleScanner) scanJob(project string, pipeline pipelineItem, workflow } } - _ = pipeline return nil } @@ -408,7 +407,6 @@ func (s *circleScanner) scanJobLogs(project string, workflow workflowItem, detai } } - _ = project return nil } @@ -571,7 +569,7 @@ func InitializeOptions(input InitializeOptionsInput) (ScanOptions, error) { if err != nil { fallbackProjects, fallbackErr := apiClient.ListAccessibleProjectsV1(context.Background(), input.VCS, orgName) if fallbackErr != nil { - return ScanOptions{}, err + return ScanOptions{}, fmt.Errorf("ListOrganizationProjects failed: %v; fallback ListAccessibleProjectsV1 failed: %w", err, fallbackErr) } projects = uniqueStrings(append(projects, fallbackProjects...)) } else { diff --git a/pkg/circle/scan/transport_test.go b/pkg/circle/scan/transport_test.go index b42adb4c..1271f56c 100644 --- a/pkg/circle/scan/transport_test.go +++ b/pkg/circle/scan/transport_test.go @@ -90,7 +90,7 @@ func TestListAccessibleProjectsV1FiltersAndNormalizes(t *testing.T) { t.Fatalf("failed to parse base url: %v", err) } - client := newCircleAPIClient(baseURL, "token", ts.Client()) + client := newCircleAPIClient(baseURL, "token", ts.Client()) projects, err := client.ListAccessibleProjectsV1(context.Background(), "github", "team") if err != nil { t.Fatalf("expected v1 discovery to succeed, got error: %v", err) From 6fde8df04ed6548735c70f730b950e37e5a28f9d Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:39:50 +0000 Subject: [PATCH 03/11] feat(circle): link log hits directly to the step that triggered them --- pkg/circle/scan/scanner.go | 20 ++++++++++++++++++-- pkg/circle/scan/scanner_test.go | 21 +++++++++++++++++++++ pkg/circle/scan/transport.go | 4 ++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/pkg/circle/scan/scanner.go b/pkg/circle/scan/scanner.go index 4cda44a5..cd5ce16e 100644 --- a/pkg/circle/scan/scanner.go +++ b/pkg/circle/scan/scanner.go @@ -394,14 +394,20 @@ func (s *circleScanner) scanJobLogs(project string, workflow workflowItem, detai Str("project", project). Str("workflowID", workflow.ID). Str("jobName", details.Name). + Str("stepName", step.Name). Int("findings", len(logResult.Findings)). Msg("Detected findings in job log output") } + stepURL := circleJobStepURL(details.WebURL, action.Step, action.Index, locationURL) + stepLabel := step.Name + if action.Name != "" && action.Name != step.Name { + stepLabel = step.Name + " / " + action.Name + } result.ReportFindings(logResult.Findings, result.ReportOptions{ - LocationURL: locationURL, + LocationURL: stepURL, JobName: workflow.Name, - BuildName: details.Name, + BuildName: details.Name + " / " + stepLabel, Type: logging.SecretTypeLog, }) } @@ -676,3 +682,13 @@ func circleAppWorkflowURL(workflowID string) string { } return fmt.Sprintf("https://app.circleci.com/pipelines/workflows/%s", workflowID) } + +// circleJobStepURL builds a direct link to a specific step in the CircleCI UI. +// Format: /steps/: (e.g. .../jobs/42/steps/3:0) +// Falls back to fallback when the job WebURL is not available. +func circleJobStepURL(jobWebURL string, step, index int, fallback string) string { + if strings.TrimSpace(jobWebURL) == "" { + return fallback + } + return fmt.Sprintf("%s/steps/%d:%d", strings.TrimRight(jobWebURL, "/"), step, index) +} diff --git a/pkg/circle/scan/scanner_test.go b/pkg/circle/scan/scanner_test.go index 7f5de508..10096d74 100644 --- a/pkg/circle/scan/scanner_test.go +++ b/pkg/circle/scan/scanner_test.go @@ -138,6 +138,27 @@ func TestCircleAppWorkflowURL(t *testing.T) { } } +func TestCircleJobStepURL(t *testing.T) { + fallback := "https://app.circleci.com/pipelines/workflows/wf-123" + + // empty WebURL falls back gracefully + if got := circleJobStepURL("", 0, 0, fallback); got != fallback { + t.Fatalf("expected fallback url, got %q", got) + } + + // normal step link + jobURL := "https://app.circleci.com/pipelines/workflows/wf-123/jobs/42" + want := "https://app.circleci.com/pipelines/workflows/wf-123/jobs/42/steps/3:1" + if got := circleJobStepURL(jobURL, 3, 1, fallback); got != want { + t.Fatalf("expected %q, got %q", want, got) + } + + // trailing slash in WebURL is stripped + if got := circleJobStepURL(jobURL+"/", 0, 0, fallback); got != jobURL+"/steps/0:0" { + t.Fatalf("unexpected trailing-slash result: %q", got) + } +} + func TestFlattenLogOutput(t *testing.T) { t.Run("json array", func(t *testing.T) { raw := []byte(`[{"message":"line1"},{"message":"line2"}]`) diff --git a/pkg/circle/scan/transport.go b/pkg/circle/scan/transport.go index 757e400c..b0d6dc24 100644 --- a/pkg/circle/scan/transport.go +++ b/pkg/circle/scan/transport.go @@ -93,7 +93,11 @@ type projectJobResponse struct { Name string `json:"name"` WebURL string `json:"web_url"` Steps []struct { + Name string `json:"name"` Actions []struct { + Step int `json:"step"` + Index int `json:"index"` + Name string `json:"name"` OutputURL string `json:"output_url"` } `json:"actions"` } `json:"steps"` From 1973c17b88d94d4012990234c74888c71e085868 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:43:37 +0000 Subject: [PATCH 04/11] fix(circle): build step URLs from workflow+job IDs instead of legacy WebURL --- pkg/circle/scan/scanner.go | 18 +++++++----------- pkg/circle/scan/scanner_test.go | 18 +++++------------- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/pkg/circle/scan/scanner.go b/pkg/circle/scan/scanner.go index cd5ce16e..c31c89a7 100644 --- a/pkg/circle/scan/scanner.go +++ b/pkg/circle/scan/scanner.go @@ -338,7 +338,7 @@ func (s *circleScanner) scanJob(project string, workflow workflowItem, job workf locationURL := circleAppWorkflowURL(workflow.ID) - if err := s.scanJobLogs(project, workflow, jobDetails, locationURL); err != nil { + if err := s.scanJobLogs(project, workflow, job.JobNumber, jobDetails, locationURL); err != nil { log.Debug().Err(err).Str("project", project).Int("job", job.JobNumber).Msg("Failed scanning job logs") } @@ -357,7 +357,7 @@ func (s *circleScanner) scanJob(project string, workflow workflowItem, job workf return nil } -func (s *circleScanner) scanJobLogs(project string, workflow workflowItem, details projectJobResponse, locationURL string) error { +func (s *circleScanner) scanJobLogs(project string, workflow workflowItem, jobNum int, details projectJobResponse, locationURL string) error { log.Debug(). Str("project", project). Str("workflowID", workflow.ID). @@ -399,7 +399,7 @@ func (s *circleScanner) scanJobLogs(project string, workflow workflowItem, detai Msg("Detected findings in job log output") } - stepURL := circleJobStepURL(details.WebURL, action.Step, action.Index, locationURL) + stepURL := circleJobStepURL(workflow.ID, jobNum, action.Step, action.Index) stepLabel := step.Name if action.Name != "" && action.Name != step.Name { stepLabel = step.Name + " / " + action.Name @@ -683,12 +683,8 @@ func circleAppWorkflowURL(workflowID string) string { return fmt.Sprintf("https://app.circleci.com/pipelines/workflows/%s", workflowID) } -// circleJobStepURL builds a direct link to a specific step in the CircleCI UI. -// Format: /steps/: (e.g. .../jobs/42/steps/3:0) -// Falls back to fallback when the job WebURL is not available. -func circleJobStepURL(jobWebURL string, step, index int, fallback string) string { - if strings.TrimSpace(jobWebURL) == "" { - return fallback - } - return fmt.Sprintf("%s/steps/%d:%d", strings.TrimRight(jobWebURL, "/"), step, index) +// circleJobStepURL builds a direct link to a specific step in the CircleCI app UI. +// Format: https://app.circleci.com/pipelines/workflows//jobs//steps/: +func circleJobStepURL(workflowID string, jobNum, step, index int) string { + return fmt.Sprintf("https://app.circleci.com/pipelines/workflows/%s/jobs/%d/steps/%d:%d", workflowID, jobNum, step, index) } diff --git a/pkg/circle/scan/scanner_test.go b/pkg/circle/scan/scanner_test.go index 10096d74..04c75d13 100644 --- a/pkg/circle/scan/scanner_test.go +++ b/pkg/circle/scan/scanner_test.go @@ -139,23 +139,15 @@ func TestCircleAppWorkflowURL(t *testing.T) { } func TestCircleJobStepURL(t *testing.T) { - fallback := "https://app.circleci.com/pipelines/workflows/wf-123" - - // empty WebURL falls back gracefully - if got := circleJobStepURL("", 0, 0, fallback); got != fallback { - t.Fatalf("expected fallback url, got %q", got) - } - - // normal step link - jobURL := "https://app.circleci.com/pipelines/workflows/wf-123/jobs/42" want := "https://app.circleci.com/pipelines/workflows/wf-123/jobs/42/steps/3:1" - if got := circleJobStepURL(jobURL, 3, 1, fallback); got != want { + if got := circleJobStepURL("wf-123", 42, 3, 1); got != want { t.Fatalf("expected %q, got %q", want, got) } - // trailing slash in WebURL is stripped - if got := circleJobStepURL(jobURL+"/", 0, 0, fallback); got != jobURL+"/steps/0:0" { - t.Fatalf("unexpected trailing-slash result: %q", got) + // step and index zero + want0 := "https://app.circleci.com/pipelines/workflows/wf-abc/jobs/7/steps/0:0" + if got := circleJobStepURL("wf-abc", 7, 0, 0); got != want0 { + t.Fatalf("expected %q, got %q", want0, got) } } From 5178070508dbbc701556561da6735d7bd9d4f80e Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:56:03 +0000 Subject: [PATCH 05/11] fix(circle): generate stable app job URLs for log hits --- pkg/circle/scan/scanner.go | 36 ++++++++++++++++++++++++--------- pkg/circle/scan/scanner_test.go | 18 ++++++++++------- pkg/circle/scan/transport.go | 1 + 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/pkg/circle/scan/scanner.go b/pkg/circle/scan/scanner.go index c31c89a7..61c6159c 100644 --- a/pkg/circle/scan/scanner.go +++ b/pkg/circle/scan/scanner.go @@ -303,7 +303,7 @@ func (s *circleScanner) scanWorkflow(project string, pipeline pipelineItem, work Msg("Scanning job") s.jobsScanned.Add(1) - if err := s.scanJob(project, workflow, job); err != nil { + if err := s.scanJob(project, pipeline, workflow, job); err != nil { log.Warn().Err(err).Str("project", project).Int("jobNumber", job.JobNumber).Msg("Job scan failed, continuing") } } @@ -311,7 +311,7 @@ func (s *circleScanner) scanWorkflow(project string, pipeline pipelineItem, work return nil } -func (s *circleScanner) scanJob(project string, workflow workflowItem, job workflowJobItem) error { +func (s *circleScanner) scanJob(project string, pipeline pipelineItem, workflow workflowItem, job workflowJobItem) error { jobDetails, err := s.options.APIClient.GetProjectJob(s.options.Context, project, job.JobNumber) if err != nil { return err @@ -331,6 +331,7 @@ func (s *circleScanner) scanJob(project string, workflow workflowItem, job workf log.Debug(). Str("project", project). + Int("pipelineNumber", pipeline.Number). Str("workflowID", workflow.ID). Int("jobNumber", job.JobNumber). Int("steps", len(jobDetails.Steps)). @@ -338,7 +339,7 @@ func (s *circleScanner) scanJob(project string, workflow workflowItem, job workf locationURL := circleAppWorkflowURL(workflow.ID) - if err := s.scanJobLogs(project, workflow, job.JobNumber, jobDetails, locationURL); err != nil { + if err := s.scanJobLogs(project, pipeline, workflow, job.JobNumber, jobDetails, locationURL); err != nil { log.Debug().Err(err).Str("project", project).Int("job", job.JobNumber).Msg("Failed scanning job logs") } @@ -357,9 +358,10 @@ func (s *circleScanner) scanJob(project string, workflow workflowItem, job workf return nil } -func (s *circleScanner) scanJobLogs(project string, workflow workflowItem, jobNum int, details projectJobResponse, locationURL string) error { +func (s *circleScanner) scanJobLogs(project string, pipeline pipelineItem, workflow workflowItem, jobNum int, details projectJobResponse, locationURL string) error { log.Debug(). Str("project", project). + Int("pipelineNumber", pipeline.Number). Str("workflowID", workflow.ID). Str("jobName", details.Name). Int("steps", len(details.Steps)). @@ -399,13 +401,13 @@ func (s *circleScanner) scanJobLogs(project string, workflow workflowItem, jobNu Msg("Detected findings in job log output") } - stepURL := circleJobStepURL(workflow.ID, jobNum, action.Step, action.Index) + jobURL := circleAppJobURL(project, pipeline.Number, workflow.ID, jobNum, locationURL) stepLabel := step.Name if action.Name != "" && action.Name != step.Name { stepLabel = step.Name + " / " + action.Name } result.ReportFindings(logResult.Findings, result.ReportOptions{ - LocationURL: stepURL, + LocationURL: jobURL, JobName: workflow.Name, BuildName: details.Name + " / " + stepLabel, Type: logging.SecretTypeLog, @@ -683,8 +685,22 @@ func circleAppWorkflowURL(workflowID string) string { return fmt.Sprintf("https://app.circleci.com/pipelines/workflows/%s", workflowID) } -// circleJobStepURL builds a direct link to a specific step in the CircleCI app UI. -// Format: https://app.circleci.com/pipelines/workflows//jobs//steps/: -func circleJobStepURL(workflowID string, jobNum, step, index int) string { - return fmt.Sprintf("https://app.circleci.com/pipelines/workflows/%s/jobs/%d/steps/%d:%d", workflowID, jobNum, step, index) +// circleAppJobURL builds the stable CircleCI app job URL. +// Format: https://app.circleci.com/pipelines/////workflows//jobs/ +func circleAppJobURL(project string, pipelineNumber int, workflowID string, jobNum int, fallback string) string { + parts := strings.Split(project, "/") + if len(parts) != 3 || strings.TrimSpace(workflowID) == "" || pipelineNumber <= 0 || jobNum <= 0 { + return fallback + } + + vcs := normalizeVCSName(parts[0]) + return fmt.Sprintf( + "https://app.circleci.com/pipelines/%s/%s/%s/%d/workflows/%s/jobs/%d", + vcs, + parts[1], + parts[2], + pipelineNumber, + workflowID, + jobNum, + ) } diff --git a/pkg/circle/scan/scanner_test.go b/pkg/circle/scan/scanner_test.go index 04c75d13..58734214 100644 --- a/pkg/circle/scan/scanner_test.go +++ b/pkg/circle/scan/scanner_test.go @@ -138,16 +138,20 @@ func TestCircleAppWorkflowURL(t *testing.T) { } } -func TestCircleJobStepURL(t *testing.T) { - want := "https://app.circleci.com/pipelines/workflows/wf-123/jobs/42/steps/3:1" - if got := circleJobStepURL("wf-123", 42, 3, 1); got != want { +func TestCircleAppJobURL(t *testing.T) { + fallback := "https://app.circleci.com/pipelines/workflows/wf-123" + + want := "https://app.circleci.com/pipelines/github/storybookjs/storybook/119097/workflows/4ddee5f3-d2bf-4b90-a3a9-3939595fd3c4/jobs/1339721" + if got := circleAppJobURL("github/storybookjs/storybook", 119097, "4ddee5f3-d2bf-4b90-a3a9-3939595fd3c4", 1339721, fallback); got != want { t.Fatalf("expected %q, got %q", want, got) } - // step and index zero - want0 := "https://app.circleci.com/pipelines/workflows/wf-abc/jobs/7/steps/0:0" - if got := circleJobStepURL("wf-abc", 7, 0, 0); got != want0 { - t.Fatalf("expected %q, got %q", want0, got) + if got := circleAppJobURL("bad-slug", 119097, "wf-123", 42, fallback); got != fallback { + t.Fatalf("expected fallback for invalid slug, got %q", got) + } + + if got := circleAppJobURL("github/storybookjs/storybook", 0, "wf-123", 42, fallback); got != fallback { + t.Fatalf("expected fallback for invalid pipeline number, got %q", got) } } diff --git a/pkg/circle/scan/transport.go b/pkg/circle/scan/transport.go index b0d6dc24..a00eed72 100644 --- a/pkg/circle/scan/transport.go +++ b/pkg/circle/scan/transport.go @@ -64,6 +64,7 @@ type v1ProjectItem struct { type pipelineItem struct { ID string `json:"id"` + Number int `json:"number"` State string `json:"state"` CreatedAt string `json:"created_at"` } From dac41bd16e4f626937a7eaf4483f6872884a19a8 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:00:53 +0000 Subject: [PATCH 06/11] fix(circle): use stable app job URLs for tests and artifacts --- pkg/circle/scan/scanner.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pkg/circle/scan/scanner.go b/pkg/circle/scan/scanner.go index 61c6159c..f1bdcf8c 100644 --- a/pkg/circle/scan/scanner.go +++ b/pkg/circle/scan/scanner.go @@ -338,19 +338,20 @@ func (s *circleScanner) scanJob(project string, pipeline pipelineItem, workflow Msg("Fetched job details") locationURL := circleAppWorkflowURL(workflow.ID) + jobURL := circleAppJobURL(project, pipeline.Number, workflow.ID, job.JobNumber, locationURL) - if err := s.scanJobLogs(project, pipeline, workflow, job.JobNumber, jobDetails, locationURL); err != nil { + if err := s.scanJobLogs(project, workflow, jobURL, jobDetails); err != nil { log.Debug().Err(err).Str("project", project).Int("job", job.JobNumber).Msg("Failed scanning job logs") } if s.options.IncludeTests { - if err := s.scanJobTests(project, workflow, job, jobDetails, locationURL); err != nil { + if err := s.scanJobTests(project, workflow, job, jobDetails, jobURL); err != nil { log.Debug().Err(err).Str("project", project).Int("job", job.JobNumber).Msg("Failed scanning job tests") } } if s.options.Artifacts { - if err := s.scanJobArtifacts(project, workflow, job, jobDetails, locationURL); err != nil { + if err := s.scanJobArtifacts(project, workflow, job, jobDetails, jobURL); err != nil { log.Debug().Err(err).Str("project", project).Int("job", job.JobNumber).Msg("Failed scanning job artifacts") } } @@ -358,10 +359,9 @@ func (s *circleScanner) scanJob(project string, pipeline pipelineItem, workflow return nil } -func (s *circleScanner) scanJobLogs(project string, pipeline pipelineItem, workflow workflowItem, jobNum int, details projectJobResponse, locationURL string) error { +func (s *circleScanner) scanJobLogs(project string, workflow workflowItem, jobURL string, details projectJobResponse) error { log.Debug(). Str("project", project). - Int("pipelineNumber", pipeline.Number). Str("workflowID", workflow.ID). Str("jobName", details.Name). Int("steps", len(details.Steps)). @@ -401,7 +401,6 @@ func (s *circleScanner) scanJobLogs(project string, pipeline pipelineItem, workf Msg("Detected findings in job log output") } - jobURL := circleAppJobURL(project, pipeline.Number, workflow.ID, jobNum, locationURL) stepLabel := step.Name if action.Name != "" && action.Name != step.Name { stepLabel = step.Name + " / " + action.Name From 9bfb9b7023f67d793dd144f25e81c1818e01bf01 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:47:16 +0000 Subject: [PATCH 07/11] fix(circle): resolve circleci-native org slug to UUID via collaborations API --- pkg/circle/scan/scanner.go | 10 ++++- pkg/circle/scan/transport.go | 28 +++++++++++++ pkg/circle/scan/transport_test.go | 65 ++++++++++++++++++++++++++++--- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/pkg/circle/scan/scanner.go b/pkg/circle/scan/scanner.go index f1bdcf8c..34dbc559 100644 --- a/pkg/circle/scan/scanner.go +++ b/pkg/circle/scan/scanner.go @@ -574,7 +574,15 @@ func InitializeOptions(input InitializeOptionsInput) (ScanOptions, error) { if strings.TrimSpace(input.Organization) != "" { resolved, err := apiClient.ListOrganizationProjects(context.Background(), input.Organization, input.VCS) if err != nil { - fallbackProjects, fallbackErr := apiClient.ListAccessibleProjectsV1(context.Background(), input.VCS, orgName) + // v1 fallback only makes sense for GitHub/Bitbucket orgs whose username + // matches the GitHub/Bitbucket username in v1 project records. For native + // circleci/ orgs, the orgName is a UUID-like slug that will never match a + // VCS username, so skip the v1 fallback and surface the original error. + v1Filter := orgName + if strings.HasPrefix(strings.ToLower(input.Organization), "circleci/") { + v1Filter = "" + } + fallbackProjects, fallbackErr := apiClient.ListAccessibleProjectsV1(context.Background(), input.VCS, v1Filter) if fallbackErr != nil { return ScanOptions{}, fmt.Errorf("ListOrganizationProjects failed: %v; fallback ListAccessibleProjectsV1 failed: %w", err, fallbackErr) } diff --git a/pkg/circle/scan/transport.go b/pkg/circle/scan/transport.go index a00eed72..a323984b 100644 --- a/pkg/circle/scan/transport.go +++ b/pkg/circle/scan/transport.go @@ -15,6 +15,7 @@ import ( ) type CircleClient interface { + ListCollaborations(ctx context.Context) ([]collaborationItem, error) ListOrganizationProjects(ctx context.Context, orgSlug, defaultVCS string) ([]string, error) ListAccessibleProjectsV1(ctx context.Context, defaultVCS, orgFilter string) ([]string, error) ListPipelines(ctx context.Context, projectSlug, branch, pageToken string) ([]pipelineItem, string, error) @@ -43,6 +44,13 @@ func newCircleAPIClient(baseURL *url.URL, token string, httpClient *http.Client) } } +type collaborationItem struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + VCSType string `json:"vcs-type"` +} + type pipelineListResponse struct { Items []pipelineItem `json:"items"` NextPageToken string `json:"next_page_token"` @@ -140,6 +148,14 @@ func (c *circleAPIClient) ListPipelines(ctx context.Context, projectSlug, branch return out.Items, out.NextPageToken, nil } +func (c *circleAPIClient) ListCollaborations(ctx context.Context) ([]collaborationItem, error) { + var items []collaborationItem + if err := c.getJSON(ctx, "me/collaborations", nil, &items); err != nil { + return nil, err + } + return items, nil +} + func (c *circleAPIClient) ListOrganizationProjects(ctx context.Context, orgSlug, defaultVCS string) ([]string, error) { candidates := []string{orgSlug} if !strings.Contains(orgSlug, "/") { @@ -147,6 +163,18 @@ func (c *circleAPIClient) ListOrganizationProjects(ctx context.Context, orgSlug, candidates = append(candidates, fmt.Sprintf("%s/%s", vcsSlug, orgSlug)) } } + // For circleci-native orgs the v2 API requires the org UUID, not the slug. + // Attempt to resolve it via the collaborations endpoint. + if collabs, err := c.ListCollaborations(ctx); err == nil { + for _, collab := range collabs { + if strings.EqualFold(collab.Slug, orgSlug) || strings.EqualFold(collab.Name, orgSlug) { + if collab.ID != "" { + candidates = append(candidates, collab.ID) + } + break + } + } + } candidates = uniqueStrings(candidates) var lastErr error diff --git a/pkg/circle/scan/transport_test.go b/pkg/circle/scan/transport_test.go index 1271f56c..d252fa80 100644 --- a/pkg/circle/scan/transport_test.go +++ b/pkg/circle/scan/transport_test.go @@ -22,6 +22,10 @@ func TestListOrganizationProjectsCandidateFallback(t *testing.T) { mu.Unlock() switch r.URL.Path { + case "/api/v2/me/collaborations": + // Return empty — no UUID resolution available in this test scenario. + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) case "/api/v2/organization/my-org/project": w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message":"not found"}`)) @@ -57,14 +61,63 @@ func TestListOrganizationProjectsCandidateFallback(t *testing.T) { mu.Lock() defer mu.Unlock() - if len(requests) < 2 { - t.Fatalf("expected at least 2 requests, got %d", len(requests)) + // requests[0] = me/collaborations (UUID resolution attempt) + // requests[1] = organization/my-org/project (first slug candidate, 404) + // requests[2] = organization/github/my-org/project (VCS-prefixed candidate, succeeds) + if len(requests) < 3 { + t.Fatalf("expected at least 3 requests, got %d: %v", len(requests), requests) + } + if requests[0] != "/api/v2/me/collaborations" { + t.Fatalf("expected collaborations request first, got %q", requests[0]) + } + if requests[1] != "/api/v2/organization/my-org/project" { + t.Fatalf("expected first candidate request path, got %q", requests[1]) + } + if requests[2] != "/api/v2/organization/github/my-org/project" { + t.Fatalf("expected second candidate request path, got %q", requests[2]) } - if requests[0] != "/api/v2/organization/my-org/project" { - t.Fatalf("expected first candidate request path, got %q", requests[0]) +} + +func TestListOrganizationProjectsCollaborationUUIDResolution(t *testing.T) { + const orgUUID = "96df906d-3617-46fd-96d0-8f80a8c4d00a" + const orgSlug = "circleci/KdZvpc432VpdV8UBajzc9f" + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v2/me/collaborations": + payload := []collaborationItem{{ + ID: orgUUID, + Slug: orgSlug, + Name: "My Org", + VCSType: "circleci", + }} + _ = json.NewEncoder(w).Encode(payload) + case "/api/v2/organization/" + orgSlug + "/project": + // slug-based call fails — UUID not accepted at this path + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + case "/api/v2/organization/" + orgUUID + "/project": + // UUID-based call succeeds + _, _ = w.Write([]byte(`{"items":[{"slug":"github/my-org/repo-a"}],"next_page_token":""}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + baseURL, err := url.Parse(ts.URL + "/api/v2/") + if err != nil { + t.Fatalf("failed to parse base url: %v", err) + } + + client := newCircleAPIClient(baseURL, "token", ts.Client()) + projects, err := client.ListOrganizationProjects(context.Background(), orgSlug, "github") + if err != nil { + t.Fatalf("expected UUID-resolution to succeed, got error: %v", err) } - if requests[1] != "/api/v2/organization/github/my-org/project" { - t.Fatalf("expected second candidate request path, got %q", requests[1]) + if len(projects) != 1 || projects[0] != "github/my-org/repo-a" { + t.Fatalf("unexpected projects: %#v", projects) } } From 46a3d9451cc1eafe38f81f20a9da27b8bcf567eb Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:20:09 +0000 Subject: [PATCH 08/11] fix(circle): accept github/org and app URL forms for --org --- pkg/circle/scan/normalize.go | 36 ++++++++++++++++++++++++ pkg/circle/scan/scanner_test.go | 42 ++++++++++++++++++++++++++++ pkg/circle/scan/transport.go | 16 ++++++----- pkg/circle/scan/transport_test.go | 46 +++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 7 deletions(-) diff --git a/pkg/circle/scan/normalize.go b/pkg/circle/scan/normalize.go index 269a43c3..0d07c80d 100644 --- a/pkg/circle/scan/normalize.go +++ b/pkg/circle/scan/normalize.go @@ -2,6 +2,7 @@ package scan import ( "fmt" + "net/url" "strings" ) @@ -167,3 +168,38 @@ func uniqueStrings(values []string) []string { } return out } + +func orgSlugCandidates(value, defaultVCS string) []string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + + candidates := []string{trimmed} + + // Support org/project URLs like: + // https://app.circleci.com/pipelines/github/storybookjs/storybook + if parsed, err := url.Parse(trimmed); err == nil && parsed.Host != "" { + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) >= 3 && parts[0] == "pipelines" { + vcs := normalizeVCSName(parts[1]) + org := strings.TrimSpace(parts[2]) + if vcs != "" && org != "" { + candidates = append(candidates, fmt.Sprintf("%s/%s", vcs, org), org) + } + } + } + + orgName := normalizedOrgName(trimmed) + if orgName != "" && !strings.EqualFold(orgName, trimmed) { + candidates = append(candidates, orgName) + } + + if !strings.Contains(orgName, "/") && orgName != "" { + for _, vcsSlug := range vcsSlugCandidates(defaultVCS) { + candidates = append(candidates, fmt.Sprintf("%s/%s", vcsSlug, orgName)) + } + } + + return uniqueStrings(candidates) +} diff --git a/pkg/circle/scan/scanner_test.go b/pkg/circle/scan/scanner_test.go index 58734214..75bf591f 100644 --- a/pkg/circle/scan/scanner_test.go +++ b/pkg/circle/scan/scanner_test.go @@ -61,6 +61,48 @@ func TestNormalizedOrgName(t *testing.T) { } } +func TestOrgSlugCandidates(t *testing.T) { + t.Run("prefixed org adds plain candidate", func(t *testing.T) { + got := orgSlugCandidates("github/storybookjs", "github") + if len(got) == 0 { + t.Fatal("expected non-empty candidates") + } + if got[0] != "github/storybookjs" { + t.Fatalf("expected first candidate to preserve input, got %q", got[0]) + } + seenPlain := false + for _, c := range got { + if c == "storybookjs" { + seenPlain = true + break + } + } + if !seenPlain { + t.Fatalf("expected plain org candidate in %v", got) + } + }) + + t.Run("app pipelines url extracts vcs org", func(t *testing.T) { + got := orgSlugCandidates("https://app.circleci.com/pipelines/github/storybookjs/storybook", "github") + if len(got) == 0 { + t.Fatal("expected non-empty candidates") + } + seenPrefixed := false + seenPlain := false + for _, c := range got { + if c == "github/storybookjs" { + seenPrefixed = true + } + if c == "storybookjs" { + seenPlain = true + } + } + if !seenPrefixed || !seenPlain { + t.Fatalf("expected both github/storybookjs and storybookjs candidates, got %v", got) + } + }) +} + func TestVCSFromURL(t *testing.T) { tests := []struct { in string diff --git a/pkg/circle/scan/transport.go b/pkg/circle/scan/transport.go index a323984b..7f000fc6 100644 --- a/pkg/circle/scan/transport.go +++ b/pkg/circle/scan/transport.go @@ -157,17 +157,19 @@ func (c *circleAPIClient) ListCollaborations(ctx context.Context) ([]collaborati } func (c *circleAPIClient) ListOrganizationProjects(ctx context.Context, orgSlug, defaultVCS string) ([]string, error) { - candidates := []string{orgSlug} - if !strings.Contains(orgSlug, "/") { - for _, vcsSlug := range vcsSlugCandidates(defaultVCS) { - candidates = append(candidates, fmt.Sprintf("%s/%s", vcsSlug, orgSlug)) - } - } + candidates := orgSlugCandidates(orgSlug, defaultVCS) // For circleci-native orgs the v2 API requires the org UUID, not the slug. // Attempt to resolve it via the collaborations endpoint. if collabs, err := c.ListCollaborations(ctx); err == nil { for _, collab := range collabs { - if strings.EqualFold(collab.Slug, orgSlug) || strings.EqualFold(collab.Name, orgSlug) { + matched := false + for _, candidate := range candidates { + if strings.EqualFold(collab.Slug, candidate) || strings.EqualFold(collab.Name, candidate) { + matched = true + break + } + } + if matched { if collab.ID != "" { candidates = append(candidates, collab.ID) } diff --git a/pkg/circle/scan/transport_test.go b/pkg/circle/scan/transport_test.go index d252fa80..3683f52d 100644 --- a/pkg/circle/scan/transport_test.go +++ b/pkg/circle/scan/transport_test.go @@ -121,6 +121,52 @@ func TestListOrganizationProjectsCollaborationUUIDResolution(t *testing.T) { } } +func TestListOrganizationProjectsPrefixedOrgFallback(t *testing.T) { + var requests []string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.URL.Path) + switch r.URL.Path { + case "/api/v2/me/collaborations": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + case "/api/v2/organization/github/storybookjs/project": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + case "/api/v2/organization/storybookjs/project": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[{"slug":"storybookjs/repo-a"}],"next_page_token":""}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + baseURL, err := url.Parse(ts.URL + "/api/v2/") + if err != nil { + t.Fatalf("failed to parse base url: %v", err) + } + + client := newCircleAPIClient(baseURL, "token", ts.Client()) + projects, err := client.ListOrganizationProjects(context.Background(), "github/storybookjs", "github") + if err != nil { + t.Fatalf("expected prefixed fallback to succeed, got error: %v", err) + } + if len(projects) != 1 || projects[0] != "github/storybookjs/repo-a" { + t.Fatalf("unexpected projects: %#v", projects) + } + + if len(requests) < 3 { + t.Fatalf("expected at least 3 requests, got %d (%v)", len(requests), requests) + } + if requests[1] != "/api/v2/organization/github/storybookjs/project" { + t.Fatalf("unexpected first org candidate request: %q", requests[1]) + } + if requests[2] != "/api/v2/organization/storybookjs/project" { + t.Fatalf("unexpected fallback org candidate request: %q", requests[2]) + } +} + func TestListAccessibleProjectsV1FiltersAndNormalizes(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1.1/projects" { From 62d8e8a0585dba199b7b94c07fed8d57a464a660 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:55:43 +0000 Subject: [PATCH 09/11] docs(circle): clarify --org visibility limits and suggest --project fallback --- docs/introduction/configuration.md | 6 +++++- pipeleek.example.yaml | 5 +++-- pkg/circle/scan/scanner.go | 17 ++++++++++++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 39ba5e5e..a5f8dba2 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -211,13 +211,17 @@ circle: project: [my-org/my-repo] # circle scan --project (optional if org is set) vcs: github # circle scan --vcs org: my-org # circle scan --org (also enables org-wide discovery when project is omitted) + # --org accepts: my-org, github/my-org, or app URL forms like + # https://app.circleci.com/pipelines/github/my-org/my-repo + # Note: org-wide discovery requires token visibility to that org. If not, + # use explicit --project selectors instead. branch: main # circle scan --branch status: [success, failed] # circle scan --status workflow: [build, deploy] # circle scan --workflow job: [unit-tests, release] # circle scan --job since: 2026-01-01T00:00:00Z # circle scan --since (RFC3339) until: 2026-01-31T23:59:59Z # circle scan --until (RFC3339) - max_pipelines: 50 # circle scan --max-pipelines + max_pipelines: 0 # circle scan --max-pipelines (0 = no limit) tests: true # circle scan --tests insights: true # circle scan --insights ``` diff --git a/pipeleek.example.yaml b/pipeleek.example.yaml index 6df71c03..c42ab8cf 100644 --- a/pipeleek.example.yaml +++ b/pipeleek.example.yaml @@ -212,14 +212,15 @@ circle: scan: project: ["example-org/example-repo"] # Optional project selector(s): org/repo or vcs/org/repo vcs: "github" # Default VCS used when project entries omit prefix - org: "example-org" # Optional org filter; if project is omitted this discovers org projects + org: "example-org" # Optional org filter; supports my-org, github/my-org, or app.circleci.com/pipelines URLs + # Org-wide discovery requires token visibility to that org. If discovery fails, use explicit --project entries. branch: "main" # Optional branch filter status: ["success", "failed"] # Optional pipeline/workflow/job status filter workflow: ["build", "deploy"] # Optional workflow name filter job: ["unit-tests", "release"] # Optional job name filter since: "2026-01-01T00:00:00Z" # Optional RFC3339 start timestamp until: "2026-01-31T23:59:59Z" # Optional RFC3339 end timestamp - max_pipelines: 50 # Maximum number of pipelines to scan per project (0 = no limit) + max_pipelines: 0 # Maximum number of pipelines to scan per project (0 = no limit) tests: true # Scan job test results insights: true # Scan workflow insights endpoints # Inherits common.* settings diff --git a/pkg/circle/scan/scanner.go b/pkg/circle/scan/scanner.go index 34dbc559..17f5e068 100644 --- a/pkg/circle/scan/scanner.go +++ b/pkg/circle/scan/scanner.go @@ -584,7 +584,22 @@ func InitializeOptions(input InitializeOptionsInput) (ScanOptions, error) { } fallbackProjects, fallbackErr := apiClient.ListAccessibleProjectsV1(context.Background(), input.VCS, v1Filter) if fallbackErr != nil { - return ScanOptions{}, fmt.Errorf("ListOrganizationProjects failed: %v; fallback ListAccessibleProjectsV1 failed: %w", err, fallbackErr) + vcsHint := normalizeVCSName(input.VCS) + if vcsHint == "" { + vcsHint = "github" + } + orgHint := normalizedOrgName(input.Organization) + if orgHint == "" { + orgHint = "" + } + return ScanOptions{}, fmt.Errorf( + "ListOrganizationProjects failed: %v; fallback ListAccessibleProjectsV1 failed: %w. This token likely cannot enumerate org %q. Try scanning explicit project(s) with --project %s/%s/ or use a token with org access", + err, + fallbackErr, + input.Organization, + vcsHint, + orgHint, + ) } projects = uniqueStrings(append(projects, fallbackProjects...)) } else { From a8238cbb3c726b80afab46e00ba6596a11789702 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Wed, 8 Apr 2026 06:59:06 +0000 Subject: [PATCH 10/11] fix(circle): add actionable hints when --org discovery fails --- pkg/circle/scan/normalize.go | 26 ++++++++++++++++++++++++++ pkg/circle/scan/scanner.go | 19 ++++--------------- pkg/circle/scan/scanner_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/pkg/circle/scan/normalize.go b/pkg/circle/scan/normalize.go index 0d07c80d..0aea75cf 100644 --- a/pkg/circle/scan/normalize.go +++ b/pkg/circle/scan/normalize.go @@ -203,3 +203,29 @@ func orgSlugCandidates(value, defaultVCS string) []string { return uniqueStrings(candidates) } + +func orgDiscoveryHint(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + + if parsed, err := url.Parse(trimmed); err == nil && parsed.Host != "" { + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) >= 4 && parts[0] == "pipelines" { + vcs := normalizeVCSName(parts[1]) + org := strings.TrimSpace(parts[2]) + repo := strings.TrimSpace(parts[3]) + if vcs != "" && org != "" && repo != "" { + return fmt.Sprintf("--org appears to be a project URL; use --project %s/%s/%s instead", vcs, org, repo) + } + } + } + + parts := strings.Split(strings.Trim(trimmed, "/"), "/") + if len(parts) == 3 && normalizeVCSName(parts[0]) != "" { + return fmt.Sprintf("--org appears to be a project selector; use --project %s instead", strings.Join(parts, "/")) + } + + return "org-wide discovery requires token visibility to that CircleCI org; if discovery fails, scan explicit projects with --project" +} diff --git a/pkg/circle/scan/scanner.go b/pkg/circle/scan/scanner.go index 17f5e068..95e660c7 100644 --- a/pkg/circle/scan/scanner.go +++ b/pkg/circle/scan/scanner.go @@ -584,22 +584,11 @@ func InitializeOptions(input InitializeOptionsInput) (ScanOptions, error) { } fallbackProjects, fallbackErr := apiClient.ListAccessibleProjectsV1(context.Background(), input.VCS, v1Filter) if fallbackErr != nil { - vcsHint := normalizeVCSName(input.VCS) - if vcsHint == "" { - vcsHint = "github" + hint := orgDiscoveryHint(input.Organization) + if hint != "" { + return ScanOptions{}, fmt.Errorf("ListOrganizationProjects failed: %v; fallback ListAccessibleProjectsV1 failed: %w. Hint: %s", err, fallbackErr, hint) } - orgHint := normalizedOrgName(input.Organization) - if orgHint == "" { - orgHint = "" - } - return ScanOptions{}, fmt.Errorf( - "ListOrganizationProjects failed: %v; fallback ListAccessibleProjectsV1 failed: %w. This token likely cannot enumerate org %q. Try scanning explicit project(s) with --project %s/%s/ or use a token with org access", - err, - fallbackErr, - input.Organization, - vcsHint, - orgHint, - ) + return ScanOptions{}, fmt.Errorf("ListOrganizationProjects failed: %v; fallback ListAccessibleProjectsV1 failed: %w", err, fallbackErr) } projects = uniqueStrings(append(projects, fallbackProjects...)) } else { diff --git a/pkg/circle/scan/scanner_test.go b/pkg/circle/scan/scanner_test.go index 75bf591f..8b4bdf8a 100644 --- a/pkg/circle/scan/scanner_test.go +++ b/pkg/circle/scan/scanner_test.go @@ -103,6 +103,31 @@ func TestOrgSlugCandidates(t *testing.T) { }) } +func TestOrgDiscoveryHint(t *testing.T) { + t.Run("project url input", func(t *testing.T) { + hint := orgDiscoveryHint("https://app.circleci.com/pipelines/github/storybookjs/storybook") + want := "--org appears to be a project URL; use --project github/storybookjs/storybook instead" + if hint != want { + t.Fatalf("unexpected hint: %q", hint) + } + }) + + t.Run("project selector input", func(t *testing.T) { + hint := orgDiscoveryHint("github/storybookjs/storybook") + want := "--org appears to be a project selector; use --project github/storybookjs/storybook instead" + if hint != want { + t.Fatalf("unexpected hint: %q", hint) + } + }) + + t.Run("org input", func(t *testing.T) { + hint := orgDiscoveryHint("github/storybookjs") + if hint == "" { + t.Fatal("expected non-empty generic hint") + } + }) +} + func TestVCSFromURL(t *testing.T) { tests := []struct { in string From 4b970c9577d4a28130bec1845ab0c9c562d8d334 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:50:03 +0000 Subject: [PATCH 11/11] test(circle): add org discovery hint and collaboration edge-case coverage --- pkg/circle/scan/scanner_test.go | 72 ++++++++++++++++++++++++++++++- pkg/circle/scan/transport_test.go | 48 +++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/pkg/circle/scan/scanner_test.go b/pkg/circle/scan/scanner_test.go index 8b4bdf8a..359bab0d 100644 --- a/pkg/circle/scan/scanner_test.go +++ b/pkg/circle/scan/scanner_test.go @@ -1,6 +1,12 @@ package scan -import "testing" +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) func TestNormalizeProjectSlug(t *testing.T) { tests := []struct { @@ -247,3 +253,67 @@ func TestFlattenLogOutput(t *testing.T) { } }) } + +func TestInitializeOptionsOrgDiscoveryHints(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v2/me/collaborations": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + case strings.HasPrefix(r.URL.Path, "/api/v2/organization/") && strings.HasSuffix(r.URL.Path, "/project"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + case r.URL.Path == "/api/v1.1/projects": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]v1ProjectItem{{ + Username: "pipeleek", + Reponame: "demo", + VCSType: "github", + VCSURL: "https://github.com/pipeleek/demo", + }}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + tests := []struct { + name string + org string + hintMatch string + }{ + { + name: "generic org visibility hint", + org: "github/storybookjs", + hintMatch: "Hint: org-wide discovery requires token visibility", + }, + { + name: "project url hint", + org: "https://app.circleci.com/pipelines/github/storybookjs/storybook", + hintMatch: "Hint: --org appears to be a project URL; use --project github/storybookjs/storybook instead", + }, + { + name: "project selector hint", + org: "github/storybookjs/storybook", + hintMatch: "Hint: --org appears to be a project selector; use --project github/storybookjs/storybook instead", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := InitializeOptions(InitializeOptionsInput{ + Token: "test-token", + CircleURL: ts.URL, + Organization: tt.org, + VCS: "github", + MaxArtifactSize: "1MB", + }) + if err == nil { + t.Fatal("expected InitializeOptions to fail") + } + if !strings.Contains(err.Error(), tt.hintMatch) { + t.Fatalf("expected error to contain %q, got %q", tt.hintMatch, err.Error()) + } + }) + } +} diff --git a/pkg/circle/scan/transport_test.go b/pkg/circle/scan/transport_test.go index 3683f52d..f8b7acee 100644 --- a/pkg/circle/scan/transport_test.go +++ b/pkg/circle/scan/transport_test.go @@ -167,6 +167,54 @@ func TestListOrganizationProjectsPrefixedOrgFallback(t *testing.T) { } } +func TestListOrganizationProjectsCollaborationNameCaseInsensitive(t *testing.T) { + const orgUUID = "96df906d-3617-46fd-96d0-8f80a8c4d00a" + var uuidRequests int + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v2/me/collaborations": + // Name matches the normalized org candidate with mixed casing. + payload := []collaborationItem{{ + ID: orgUUID, + Slug: "circleci/some-other-org", + Name: "StOrYbOoKjS", + }} + _ = json.NewEncoder(w).Encode(payload) + case "/api/v2/organization/github/storybookjs/project": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + case "/api/v2/organization/storybookjs/project": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + case "/api/v2/organization/" + orgUUID + "/project": + uuidRequests++ + _, _ = w.Write([]byte(`{"items":[{"slug":"github/storybookjs/repo-a"}],"next_page_token":""}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + baseURL, err := url.Parse(ts.URL + "/api/v2/") + if err != nil { + t.Fatalf("failed to parse base url: %v", err) + } + + client := newCircleAPIClient(baseURL, "token", ts.Client()) + projects, err := client.ListOrganizationProjects(context.Background(), "github/storybookjs", "github") + if err != nil { + t.Fatalf("expected name-based UUID fallback to succeed, got error: %v", err) + } + if len(projects) != 1 || projects[0] != "github/storybookjs/repo-a" { + t.Fatalf("unexpected projects: %#v", projects) + } + if uuidRequests != 1 { + t.Fatalf("expected exactly one UUID project request, got %d", uuidRequests) + } +} + func TestListAccessibleProjectsV1FiltersAndNormalizes(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1.1/projects" {