From d6df9693c3bb2a8e4713f7d4c12edcb000f8d1bf Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 26 Aug 2026 09:52:51 +0200 Subject: [PATCH 1/5] feat(crafter): redact secrets from AI coding sessions before CAS upload AI coding session evidence carries the raw conversation transcript in data.raw_session, which routinely includes whatever the agent read or printed, credentials included. That content was stored verbatim. Secrets are now detected and replaced before the material is stored. Only the stored copy is rewritten: the file on disk is left untouched and remains what policies are evaluated against, so a secret-detection policy still sees what the agent actually captured. Redaction is on by default and fails closed, with --skip-secret-redaction as an explicit opt-out that is recorded in the attestation so policies can reject it. Detection uses betterleaks, which also replaces gitleaks as the library backing the GITLEAKS_JSON material parser. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 8d801181-6ee5-4dcd-b572-03033a0d564b Signed-off-by: Jose I. Paris --- app/cli/cmd/attestation_add.go | 25 +- app/cli/documentation/cli-reference.mdx | 1 + app/cli/pkg/action/attestation_add.go | 7 + go.mod | 34 +- go.sum | 190 ++----- internal/redaction/betterleaks.go | 141 +++++ internal/redaction/betterleaks_test.go | 309 +++++++++++ internal/redaction/redaction.go | 482 ++++++++++++++++++ internal/redaction/redaction_test.go | 273 ++++++++++ .../api/attestation/v1/crafting_state.go | 40 +- .../api/attestation/v1/crafting_state_test.go | 129 +++++ pkg/attestation/crafter/crafter.go | 16 +- .../materials/aicodingsession/redact.go | 138 +++++ .../materials/aicodingsession/redact_test.go | 272 ++++++++++ .../testdata/session-fp-shaped.json | 87 ++++ .../testdata/session-with-secrets.json | 90 ++++ .../materials/chainloop_ai_coding_session.go | 102 +++- ...inloop_ai_coding_session_redaction_test.go | 286 +++++++++++ pkg/attestation/crafter/materials/gitleaks.go | 2 +- .../crafter/materials/materials.go | 48 +- 20 files changed, 2465 insertions(+), 207 deletions(-) create mode 100644 internal/redaction/betterleaks.go create mode 100644 internal/redaction/betterleaks_test.go create mode 100644 internal/redaction/redaction.go create mode 100644 internal/redaction/redaction_test.go create mode 100644 pkg/attestation/crafter/materials/aicodingsession/redact.go create mode 100644 pkg/attestation/crafter/materials/aicodingsession/redact_test.go create mode 100644 pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json create mode 100644 pkg/attestation/crafter/materials/aicodingsession/testdata/session-with-secrets.json create mode 100644 pkg/attestation/crafter/materials/chainloop_ai_coding_session_redaction_test.go diff --git a/app/cli/cmd/attestation_add.go b/app/cli/cmd/attestation_add.go index da095bc9d..55ef9e243 100644 --- a/app/cli/cmd/attestation_add.go +++ b/app/cli/cmd/attestation_add.go @@ -41,6 +41,7 @@ func newAttestationAddCmd() *cobra.Command { var artifactCASConn *grpc.ClientConn var annotationsFlag []string var noStrictValidation bool + var skipSecretRedaction bool var policyInputFromFileFlag []string var policyInputFlag []string var appendFlag bool @@ -102,17 +103,18 @@ func newAttestationAddCmd() *cobra.Command { a, err := action.NewAttestationAdd( &action.AttestationAddOpts{ - ActionsOpts: ActionOpts, - CASURI: viper.GetString(confOptions.CASAPI.viperKey), - CASCAPath: viper.GetString(confOptions.CASCA.viperKey), - ConnectionInsecure: apiInsecure(), - RegistryServer: registryServer, - RegistryUsername: registryUsername, - RegistryPassword: registryPassword, - LocalStatePath: attestationLocalStatePath, - NoStrictValidation: noStrictValidation, - MaxExtractEntries: maxExtractEntries, - MaxExtractSize: int64(maxExtractSizeBytes), + ActionsOpts: ActionOpts, + CASURI: viper.GetString(confOptions.CASAPI.viperKey), + CASCAPath: viper.GetString(confOptions.CASCA.viperKey), + ConnectionInsecure: apiInsecure(), + RegistryServer: registryServer, + RegistryUsername: registryUsername, + RegistryPassword: registryPassword, + LocalStatePath: attestationLocalStatePath, + NoStrictValidation: noStrictValidation, + SkipSecretRedaction: skipSecretRedaction, + MaxExtractEntries: maxExtractEntries, + MaxExtractSize: int64(maxExtractSizeBytes), }, ) if err != nil { @@ -200,6 +202,7 @@ func newAttestationAddCmd() *cobra.Command { flagAttestationID(cmd) cmd.Flags().StringVar(&kind, "kind", "", fmt.Sprintf("kind of the material to be recorded: %q", schemaapi.ListAvailableMaterialKind())) cmd.Flags().BoolVar(&noStrictValidation, "no-strict-validation", false, "skip strict schema validation for structured materials (SBOM_CYCLONEDX_JSON, OPENAPI_SPEC, ASYNCAPI_SPEC, OSSF_SCORECARD_JSON)") + cmd.Flags().BoolVar(&skipSecretRedaction, "skip-secret-redaction", false, "store evidence exactly as captured, without redacting detected secrets first (CHAINLOOP_AI_CODING_SESSION). Recorded in the attestation so policies can reject it") cmd.Flags().StringArrayVar(&policyInputFromFileFlag, "policy-input-from-file", nil, "feed a policy input from a column of a CSV or JSON file, in the format [:]=[:] (e.g. ignored_paths=exception.csv:Path); the values are APPENDED to any contract-declared value; an optional : prefix scopes the input to a single policy (matched by name or ref), otherwise it applies to every declaring policy; is a single top-level column/field name and defaults to the input name; repeatable. The file is also recorded as EVIDENCE.") cmd.Flags().StringArrayVar(&policyInputFlag, "policy-input", nil, "set a policy input to a literal value that REPLACES (overrides) any contract-declared value for the input, in the format [:]= (e.g. min_iterations=10); use this to override a scalar input at run time; an optional : prefix scopes it to a single policy (matched by name or ref), otherwise it applies to every declaring policy; repeatable.") cmd.Flags().BoolVar(&appendFlag, "append", false, "reserved for a future release: will control whether --policy-input and --policy-input-from-file append to (rather than replace) the contract-declared value; has no effect yet") diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index be9aaeb96..09352342d 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -278,6 +278,7 @@ Options --registry-password string registry password, ($CHAINLOOP_REGISTRY_PASSWORD) --registry-server string OCI repository server, ($CHAINLOOP_REGISTRY_SERVER) --registry-username string registry username, ($CHAINLOOP_REGISTRY_USERNAME) +--skip-secret-redaction store evidence exactly as captured, without redacting detected secrets first (CHAINLOOP_AI_CODING_SESSION). Recorded in the attestation so policies can reject it --value string value to be recorded ``` diff --git a/app/cli/pkg/action/attestation_add.go b/app/cli/pkg/action/attestation_add.go index 99c822f28..e6b36634b 100644 --- a/app/cli/pkg/action/attestation_add.go +++ b/app/cli/pkg/action/attestation_add.go @@ -42,6 +42,9 @@ type AttestationAddOpts struct { LocalStatePath string // NoStrictValidation skips strict schema validation NoStrictValidation bool + // SkipSecretRedaction uploads evidence that would normally be scrubbed + // exactly as captured. The bypass is recorded in the attestation. + SkipSecretRedaction bool // MaxExtractEntries limits the number of entries extracted from an archive. // Zero defaults to materials.DefaultArchiveLimits().MaxEntries. MaxExtractEntries int @@ -76,6 +79,10 @@ func NewAttestationAdd(cfg *AttestationAddOpts) (*AttestationAdd, error) { if cfg.NoStrictValidation { opts = append(opts, crafter.WithNoStrictValidation(cfg.NoStrictValidation)) } + if cfg.SkipSecretRedaction { + cfg.Logger.Warn().Msg("secret redaction is disabled, evidence will be stored exactly as captured") + opts = append(opts, crafter.WithSkipSecretRedaction(cfg.SkipSecretRedaction)) + } defaults := materials.DefaultArchiveLimits() maxEntries := cfg.MaxExtractEntries diff --git a/go.mod b/go.mod index 07b7f4c56..2fab64681 100644 --- a/go.mod +++ b/go.mod @@ -97,7 +97,6 @@ require ( github.com/sigstore/timestamp-authority/v2 v2.1.3 github.com/styrainc/regal v0.35.1 github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 - github.com/zricethezav/gitleaks/v8 v8.30.1 gitlab.com/gitlab-org/security-products/analyzers/report/v5 v5.13.1 go.step.sm/crypto v0.87.0 google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d @@ -108,6 +107,7 @@ require github.com/vektah/gqlparser/v2 v2.5.36 require ( github.com/XSAM/otelsql v0.43.0 + github.com/betterleaks/betterleaks v1.8.1 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/moby/moby/api v1.55.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 @@ -130,17 +130,14 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect - github.com/BobuSumisu/aho-corasick v1.0.3 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect - github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/STARRY-S/zip v0.2.3 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/anchore/go-struct-converter v0.1.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/antithesishq/antithesis-sdk-go v0.7.2-default-no-op // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect @@ -151,17 +148,16 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.55.0 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect github.com/bodgit/plumbing v1.3.0 // indirect - github.com/bodgit/sevenzip v1.6.1 // indirect + github.com/bodgit/sevenzip v1.6.2 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/casbin/govaluate v1.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/charmbracelet/lipgloss v0.5.0 // indirect + github.com/charlievieth/fastwalk v1.0.14 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -174,6 +170,7 @@ require ( github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect @@ -181,6 +178,7 @@ require ( github.com/emirpasic/gods v1.18.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/expr-lang/expr v1.17.8 // indirect github.com/fatih/color v1.19.0 // indirect github.com/fatih/semgroup v1.2.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect @@ -206,11 +204,13 @@ require ( github.com/go-openapi/swag/typeutils v0.28.0 // indirect github.com/go-openapi/swag/yamlutils v0.28.0 // indirect github.com/go-playground/assert/v2 v2.2.0 // indirect + github.com/go-sprout/sprout v1.0.3 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/google/cel-go v0.30.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-github/v72 v72.0.0 // indirect github.com/google/go-github/v88 v88.0.0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/renameio/v2 v2.0.0 // indirect @@ -218,7 +218,6 @@ require ( github.com/h2non/filetype v1.1.3 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect - github.com/huandu/xstrings v1.5.0 // indirect github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect @@ -235,15 +234,14 @@ require ( github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect github.com/lestrrat-go/jwx/v3 v3.1.1 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect - github.com/mholt/archives v0.1.5 // indirect + github.com/mholt/archives v0.1.6-0.20260429171216-ef71b7a32fae // indirect github.com/miekg/dns v1.1.62 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/minio/minlz v1.0.1 // indirect + github.com/minio/minlz v1.1.1 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -251,7 +249,6 @@ require ( github.com/moby/moby/client v0.5.1 // indirect github.com/moby/sys/user v0.4.1 // indirect github.com/moby/sys/userns v0.1.0 // indirect - github.com/muesli/termenv v0.15.1 // indirect github.com/natefinch/atomic v1.0.1 // indirect github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect @@ -265,20 +262,23 @@ require ( github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/philhofer/fwd v1.2.0 // indirect - github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/xattr v0.4.12 // indirect + github.com/pkoukk/tiktoken-go v0.1.8 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/rrethy/ahocorasick v1.0.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect - github.com/shopspring/decimal v1.4.0 // indirect + github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed // indirect + github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf // indirect github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect @@ -296,8 +296,6 @@ require ( github.com/transparency-dev/formats v0.1.1 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/valyala/fastjson v1.6.10 // indirect - github.com/wasilibs/go-re2 v1.9.0 // indirect - github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect @@ -320,7 +318,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect - go4.org v0.0.0-20230225012048-214862532bf5 // indirect + go4.org v0.0.0-20260112195520-a5071408f32f // indirect goa.design/goa/v3 v3.27.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/ini.v1 v1.67.3 // indirect diff --git a/go.sum b/go.sum index 300380037..7ac174972 100644 --- a/go.sum +++ b/go.sum @@ -12,24 +12,14 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= cloud.google.com/go/kms v1.33.0 h1:pG0X78m212b2pv9N4fdMoUO69LuZGQ9kSvn8sHBOFAo= @@ -40,14 +30,10 @@ cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub/v2 v2.6.1 h1:jX6gnC4n8BgYx6MOYICgbbaXZpr1vKeNOE3Bn17P5zg= cloud.google.com/go/pubsub/v2 v2.6.1/go.mod h1:1y2lZnKfUFPZz0PU4YmXyk4lA11+xmYA42zbC32RkxQ= cloud.google.com/go/secretmanager v1.21.0 h1:e56QQaKWRyzBdUz40AeZaio/ZHAl268cFx3QFAAw9CY= cloud.google.com/go/secretmanager v1.21.0/go.mod h1:+nlV+GYqTD8DM+x7Kk3UF7ZPYgdYMowrkZxAmMXORQ8= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSPZg= cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= @@ -94,8 +80,6 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -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/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= @@ -110,12 +94,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= -github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= -github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= -github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 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= @@ -144,8 +124,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/anchore/go-struct-converter v0.1.0 h1:2rDRssAl6mgKBSLNiVCMADgZRhoqtw9dedlWa0OhD30= github.com/anchore/go-struct-converter v0.1.0/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 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/antithesishq/antithesis-sdk-go v0.7.2-default-no-op h1:p2zFsAzvhIpFya8AIOHIbWf7NGvO34QpLGclyf7nXj8= @@ -211,8 +191,6 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTw github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc= github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE= github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= -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/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -220,6 +198,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 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/betterleaks/betterleaks v1.8.1 h1:tTSQAdQe+S4MF69o6VtpRDlml1b73F1xNUUA3PcrHiI= +github.com/betterleaks/betterleaks v1.8.1/go.mod h1:M8pR9QvWFx+iWcRH2HG6Qe1wP9DLXhHt75iMNyZ7Y+s= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 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= @@ -230,8 +210,8 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= -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/sevenzip v1.6.2 h1:6/0mwj5KaRXpuf9iSiE+VpG7VpzFJ8D60P53VjxRv34= +github.com/bodgit/sevenzip v1.6.2/go.mod h1:q8DktB7GbvNn0Q6u4Iq6zULE0vo3rWtRHQg5L1XmjuU= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= @@ -255,8 +235,8 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= -github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= +github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= +github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -329,6 +309,8 @@ github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1G github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= @@ -372,6 +354,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJP github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= +github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -428,8 +412,6 @@ github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyG github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= github.com/go-git/go-git/v6 v6.0.0-alpha.5 h1:sE+OlkHgYWNMVmN1s9sR7uyFgsWLtxcNWse/vBYKxRE= github.com/go-git/go-git/v6 v6.0.0-alpha.5/go.mod h1:3IjhiZnM+uBmUrOGSeqrJpsmi4Vd0H2NZO/uK2a7d0s= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= @@ -516,6 +498,8 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= +github.com/go-sprout/sprout v1.0.3 h1:LLuz0D3aYazgbVTOwCVuMor3LOUVYinipXRIdjA/D+I= +github.com/go-sprout/sprout v1.0.3/go.mod h1:cFFzpnyGGry3cmN0UNCAM1f7AGok6vPVabeYQzBMBZY= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= @@ -546,14 +530,10 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= @@ -602,6 +582,8 @@ github.com/google/go-containerregistry v0.21.9 h1:F+D4uZ3iA3DLMJLfhaqMdHJbzeqm/2 github.com/google/go-containerregistry v0.21.9/go.mod h1:dP5XNKcL7kMFF/TB3LfvWmVhAcv7iqkHb3oDK8aauTo= github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M= github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4= +github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= +github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M= github.com/google/go-github/v88 v88.0.0/go.mod h1:rufTDgn2N45wjhukLTyxmvc9nilSp3mr3Rgtt6b1MPw= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= @@ -611,13 +593,8 @@ github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= @@ -638,8 +615,6 @@ github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4= github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -723,10 +698,7 @@ github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6 github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= -github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw= github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= @@ -772,8 +744,6 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= @@ -828,9 +798,6 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= @@ -848,16 +815,14 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k 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-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -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.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= -github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= +github.com/mholt/archives v0.1.6-0.20260429171216-ef71b7a32fae h1:J5ek2lGxYgdh5SMMmlNTSKLmS1x2oJQla/V0NaAH7vo= +github.com/mholt/archives v0.1.6-0.20260429171216-ef71b7a32fae/go.mod h1:IbMrpOL3881/V4qoZRFTSTSRzjjZkD3qoRLX07MitpY= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= @@ -874,8 +839,8 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9Dw= github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= -github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= -github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM= +github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= @@ -919,12 +884,8 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 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.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= -github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= -github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= 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/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -1021,8 +982,8 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= -github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= 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.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= @@ -1036,6 +997,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= +github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo= +github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -1092,6 +1055,8 @@ github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6So github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/rrethy/ahocorasick v1.0.0 h1:YKkCB+E5PXc0xmLfMrWbfNht8vG9Re97IHSWZk/Lk8E= +github.com/rrethy/ahocorasick v1.0.0/go.mod h1:nq8oScE7Vy1rOppoQxpQiiDmPHuKCuk9rXrNcxUV3R0= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= @@ -1102,7 +1067,6 @@ github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= @@ -1126,8 +1090,10 @@ github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed h1:KT7hI8vYXgU0s2qaMkrfq9tCA1w/iEPgfredVP+4Tzw= +github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= +github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf h1:o1uxfymjZ7jZ4MsgCErcwWGtVKSiNAXtS59Lhs6uI/g= +github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sigstore/cosign/v3 v3.1.3 h1:001JQRI/PJ/5T+g/kJ1KTvKFbb322+fomc+pHDZ/6sg= github.com/sigstore/cosign/v3 v3.1.3/go.mod h1:DmjtYkWDMdbG26X+QSOPB6QQGkLjRjQCIxxNs6wV6bA= @@ -1264,10 +1230,6 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= -github.com/wasilibs/go-re2 v1.9.0 h1:kjAd8qbNvV4Ve2Uf+zrpTCrDHtqH4dlsRXktywo73JQ= -github.com/wasilibs/go-re2 v1.9.0/go.mod h1:0sRtscWgpUdNA137bmr1IUgrRX0Su4dcn9AEe61y+yI= -github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= -github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= @@ -1300,7 +1262,6 @@ github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= @@ -1315,8 +1276,6 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -github.com/zricethezav/gitleaks/v8 v8.30.1 h1:PmEvCfVI7ti9dV3s5aMZUY7sS2GxRvG3yzih7E+cS3w= -github.com/zricethezav/gitleaks/v8 v8.30.1/go.mod h1:rTDwxRjufMKAkhTI/Mijd07nday1yOhf9qywjwz5Irw= gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= gitlab.com/gitlab-org/security-products/analyzers/common/v3 v3.4.0 h1:+1k1NQiBSVeLDePX82ec3nHdrOsDvhMmA09LL25omTk= @@ -1329,10 +1288,7 @@ go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -1392,30 +1348,22 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= -go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= +go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= +go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= goa.design/goa/v3 v3.27.0 h1:WSb7INd1AgU1BMHUYJoC+NdUgPj9DxOK+C4qJwuuiYE= goa.design/goa/v3 v3.27.0/go.mod h1:+KpTEiO/br2yJ5tub4tttTTd0+CSkIqEAAHzDpKKmSM= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= @@ -1425,20 +1373,13 @@ golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTk golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1453,36 +1394,25 @@ golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73r 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= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1492,7 +1422,6 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1508,9 +1437,6 @@ golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7w 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-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1521,10 +1447,8 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1548,9 +1472,7 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -1558,22 +1480,16 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= @@ -1583,35 +1499,20 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1622,36 +1523,16 @@ golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNq gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.292.0 h1:Ewiwo/GTtiaPZSNAZQUcWLh8AYDEoPmIXyJfeoTSMHU= google.golang.org/api v0.292.0/go.mod h1:07kjmMnFGm2RQuCza2EZM/5N68G/fVvFb1xKjWqoFA0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.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/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= @@ -1669,14 +1550,12 @@ google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZi google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= @@ -1735,8 +1614,6 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= @@ -1756,9 +1633,6 @@ nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/internal/redaction/betterleaks.go b/internal/redaction/betterleaks.go new file mode 100644 index 000000000..9ba4c7924 --- /dev/null +++ b/internal/redaction/betterleaks.go @@ -0,0 +1,141 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package redaction + +import ( + "context" + "fmt" + "sync" + + "github.com/betterleaks/betterleaks/detect" + "github.com/betterleaks/betterleaks/sources" +) + +// betterleaksScanner detects secrets with the betterleaks default ruleset. +type betterleaksScanner struct { + // mu serialises Run. The detector keeps per-run state on itself + // (ValidationCounts is cleared at the start of every Run), so concurrent + // scans on a shared detector would race. Redaction is not on a hot + // concurrent path, so serialising is cheaper than owning a detector per + // caller: constructing one compiles the whole ruleset. + mu sync.Mutex + detector *detect.Detector +} + +var defaultScanner = sync.OnceValues(newBetterleaksScanner) + +// DefaultScanner returns the process-wide betterleaks-backed scanner. +// Constructing a detector compiles several hundred regexes and builds a keyword +// trie, so it is built once, lazily, and only for attestations that need it. +func DefaultScanner() (Scanner, error) { + return defaultScanner() +} + +func newBetterleaksScanner() (Scanner, error) { + // Validation stays off, which is what the default constructor gives us: + // validating would reach out to third-party APIs to check whether a candidate + // credential is live, and crafting a material must not do that. + d, err := detect.NewDetectorDefaultConfig() + if err != nil { + return nil, fmt.Errorf("loading the default secret scanning rules: %w", err) + } + + // A session transcript is text the model was free to write, so an in-band + // "betterleaks:allow" or "gitleaks:allow" must not switch redaction off. + d.IgnoreGitleaksAllow = true + // Findings must carry the verbatim secret: it is what we search for in the + // document in order to replace it. + d.Redact = 0 + // Recursive decoding would report the *decoded* secret, which is not a + // substring of the document and therefore cannot be located and replaced. + // Encoded secrets are a known gap. + d.MaxDecodeDepth = 0 + // Not a size guard: an oversized fragment is silently *skipped*, which would + // mean silent under-redaction. The size cap lives in Redactor and fails + // closed instead. + d.MaxTargetMegaBytes = 0 + // Run otherwise accumulates every finding onto the detector for the benefit + // of a deprecated accessor we do not use. Since this detector is a + // process-wide singleton scanned against repeatedly, that would grow without + // bound. Results are read from the Run iterator instead. + d.SkipFindingAppend = true + + return &betterleaksScanner{detector: d}, nil +} + +func (s *betterleaksScanner) Scan(ctx context.Context, text string) ([]Finding, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + s.mu.Lock() + defer s.mu.Unlock() + + var findings []Finding + for result := range s.detector.Run(ctx, stringSource{text: text}) { + if result.Err != nil { + return nil, fmt.Errorf("scanning for secrets: %w", result.Err) + } + + findings = appendSecret(findings, result.Finding.RuleID, result.Finding.Secret) + + // Composite rules report only their primary match. An AWS access key id, + // for instance, only matches when a secret access key is found near it, + // and the rule for that component is marked as not independently + // reportable — so the primary finding names the harmless public + // identifier while the actual credential arrives only as a component. + // Both halves have to be redacted. + for _, set := range result.Finding.ComponentSets { + for _, component := range set.Components { + if component == nil { + continue + } + findings = appendSecret(findings, component.RuleID, component.Secret) + } + } + } + + // Run stops iterating when the context is cancelled, so a partial result + // here would mean silently under-redacting. + if err := ctx.Err(); err != nil { + return nil, err + } + + return findings, nil +} + +// appendSecret records a locatable secret. A finding without one cannot be +// searched for in the document, so it is dropped rather than reported. +func appendSecret(dst []Finding, ruleID, secret string) []Finding { + if secret == "" { + return dst + } + return append(dst, Finding{RuleID: ruleID, Secret: secret}) +} + +// stringSource adapts an in-memory document to the source interface the scanner +// consumes. Run is the only scanning entry point that is neither deprecated nor +// context-blind, and it takes a source rather than a string. +type stringSource struct { + text string +} + +func (s stringSource) Fragments(ctx context.Context, yield sources.FragmentsFunc) error { + if err := ctx.Err(); err != nil { + return err + } + return yield(sources.Fragment{Raw: s.text}, nil) +} diff --git a/internal/redaction/betterleaks_test.go b/internal/redaction/betterleaks_test.go new file mode 100644 index 000000000..756addac8 --- /dev/null +++ b/internal/redaction/betterleaks_test.go @@ -0,0 +1,309 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package redaction + +import ( + "context" + "strings" + "sync" + "testing" + + betterleaksconfig "github.com/betterleaks/betterleaks/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Synthetic credentials shaped to match the default ruleset. Note that +// AKIAIOSFODNN7EXAMPLE would NOT match: the aws-access-token rule allowlists +// anything ending in "EXAMPLE". The character class after the AKIA prefix is +// base32, so no 0, 1, 8 or 9. +// +// The AWS pair is assembled from fragments so the literal never appears in a +// source file. GitHub's push protection recognises the same AWS patterns +// betterleaks does, and would reject a push containing a realistic-looking key +// even in test data. +const ( + fakeAWSKey = "AKIA" + "4G7TI63VCBIRS4GW" + fakeAWSSecret = "kQ7zXn2VbW9pLm4RtY6" + "uHs3JdF8gA1cE5oPzQwXn" + fakeGitHubPAT = "ghp_erOZlZv0B1e3amrQugdwZ8Ro2W4kDql9WPTf" +) + +var fakeAnthropicKey = "sk-ant-api03-" + strings.Repeat("a", 93) + "AA" + +// awsPair is the shape an AWS leak has to take to be detected at all: the +// aws-access-token rule is composite and requires a secret access key nearby. +// The `\n` is the escaped two-character sequence, which is how a newline appears +// inside a JSON string leaf - the real shape of a transcript. +const awsPair = `AWS_ACCESS_KEY_ID=` + fakeAWSKey + `\nAWS_SECRET_ACCESS_KEY=` + fakeAWSSecret + +func TestDefaultScannerDetects(t *testing.T) { + scanner, err := DefaultScanner() + require.NoError(t, err) + + testCases := []struct { + name string + text string + wantRule string + }{ + { + name: "aws access token paired with its secret access key", + text: `"content": "run export ` + awsPair + ` first"`, + wantRule: "aws-access-token", + }, + { + name: "anthropic api key", + text: `"content": "ANTHROPIC_API_KEY=` + fakeAnthropicKey + `"`, + wantRule: "anthropic-api-key", + }, + { + name: "github personal access token", + text: `"repository": "https://oauth2:` + fakeGitHubPAT + `@github.com/example/repo.git"`, + wantRule: "github-pat", + }, + { + // A session transcript is attacker-influenceable text, so neither + // in-band bypass marker may suppress a finding. + name: "gitleaks:allow must not suppress the finding", + text: `"content": "` + awsPair + ` // gitleaks:allow"`, + wantRule: "aws-access-token", + }, + { + name: "betterleaks:allow must not suppress the finding", + text: `"content": "` + awsPair + ` // betterleaks:allow"`, + wantRule: "aws-access-token", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + findings, err := scanner.Scan(context.Background(), tc.text) + require.NoError(t, err) + require.NotEmpty(t, findings, "expected the default ruleset to match") + + var rules []string + for _, f := range findings { + rules = append(rules, f.RuleID) + assert.NotEmpty(t, f.Secret, "a finding without a secret cannot be located") + // The whole redaction strategy depends on this: a secret that is + // not a substring of the scanned text cannot be replaced. + assert.Contains(t, tc.text, f.Secret, "the secret must be a substring of the scanned text") + } + assert.Contains(t, rules, tc.wantRule) + }) + } +} + +func TestDefaultScannerIgnoresCleanText(t *testing.T) { + scanner, err := DefaultScanner() + require.NoError(t, err) + + // Realistic decoys that must survive redaction untouched. + clean := []string{ + `"commit_start": "9f8e7d6c5b4a39281706f5e4d3c2b1a0f9e8d7c6"`, + `"id": "3bf79921-3c03-81b6-afff-cb246849866f"`, + `"total_tokens": 1234567890`, + `"content": "hello world"`, + `"path": "app/controlplane/internal/service/attestation.go"`, + `"models_used": ["claude-opus-5", "claude-sonnet-5"]`, + } + + for _, text := range clean { + t.Run(text, func(t *testing.T) { + findings, err := scanner.Scan(context.Background(), text) + require.NoError(t, err) + assert.Empty(t, findings) + }) + } +} + +// TestDefaultPlaceholderIsNotDetectable is the gate on the placeholder format. +// The convergence loop in Redact terminates only because a placeholder is not +// itself detected as a secret; if one rule matched it, redaction would replace +// its own output forever and fail with ErrNotConverged. Checked against every +// rule in the shipped ruleset rather than a hand-picked sample, so growing the +// ruleset cannot quietly invalidate the assumption. +func TestDefaultPlaceholderIsNotDetectable(t *testing.T) { + scanner, err := DefaultScanner() + require.NoError(t, err) + + cfg, err := betterleaksconfig.Default() + require.NoError(t, err) + require.NotEmpty(t, cfg.Rules) + t.Logf("checking placeholders for %d rules", len(cfg.Rules)) + + ruleIDs := make([]string, 0, len(cfg.Rules)+1) + for id := range cfg.Rules { + ruleIDs = append(ruleIDs, id) + } + // The empty rule id yields the bare "[REDACTED]" placeholder. + ruleIDs = append(ruleIDs, "") + + for _, id := range ruleIDs { + placeholder := DefaultPlaceholder(id) + // Scan the placeholder bare and in the key/value shape that triggers the + // generic keyword-based rules. + for _, text := range []string{placeholder, `"api_key": "` + placeholder + `"`} { + findings, err := scanner.Scan(context.Background(), text) + require.NoError(t, err) + assert.Empty(t, findings, "placeholder for rule %q is itself detected as a secret in %q", id, text) + } + } +} + +// TestDefaultScannerRepeatedScansDoNotAccumulate guards SkipFindingAppend. The +// scanner is a process-wide singleton and Redact scans the same document more +// than once, so a detector that retained every finding would grow without bound +// across attestations. +func TestDefaultScannerRepeatedScansDoNotAccumulate(t *testing.T) { + scanner, err := DefaultScanner() + require.NoError(t, err) + + text := `"content": "` + awsPair + `"` + + first, err := scanner.Scan(context.Background(), text) + require.NoError(t, err) + require.Len(t, first, 2, "the composite rule reports the key id plus its secret component") + + for range 5 { + again, err := scanner.Scan(context.Background(), text) + require.NoError(t, err) + assert.Equal(t, first, again, "repeated scans must return the same findings, not accumulate") + } +} + +// TestDefaultScannerConcurrentScans exercises the shared detector under -race. +// Run mutates per-run state on the detector itself, so the scanner has to +// serialise access. +func TestDefaultScannerConcurrentScans(t *testing.T) { + scanner, err := DefaultScanner() + require.NoError(t, err) + + texts := []string{ + `"content": "` + awsPair + `"`, + `"content": "nothing to see here"`, + `"repository": "https://oauth2:` + fakeGitHubPAT + `@github.com/example/repo.git"`, + } + + var wg sync.WaitGroup + errs := make([]error, 12) + for i := range errs { + wg.Add(1) + go func(idx int, text string) { + defer wg.Done() + _, errs[idx] = scanner.Scan(context.Background(), text) + }(i, texts[i%len(texts)]) + } + wg.Wait() + + for _, err := range errs { + require.NoError(t, err) + } +} + +func TestDefaultScannerCancelledContext(t *testing.T) { + scanner, err := DefaultScanner() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = scanner.Scan(ctx, `"content": "`+awsPair+`"`) + require.ErrorIs(t, err, context.Canceled) +} + +// TestDefaultScannerReportsCompositeComponents pins down the most dangerous +// asymmetry in the ruleset. aws-access-token is a composite rule: it fires only +// when a secret access key is found nearby, it reports the AWS key *id* as its +// secret, and the rule matching the actual credential is marked as not +// independently reportable. Reporting only the primary finding would therefore +// redact the harmless public identifier and leave the real credential in place, +// so the scanner has to flatten the components back out. +func TestDefaultScannerReportsCompositeComponents(t *testing.T) { + scanner, err := DefaultScanner() + require.NoError(t, err) + + findings, err := scanner.Scan(context.Background(), `"content": "`+awsPair+`"`) + require.NoError(t, err) + + secretsByRule := make(map[string]string, len(findings)) + for _, f := range findings { + secretsByRule[f.RuleID] = f.Secret + } + + assert.Equal(t, fakeAWSKey, secretsByRule["aws-access-token"]) + assert.Equal(t, fakeAWSSecret, secretsByRule["aws-secret-access-key"], + "the secret access key must be reported, not just the key id") +} + +// TestRedactCredentialInURIConverges is a regression test for a rule that +// matches a *position* rather than a value. Once the token in a URI's userinfo +// is replaced, the rule for credentials embedded in a URI matches the +// placeholder sitting in its place, reporting it as the secret. Rewriting that +// would satisfy the rule again on the next pass, forever, so the engine has to +// recognise its own output. Uses the real ruleset because the behaviour is a +// property of the rules, not of the engine alone. +func TestRedactCredentialInURIConverges(t *testing.T) { + scanner, err := DefaultScanner() + require.NoError(t, err) + + doc := []byte(`{"repository":"https://oauth2:` + fakeGitHubPAT + `@github.com/example/repo.git"}`) + + once, report, err := New(scanner).Redact(context.Background(), doc) + require.NoError(t, err) + require.True(t, report.Changed()) + assert.NotContains(t, string(once), fakeGitHubPAT) + assert.Contains(t, string(once), "[REDACTED:github-pat]") + // Two passes: one that redacts, one that confirms nothing is left. + assert.Equal(t, 2, report.Passes) + + // Redacting the result again must be a no-op, not a rename. + twice, report, err := New(scanner).Redact(context.Background(), once) + require.NoError(t, err) + assert.Equal(t, string(once), string(twice)) + assert.False(t, report.Changed()) +} + +func BenchmarkDefaultScannerInit(b *testing.B) { + for b.Loop() { + if _, err := newBetterleaksScanner(); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkRedact(b *testing.B) { + scanner, err := DefaultScanner() + require.NoError(b, err) + + // A synthetic multi-megabyte transcript with a secret near the end. + turn := `{"role":"assistant","content":"` + strings.Repeat("some ordinary transcript text ", 40) + `"},` + var sb strings.Builder + sb.WriteString(`{"data":{"raw_session":{"main":[`) + for sb.Len() < 5<<20 { + sb.WriteString(turn) + } + sb.WriteString(`{"role":"user","content":"` + awsPair + `"}]}}}`) + doc := []byte(sb.String()) + + r := New(scanner, WithMaxBytes(64<<20), WithTimeout(0)) + b.SetBytes(int64(len(doc))) + b.ResetTimer() + for b.Loop() { + if _, _, err := r.Redact(context.Background(), doc); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go new file mode 100644 index 000000000..8c031e823 --- /dev/null +++ b/internal/redaction/redaction.go @@ -0,0 +1,482 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package redaction removes detected secrets from JSON documents before they +// leave the machine. +// +// Detection and rewriting are deliberately split. Secrets are detected against +// the document rendered as indented JSON, because the patterns that catch +// generic credentials rely on seeing a key next to its value. The rewrite then +// happens structurally, on the decoded value tree, so the output cannot be +// malformed JSON. A convergence loop re-scans after every rewrite, which makes +// idempotency a property of the algorithm rather than of the placeholder format. +package redaction + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +var ( + // ErrTooLarge is returned when a document exceeds the configured size cap. + // Redaction fails closed rather than shipping unscanned content. + ErrTooLarge = errors.New("document too large to redact") + // ErrNotConverged is returned when repeated passes keep detecting secrets, + // which in practice means a placeholder is itself matching a rule. + ErrNotConverged = errors.New("secret redaction did not converge") + // ErrInvalidJSON is returned when the document is not a single JSON object. + ErrInvalidJSON = errors.New("document is not a JSON object") +) + +// Defaults applied by New when the corresponding option is not supplied. +// +// The size cap and the timeout are related. A successful redaction costs two +// passes over the document: one that finds the secrets and one that confirms +// none are left. Both together run at roughly 2.5 MB/s (measured by +// BenchmarkRedact on an M3 Pro; the cost is regex matching, and rendering the +// document is negligible beside it), so a document at the cap takes on the order +// of 20s of real work. The timeout sits well above that deliberately: it is +// there to stop pathological backtracking, not to bound ordinary work. +const ( + DefaultMaxBytes = 24 << 20 + DefaultTimeout = 2 * time.Minute + DefaultMaxPasses = 4 +) + +// Finding is a located secret. It is deliberately decoupled from any particular +// scanning engine's types. +type Finding struct { + // RuleID names the rule that matched. It ends up in the placeholder, so it + // must never contain secret material. + RuleID string + // Secret is the matched credential, verbatim as it appeared in the scanned + // text. + Secret string +} + +// Scanner detects secrets in a text fragment. +type Scanner interface { + Scan(ctx context.Context, text string) ([]Finding, error) +} + +// PathFilter reports whether the string leaf at the given path may be rewritten. +// Paths look like "/data/raw_session/main/0/content": a leading slash, object +// keys and array indices separated by slashes. +type PathFilter func(path string) bool + +// Report summarises a Redact call. It never contains secret material, so it is +// safe to log and to surface as material annotations. +type Report struct { + // Replacements is the total number of substitutions performed. + Replacements int + // ByRule counts substitutions per rule id. + ByRule map[string]int + // Unlocated counts, per rule id, secrets the scanner reported but which + // could not be attributed to any eligible leaf. That happens when the match + // lies in a path the filter protects, or when it spans the JSON punctuation + // between two adjacent leaves. + Unlocated map[string]int + // Passes is the number of detection passes performed. + Passes int +} + +// Changed reports whether anything was redacted. +func (r *Report) Changed() bool { return r != nil && r.Replacements > 0 } + +// RuleIDs returns the sorted, deduplicated ids of the rules that actually +// redacted something. +func (r *Report) RuleIDs() []string { + if r == nil { + return nil + } + out := make([]string, 0, len(r.ByRule)) + for id := range r.ByRule { + out = append(out, id) + } + sort.Strings(out) + return out +} + +// Redactor rewrites JSON documents, replacing detected secrets with +// placeholders. It is safe for concurrent use as long as the Scanner is. +type Redactor struct { + scanner Scanner + pathFilter PathFilter + maxBytes int + timeout time.Duration + maxPasses int + placeholder func(ruleID string) string + isPlaceholder func(string) bool +} + +// Option customises a Redactor. +type Option func(*Redactor) + +// WithPathFilter restricts which string leaves may be rewritten. The default +// allows every leaf. +func WithPathFilter(f PathFilter) Option { + return func(r *Redactor) { + if f != nil { + r.pathFilter = f + } + } +} + +// WithMaxBytes caps the document size. Larger documents fail with ErrTooLarge +// rather than being uploaded unscanned. +func WithMaxBytes(n int) Option { + return func(r *Redactor) { r.maxBytes = n } +} + +// WithTimeout bounds the total time spent scanning. +func WithTimeout(d time.Duration) Option { + return func(r *Redactor) { r.timeout = d } +} + +// WithMaxPasses bounds the convergence loop. +func WithMaxPasses(n int) Option { + return func(r *Redactor) { + if n > 0 { + r.maxPasses = n + } + } +} + +// WithPlaceholder overrides the replacement text derived from a rule id. +// +// The two functions are supplied together on purpose. Some rules match a +// position rather than a value, so they report a placeholder as though it were a +// secret; recognising our own output is what stops redaction from rewriting it +// and keeps redacting an already-redacted document a no-op. A format without a +// matching recogniser would silently lose that. +func WithPlaceholder(format func(ruleID string) string, matches func(string) bool) Option { + return func(r *Redactor) { + if format != nil { + r.placeholder = format + } + if matches != nil { + r.isPlaceholder = matches + } + } +} + +// New builds a Redactor around the given Scanner. +func New(s Scanner, opts ...Option) *Redactor { + r := &Redactor{ + scanner: s, + pathFilter: func(string) bool { return true }, + maxBytes: DefaultMaxBytes, + timeout: DefaultTimeout, + maxPasses: DefaultMaxPasses, + placeholder: DefaultPlaceholder, + isPlaceholder: IsDefaultPlaceholder, + } + for _, o := range opts { + o(r) + } + return r +} + +// DefaultPlaceholder is the replacement text for a redacted secret. It is +// deterministic, so redacting the same document twice yields the same digest, +// and it names the rule so a reviewer can tell what kind of credential was +// present without being able to recover it. +func DefaultPlaceholder(ruleID string) string { + if ruleID == "" { + return "[REDACTED]" + } + return "[REDACTED:" + ruleID + "]" +} + +// defaultPlaceholderPattern recognises the output of DefaultPlaceholder for any +// rule id, including ids this build has never seen. +var defaultPlaceholderPattern = regexp.MustCompile(`^\[REDACTED(?::[^\]\s]*)?\]$`) + +// IsDefaultPlaceholder reports whether s is a placeholder DefaultPlaceholder +// could have produced. +func IsDefaultPlaceholder(s string) bool { + return defaultPlaceholderPattern.MatchString(s) +} + +// Redact returns a copy of doc with every detected secret replaced. When +// nothing is detected the input is returned verbatim, so that a document +// without secrets keeps its original digest. +func (r *Redactor) Redact(ctx context.Context, doc []byte) ([]byte, *Report, error) { + if r.scanner == nil { + return nil, nil, errors.New("no scanner configured") + } + if r.maxBytes > 0 && len(doc) > r.maxBytes { + return nil, nil, fmt.Errorf("%w: %d bytes exceeds the %d byte limit", ErrTooLarge, len(doc), r.maxBytes) + } + + root, err := decodeObject(doc) + if err != nil { + return nil, nil, err + } + + if r.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, r.timeout) + defer cancel() + } + + report := &Report{ByRule: map[string]int{}, Unlocated: map[string]int{}} + // Secrets no eligible leaf contains, so that the loop stops chasing them. + skip := make(map[string]struct{}) + converged := false + + for pass := 1; pass <= r.maxPasses; pass++ { + report.Passes = pass + + // Indented rendering is what makes generic key/value rules work: it puts + // each pair on its own line, which is also the shape the scanner's own + // per-line deduplication assumes. + text, err := encode(root, true) + if err != nil { + return nil, nil, fmt.Errorf("rendering document: %w", err) + } + + findings, err := r.scanner.Scan(ctx, text) + if err != nil { + return nil, nil, fmt.Errorf("scanning for secrets: %w", err) + } + + pending := pendingSecrets(findings, skip, r.isPlaceholder) + if len(pending) == 0 { + converged = true + break + } + + w := &rewriter{ + pathFilter: r.pathFilter, + placeholder: r.placeholder, + secrets: pending, + byRule: map[string]int{}, + located: map[string]struct{}{}, + } + w.rewriteMap(root, "") + + for _, s := range pending { + if _, ok := w.located[s.secret]; !ok { + skip[s.secret] = struct{}{} + report.Unlocated[s.ruleID]++ + } + } + for rule, n := range w.byRule { + report.ByRule[rule] += n + } + report.Replacements += w.count + + if w.count == 0 { + // Nothing could be rewritten, so a further pass would see the same + // document and report the same findings. + converged = true + break + } + } + + if !converged { + return nil, nil, fmt.Errorf("%w after %d passes: a placeholder is likely being detected as a secret", ErrNotConverged, r.maxPasses) + } + + if report.Replacements == 0 { + // Hand back the original bytes: re-encoding would reorder object keys and + // change the artifact digest for no benefit. + return doc, report, nil + } + + out, err := encode(root, false) + if err != nil { + return nil, nil, fmt.Errorf("re-encoding redacted document: %w", err) + } + return []byte(out), report, nil +} + +// secretRule pairs a secret with the rule that matched it. +type secretRule struct { + secret string + ruleID string +} + +// pendingSecrets deduplicates findings and drops the ones the loop has decided +// not to chase. The result is ordered longest-secret-first so that a secret +// contained within a longer one cannot partially clobber it. +func pendingSecrets(findings []Finding, skip map[string]struct{}, isPlaceholder func(string) bool) []secretRule { + seen := make(map[string]struct{}, len(findings)) + out := make([]secretRule, 0, len(findings)) + + for _, f := range findings { + if f.Secret == "" { + continue + } + if _, skipped := skip[f.Secret]; skipped { + continue + } + // A finding whose whole secret is a placeholder is a rule matching the + // position it sits in, not a credential. Rewriting it would churn the + // document on every run and never terminate. + if isPlaceholder != nil && isPlaceholder(f.Secret) { + continue + } + if _, dup := seen[f.Secret]; dup { + continue + } + seen[f.Secret] = struct{}{} + out = append(out, secretRule{secret: f.Secret, ruleID: f.RuleID}) + } + + sort.Slice(out, func(i, j int) bool { + if len(out[i].secret) != len(out[j].secret) { + return len(out[i].secret) > len(out[j].secret) + } + return out[i].secret < out[j].secret + }) + return out +} + +// rewriter walks a decoded JSON value tree replacing secrets in eligible string +// leaves. +type rewriter struct { + pathFilter PathFilter + placeholder func(string) string + secrets []secretRule + byRule map[string]int + located map[string]struct{} + count int +} + +func (w *rewriter) rewriteMap(m map[string]any, path string) { + for k, child := range m { + m[k] = w.rewrite(child, path+"/"+k) + } +} + +func (w *rewriter) rewrite(node any, path string) any { + switch v := node.(type) { + case map[string]any: + w.rewriteMap(v, path) + return v + case []any: + for i, child := range v { + v[i] = w.rewrite(child, path+"/"+strconv.Itoa(i)) + } + return v + case string: + if !w.pathFilter(path) { + return v + } + return w.redactLeaf(v) + default: + // Numbers, booleans and null cannot carry a secret the scanner reported + // as a string. + return node + } +} + +// redactLeaf substitutes secrets inside a single string leaf. Matching happens +// against the leaf's JSON-encoded form, because that is the text the scanner +// saw: a secret containing a newline, for instance, reaches us as the two +// characters `\n`. +func (w *rewriter) redactLeaf(s string) string { + body, err := encodeStringBody(s) + if err != nil { + return s + } + + var ( + n int + lastRule string + ) + for _, sr := range w.secrets { + c := strings.Count(body, sr.secret) + if c == 0 { + continue + } + body = strings.ReplaceAll(body, sr.secret, w.placeholder(sr.ruleID)) + n += c + w.byRule[sr.ruleID] += c + w.located[sr.secret] = struct{}{} + lastRule = sr.ruleID + } + if n == 0 { + return s + } + w.count += n + + var out string + if err := json.Unmarshal([]byte(`"`+body+`"`), &out); err != nil { + // A replacement cut through a JSON escape sequence. Drop the whole leaf + // rather than risk leaving part of the secret behind. + return w.placeholder(lastRule) + } + return out +} + +// decodeObject parses doc into a value tree, keeping numbers in their original +// textual form so re-encoding does not reformat them. +func decodeObject(doc []byte) (map[string]any, error) { + dec := json.NewDecoder(bytes.NewReader(doc)) + dec.UseNumber() + + var root any + if err := dec.Decode(&root); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidJSON, err) + } + if dec.More() { + return nil, fmt.Errorf("%w: unexpected trailing content", ErrInvalidJSON) + } + + obj, ok := root.(map[string]any) + if !ok { + return nil, fmt.Errorf("%w: expected an object at the document root", ErrInvalidJSON) + } + return obj, nil +} + +// encode serialises v. HTML escaping is disabled so transcript text keeps its +// angle brackets and ampersands instead of being mangled into \u sequences. +func encode(v any, indent bool) (string, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if indent { + enc.SetIndent("", " ") + } + if err := enc.Encode(v); err != nil { + return "", err + } + return strings.TrimRight(buf.String(), "\n"), nil +} + +// encodeStringBody returns the JSON encoding of s without the surrounding +// quotes, using the same escaping rules as encode. +func encodeStringBody(s string) (string, error) { + encoded, err := encode(s, false) + if err != nil { + return "", err + } + if len(encoded) < 2 { + return "", fmt.Errorf("unexpected encoding %q for a string leaf", encoded) + } + return encoded[1 : len(encoded)-1], nil +} diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go new file mode 100644 index 000000000..4af59751d --- /dev/null +++ b/internal/redaction/redaction_test.go @@ -0,0 +1,273 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package redaction + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeScanner reports a fixed set of findings, so the engine's walk, rewrite and +// convergence behaviour can be tested without depending on the real ruleset. +type fakeScanner struct { + findings []Finding + // requirePresent only reports findings whose secret actually appears in the + // scanned text, which is how a real detector behaves. + requirePresent bool + calls int +} + +func (f *fakeScanner) Scan(ctx context.Context, text string) ([]Finding, error) { + f.calls++ + if err := ctx.Err(); err != nil { + return nil, err + } + if !f.requirePresent { + return f.findings, nil + } + var out []Finding + for _, fi := range f.findings { + if strings.Contains(text, fi.Secret) { + out = append(out, fi) + } + } + return out, nil +} + +func TestRedact(t *testing.T) { + testCases := []struct { + name string + doc string + findings []Finding + opts []Option + // reportAlways makes the fake scanner report its findings even when the + // secret does not appear in the scanned text, to exercise the engine's + // handling of findings it cannot attribute to any leaf. + reportAlways bool + wantErr error + wantUnchanged bool // output must be byte-identical to the input + wantReplacements int + wantByRule map[string]int + wantUnlocated map[string]int + mustNotContain []string + mustContain []string + }{ + { + name: "no findings returns the input verbatim", + doc: `{"data":{"a":"hello world","n":1}}`, + wantUnchanged: true, + }, + { + name: "secret in a nested leaf is replaced", + doc: `{"data":{"raw_session":{"main":[{"content":"run FAKE-AWS-KEY-NOT-A-REAL-PATTERN now"}]},"keep":"untouched"}}`, + findings: []Finding{{RuleID: "aws-access-token", Secret: "FAKE-AWS-KEY-NOT-A-REAL-PATTERN"}}, + wantReplacements: 1, + wantByRule: map[string]int{"aws-access-token": 1}, + mustNotContain: []string{"FAKE-AWS-KEY-NOT-A-REAL-PATTERN"}, + mustContain: []string{"[REDACTED:aws-access-token]", "untouched", "run ", " now"}, + }, + { + name: "same secret across three leaves", + doc: `{"a":"x SEC x","b":{"c":"SEC"},"d":["SEC"]}`, + findings: []Finding{{RuleID: "r1", Secret: "SEC"}}, + wantReplacements: 3, + wantByRule: map[string]int{"r1": 3}, + mustNotContain: []string{`"SEC"`}, + }, + { + name: "secret twice in one leaf", + doc: `{"a":"SEC and SEC again"}`, + findings: []Finding{{RuleID: "r1", Secret: "SEC"}}, + wantReplacements: 2, + wantByRule: map[string]int{"r1": 2}, + mustNotContain: []string{"SEC and"}, + }, + { + name: "escapes and non-ascii survive", + doc: `{"a":"line1\nSEC\ttab \"quoted\" café 🚀"}`, + findings: []Finding{{RuleID: "r1", Secret: "SEC"}}, + wantReplacements: 1, + mustNotContain: []string{`\u003c`, `"SEC`}, + mustContain: []string{`line1\n`, `\ttab`, `\"quoted\"`, "", "café", "🚀"}, + }, + { + name: "severed escape sequence drops the whole leaf", + doc: `{"a":"before\nSEC after"}`, + findings: []Finding{{RuleID: "r1", Secret: "nSEC"}}, + wantReplacements: 1, + mustNotContain: []string{"before", "after"}, + mustContain: []string{"[REDACTED:r1]"}, + }, + { + name: "protected path is left alone and recorded", + doc: `{"keepme":"SEC","other":"plain"}`, + findings: []Finding{{RuleID: "r1", Secret: "SEC"}}, + opts: []Option{WithPathFilter(func(p string) bool { + return p != "/keepme" + })}, + wantUnchanged: true, + wantUnlocated: map[string]int{"r1": 1}, + }, + { + name: "finding present nowhere is classified as an artifact", + doc: `{"a":"plain"}`, + findings: []Finding{{RuleID: "r1", Secret: "NOT-IN-DOC"}}, + reportAlways: true, + wantUnchanged: true, + wantUnlocated: map[string]int{"r1": 1}, + }, + { + name: "placeholder that keeps matching does not converge", + doc: `{"a":"SEC"}`, + findings: []Finding{{RuleID: "r1", Secret: "SEC"}}, + // A custom placeholder with no recogniser: the engine cannot tell its + // own output apart from a secret, so it rewrites it forever. This is + // the backstop that stops that being an infinite loop. + opts: []Option{WithPlaceholder(func(string) string { + return "SEC" + }, nil)}, + wantErr: ErrNotConverged, + }, + { + name: "numbers keep their exact representation", + doc: `{"a":"SEC","big":12345678901234567890,"exp":1e10,"f":0.30000000000000004}`, + findings: []Finding{{RuleID: "r1", Secret: "SEC"}}, + wantReplacements: 1, + mustContain: []string{"12345678901234567890", "1e10", "0.30000000000000004"}, + }, + { + name: "not json", + doc: `not json at all`, + wantErr: ErrInvalidJSON, + }, + { + name: "json array root is rejected", + doc: `["a"]`, + wantErr: ErrInvalidJSON, + }, + { + name: "json null root is rejected", + doc: `null`, + wantErr: ErrInvalidJSON, + }, + { + name: "trailing content is rejected", + doc: `{"a":"b"} trailing`, + wantErr: ErrInvalidJSON, + }, + { + name: "document over the size cap", + doc: `{"a":"aaaaaaaaaaaaaaaaaaaa"}`, + opts: []Option{WithMaxBytes(8)}, + wantErr: ErrTooLarge, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + scanner := &fakeScanner{findings: tc.findings, requirePresent: !tc.reportAlways} + r := New(scanner, tc.opts...) + + got, report, err := r.Redact(context.Background(), []byte(tc.doc)) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + assert.Nil(t, got) + if errors.Is(tc.wantErr, ErrTooLarge) { + assert.Zero(t, scanner.calls, "scanner must not run on an oversized document") + } + return + } + require.NoError(t, err) + require.NotNil(t, report) + + if tc.wantUnchanged { + assert.Equal(t, tc.doc, string(got), "output must be byte-identical") + assert.False(t, report.Changed()) + assert.Zero(t, report.Replacements) + } else { + assert.True(t, report.Changed()) + assert.Equal(t, tc.wantReplacements, report.Replacements) + assert.True(t, json.Valid(got), "output must be valid JSON") + } + + if tc.wantByRule != nil { + assert.Equal(t, tc.wantByRule, report.ByRule) + } + if tc.wantUnlocated != nil { + assert.Equal(t, tc.wantUnlocated, report.Unlocated) + } + for _, s := range tc.mustNotContain { + assert.NotContains(t, string(got), s) + } + for _, s := range tc.mustContain { + assert.Contains(t, string(got), s) + } + }) + } +} + +func TestRedactIsIdempotent(t *testing.T) { + docs := []string{ + `{"data":{"raw_session":{"main":[{"content":"FAKE-AWS-KEY-NOT-A-REAL-PATTERN"}]}}}`, + `{"a":"SEC","b":"no secret here"}`, + `{"a":"line\nSEC","b":["SEC","SEC"]}`, + } + findings := []Finding{ + {RuleID: "aws-access-token", Secret: "FAKE-AWS-KEY-NOT-A-REAL-PATTERN"}, + {RuleID: "r1", Secret: "SEC"}, + } + + for _, doc := range docs { + t.Run(doc, func(t *testing.T) { + r := New(&fakeScanner{findings: findings, requirePresent: true}) + + once, _, err := r.Redact(context.Background(), []byte(doc)) + require.NoError(t, err) + twice, report, err := r.Redact(context.Background(), once) + require.NoError(t, err) + + assert.Equal(t, string(once), string(twice)) + assert.False(t, report.Changed(), "a second pass must find nothing") + }) + } +} + +func TestRedactCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + r := New(&fakeScanner{findings: []Finding{{RuleID: "r1", Secret: "SEC"}}, requirePresent: true}) + got, _, err := r.Redact(ctx, []byte(`{"a":"SEC"}`)) + + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + assert.Nil(t, got) +} + +// RuleIDs reports only the rules that actually redacted something, so it is +// safe to publish as a material annotation. +func TestReportRuleIDs(t *testing.T) { + r := &Report{ByRule: map[string]int{"b": 2, "a": 1}, Unlocated: map[string]int{"c": 1}} + assert.Equal(t, []string{"a", "b"}, r.RuleIDs()) + assert.Nil(t, (*Report)(nil).RuleIDs()) +} diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state.go b/pkg/attestation/crafter/api/attestation/v1/crafting_state.go index 7cc702a14..e5ae4259e 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.go @@ -40,6 +40,10 @@ import ( const AnnotationPrefix = "chainloop." +// AnnotationValueTrue is the value of an annotation that acts as a flag. Shared +// so that whoever sets one and whoever reads it cannot drift apart. +const AnnotationValueTrue = "true" + var ( AnnotationMaterialType = CreateAnnotation("material.type") AnnotationMaterialName = CreateAnnotation("material.name") @@ -53,6 +57,22 @@ var ( AnnotationsSBOMMainComponentName = CreateAnnotation("material.sbom.main_component.name") AnnotationsSBOMMainComponentType = CreateAnnotation("material.sbom.main_component.type") AnnotationsSBOMMainComponentVersion = CreateAnnotation("material.sbom.main_component.version") + + // AnnotationMaterialRedacted marks a material whose stored content was + // transformed by its crafter before upload, to strip secrets out of it. Two + // things follow from it: the recorded digest describes the redacted artifact + // rather than the file on disk, and policy evaluation must read the untouched + // local file instead of the stored content (see GetEvaluableContent). + AnnotationMaterialRedacted = CreateAnnotation("material.redacted") + // AnnotationMaterialRedactionCount is how many secrets were replaced. + AnnotationMaterialRedactionCount = CreateAnnotation("material.redaction.count") + // AnnotationMaterialRedactionRules lists the detection rules that matched, + // so a policy can act on the kind of credential that was present. + AnnotationMaterialRedactionRules = CreateAnnotation("material.redaction.rules") + // AnnotationMaterialRedactionSkipped marks a material uploaded without + // redaction because the operator explicitly asked for it. Recorded so the + // bypass is visible to policies rather than silent. + AnnotationMaterialRedactionSkipped = CreateAnnotation("material.redaction.skipped") ) type NormalizedMaterialOutput struct { @@ -100,13 +120,25 @@ func (m *Attestation_Material) GetEvaluableContent(value string) ([]byte, error) } if artifact != nil { - if m.InlineCas { + // Policies must evaluate the artifact as it was produced. For a redacted + // material the inline bytes are the sanitized copy, so the untouched file + // on disk wins; without a local path there is nothing better to read. + // This keeps the policy input identical whatever CAS backend is in use, + // which matters most in dry runs, where the backend is always inline and + // is exactly where people test their policies. + useInlineContent := m.InlineCas + if m.GetAnnotations()[AnnotationMaterialRedacted] == AnnotationValueTrue && value != "" { + useInlineContent = false + } + + switch { + case useInlineContent: rawMaterial = artifact.GetContent() - } else if value == "" { + case value == "": return nil, errors.New("artifact path required") - } else if m.MaterialType != v1.CraftingSchema_Material_HELM_CHART && + case m.MaterialType != v1.CraftingSchema_Material_HELM_CHART && m.MaterialType != v1.CraftingSchema_Material_JUNIT_XML && - m.MaterialType != v1.CraftingSchema_Material_RADAMSA_CRASHES { + m.MaterialType != v1.CraftingSchema_Material_RADAMSA_CRASHES: // read content from local filesystem (except for tgz charts and // metadata-only materials like radamsa crashes) rawMaterial, err = os.ReadFile(value) diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go b/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go index 5e45f6f90..3df00d172 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go @@ -490,3 +490,132 @@ func TestTruffleHogCleanScanIsEvaluable(t *testing.T) { require.True(t, ok, "clean scan must project to an elements array") assert.Empty(t, elements, "a clean scan has zero findings") } + +// TestGetEvaluableContentRedactedPrefersDisk covers the interaction between an +// inline CAS backend and a crafter that redacted the content before storing it. +// Inline materials normally carry their own content, but a redacted material's +// inline bytes are the sanitized copy, so policies have to read the untouched +// file from disk instead. Without that, the policy input would silently differ +// between an inline backend and every other one. +func TestGetEvaluableContentRedactedPrefersDisk(t *testing.T) { + const ( + // Any distinguishable value works here; this test is about which source + // the content is read from, not about detection. + onDiskSecret = "the-unredacted-original" + inlineSecret = "[REDACTED:aws-access-token]" + + onDisk = `{"secret":"` + onDiskSecret + `"}` + inline = `{"secret":"` + inlineSecret + `"}` + ) + + diskPath := filepath.Join(t.TempDir(), "session.json") + require.NoError(t, os.WriteFile(diskPath, []byte(onDisk), 0o600)) + + testCases := []struct { + name string + inlineCas bool + redacted bool + path string + wantSecret string + wantErr bool + }{ + { + name: "inline without the marker keeps reading the inline content", + inlineCas: true, + path: diskPath, + wantSecret: inlineSecret, + }, + { + name: "inline and redacted reads the original from disk", + inlineCas: true, + redacted: true, + path: diskPath, + wantSecret: onDiskSecret, + }, + { + name: "inline and redacted with no path falls back to the inline content", + inlineCas: true, + redacted: true, + wantSecret: inlineSecret, + }, + { + name: "inline and redacted with an unreadable path fails loudly", + inlineCas: true, + redacted: true, + path: filepath.Join(t.TempDir(), "missing.json"), + // Silently falling back to the inline content here would point + // policies at redacted material without saying so. + wantErr: true, + }, + { + name: "non-inline is unaffected by the marker", + redacted: true, + path: diskPath, + wantSecret: onDiskSecret, + }, + { + name: "non-inline without the marker reads from disk as before", + path: diskPath, + wantSecret: onDiskSecret, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + m := &Attestation_Material{ + MaterialType: schemaapi.CraftingSchema_Material_CHAINLOOP_AI_CODING_SESSION, + InlineCas: tc.inlineCas, + M: &Attestation_Material_Artifact_{ + Artifact: &Attestation_Material_Artifact{ + Name: "session.json", + Digest: "sha256:deadbeef", + Content: []byte(inline), + }, + }, + } + if tc.redacted { + m.Annotations = map[string]string{AnnotationMaterialRedacted: "true"} + } + + content, err := m.GetEvaluableContent(tc.path) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + // GetEvaluableContent also injects a chainloop_metadata block, so + // assert on the field that distinguishes the two sources. + var decoded map[string]any + require.NoError(t, json.Unmarshal(content, &decoded)) + assert.Equal(t, tc.wantSecret, decoded["secret"]) + }) + } +} + +// TestTruffleHogCleanScanIsEvaluableInline is the inline counterpart of +// TestTruffleHogCleanScanIsEvaluable. It pins down that the canonical empty +// content substituted for a zero-byte report projects the same either way, so +// preferring the disk path for redacted materials cannot regress it. +func TestTruffleHogCleanScanIsEvaluableInline(t *testing.T) { + m := &Attestation_Material{ + MaterialType: schemaapi.CraftingSchema_Material_TRUFFLEHOG_JSON, + InlineCas: true, + M: &Attestation_Material_Artifact_{ + Artifact: &Attestation_Material_Artifact{ + Name: "secrets", + Digest: "sha256:deadbeef", + Content: []byte("[]"), + }, + }, + } + + content, err := m.GetEvaluableContent("testdata/trufflehog-clean-scan.jsonl") + require.NoError(t, err) + + var decoded map[string]any + require.NoError(t, json.NewDecoder(bytes.NewReader(content)).Decode(&decoded)) + elements, ok := decoded["elements"].([]any) + require.True(t, ok, "clean scan must project to an elements array") + assert.Empty(t, elements) +} diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 2bf383ed5..5945ecd7a 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -77,6 +77,9 @@ type Crafter struct { // noStrictValidation skips strict schema validation noStrictValidation bool + // skipSecretRedaction stores evidence exactly as captured, without stripping + // secrets out of it first + skipSecretRedaction bool // collectors are auto-discovery collectors that run during attestation init collectors []Collector @@ -136,6 +139,16 @@ func WithNoStrictValidation(noStrictValidation bool) NewOpt { } } +// WithSkipSecretRedaction disables the redaction of secrets from evidence that +// supports it, storing the content exactly as captured. The bypass is recorded +// in the attestation so a policy can reject it. +func WithSkipSecretRedaction(skipSecretRedaction bool) NewOpt { + return func(c *Crafter) error { + c.skipSecretRedaction = skipSecretRedaction + return nil + } +} + // WorkingDir returns the working directory used for file discovery. func (c *Crafter) WorkingDir() string { return c.workingDir @@ -749,7 +762,8 @@ func (c *Crafter) stageMaterial(ctx context.Context, m *schemaapi.CraftingSchema // 3- Craft resulting material mt, err := materials.Craft(context.Background(), m, value, casBackend, c.ociRegistryAuth, c.Logger, &materials.CraftingOpts{ - NoStrictValidation: c.noStrictValidation, + NoStrictValidation: c.noStrictValidation, + SkipSecretRedaction: c.skipSecretRedaction, }) if err != nil { return nil, err diff --git a/pkg/attestation/crafter/materials/aicodingsession/redact.go b/pkg/attestation/crafter/materials/aicodingsession/redact.go new file mode 100644 index 000000000..bdfbf0499 --- /dev/null +++ b/pkg/attestation/crafter/materials/aicodingsession/redact.go @@ -0,0 +1,138 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aicodingsession + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/chainloop-dev/chainloop/internal/redaction" + "github.com/chainloop-dev/chainloop/internal/schemavalidators" +) + +// protectedPaths are the string leaves redaction must never rewrite: the +// identifiers, enumerations, timestamps and file paths that the platform joins +// records on and that policies read. "*" matches a single array index or object +// key. +// +// Everything not listed is eligible, which notably includes the whole +// raw_session transcript, warnings, the repository URL (a common place for +// embedded credentials), and any field a future schema version introduces. +// +// A deny-list rather than an allow-list is deliberate. An allow-list would +// silently stop scanning every newly added free-form field, and a silent gap in +// a redaction control is far worse than an over-eager match, which is at least +// counted in the report and published as an annotation. +var protectedPaths = []string{ + "/chainloop.material.evidence.id", + "/schema", + "/data/schema_version", + "/data/agent/name", + "/data/agent/version", + "/data/session/id", + "/data/session/slug", + "/data/session/started_at", + "/data/session/ended_at", + "/data/git_context/branch", + "/data/git_context/commit_start", + "/data/git_context/commit_end", + "/data/git_context/commits/*", + "/data/code_changes/files/*/path", + "/data/code_changes/files/*/status", + "/data/code_changes/files/*/attribution", + "/data/code_changes/files/*/session_ids/*", + "/data/model/primary", + "/data/model/provider", + "/data/model/models_used/*", + "/data/tools_used/summary/*/tool_name", + "/data/subagents/*/id", + "/data/subagents/*/type", +} + +// Redact removes detected secrets from an AI coding session evidence document. +// +// It returns the sanitised bytes and a summary of what was replaced. A document +// with no detected secrets is returned verbatim, so that the common case keeps +// its original digest. The result is guaranteed to still validate against the AI +// coding session schema; if it does not, redaction fails rather than uploading +// either an invalid document or an unredacted one. +func Redact(ctx context.Context, evidence []byte) ([]byte, *redaction.Report, error) { + scanner, err := redaction.DefaultScanner() + if err != nil { + return nil, nil, fmt.Errorf("initialising the secret scanner: %w", err) + } + + redacted, report, err := redaction.New(scanner, redaction.WithPathFilter(eligible)).Redact(ctx, evidence) + if err != nil { + return nil, nil, err + } + + if report.Changed() { + if err := validate(redacted); err != nil { + return nil, nil, fmt.Errorf("redacting secrets produced an invalid AI coding session: %w", err) + } + } + + return redacted, report, nil +} + +// eligible reports whether the string leaf at path may be rewritten. +func eligible(path string) bool { + for _, pattern := range protectedPaths { + if matchPath(pattern, path) { + return false + } + } + return true +} + +// matchPath compares a slash-separated path against a pattern in which "*" +// stands for exactly one segment. +func matchPath(pattern, path string) bool { + patternSegments := strings.Split(pattern, "/") + pathSegments := strings.Split(path, "/") + if len(patternSegments) != len(pathSegments) { + return false + } + for i, want := range patternSegments { + if want != "*" && want != pathSegments[i] { + return false + } + } + return true +} + +// validate re-checks the redacted document against the AI coding session schema, +// mirroring what the crafter validates on the way in. +func validate(evidence []byte) error { + var envelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(evidence, &envelope); err != nil { + return fmt.Errorf("decoding the evidence envelope: %w", err) + } + + // Decoded generically because that is what the JSON schema validator + // consumes; the crafter keeps using the typed Data struct for everything else. + var data any + if err := json.Unmarshal(envelope.Data, &data); err != nil { + return fmt.Errorf("decoding the data field: %w", err) + } + + return schemavalidators.ValidateAICodingSession(data, schemavalidators.AICodingSessionVersion0_1) +} diff --git a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go new file mode 100644 index 000000000..24869fc01 --- /dev/null +++ b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go @@ -0,0 +1,272 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aicodingsession + +import ( + "bytes" + "context" + "encoding/json" + "os" + "strconv" + "strings" + "testing" + + "github.com/chainloop-dev/chainloop/internal/schemavalidators" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Synthetic credentials embedded in testdata/session-with-secrets.json. +// +// The AWS pair sits in the fixture as __AWS_ACCESS_KEY_ID__ and +// __AWS_SECRET_ACCESS_KEY__ placeholders, substituted by readFixture, and is +// assembled here from fragments so the literal appears in no source file. +// GitHub's push protection recognises the same AWS patterns betterleaks does, so +// a realistic-looking key is rejected on push even as test data. +const ( + fixtureAWSKey = "AKIA" + "4G7TI63VCBIRS4GW" + fixtureAWSSecret = "kQ7zXn2VbW9pLm4RtY6" + "uHs3JdF8gA1cE5oPzQwXn" + fixtureGitHubPAT = "ghp_erOZlZv0B1e3amrQugdwZ8Ro2W4kDql9WPTf" + fixtureAnthropicKey = "sk-ant-api03-sT5wsx9DwmaHZDL0dUWKNhAhULxa35sUzyLFK95QBTZMDJTYn8p0J7ZQbwpYGYCQeW5eXAAGtVSmhp7UO9vxHJtSBC0xpAA" +) + +// readFixture loads a session fixture, substituting the AWS credential +// placeholders for the values the detector must actually match. +func readFixture(t *testing.T, path string) []byte { + t.Helper() + + content, err := os.ReadFile(path) + require.NoError(t, err) + + content = bytes.ReplaceAll(content, []byte("__AWS_ACCESS_KEY_ID__"), []byte(fixtureAWSKey)) + content = bytes.ReplaceAll(content, []byte("__AWS_SECRET_ACCESS_KEY__"), []byte(fixtureAWSSecret)) + return content +} + +func TestEligible(t *testing.T) { + testCases := []struct { + path string + wantEligible bool + }{ + // Protected: identifiers, enums and timestamps the platform joins on. + {"/chainloop.material.evidence.id", false}, + {"/schema", false}, + {"/data/schema_version", false}, + {"/data/agent/name", false}, + {"/data/agent/version", false}, + {"/data/session/id", false}, + {"/data/session/slug", false}, + {"/data/session/started_at", false}, + {"/data/session/ended_at", false}, + {"/data/git_context/branch", false}, + {"/data/git_context/commit_start", false}, + {"/data/git_context/commit_end", false}, + {"/data/git_context/commits/0", false}, + {"/data/git_context/commits/17", false}, + {"/data/code_changes/files/0/path", false}, + {"/data/code_changes/files/3/status", false}, + {"/data/code_changes/files/3/attribution", false}, + {"/data/code_changes/files/3/session_ids/2", false}, + {"/data/model/primary", false}, + {"/data/model/provider", false}, + {"/data/model/models_used/1", false}, + {"/data/tools_used/summary/4/tool_name", false}, + {"/data/subagents/0/id", false}, + {"/data/subagents/0/type", false}, + + // Eligible: free-form text, wherever it lives. + {"/data/git_context/repository", true}, + {"/data/git_context/work_dir", true}, + {"/data/warnings/0", true}, + {"/data/subagents/0/description", true}, + {"/data/raw_session/main/0/message/content", true}, + {"/data/raw_session/reviewer/12/message/content/0/text", true}, + // A field a future schema version might add must be scanned by default. + {"/data/something_new", true}, + {"/data/session/some_new_free_text", true}, + // Near-misses on protected patterns must stay eligible. + {"/data/code_changes/files/0/path/0", true}, + {"/data/git_context/commits", true}, + {"/data/subagents/0", true}, + } + + for _, tc := range testCases { + t.Run(tc.path, func(t *testing.T) { + assert.Equal(t, tc.wantEligible, eligible(tc.path)) + }) + } +} + +func TestRedact(t *testing.T) { + testCases := []struct { + name string + file string + // wantUnchanged asserts the document is handed back byte-for-byte, so a + // session without secrets keeps its digest. + wantUnchanged bool + wantRules []string + wantByRule map[string]int + mustNotContain []string + }{ + { + name: "secrets across transcript threads, warnings and the repository URL", + file: "testdata/session-with-secrets.json", + wantRules: []string{"anthropic-api-key", "aws-access-token", "aws-secret-access-key", "github-pat"}, + // The AWS key id appears in both raw_session threads and in a subagent + // description, and is only detectable at all because its secret access + // key sits next to one of them; the PAT is in the repository URL and in + // a warning. Note that once a secret is detected anywhere it is removed + // everywhere it appears, including the two leaves where the composite + // rule would not have fired on its own. + wantByRule: map[string]int{ + "anthropic-api-key": 1, + "aws-access-token": 3, + "aws-secret-access-key": 1, + "github-pat": 2, + }, + mustNotContain: []string{fixtureAWSKey, fixtureAWSSecret, fixtureGitHubPAT, fixtureAnthropicKey}, + }, + { + name: "false-positive shaped content is left alone", + file: "testdata/session-fp-shaped.json", + wantUnchanged: true, + }, + { + name: "clean session", + file: "../testdata/ai-coding-session.json", + wantUnchanged: true, + }, + { + name: "minimal session without a raw_session", + file: "../testdata/ai-coding-session-minimal.json", + wantUnchanged: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + in := readFixture(t, tc.file) + + got, report, err := Redact(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, report) + + if tc.wantUnchanged { + assert.Equal(t, string(in), string(got), "output must be byte-identical") + assert.False(t, report.Changed()) + return + } + + assert.True(t, report.Changed()) + assert.Equal(t, tc.wantRules, report.RuleIDs()) + assert.Equal(t, tc.wantByRule, report.ByRule) + assert.Empty(t, report.Unlocated) + for _, s := range tc.mustNotContain { + assert.NotContains(t, string(got), s) + } + + // The redacted document must still be a valid AI coding session. + require.NoError(t, validateEvidence(got)) + + // Protected leaves must survive verbatim. + for _, path := range []string{ + "chainloop.material.evidence.id", + "schema", + "data/schema_version", + "data/agent/name", + "data/session/id", + "data/session/started_at", + "data/git_context/commit_start", + "data/git_context/commit_end", + "data/model/primary", + "data/subagents/0/id", + "data/subagents/0/type", + "data/code_changes/files/0/path", + "data/tools_used/summary/0/tool_name", + } { + assert.Equal(t, jsonAt(t, in, path), jsonAt(t, got, path), "path %q must not change", path) + } + + // Surrounding prose in a redacted leaf must be preserved. + assert.Contains(t, string(got), "deploy this, use AWS_ACCESS_KEY_ID=") + assert.Contains(t, string(got), "[REDACTED:aws-access-token]") + assert.Contains(t, string(got), "and a block with \\\"quoted\\\" values. Done ✅") + }) + } +} + +func TestRedactIsIdempotent(t *testing.T) { + in := readFixture(t, "testdata/session-with-secrets.json") + + once, _, err := Redact(context.Background(), in) + require.NoError(t, err) + twice, report, err := Redact(context.Background(), once) + require.NoError(t, err) + + assert.Equal(t, string(once), string(twice)) + assert.False(t, report.Changed(), "a second pass must find nothing") +} + +func TestRedactRejectsInvalidInput(t *testing.T) { + _, _, err := Redact(context.Background(), []byte(`not json`)) + require.Error(t, err) +} + +// validateEvidence mirrors the crafter's own validation of the data field. +func validateEvidence(doc []byte) error { + var envelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(doc, &envelope); err != nil { + return err + } + var raw any + if err := json.Unmarshal(envelope.Data, &raw); err != nil { + return err + } + return schemavalidators.ValidateAICodingSession(raw, schemavalidators.AICodingSessionVersion0_1) +} + +// jsonAt resolves a slash-separated path against a JSON document and returns +// the value's canonical encoding, so two documents can be compared at that path. +// Slashes rather than dots because object keys here contain dots +// ("chainloop.material.evidence.id"). +func jsonAt(t *testing.T, doc []byte, path string) string { + t.Helper() + + var node any + require.NoError(t, json.Unmarshal(doc, &node)) + + for seg := range strings.SplitSeq(path, "/") { + switch v := node.(type) { + case map[string]any: + child, ok := v[seg] + require.True(t, ok, "missing key %q in path %q", seg, path) + node = child + case []any: + idx, err := strconv.Atoi(seg) + require.NoError(t, err, "bad index %q in path %q", seg, path) + require.Less(t, idx, len(v), "index out of range in path %q", path) + node = v[idx] + default: + require.Failf(t, "cannot descend", "into %T at %q in path %q", node, seg, path) + } + } + + out, err := json.Marshal(node) + require.NoError(t, err) + return string(out) +} diff --git a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json new file mode 100644 index 000000000..2f780f9a3 --- /dev/null +++ b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json @@ -0,0 +1,87 @@ +{ + "chainloop.material.evidence.id": "CHAINLOOP_AI_CODING_SESSION", + "schema": "https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json", + "data": { + "schema_version": "v1", + "agent": { + "name": "claude-code", + "version": "2.1.83" + }, + "session": { + "id": "3bf79921-3c03-81b6-afff-cb246849866f", + "slug": "memoized-purring-valley", + "started_at": "2026-03-25T15:10:49.161Z", + "ended_at": "2026-03-25T16:59:14.988Z", + "duration_seconds": 6505 + }, + "git_context": { + "repository": "git@github.com:chainloop-dev/chainloop.git", + "branch": "jiparis/pfm-6984-redact-secrets-in-session", + "work_dir": "/Users/jiparis/projects/chainloop", + "commit_start": "9f8e7d6c5b4a39281706f5e4d3c2b1a0f9e8d7c6", + "commit_end": "e25fa85e1c4b7a9d3f0821650cd7e4b39a1f8c2d", + "commits": [ + "9f8e7d6 perf(crafter): de-duplicate AccessChk security descriptors", + "e25fa85 Bump Helm Chart and Dagger Version" + ], + "commit_count": 2 + }, + "code_changes": { + "files_modified": 3, + "lines_added": 1204, + "lines_removed": 87, + "files": [ + {"path": "app/controlplane/internal/service/attestation.go", "status": "modified", "attribution": "ai"}, + {"path": "pkg/attestation/crafter/materials/aicodingsession/redact.go", "status": "created", "attribution": "ai", "session_ids": ["3bf79921-3c03-81b6-afff-cb246849866f"]} + ] + }, + "model": { + "primary": "claude-opus-5", + "provider": "anthropic", + "models_used": ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"] + }, + "usage": { + "input_tokens": 63, + "output_tokens": 2989, + "total_tokens": 1234567890, + "cache_read_input_tokens": 933233, + "cache_creation_input_tokens": 47538, + "estimated_cost_usd": 0.30000000000000004 + }, + "tools_used": { + "summary": [ + {"tool_name": "Bash", "invocation_count": 13}, + {"tool_name": "Edit", "invocation_count": 4} + ], + "total_invocations": 17 + }, + "conversation": { + "total_messages": 87, + "user_messages": 34, + "assistant_messages": 36 + }, + "subagents": [ + { + "id": "ae0fbd58ba98882bb", + "type": "Explore", + "description": "Explore attestation crafter lifecycle", + "tokens": {"input": 73380, "output": 4096} + } + ], + "raw_session": { + "main": [ + { + "type": "user", + "message": {"role": "user", "content": "the sha256 digest is 9f8e7d6c5b4a39281706f5e4d3c2b1a0f9e8d7c6f9e8d7c6b5a4938271605f4e"}, + "timestamp": "2026-03-25T15:13:37.330Z" + }, + { + "type": "assistant", + "message": {"role": "assistant", "content": "Reading pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go and running `go test ./... -run TestRedact`. Base64 of hello: aGVsbG8=. Escaped: & \"quotes\" and a tab\there."}, + "timestamp": "2026-03-25T15:13:40.100Z" + } + ] + }, + "warnings": [] + } +} diff --git a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-with-secrets.json b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-with-secrets.json new file mode 100644 index 000000000..a6f7a9757 --- /dev/null +++ b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-with-secrets.json @@ -0,0 +1,90 @@ +{ + "chainloop.material.evidence.id": "CHAINLOOP_AI_CODING_SESSION", + "schema": "https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json", + "data": { + "schema_version": "v1", + "agent": { + "name": "claude-code", + "version": "2.1.83" + }, + "session": { + "id": "fa8acbe6-a176-4c2a-b51e-fd4541615eb5", + "slug": "stateful-wobbling-sutherland", + "started_at": "2026-03-25T15:10:49.161Z", + "ended_at": "2026-03-25T16:59:14.988Z", + "duration_seconds": 6505 + }, + "git_context": { + "repository": "https://oauth2:ghp_erOZlZv0B1e3amrQugdwZ8Ro2W4kDql9WPTf@github.com/example/repo.git", + "branch": "main", + "work_dir": "/home/user/repo", + "commit_start": "ae79df1aae6ef53fea636b4aad0a8c3d372178e9", + "commit_end": "5869b91e80fea5ac3d4502c728b118e85d65d825", + "commits": [ + "ae79df1 Add Go hello world application", + "5869b91 Add --bye flag" + ], + "commit_count": 2 + }, + "code_changes": { + "files_created": 2, + "lines_added": 36, + "files": [ + {"path": "go.mod", "status": "created"}, + {"path": "main.go", "status": "created"} + ] + }, + "model": { + "primary": "claude-opus-4-6", + "provider": "anthropic", + "models_used": ["claude-opus-4-6"] + }, + "usage": { + "input_tokens": 63, + "output_tokens": 2989, + "total_tokens": 3052, + "estimated_cost_usd": 0.8388 + }, + "tools_used": { + "summary": [ + {"tool_name": "Bash", "invocation_count": 13} + ], + "total_invocations": 13 + }, + "subagents": [ + { + "id": "agent-1", + "type": "general-purpose", + "description": "deploy with AWS_ACCESS_KEY_ID=__AWS_ACCESS_KEY_ID__", + "tokens": {"input": 10, "output": 20} + } + ], + "raw_session": { + "main": [ + { + "type": "user", + "message": {"role": "user", "content": "deploy this, use AWS_ACCESS_KEY_ID=__AWS_ACCESS_KEY_ID__\nAWS_SECRET_ACCESS_KEY=__AWS_SECRET_ACCESS_KEY__"}, + "timestamp": "2026-03-25T15:13:37.330Z" + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": "I read .env and found:\nANTHROPIC_API_KEY=sk-ant-api03-sT5wsx9DwmaHZDL0dUWKNhAhULxa35sUzyLFK95QBTZMDJTYn8p0J7ZQbwpYGYCQeW5eXAAGtVSmhp7UO9vxHJtSBC0xpAA\nand a block with \"quoted\" values. Done ✅" + }, + "timestamp": "2026-03-25T15:13:40.100Z" + } + ], + "reviewer": [ + { + "type": "user", + "message": {"role": "user", "content": "re-run with AWS_ACCESS_KEY_ID=__AWS_ACCESS_KEY_ID__ please"}, + "timestamp": "2026-03-25T15:20:00.000Z" + } + ] + }, + "warnings": [ + "could not parse token ghp_erOZlZv0B1e3amrQugdwZ8Ro2W4kDql9WPTf from the environment" + ] + } +} diff --git a/pkg/attestation/crafter/materials/chainloop_ai_coding_session.go b/pkg/attestation/crafter/materials/chainloop_ai_coding_session.go index 1192c238a..2305b6d21 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_coding_session.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_coding_session.go @@ -20,8 +20,11 @@ import ( "encoding/json" "fmt" "os" + "strconv" + "strings" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/internal/redaction" "github.com/chainloop-dev/chainloop/internal/schemavalidators" api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" @@ -34,22 +37,42 @@ var annotationAICodingModel = api.CreateAnnotation("material.aiagent.model") type ChainloopAICodingSessionCrafter struct { *crafterCommon - backend *casclient.CASBackend + backend *casclient.CASBackend + skipRedaction bool +} + +// AICodingSessionCraftOpt tunes how an AI coding session is crafted. +type AICodingSessionCraftOpt func(*ChainloopAICodingSessionCrafter) + +// WithAICodingSessionSkipRedaction uploads the session exactly as captured, +// without stripping secrets out of it first. +func WithAICodingSessionSkipRedaction(skip bool) AICodingSessionCraftOpt { + return func(c *ChainloopAICodingSessionCrafter) { c.skipRedaction = skip } } // NewChainloopAICodingSessionCrafter generates a new CHAINLOOP_AI_CODING_SESSION material. // This material type contains AI coding session telemetry collected during attestation. -func NewChainloopAICodingSessionCrafter(schema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger) (*ChainloopAICodingSessionCrafter, error) { +func NewChainloopAICodingSessionCrafter(schema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger, opts ...AICodingSessionCraftOpt) (*ChainloopAICodingSessionCrafter, error) { if schema.Type != schemaapi.CraftingSchema_Material_CHAINLOOP_AI_CODING_SESSION { return nil, fmt.Errorf("material type is not chainloop_ai_coding_session") } craftCommon := &crafterCommon{logger: l, input: schema} - return &ChainloopAICodingSessionCrafter{backend: backend, crafterCommon: craftCommon}, nil + c := &ChainloopAICodingSessionCrafter{backend: backend, crafterCommon: craftCommon} + for _, o := range opts { + o(c) + } + return c, nil } -// Craft validates the AI coding session against the JSON schema, calculates the digest, -// uploads it and returns the material definition. +// Craft validates the AI coding session against the JSON schema, redacts any +// secrets found in it, calculates the digest, uploads it and returns the +// material definition. +// +// Only the stored copy is redacted. The file on disk is left untouched, and it +// is what policies are evaluated against once this material has been staged, so +// a policy that looks for leaked credentials still sees what the agent actually +// captured. func (c *ChainloopAICodingSessionCrafter) Craft(ctx context.Context, artifactPath string) (*api.Attestation_Material, error) { f, err := os.ReadFile(artifactPath) if err != nil { @@ -83,11 +106,18 @@ func (c *ChainloopAICodingSessionCrafter) Craft(ctx context.Context, artifactPat return nil, fmt.Errorf("AI coding session validation failed: %w", err) } - material, err := uploadAndCraft(ctx, c.input, c.backend, artifactPath, c.logger) + craftOpts, report, err := c.redact(ctx, f) + if err != nil { + return nil, err + } + + material, err := uploadAndCraft(ctx, c.input, c.backend, artifactPath, c.logger, craftOpts...) if err != nil { return nil, err } + c.annotateRedaction(material, report) + // Surface agent name as an annotation if data.Agent.Name != "" { material.Annotations[annotationAIAgentName] = data.Agent.Name @@ -100,3 +130,63 @@ func (c *ChainloopAICodingSessionCrafter) Craft(ctx context.Context, artifactPat return material, nil } + +// redact strips secrets out of the session content, returning the options that +// make uploadAndCraft store the sanitized copy instead of the file on disk. +// +// Redaction fails closed: if the content cannot be scanned or the result no +// longer matches the schema, the material is not crafted at all rather than +// uploaded unscanned. The operator's escape hatch is --skip-secret-redaction, +// whose use is recorded in the attestation. +func (c *ChainloopAICodingSessionCrafter) redact(ctx context.Context, content []byte) ([]uploadAndCraftOption, *redaction.Report, error) { + if c.skipRedaction { + c.logger.Warn().Msg("secret redaction is DISABLED: the AI coding session will be stored exactly as captured") + return nil, nil, nil + } + + redacted, report, err := aicodingsession.Redact(ctx, content) + if err != nil { + return nil, nil, fmt.Errorf("redacting secrets from the AI coding session: %w", err) + } + + if !report.Changed() { + // Nothing was replaced, so upload the file as it is on disk and keep the + // digest reproducible from the source file. + return nil, report, nil + } + + return []uploadAndCraftOption{withContentOverride(redacted)}, report, nil +} + +// annotateRedaction records what redaction did, so that it is visible in the +// attestation and actionable by policies rather than an invisible rewrite. +func (c *ChainloopAICodingSessionCrafter) annotateRedaction(material *api.Attestation_Material, report *redaction.Report) { + if c.skipRedaction { + material.Annotations[api.AnnotationMaterialRedactionSkipped] = api.AnnotationValueTrue + return + } + + if report == nil { + return + } + + if len(report.Unlocated) > 0 { + // Detected in the document as a whole but not attributable to any + // rewritable field: either a protected field or a match spanning the + // boundary between two of them. + c.logger.Warn().Interface("rules", report.Unlocated). + Msg("some detected secrets could not be redacted") + } + + if !report.Changed() { + return + } + + rules := report.RuleIDs() + material.Annotations[api.AnnotationMaterialRedacted] = api.AnnotationValueTrue + material.Annotations[api.AnnotationMaterialRedactionCount] = strconv.Itoa(report.Replacements) + material.Annotations[api.AnnotationMaterialRedactionRules] = strings.Join(rules, ",") + + c.logger.Info().Int("count", report.Replacements).Strs("rules", rules). + Msg("redacted secrets from the AI coding session before upload") +} diff --git a/pkg/attestation/crafter/materials/chainloop_ai_coding_session_redaction_test.go b/pkg/attestation/crafter/materials/chainloop_ai_coding_session_redaction_test.go new file mode 100644 index 000000000..c456b7bf3 --- /dev/null +++ b/pkg/attestation/crafter/materials/chainloop_ai_coding_session_redaction_test.go @@ -0,0 +1,286 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package materials + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "strconv" + "testing" + + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" + "github.com/chainloop-dev/chainloop/pkg/casclient" + mUploader "github.com/chainloop-dev/chainloop/pkg/casclient/mocks" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestUploadAndCraftContentOverride covers the seam redaction relies on: the +// stored bytes are the substituted ones, and every derived field (digest, size, +// uploaded body) describes them rather than the file on disk. +func TestUploadAndCraftContentOverride(t *testing.T) { + const ( + onDisk = `{"original":"content that is definitely longer"}` + redacted = `{"original":"[REDACTED]"}` + ) + + testCases := []struct { + name string + override []byte + skipUpload bool + maxSize int64 + wantErr string + wantStored string + wantUpload bool + }{ + { + name: "no override stores the file on disk", + wantStored: onDisk, + wantUpload: true, + }, + { + name: "override stores the substituted bytes", + override: []byte(redacted), + wantStored: redacted, + wantUpload: true, + }, + { + name: "an empty override is rejected rather than stored", + override: []byte{}, + wantErr: "file is empty", + }, + { + // The CAS stores the substituted content, so its size is the one that + // has to fit the backend. + name: "the size limit applies to the override", + override: []byte(`{"original":"padded out to be much larger than the limit"}`), + maxSize: 20, + wantErr: "too big", + }, + { + name: "skip upload still records the override digest", + override: []byte(redacted), + skipUpload: true, + wantStored: redacted, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + logger := zerolog.Nop() + path := filepath.Join(t.TempDir(), "session.json") + require.NoError(t, os.WriteFile(path, []byte(onDisk), 0o600)) + + schema := &schemaapi.CraftingSchema_Material{ + Name: "test", + Type: schemaapi.CraftingSchema_Material_CHAINLOOP_AI_CODING_SESSION, + SkipUpload: tc.skipUpload, + } + + backend := &casclient.CASBackend{Name: "test", MaxSize: tc.maxSize} + var uploaded []byte + if tc.wantUpload { + uploader := mUploader.NewUploader(t) + uploader.On("Upload", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + var err error + uploaded, err = io.ReadAll(args.Get(1).(io.Reader)) + require.NoError(t, err) + }). + Return(&casclient.UpDownStatus{Digest: "deadbeef"}, nil) + backend.Uploader = uploader + } + + var opts []uploadAndCraftOption + if tc.override != nil { + opts = append(opts, withContentOverride(tc.override)) + } + + got, err := uploadAndCraft(context.TODO(), schema, backend, path, &logger, opts...) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + + // The recorded filename must be unaffected by the substitution. + assert.Equal(t, "session.json", got.GetArtifact().Name) + assert.Equal(t, strconv.Itoa(len(tc.wantStored)), got.Annotations[AnnotationMaterialSize]) + assert.Equal(t, sha256Digest(tc.wantStored), got.GetArtifact().Digest) + + if tc.wantUpload { + assert.Equal(t, tc.wantStored, string(uploaded)) + assert.True(t, got.UploadedToCas) + } else { + assert.False(t, got.UploadedToCas) + } + }) + } +} + +// The AWS credentials the session fixture carries as __AWS_ACCESS_KEY_ID__ and +// __AWS_SECRET_ACCESS_KEY__ placeholders, assembled from fragments so the literal +// appears in no source file: GitHub's push protection recognises the same AWS +// patterns betterleaks does, and rejects a realistic-looking key even in test +// data. +const ( + awsKey = "AKIA" + "4G7TI63VCBIRS4GW" + awsSecret = "kQ7zXn2VbW9pLm4RtY6" + "uHs3JdF8gA1cE5oPzQwXn" +) + +// materializeFixture writes a copy of a session fixture with the AWS credential +// placeholders substituted, and returns its path. The crafter reads the artifact +// from disk, so the substitution has to land in a real file. +func materializeFixture(t *testing.T, src string) string { + t.Helper() + + content, err := os.ReadFile(src) + require.NoError(t, err) + + content = bytes.ReplaceAll(content, []byte("__AWS_ACCESS_KEY_ID__"), []byte(awsKey)) + content = bytes.ReplaceAll(content, []byte("__AWS_SECRET_ACCESS_KEY__"), []byte(awsSecret)) + + path := filepath.Join(t.TempDir(), filepath.Base(src)) + require.NoError(t, os.WriteFile(path, content, 0o600)) + return path +} + +// TestChainloopAICodingSessionCrafterRedaction is the end-to-end guarantee: the +// bytes that leave the machine carry no secrets, while the file on disk — what +// policies are evaluated against afterwards — is left untouched. +func TestChainloopAICodingSessionCrafterRedaction(t *testing.T) { + const ( + withSecrets = "./aicodingsession/testdata/session-with-secrets.json" + clean = "./testdata/ai-coding-session.json" + ) + + testCases := []struct { + name string + filePath string + skipRedaction bool + inlineBackend bool + wantRedacted bool + wantCount string + wantRules string + }{ + { + name: "secrets are stripped before upload", + filePath: withSecrets, + wantRedacted: true, + wantCount: "7", + wantRules: "anthropic-api-key,aws-access-token,aws-secret-access-key,github-pat", + }, + { + // An inline backend embeds the content into the attestation itself, + // so it matters most there that the redacted copy is the stored one. + name: "an inline backend embeds the redacted copy", + filePath: withSecrets, + inlineBackend: true, + wantRedacted: true, + wantCount: "7", + wantRules: "anthropic-api-key,aws-access-token,aws-secret-access-key,github-pat", + }, + { + name: "the opt-out is recorded in the attestation", + filePath: withSecrets, + skipRedaction: true, + }, + { + name: "a clean session is not marked as redacted", + filePath: clean, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + logger := zerolog.Nop() + schema := &schemaapi.CraftingSchema_Material{ + Name: "test", + Type: schemaapi.CraftingSchema_Material_CHAINLOOP_AI_CODING_SESSION, + } + + path := materializeFixture(t, tc.filePath) + original, err := os.ReadFile(path) + require.NoError(t, err) + + // A nil Uploader is what the CLI builds for an inline CAS backend. + backend := &casclient.CASBackend{Name: "not-set"} + var stored []byte + if !tc.inlineBackend { + uploader := mUploader.NewUploader(t) + uploader.On("Upload", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + var readErr error + stored, readErr = io.ReadAll(args.Get(1).(io.Reader)) + require.NoError(t, readErr) + }). + Return(&casclient.UpDownStatus{Digest: "deadbeef"}, nil) + backend.Uploader = uploader + } + + crafter, err := NewChainloopAICodingSessionCrafter(schema, backend, &logger, + WithAICodingSessionSkipRedaction(tc.skipRedaction)) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), path) + require.NoError(t, err) + + if tc.inlineBackend { + require.True(t, got.InlineCas) + stored = got.GetArtifact().Content + } + + assert.Equal(t, tc.wantCount, got.Annotations[api.AnnotationMaterialRedactionCount]) + assert.Equal(t, tc.wantRules, got.Annotations[api.AnnotationMaterialRedactionRules]) + + switch { + case tc.wantRedacted: + assert.Equal(t, "true", got.Annotations[api.AnnotationMaterialRedacted]) + assert.NotContains(t, string(stored), awsKey) + assert.Contains(t, string(stored), "[REDACTED:aws-access-token]") + // The digest describes the redacted artifact, not the source file. + assert.NotEqual(t, sha256Digest(string(original)), got.GetArtifact().Digest) + case tc.skipRedaction: + assert.Equal(t, "true", got.Annotations[api.AnnotationMaterialRedactionSkipped]) + assert.Contains(t, string(stored), awsKey) + assert.Equal(t, sha256Digest(string(original)), got.GetArtifact().Digest) + default: + assert.NotContains(t, got.Annotations, api.AnnotationMaterialRedacted) + assert.NotContains(t, got.Annotations, api.AnnotationMaterialRedactionSkipped) + // Nothing to redact, so the digest stays reproducible from the file. + assert.Equal(t, sha256Digest(string(original)), got.GetArtifact().Digest) + } + + // Redaction must never touch the file the policies will read. + stillOnDisk, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(original), string(stillOnDisk)) + }) + } +} + +func sha256Digest(content string) string { + sum := sha256.Sum256([]byte(content)) + return "sha256:" + hex.EncodeToString(sum[:]) +} diff --git a/pkg/attestation/crafter/materials/gitleaks.go b/pkg/attestation/crafter/materials/gitleaks.go index 68f40018d..a6cbff237 100644 --- a/pkg/attestation/crafter/materials/gitleaks.go +++ b/pkg/attestation/crafter/materials/gitleaks.go @@ -21,11 +21,11 @@ import ( "fmt" "os" + "github.com/betterleaks/betterleaks/report" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/casclient" "github.com/rs/zerolog" - "github.com/zricethezav/gitleaks/v8/report" ) type GitleaksReportCrafter struct { diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go index 89337c6c0..df117989a 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -23,6 +23,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strconv" "strings" "time" @@ -165,6 +166,15 @@ type uploadAndCraftOpts struct { // digest, which would collide across unrelated empty materials of any type. // The original filename is preserved. emptyContentFallback []byte + + // contentOverride, when set, is stored and uploaded instead of the bytes on + // disk. It exists for crafters that must transform an artifact before it + // leaves the machine, such as redacting secrets out of an AI coding session. + // The recorded digest, size and uploaded (or inline) body then all describe + // the transformed content, which is the only content that ever reaches the + // CAS. The file on disk is left untouched, and remains what policies are + // evaluated against. The original filename is preserved. + contentOverride []byte } type uploadAndCraftOption func(*uploadAndCraftOpts) @@ -175,6 +185,12 @@ func withEmptyContentFallback(content []byte) uploadAndCraftOption { return func(o *uploadAndCraftOpts) { o.emptyContentFallback = content } } +// withContentOverride stores and uploads the given bytes instead of the file on +// disk, preserving the artifact's filename. +func withContentOverride(content []byte) uploadAndCraftOption { + return func(o *uploadAndCraftOpts) { o.contentOverride = content } +} + // uploadAndCraft uploads the artifact to CAS and crafts the material // this function is used by all the uploadable artifacts crafters (SBOMs, JUnit, and more in the future) func uploadAndCraft(ctx context.Context, input *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, artifactPath string, l *zerolog.Logger, opts ...uploadAndCraftOption) (*api.Attestation_Material, error) { @@ -183,8 +199,8 @@ func uploadAndCraft(ctx context.Context, input *schemaapi.CraftingSchema_Materia o(options) } - // 1 - Check the file can be stored in the provided CAS backend - result, err := fileStats(artifactPath) + // 1 - Resolve the content to store and check it fits the provided CAS backend + result, err := resolveContent(artifactPath, options.contentOverride) if err != nil { return nil, fmt.Errorf("getting file stats: %w", err) } @@ -209,7 +225,8 @@ func uploadAndCraft(ctx context.Context, input *schemaapi.CraftingSchema_Materia l.Debug().Str("filename", result.filename).Str("digest", result.digest).Str("path", artifactPath). Str("size", bytefmt.ByteSize(uint64(result.size))). Str("max_size", bytefmt.ByteSize(uint64(backend.MaxSize))). - Str("backend", backend.Name).Bool("skip_upload", shouldSkipUpload).Msg("crafting file") + Str("backend", backend.Name).Bool("skip_upload", shouldSkipUpload). + Bool("content_override", options.contentOverride != nil).Msg("crafting file") // If there is a max size set and the file is bigger than that, return an error // Only check size if we're actually going to upload (not skipped) @@ -239,8 +256,8 @@ func uploadAndCraft(ctx context.Context, input *schemaapi.CraftingSchema_Materia case backend.Uploader != nil: l.Debug().Str("backend", backend.Name).Msg("uploading") - // Reuse the already-open, already-hashed reader from fileStats - // to avoid a redundant SHA256 pass inside Uploader.UploadFile. + // Reuse the already-hashed reader resolved above to avoid a + // redundant SHA256 pass inside Uploader.UploadFile. _, err = backend.Uploader.Upload(ctx, result.r, result.filename, result.digest) if err != nil { return nil, fmt.Errorf("%w: %w", ErrBaseUploadAndCraft, fmt.Errorf("uploading material: %w", err)) @@ -272,6 +289,17 @@ type fileInfo struct { r io.ReadCloser } +// resolveContent returns the content to be stored for an artifact: normally the +// file on disk, or the bytes a crafter substituted for it. fileStats derives the +// filename from the path with os.FileInfo.Name, which is already the base name, +// so both branches record the same artifact name. +func resolveContent(artifactPath string, override []byte) (*fileInfo, error) { + if override != nil { + return fileStatsFromBytes(filepath.Base(artifactPath), override) + } + return fileStats(artifactPath) +} + // Returns the sha256 hash of the file, its size and an error func fileStats(filepath string) (*fileInfo, error) { stat, err := os.Stat(filepath) @@ -302,8 +330,9 @@ func fileStats(filepath string) (*fileInfo, error) { } // fileStatsFromBytes builds a fileInfo from in-memory content, preserving the -// given filename. Used to craft a canonical representation when the on-disk -// file is empty (see uploadAndCraft's emptyContentFallback). +// given filename. Used when the stored bytes differ from the bytes on disk: to +// craft a canonical representation of an empty file (emptyContentFallback), or +// to store a transformed copy of the artifact (contentOverride). func fileStatsFromBytes(filename string, content []byte) (*fileInfo, error) { hash, _, err := cr_v1.SHA256(bytes.NewReader(content)) if err != nil { @@ -324,6 +353,9 @@ type Craftable interface { // CraftingOpts contains options for crafting materials type CraftingOpts struct { NoStrictValidation bool + // SkipSecretRedaction stores evidence exactly as captured, without stripping + // secrets out of it first. Only material types that redact honour it. + SkipSecretRedaction bool } //nolint:gocyclo @@ -403,7 +435,7 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia case schemaapi.CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG: crafter, err = NewChainloopAIAgentConfigCrafter(materialSchema, casBackend, logger) case schemaapi.CraftingSchema_Material_CHAINLOOP_AI_CODING_SESSION: - crafter, err = NewChainloopAICodingSessionCrafter(materialSchema, casBackend, logger) + crafter, err = NewChainloopAICodingSessionCrafter(materialSchema, casBackend, logger, WithAICodingSessionSkipRedaction(opts.SkipSecretRedaction)) case schemaapi.CraftingSchema_Material_OPENAPI_SPEC: crafter, err = NewOpenAPICrafter(materialSchema, casBackend, logger, WithOpenAPINoStrictValidation(opts.NoStrictValidation)) case schemaapi.CraftingSchema_Material_ASYNCAPI_SPEC: From be4163cdb0dbcdf2307eddec00074e8192450a24 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 26 Aug 2026 10:36:56 +0200 Subject: [PATCH 2/5] fix(redaction): refuse documents that cannot be fully scanned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decoding an object into a map keeps only the last value for a repeated key. A secret in an earlier one therefore never reached the scanner, and because nothing was replaced the original bytes — secret included — were returned as clean. Duplicate keys are now rejected before decoding. Also require the input to be exhausted after the root object: Decoder.More is not a top-level end-of-input check and reports false for a trailing "]" or "}", which let a malformed document through. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 8d801181-6ee5-4dcd-b572-03033a0d564b --- internal/redaction/redaction.go | 94 ++++++++++++++++++- internal/redaction/redaction_test.go | 37 ++++++++ .../materials/aicodingsession/redact_test.go | 27 ++++++ 3 files changed, 157 insertions(+), 1 deletion(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 8c031e823..127b58444 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -30,6 +30,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "regexp" "sort" "strconv" @@ -46,6 +47,11 @@ var ( ErrNotConverged = errors.New("secret redaction did not converge") // ErrInvalidJSON is returned when the document is not a single JSON object. ErrInvalidJSON = errors.New("document is not a JSON object") + // ErrDuplicateKey is returned when an object repeats a key. Decoding keeps + // only the last value for a repeated key, so a secret in an earlier one would + // never be scanned; redaction refuses the document rather than pass it + // through unexamined. + ErrDuplicateKey = errors.New("document contains a duplicate object key") ) // Defaults applied by New when the corresponding option is not supplied. @@ -435,6 +441,11 @@ func (w *rewriter) redactLeaf(s string) string { // decodeObject parses doc into a value tree, keeping numbers in their original // textual form so re-encoding does not reformat them. func decodeObject(doc []byte) (map[string]any, error) { + // Checked before decoding, because decoding is what loses the information. + if err := rejectDuplicateKeys(doc); err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(doc)) dec.UseNumber() @@ -442,7 +453,12 @@ func decodeObject(doc []byte) (map[string]any, error) { if err := dec.Decode(&root); err != nil { return nil, fmt.Errorf("%w: %w", ErrInvalidJSON, err) } - if dec.More() { + + // Decoder.More is not a top-level end-of-input check: it reports false for a + // trailing "]" or "}", which would let a malformed document through. Require + // the input to be exhausted instead. + var trailing json.RawMessage + if err := dec.Decode(&trailing); !errors.Is(err, io.EOF) { return nil, fmt.Errorf("%w: unexpected trailing content", ErrInvalidJSON) } @@ -453,6 +469,82 @@ func decodeObject(doc []byte) (map[string]any, error) { return obj, nil } +// jsonFrame tracks one open object or array while walking a token stream. +type jsonFrame struct { + isObject bool + keys map[string]struct{} + // expectKey is true when the next token in an object is a key rather than a + // value. + expectKey bool +} + +// rejectDuplicateKeys reports an error if any object in doc repeats a key. +// +// Decoding into a map keeps only the last value for a repeated key. A secret in +// an earlier one would therefore never reach the scanner, and because nothing +// was replaced the original bytes — secret included — would be handed back as +// "clean". Duplicate keys have no legitimate meaning in evidence, so the +// document is refused. +func rejectDuplicateKeys(doc []byte) error { + dec := json.NewDecoder(bytes.NewReader(doc)) + var stack []*jsonFrame + + top := func() *jsonFrame { + if len(stack) == 0 { + return nil + } + return stack[len(stack)-1] + } + + for { + token, err := dec.Token() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidJSON, err) + } + + if delim, ok := token.(json.Delim); ok { + switch delim { + case '{': + stack = append(stack, &jsonFrame{isObject: true, keys: map[string]struct{}{}, expectKey: true}) + case '[': + stack = append(stack, &jsonFrame{}) + case '}', ']': + if len(stack) == 0 { + return fmt.Errorf("%w: unbalanced %q", ErrInvalidJSON, delim) + } + stack = stack[:len(stack)-1] + // The nested value is complete, so the parent object expects the + // next key. + if parent := top(); parent != nil && parent.isObject { + parent.expectKey = true + } + } + continue + } + + frame := top() + if frame == nil || !frame.isObject { + // A top-level scalar or an array element: no keys involved. + continue + } + + if !frame.expectKey { + frame.expectKey = true + continue + } + + key, _ := token.(string) + if _, duplicate := frame.keys[key]; duplicate { + return fmt.Errorf("%w: %q", ErrDuplicateKey, key) + } + frame.keys[key] = struct{}{} + frame.expectKey = false + } +} + // encode serialises v. HTML escaping is disabled so transcript text keeps its // angle brackets and ampersands instead of being mangled into \u sequences. func encode(v any, indent bool) (string, error) { diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index 4af59751d..2fc10cbad 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -180,6 +180,43 @@ func TestRedact(t *testing.T) { opts: []Option{WithMaxBytes(8)}, wantErr: ErrTooLarge, }, + { + // Decoding keeps only the last value for a repeated key, so the secret + // in the first one would never be scanned — and since nothing was + // replaced, the original bytes would be handed back as clean. + name: "duplicate key hiding a secret is refused", + doc: `{"a":"SEC","a":"clean"}`, + findings: []Finding{{RuleID: "r1", Secret: "SEC"}}, + wantErr: ErrDuplicateKey, + }, + { + name: "duplicate key nested in the transcript is refused", + doc: `{"data":{"raw_session":{"main":[{"content":"SEC","content":"clean"}]}}}`, + wantErr: ErrDuplicateKey, + }, + { + name: "repeating a key in a sibling object is fine", + doc: `{"a":{"k":"x"},"b":{"k":"y"},"c":[{"k":1},{"k":2}]}`, + // Same key name, different objects: nothing is lost, nothing to refuse. + wantUnchanged: true, + }, + { + // Decoder.More reports false for a trailing bracket, so this has to be + // caught by requiring the input to be exhausted. + name: "trailing closing bracket is rejected", + doc: `{"a":"b"}]`, + wantErr: ErrInvalidJSON, + }, + { + name: "trailing closing brace is rejected", + doc: `{"a":"b"}}`, + wantErr: ErrInvalidJSON, + }, + { + name: "a second document is rejected", + doc: `{"a":"b"}{"c":"d"}`, + wantErr: ErrInvalidJSON, + }, } for _, tc := range testCases { diff --git a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go index 24869fc01..cebfcb901 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go +++ b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go @@ -24,6 +24,7 @@ import ( "strings" "testing" + "github.com/chainloop-dev/chainloop/internal/redaction" "github.com/chainloop-dev/chainloop/internal/schemavalidators" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -225,6 +226,32 @@ func TestRedactRejectsInvalidInput(t *testing.T) { require.Error(t, err) } +// A duplicate key would let a secret ride along unscanned: decoding keeps only +// the last value, so the earlier one never reaches the scanner, and with nothing +// replaced the original bytes would be uploaded as clean. Redaction has to fail +// rather than pass the document through. +func TestRedactRejectsDuplicateKeys(t *testing.T) { + doc := []byte(`{ + "chainloop.material.evidence.id": "CHAINLOOP_AI_CODING_SESSION", + "schema": "https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json", + "data": { + "schema_version": "v1", + "agent": {"name": "claude-code"}, + "session": {"id": "abc", "started_at": "2026-03-25T15:10:49.161Z", "duration_seconds": 1}, + "raw_session": { + "main": [ + {"content": "AWS_ACCESS_KEY_ID=` + fixtureAWSKey + `", "content": "nothing here"} + ] + } + } + }`) + + out, _, err := Redact(context.Background(), doc) + + require.ErrorIs(t, err, redaction.ErrDuplicateKey) + assert.Nil(t, out, "nothing may be returned for a document that cannot be scanned") +} + // validateEvidence mirrors the crafter's own validation of the data field. func validateEvidence(doc []byte) error { var envelope struct { From 0d48027e5fd195751e2aa9d41ec03962b772660d Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 26 Aug 2026 12:35:53 +0200 Subject: [PATCH 3/5] refactor(redaction): drop the size cap and scan timeout Both could fail an attestation over a transcript that was merely large, which is a worse outcome than spending a while scanning it. Redaction is now unbounded in size and time; a caller that wants a bound can impose one through the context it passes. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 8d801181-6ee5-4dcd-b572-03033a0d564b --- internal/redaction/betterleaks_test.go | 2 +- internal/redaction/redaction.go | 49 ++++---------------------- internal/redaction/redaction_test.go | 10 ------ 3 files changed, 8 insertions(+), 53 deletions(-) diff --git a/internal/redaction/betterleaks_test.go b/internal/redaction/betterleaks_test.go index 756addac8..507c4b517 100644 --- a/internal/redaction/betterleaks_test.go +++ b/internal/redaction/betterleaks_test.go @@ -298,7 +298,7 @@ func BenchmarkRedact(b *testing.B) { sb.WriteString(`{"role":"user","content":"` + awsPair + `"}]}}}`) doc := []byte(sb.String()) - r := New(scanner, WithMaxBytes(64<<20), WithTimeout(0)) + r := New(scanner) b.SetBytes(int64(len(doc))) b.ResetTimer() for b.Loop() { diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 127b58444..0386470e8 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -35,13 +35,9 @@ import ( "sort" "strconv" "strings" - "time" ) var ( - // ErrTooLarge is returned when a document exceeds the configured size cap. - // Redaction fails closed rather than shipping unscanned content. - ErrTooLarge = errors.New("document too large to redact") // ErrNotConverged is returned when repeated passes keep detecting secrets, // which in practice means a placeholder is itself matching a rule. ErrNotConverged = errors.New("secret redaction did not converge") @@ -54,20 +50,14 @@ var ( ErrDuplicateKey = errors.New("document contains a duplicate object key") ) -// Defaults applied by New when the corresponding option is not supplied. +// DefaultMaxPasses bounds the convergence loop. A successful redaction costs two +// passes: one that finds the secrets and one that confirms none are left. // -// The size cap and the timeout are related. A successful redaction costs two -// passes over the document: one that finds the secrets and one that confirms -// none are left. Both together run at roughly 2.5 MB/s (measured by -// BenchmarkRedact on an M3 Pro; the cost is regex matching, and rendering the -// document is negligible beside it), so a document at the cap takes on the order -// of 20s of real work. The timeout sits well above that deliberately: it is -// there to stop pathological backtracking, not to bound ordinary work. -const ( - DefaultMaxBytes = 24 << 20 - DefaultTimeout = 2 * time.Minute - DefaultMaxPasses = 4 -) +// Redaction is deliberately unbounded in size and time. A session that cannot be +// scanned cannot be stored, and failing an attestation over a large-but-honest +// transcript is worse than taking a while over it. Callers that need a bound can +// impose one through the context they pass to Redact. +const DefaultMaxPasses = 4 // Finding is a located secret. It is deliberately decoupled from any particular // scanning engine's types. @@ -128,8 +118,6 @@ func (r *Report) RuleIDs() []string { type Redactor struct { scanner Scanner pathFilter PathFilter - maxBytes int - timeout time.Duration maxPasses int placeholder func(ruleID string) string isPlaceholder func(string) bool @@ -148,17 +136,6 @@ func WithPathFilter(f PathFilter) Option { } } -// WithMaxBytes caps the document size. Larger documents fail with ErrTooLarge -// rather than being uploaded unscanned. -func WithMaxBytes(n int) Option { - return func(r *Redactor) { r.maxBytes = n } -} - -// WithTimeout bounds the total time spent scanning. -func WithTimeout(d time.Duration) Option { - return func(r *Redactor) { r.timeout = d } -} - // WithMaxPasses bounds the convergence loop. func WithMaxPasses(n int) Option { return func(r *Redactor) { @@ -191,8 +168,6 @@ func New(s Scanner, opts ...Option) *Redactor { r := &Redactor{ scanner: s, pathFilter: func(string) bool { return true }, - maxBytes: DefaultMaxBytes, - timeout: DefaultTimeout, maxPasses: DefaultMaxPasses, placeholder: DefaultPlaceholder, isPlaceholder: IsDefaultPlaceholder, @@ -231,21 +206,11 @@ func (r *Redactor) Redact(ctx context.Context, doc []byte) ([]byte, *Report, err if r.scanner == nil { return nil, nil, errors.New("no scanner configured") } - if r.maxBytes > 0 && len(doc) > r.maxBytes { - return nil, nil, fmt.Errorf("%w: %d bytes exceeds the %d byte limit", ErrTooLarge, len(doc), r.maxBytes) - } - root, err := decodeObject(doc) if err != nil { return nil, nil, err } - if r.timeout > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, r.timeout) - defer cancel() - } - report := &Report{ByRule: map[string]int{}, Unlocated: map[string]int{}} // Secrets no eligible leaf contains, so that the loop stops chasing them. skip := make(map[string]struct{}) diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index 2fc10cbad..881be745f 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -18,7 +18,6 @@ package redaction import ( "context" "encoding/json" - "errors" "strings" "testing" @@ -174,12 +173,6 @@ func TestRedact(t *testing.T) { doc: `{"a":"b"} trailing`, wantErr: ErrInvalidJSON, }, - { - name: "document over the size cap", - doc: `{"a":"aaaaaaaaaaaaaaaaaaaa"}`, - opts: []Option{WithMaxBytes(8)}, - wantErr: ErrTooLarge, - }, { // Decoding keeps only the last value for a repeated key, so the secret // in the first one would never be scanned — and since nothing was @@ -229,9 +222,6 @@ func TestRedact(t *testing.T) { if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) assert.Nil(t, got) - if errors.Is(tc.wantErr, ErrTooLarge) { - assert.Zero(t, scanner.calls, "scanner must not run on an oversized document") - } return } require.NoError(t, err) From bb1ded5ca259bb2be9e4f726c05078997cb663a3 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 26 Aug 2026 13:26:34 +0200 Subject: [PATCH 4/5] test(redaction): keep credential-shaped literals out of the fixtures Checkov's basic-auth check flagged the token embedded in the fixture's repository URL, the same way GitHub's push protection had already flagged the AWS pair: a secret realistic enough for the detector to find is also realistic enough for whatever scans the repository. Every credential in the session fixtures is now a placeholder resolved at test time. The repository URL is substituted whole, because the check recognises the "://user:pass@host" shape rather than the token within it, and the values are assembled from fragments so they are not flagged in the Go files instead. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 8d801181-6ee5-4dcd-b572-03033a0d564b --- internal/redaction/betterleaks_test.go | 2 +- .../materials/aicodingsession/redact_test.go | 43 +++++++++++++------ .../testdata/session-with-secrets.json | 6 +-- ...inloop_ai_coding_session_redaction_test.go | 35 ++++++++++----- 4 files changed, 59 insertions(+), 27 deletions(-) diff --git a/internal/redaction/betterleaks_test.go b/internal/redaction/betterleaks_test.go index 507c4b517..31cc1832e 100644 --- a/internal/redaction/betterleaks_test.go +++ b/internal/redaction/betterleaks_test.go @@ -38,7 +38,7 @@ import ( const ( fakeAWSKey = "AKIA" + "4G7TI63VCBIRS4GW" fakeAWSSecret = "kQ7zXn2VbW9pLm4RtY6" + "uHs3JdF8gA1cE5oPzQwXn" - fakeGitHubPAT = "ghp_erOZlZv0B1e3amrQugdwZ8Ro2W4kDql9WPTf" + fakeGitHubPAT = "ghp_erOZlZv0B1e3amrQ" + "ugdwZ8Ro2W4kDql9WPTf" ) var fakeAnthropicKey = "sk-ant-api03-" + strings.Repeat("a", 93) + "AA" diff --git a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go index cebfcb901..495447df1 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go +++ b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go @@ -30,30 +30,49 @@ import ( "github.com/stretchr/testify/require" ) -// Synthetic credentials embedded in testdata/session-with-secrets.json. +// Synthetic credentials for testdata/session-with-secrets.json. // -// The AWS pair sits in the fixture as __AWS_ACCESS_KEY_ID__ and -// __AWS_SECRET_ACCESS_KEY__ placeholders, substituted by readFixture, and is -// assembled here from fragments so the literal appears in no source file. -// GitHub's push protection recognises the same AWS patterns betterleaks does, so -// a realistic-looking key is rejected on push even as test data. +// The fixture holds placeholders rather than the credentials themselves, and +// readFixture substitutes them. Nothing credential-shaped is committed: a +// realistic-looking secret in test data gets flagged by whatever scans the +// repository, and there has been one instance of each kind already — GitHub's +// push protection rejects the AWS patterns, and Checkov's basic-auth check +// rejects a token embedded in a URL. The repository URL is therefore substituted +// whole, since it is the "://user:pass@host" shape that is recognised rather +// than the token in it. +// +// The values are assembled from fragments for the same reason: joined, they +// would be flagged in this file instead. const ( fixtureAWSKey = "AKIA" + "4G7TI63VCBIRS4GW" fixtureAWSSecret = "kQ7zXn2VbW9pLm4RtY6" + "uHs3JdF8gA1cE5oPzQwXn" - fixtureGitHubPAT = "ghp_erOZlZv0B1e3amrQugdwZ8Ro2W4kDql9WPTf" - fixtureAnthropicKey = "sk-ant-api03-sT5wsx9DwmaHZDL0dUWKNhAhULxa35sUzyLFK95QBTZMDJTYn8p0J7ZQbwpYGYCQeW5eXAAGtVSmhp7UO9vxHJtSBC0xpAA" + fixtureGitHubPAT = "ghp_erOZlZv0B1e3amrQ" + "ugdwZ8Ro2W4kDql9WPTf" + fixtureAnthropicKey = "sk-ant-api03-sT5wsx9DwmaHZDL0dUWKNhAhULxa35sUzyLFK9" + + "5QBTZMDJTYn8p0J7ZQbwpYGYCQeW5eXAAGtVSmhp7UO9vxHJtSBC0xpAA" + fixtureRepository = "https://oauth2:" + fixtureGitHubPAT + "@github.com/example/repo.git" ) -// readFixture loads a session fixture, substituting the AWS credential -// placeholders for the values the detector must actually match. +// fixtureSecrets maps each placeholder in the session fixtures to the value the +// detector has to see. Keep in sync with the copy in the materials package, +// which needs the same substitution to reach the crafter through a real file. +var fixtureSecrets = map[string]string{ + "__AWS_ACCESS_KEY_ID__": fixtureAWSKey, + "__AWS_SECRET_ACCESS_KEY__": fixtureAWSSecret, + "__GITHUB_PAT__": fixtureGitHubPAT, + "__ANTHROPIC_API_KEY__": fixtureAnthropicKey, + "__GIT_REPOSITORY_WITH_CREDENTIALS__": fixtureRepository, +} + +// readFixture loads a session fixture with its credential placeholders resolved. func readFixture(t *testing.T, path string) []byte { t.Helper() content, err := os.ReadFile(path) require.NoError(t, err) - content = bytes.ReplaceAll(content, []byte("__AWS_ACCESS_KEY_ID__"), []byte(fixtureAWSKey)) - content = bytes.ReplaceAll(content, []byte("__AWS_SECRET_ACCESS_KEY__"), []byte(fixtureAWSSecret)) + for placeholder, secret := range fixtureSecrets { + content = bytes.ReplaceAll(content, []byte(placeholder), []byte(secret)) + } return content } diff --git a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-with-secrets.json b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-with-secrets.json index a6f7a9757..3a6037023 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-with-secrets.json +++ b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-with-secrets.json @@ -15,7 +15,7 @@ "duration_seconds": 6505 }, "git_context": { - "repository": "https://oauth2:ghp_erOZlZv0B1e3amrQugdwZ8Ro2W4kDql9WPTf@github.com/example/repo.git", + "repository": "__GIT_REPOSITORY_WITH_CREDENTIALS__", "branch": "main", "work_dir": "/home/user/repo", "commit_start": "ae79df1aae6ef53fea636b4aad0a8c3d372178e9", @@ -70,7 +70,7 @@ "type": "assistant", "message": { "role": "assistant", - "content": "I read .env and found:\nANTHROPIC_API_KEY=sk-ant-api03-sT5wsx9DwmaHZDL0dUWKNhAhULxa35sUzyLFK95QBTZMDJTYn8p0J7ZQbwpYGYCQeW5eXAAGtVSmhp7UO9vxHJtSBC0xpAA\nand a block with \"quoted\" values. Done ✅" + "content": "I read .env and found:\nANTHROPIC_API_KEY=__ANTHROPIC_API_KEY__\nand a block with \"quoted\" values. Done ✅" }, "timestamp": "2026-03-25T15:13:40.100Z" } @@ -84,7 +84,7 @@ ] }, "warnings": [ - "could not parse token ghp_erOZlZv0B1e3amrQugdwZ8Ro2W4kDql9WPTf from the environment" + "could not parse token __GITHUB_PAT__ from the environment" ] } } diff --git a/pkg/attestation/crafter/materials/chainloop_ai_coding_session_redaction_test.go b/pkg/attestation/crafter/materials/chainloop_ai_coding_session_redaction_test.go index c456b7bf3..ca6697b68 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_coding_session_redaction_test.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_coding_session_redaction_test.go @@ -139,18 +139,30 @@ func TestUploadAndCraftContentOverride(t *testing.T) { } } -// The AWS credentials the session fixture carries as __AWS_ACCESS_KEY_ID__ and -// __AWS_SECRET_ACCESS_KEY__ placeholders, assembled from fragments so the literal -// appears in no source file: GitHub's push protection recognises the same AWS -// patterns betterleaks does, and rejects a realistic-looking key even in test -// data. +// The credentials the session fixture carries as placeholders. Nothing +// credential-shaped is committed, and these are assembled from fragments for the +// same reason: a realistic-looking secret gets flagged by whatever scans the +// repository, whether that is GitHub's push protection or Checkov. See the +// fixtureSecrets comment in the aicodingsession package. const ( - awsKey = "AKIA" + "4G7TI63VCBIRS4GW" - awsSecret = "kQ7zXn2VbW9pLm4RtY6" + "uHs3JdF8gA1cE5oPzQwXn" + awsKey = "AKIA" + "4G7TI63VCBIRS4GW" + awsSecret = "kQ7zXn2VbW9pLm4RtY6" + "uHs3JdF8gA1cE5oPzQwXn" + githubPAT = "ghp_erOZlZv0B1e3amrQ" + "ugdwZ8Ro2W4kDql9WPTf" + anthropicKey = "sk-ant-api03-sT5wsx9DwmaHZDL0dUWKNhAhULxa35sUzyLFK9" + "5QBTZMDJTYn8p0J7ZQbwpYGYCQeW5eXAAGtVSmhp7UO9vxHJtSBC0xpAA" + repositoryURL = "https://oauth2:" + githubPAT + "@github.com/example/repo.git" ) -// materializeFixture writes a copy of a session fixture with the AWS credential -// placeholders substituted, and returns its path. The crafter reads the artifact +// Keep in sync with fixtureSecrets in the aicodingsession package. +var fixtureSecrets = map[string]string{ + "__AWS_ACCESS_KEY_ID__": awsKey, + "__AWS_SECRET_ACCESS_KEY__": awsSecret, + "__GITHUB_PAT__": githubPAT, + "__ANTHROPIC_API_KEY__": anthropicKey, + "__GIT_REPOSITORY_WITH_CREDENTIALS__": repositoryURL, +} + +// materializeFixture writes a copy of a session fixture with its credential +// placeholders resolved, and returns its path. The crafter reads the artifact // from disk, so the substitution has to land in a real file. func materializeFixture(t *testing.T, src string) string { t.Helper() @@ -158,8 +170,9 @@ func materializeFixture(t *testing.T, src string) string { content, err := os.ReadFile(src) require.NoError(t, err) - content = bytes.ReplaceAll(content, []byte("__AWS_ACCESS_KEY_ID__"), []byte(awsKey)) - content = bytes.ReplaceAll(content, []byte("__AWS_SECRET_ACCESS_KEY__"), []byte(awsSecret)) + for placeholder, secret := range fixtureSecrets { + content = bytes.ReplaceAll(content, []byte(placeholder), []byte(secret)) + } path := filepath.Join(t.TempDir(), filepath.Base(src)) require.NoError(t, os.WriteFile(path, content, 0o600)) From f95d185e927fe93b071a7ecadfc271b3020b2631 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 26 Aug 2026 13:30:35 +0200 Subject: [PATCH 5/5] fix(redaction): keep numbers textual in the duplicate-key check The duplicate-key walk decoded numbers into float64, so a valid but out-of-range value such as 1e400 failed the whole document before redaction ran, even though the decoder that follows keeps numbers in their textual form. Refusing an honest attestation over a number nothing was going to scan is the wrong trade. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 8d801181-6ee5-4dcd-b572-03033a0d564b --- internal/redaction/redaction.go | 5 +++++ internal/redaction/redaction_test.go | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 0386470e8..bf6fa8e0c 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -452,6 +452,11 @@ type jsonFrame struct { // document is refused. func rejectDuplicateKeys(doc []byte) error { dec := json.NewDecoder(bytes.NewReader(doc)) + // Matching decodeObject: without this, Token parses numbers into float64 and + // a valid but out-of-range one such as 1e400 would fail the whole document + // here, before redaction ever runs. + dec.UseNumber() + var stack []*jsonFrame top := func() *jsonFrame { diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index 881be745f..16e246cb8 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -147,11 +147,14 @@ func TestRedact(t *testing.T) { wantErr: ErrNotConverged, }, { + // 1e400 is valid JSON but out of float64 range: every decoder on the + // path has to keep numbers in their textual form, or an honest document + // gets refused over a number nobody was going to scan anyway. name: "numbers keep their exact representation", - doc: `{"a":"SEC","big":12345678901234567890,"exp":1e10,"f":0.30000000000000004}`, + doc: `{"a":"SEC","big":12345678901234567890,"exp":1e10,"f":0.30000000000000004,"huge":1e400}`, findings: []Finding{{RuleID: "r1", Secret: "SEC"}}, wantReplacements: 1, - mustContain: []string{"12345678901234567890", "1e10", "0.30000000000000004"}, + mustContain: []string{"12345678901234567890", "1e10", "0.30000000000000004", "1e400"}, }, { name: "not json",