Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package trufflehog for openSUSE:Factory checked in at 2025-12-27 11:28:57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/trufflehog (Old) and /work/SRC/openSUSE:Factory/.trufflehog.new.1928 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "trufflehog" Sat Dec 27 11:28:57 2025 rev:107 rq:1324459 version:3.92.4 Changes: -------- --- /work/SRC/openSUSE:Factory/trufflehog/trufflehog.changes 2025-12-12 21:42:38.504537928 +0100 +++ /work/SRC/openSUSE:Factory/.trufflehog.new.1928/trufflehog.changes 2025-12-27 11:29:13.568485963 +0100 @@ -1,0 +2,11 @@ +Fri Dec 26 07:36:55 UTC 2025 - Felix Niederwanger <[email protected]> + +- Update to version 3.92.4: + * enable line numbers for ghr (#4611) + * [INS-207] Add Role-Aware Resumption Support for Legacy S3 Scan (#4600) + * Update module golang.org/x/crypto to v0.45.0 [SECURITY] (#4562) + * [INS-226] use pinned image for quay registry test (#4602) + * Pagination and Rate-Limit Handling In Docker Registry Namespace API Calls (#4557) + * [INS-170] Unify JDBC URL parsing across detectors and analyzers (#4574) + +------------------------------------------------------------------- Old: ---- trufflehog-3.92.3.obscpio New: ---- trufflehog-3.92.4.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ trufflehog.spec ++++++ --- /var/tmp/diff_new_pack.4WIDSh/_old 2025-12-27 11:29:19.200716146 +0100 +++ /var/tmp/diff_new_pack.4WIDSh/_new 2025-12-27 11:29:19.200716146 +0100 @@ -17,7 +17,7 @@ Name: trufflehog -Version: 3.92.3 +Version: 3.92.4 Release: 0 Summary: CLI tool to find exposed secrets in source and archives License: AGPL-3.0-or-later ++++++ _service ++++++ --- /var/tmp/diff_new_pack.4WIDSh/_old 2025-12-27 11:29:19.244717945 +0100 +++ /var/tmp/diff_new_pack.4WIDSh/_new 2025-12-27 11:29:19.248718108 +0100 @@ -2,7 +2,7 @@ <service name="obs_scm" mode="manual"> <param name="url">https://github.com/trufflesecurity/trufflehog.git</param> <param name="scm">git</param> - <param name="revision">v3.92.3</param> + <param name="revision">v3.92.4</param> <param name="match-tag">v*</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.4WIDSh/_old 2025-12-27 11:29:19.268718926 +0100 +++ /var/tmp/diff_new_pack.4WIDSh/_new 2025-12-27 11:29:19.272719089 +0100 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/trufflesecurity/trufflehog.git</param> - <param name="changesrevision">05cccb53bc9e13bc6d17997db5a6bcc3df44bf2f</param></service></servicedata> + <param name="changesrevision">ef6e76c3c4023279497fab4721ffa071a722fd05</param></service></servicedata> (No newline at EOF) ++++++ trufflehog-3.92.3.obscpio -> trufflehog-3.92.4.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/go.mod new/trufflehog-3.92.4/go.mod --- old/trufflehog-3.92.3/go.mod 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/go.mod 2025-12-19 16:15:11.000000000 +0100 @@ -106,11 +106,11 @@ go.uber.org/automaxprocs v1.6.0 go.uber.org/mock v0.5.2 go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.43.0 - golang.org/x/net v0.45.0 + golang.org/x/crypto v0.45.0 + golang.org/x/net v0.47.0 golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.17.0 - golang.org/x/text v0.30.0 + golang.org/x/sync v0.18.0 + golang.org/x/text v0.31.0 golang.org/x/time v0.12.0 google.golang.org/api v0.247.0 google.golang.org/protobuf v1.36.9 @@ -316,9 +316,9 @@ go.uber.org/multierr v1.11.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect - golang.org/x/mod v0.28.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/term v0.36.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/go.sum new/trufflehog-3.92.4/go.sum --- old/trufflehog-3.92.3/go.sum 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/go.sum 2025-12-19 16:15:11.000000000 +0100 @@ -854,6 +854,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 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= @@ -885,6 +887,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -908,6 +912,8 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 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= @@ -925,6 +931,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -968,11 +976,15 @@ golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 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= @@ -984,6 +996,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/detectors/jdbc/jdbc.go new/trufflehog-3.92.4/pkg/detectors/jdbc/jdbc.go --- old/trufflehog-3.92.3/pkg/detectors/jdbc/jdbc.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/detectors/jdbc/jdbc.go 2025-12-19 16:15:11.000000000 +0100 @@ -207,9 +207,9 @@ } var supportedSubprotocols = map[string]func(logContext.Context, string) (jdbc, error){ - "mysql": parseMySQL, - "postgresql": parsePostgres, - "sqlserver": parseSqlServer, + "mysql": ParseMySQL, + "postgresql": ParsePostgres, + "sqlserver": ParseSqlServer, } type pingResult struct { @@ -217,6 +217,15 @@ determinate bool } +// ConnectionInfo holds parsed connection information +type ConnectionInfo struct { + Host string // includes port if specified, e.g., "host:port" + Database string + User string + Password string + Params map[string]string +} + type jdbc interface { ping(context.Context) pingResult } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/detectors/jdbc/mysql.go new/trufflehog-3.92.4/pkg/detectors/jdbc/mysql.go --- old/trufflehog-3.92.3/pkg/detectors/jdbc/mysql.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/detectors/jdbc/mysql.go 2025-12-19 16:15:11.000000000 +0100 @@ -12,25 +12,30 @@ "github.com/go-sql-driver/mysql" ) -type mysqlJDBC struct { - conn string - userPass string - host string - params string +type MysqlJDBC struct { + ConnectionInfo } -func (s *mysqlJDBC) ping(ctx context.Context) pingResult { +func (s *MysqlJDBC) ping(ctx context.Context) pingResult { return ping(ctx, "mysql", isMySQLErrorDeterminate, - buildMySQLConnectionString(s.host, "", s.userPass, s.params)) + BuildMySQLConnectionString(s.Host, "", s.User, s.Password, s.Params)) } -func buildMySQLConnectionString(host, database, userPass, params string) string { +func BuildMySQLConnectionString(host, database, user, password string, params map[string]string) string { conn := host + "/" + database + userPass := user + if password != "" { + userPass = userPass + ":" + password + } if userPass != "" { conn = userPass + "@" + conn } - if params != "" { - conn = conn + "?" + params + if len(params) > 0 { + var paramList []string + for k, v := range params { + paramList = append(paramList, fmt.Sprintf("%s=%s", k, v)) + } + conn = conn + "?" + strings.Join(paramList, "&") } return conn } @@ -51,7 +56,7 @@ return false } -func parseMySQL(ctx logContext.Context, subname string) (jdbc, error) { +func ParseMySQL(ctx logContext.Context, subname string) (jdbc, error) { // expected form: [subprotocol:]//[user:password@]HOST[/DB][?key=val[&key=val]] if !strings.HasPrefix(subname, "//") { return nil, errors.New("expected host to start with //") @@ -70,11 +75,14 @@ Info("Skipping invalid MySQL URL - no password or host found") return nil, fmt.Errorf("missing host or password in connection string") } - return &mysqlJDBC{ - conn: subname[2:], - userPass: cfg.User + ":" + cfg.Passwd, - host: fmt.Sprintf("tcp(%s)", cfg.Addr), - params: "timeout=5s", + return &MysqlJDBC{ + ConnectionInfo: ConnectionInfo{ + User: cfg.User, + Password: cfg.Passwd, + Host: fmt.Sprintf("tcp(%s)", cfg.Addr), + Params: map[string]string{"timeout": "5s"}, + Database: cfg.DBName, + }, }, nil } @@ -107,13 +115,20 @@ return nil, fmt.Errorf("missing host or password in connection string") } - userAndPass := user + ":" + pass + // Parse database name + dbName := strings.TrimPrefix(u.Path, "/") + if dbName == "" { + dbName = "mysql" // default DB + } - return &mysqlJDBC{ - conn: subname[2:], - userPass: userAndPass, - host: fmt.Sprintf("tcp(%s)", u.Host), - params: "timeout=5s", + return &MysqlJDBC{ + ConnectionInfo: ConnectionInfo{ + User: user, + Password: pass, + Host: fmt.Sprintf("tcp(%s)", u.Host), + Params: map[string]string{"timeout": "5s"}, + Database: dbName, + }, }, nil } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/detectors/jdbc/mysql_integration_test.go new/trufflehog-3.92.4/pkg/detectors/jdbc/mysql_integration_test.go --- old/trufflehog-3.92.3/pkg/detectors/jdbc/mysql_integration_test.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/detectors/jdbc/mysql_integration_test.go 2025-12-19 16:15:11.000000000 +0100 @@ -12,6 +12,8 @@ "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/testcontainers/testcontainers-go/modules/mysql" + + logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context" ) func TestMySQL(t *testing.T) { @@ -89,7 +91,7 @@ } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { - j, err := parseMySQL(tt.input) + j, err := ParseMySQL(logContext.Background(), tt.input) if err != nil { got := result{ParseErr: true} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/detectors/jdbc/mysql_test.go new/trufflehog-3.92.4/pkg/detectors/jdbc/mysql_test.go --- old/trufflehog-3.92.3/pkg/detectors/jdbc/mysql_test.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/detectors/jdbc/mysql_test.go 2025-12-19 16:15:11.000000000 +0100 @@ -2,7 +2,6 @@ import ( "context" - "strings" "testing" logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context" @@ -59,7 +58,7 @@ for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := logContext.AddLogger(context.Background()) - j, err := parseMySQL(ctx, tt.subname) + j, err := ParseMySQL(ctx, tt.subname) if tt.shouldBeNil { if j != nil { @@ -103,15 +102,15 @@ for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := logContext.AddLogger(context.Background()) - j, err := parseMySQL(ctx, tt.subname) + j, err := ParseMySQL(ctx, tt.subname) if err != nil { t.Fatalf("parseMySQL() error = %v", err) } - mysqlConn := j.(*mysqlJDBC) - if !strings.Contains(mysqlConn.userPass, tt.wantUsername) { + mysqlConn := j.(*MysqlJDBC) + if mysqlConn.User != tt.wantUsername { t.Errorf("Connection string does not contain expected username '%s'\nGot: %s\nExpected: %s", - tt.wantUsername, mysqlConn.userPass, tt.wantUsername) + tt.wantUsername, mysqlConn.User, tt.wantUsername) } }) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/detectors/jdbc/postgres.go new/trufflehog-3.92.4/pkg/detectors/jdbc/postgres.go --- old/trufflehog-3.92.3/pkg/detectors/jdbc/postgres.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/detectors/jdbc/postgres.go 2025-12-19 16:15:11.000000000 +0100 @@ -12,20 +12,19 @@ "github.com/lib/pq" ) -type postgresJDBC struct { - conn string - params map[string]string +type PostgresJDBC struct { + ConnectionInfo } -func (s *postgresJDBC) ping(ctx context.Context) pingResult { +func (s *PostgresJDBC) ping(ctx context.Context) pingResult { // It is crucial that we try to build a connection string ourselves before using the one we found. This is because // if the found connection string doesn't include a username, the driver will attempt to connect using the current // user's name, which will fail in a way that looks like a determinate failure, thus terminating the waterfall. In // contrast, when we build a connection string ourselves, if there's no username, we try 'postgres' instead, which // actually has a chance of working. return ping(ctx, "postgres", isPostgresErrorDeterminate, - buildPostgresConnectionString(s.params, true), - buildPostgresConnectionString(s.params, false), + BuildPostgresConnectionString(s.Host, s.User, s.Password, "postgres", s.Params, true), + BuildPostgresConnectionString(s.Host, s.User, s.Password, "postgres", s.Params, false), ) } @@ -59,7 +58,7 @@ return strings.Join(data, sep) } -func parsePostgres(ctx logContext.Context, subname string) (jdbc, error) { +func ParsePostgres(ctx logContext.Context, subname string) (jdbc, error) { // expected form: [subprotocol:]//[user:password@]HOST[/DB][?key=val[&key=val]] if !strings.HasPrefix(subname, "//") { @@ -77,16 +76,22 @@ } params := map[string]string{ - "host": u.Host, - "dbname": dbName, "connect_timeout": "5", } + postgresJDBC := &PostgresJDBC{ + ConnectionInfo: ConnectionInfo{ + Host: u.Host, + Database: dbName, + Params: params, + }, + } + if u.User != nil { - params["user"] = u.User.Username() + postgresJDBC.User = u.User.Username() pass, set := u.User.Password() if set { - params["password"] = pass + postgresJDBC.Password = pass } } @@ -95,46 +100,51 @@ // https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-PROTECTION case "disable", "allow", "prefer", "require", "verify-ca", "verify-full": - params["sslmode"] = v[0] + postgresJDBC.Params["sslmode"] = v[0] } } if v := u.Query().Get("user"); v != "" { - params["user"] = v + postgresJDBC.User = v } if v := u.Query().Get("password"); v != "" { - params["password"] = v + postgresJDBC.Password = v } - if params["host"] == "" || params["password"] == "" { + if postgresJDBC.Host == "" || postgresJDBC.Password == "" { ctx.Logger().WithName("jdbc"). V(2). Info("Skipping invalid Postgres URL - no password or host found") return nil, fmt.Errorf("missing host or password in connection string") } - return &postgresJDBC{subname[2:], params}, nil + return postgresJDBC, nil } -func buildPostgresConnectionString(params map[string]string, includeDbName bool) string { +func BuildPostgresConnectionString(host string, user string, password string, dbName string, params map[string]string, includeDbName bool) string { data := map[string]string{ // default user - "user": "postgres", + "user": "postgres", + "password": password, + "host": host, + } + if user != "" { + data["user"] = user + } + if h, p, ok := strings.Cut(host, ":"); ok { + data["host"] = h + data["port"] = p } for key, val := range params { - if key == "host" { - if h, p, found := strings.Cut(val, ":"); found { - data["host"] = h - data["port"] = p - continue - } - } data[key] = val } - if !includeDbName { + if includeDbName { data["dbname"] = "postgres" + if dbName != "" { + data["dbname"] = dbName + } } connStr := joinKeyValues(data, " ") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/detectors/jdbc/postgres_integration_test.go new/trufflehog-3.92.4/pkg/detectors/jdbc/postgres_integration_test.go --- old/trufflehog-3.92.3/pkg/detectors/jdbc/postgres_integration_test.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/detectors/jdbc/postgres_integration_test.go 2025-12-19 16:15:11.000000000 +0100 @@ -15,6 +15,8 @@ "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" + + logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context" ) func TestPostgres(t *testing.T) { @@ -119,7 +121,7 @@ for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - j, err := parsePostgres(tt.input) + j, err := ParsePostgres(logContext.Background(), tt.input) if err != nil { got := result{ParseErr: true} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/detectors/jdbc/postgres_test.go new/trufflehog-3.92.4/pkg/detectors/jdbc/postgres_test.go --- old/trufflehog-3.92.3/pkg/detectors/jdbc/postgres_test.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/detectors/jdbc/postgres_test.go 2025-12-19 16:15:11.000000000 +0100 @@ -47,7 +47,7 @@ for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := logContext.AddLogger(context.Background()) - j, err := parsePostgres(ctx, tt.subname) + j, err := ParsePostgres(ctx, tt.subname) if tt.shouldBeNil { if j != nil { @@ -86,14 +86,14 @@ for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := logContext.AddLogger(context.Background()) - j, err := parsePostgres(ctx, tt.subname) + j, err := ParsePostgres(ctx, tt.subname) if err != nil { - t.Fatalf("parsePostgres() error = %v", err) + t.Fatalf("ParsePostgres() error = %v", err) } - pgConn := j.(*postgresJDBC) - if pgConn.params["user"] != tt.wantUsername { - t.Errorf("expected username '%s', got '%s'", tt.wantUsername, pgConn.params["user"]) + pgConn := j.(*PostgresJDBC) + if pgConn.User != tt.wantUsername { + t.Errorf("expected username '%s', got '%s'", tt.wantUsername, pgConn.User) } }) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/detectors/jdbc/sqlserver.go new/trufflehog-3.92.4/pkg/detectors/jdbc/sqlserver.go --- old/trufflehog-3.92.3/pkg/detectors/jdbc/sqlserver.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/detectors/jdbc/sqlserver.go 2025-12-19 16:15:11.000000000 +0100 @@ -4,7 +4,6 @@ "context" "errors" "fmt" - "net/url" "strings" logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context" @@ -12,13 +11,13 @@ mssql "github.com/microsoft/go-mssqldb" ) -type sqlServerJDBC struct { - connStr string +type SqlServerJDBC struct { + ConnectionInfo } -func (s *sqlServerJDBC) ping(ctx context.Context) pingResult { +func (s *SqlServerJDBC) ping(ctx context.Context) pingResult { return ping(ctx, "mssql", isSqlServerErrorDeterminate, - s.connStr) + BuildSQLServerConnectionString(s.Host, s.User, s.Password, "master", map[string]string{"connection+timeout": "5"})) } func isSqlServerErrorDeterminate(err error) bool { @@ -35,7 +34,7 @@ return false } -func parseSqlServer(ctx logContext.Context, subname string) (jdbc, error) { +func ParseSqlServer(ctx logContext.Context, subname string) (jdbc, error) { if !strings.HasPrefix(subname, "//") { return nil, errors.New("expected connection to start with //") } @@ -43,7 +42,9 @@ port := "1433" user := "sa" + database := "master" var password, host string + params := make(map[string]string) for i, param := range strings.Split(conn, ";") { key, value, found := strings.Cut(param, "=") @@ -59,17 +60,18 @@ } switch strings.ToLower(key) { - case "password": - password = value - case "spring.datasource.password": + case "password", "spring.datasource.password", "pwd": password = value case "server": host = value case "port": port = value - case "user", "uid", "user id": + case "user", "uid", "user id", "userid": user = value - + case "database", "databasename": + database = value + default: + params[key] = value } } @@ -80,14 +82,23 @@ return nil, fmt.Errorf("missing host or password in connection string") } - urlStr := fmt.Sprintf("sqlserver://%s:%s@%s:%s?database=master&connection+timeout=5", user, password, host, port) - jdbcUrl, err := url.Parse(urlStr) - if err != nil { - ctx.Logger().WithName("jdbc"). - V(3). - Info("Skipping invalid SQL Server URL", "url", urlStr, "err", err) - return nil, err - } + return &SqlServerJDBC{ + ConnectionInfo: ConnectionInfo{ + Host: host + ":" + port, + User: user, + Password: password, + Database: database, + Params: params, + }, + }, nil +} - return &sqlServerJDBC{connStr: jdbcUrl.String()}, nil +func BuildSQLServerConnectionString(host, user, password, database string, params map[string]string) string { + conn := fmt.Sprintf("sqlserver://%s:%s@%s?database=%s", user, password, host, database) + if len(params) > 0 { + for k, v := range params { + conn += fmt.Sprintf("&%s=%s", k, v) + } + } + return conn } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/detectors/jdbc/sqlserver_test.go new/trufflehog-3.92.4/pkg/detectors/jdbc/sqlserver_test.go --- old/trufflehog-3.92.3/pkg/detectors/jdbc/sqlserver_test.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/detectors/jdbc/sqlserver_test.go 2025-12-19 16:15:11.000000000 +0100 @@ -2,8 +2,6 @@ import ( "context" - "fmt" - "strings" "testing" logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context" @@ -56,7 +54,7 @@ t.Run(tt.name, func(t *testing.T) { ctx := logContext.AddLogger(context.Background()) - j, err := parseSqlServer(ctx, tt.subname) + j, err := ParseSqlServer(ctx, tt.subname) if tt.shouldBeNil { if j != nil { @@ -103,17 +101,16 @@ t.Run(tt.name, func(t *testing.T) { ctx := logContext.AddLogger(context.Background()) - j, err := parseSqlServer(ctx, tt.subname) + j, err := ParseSqlServer(ctx, tt.subname) if err != nil { t.Fatalf("parseSqlServer() error = %v", err) } - sqlServerConn := j.(*sqlServerJDBC) - expectedPrefix := fmt.Sprintf("sqlserver://%s:", tt.wantUsername) + sqlServerConn := j.(*SqlServerJDBC) - if !strings.Contains(sqlServerConn.connStr, expectedPrefix) { + if sqlServerConn.User != tt.wantUsername { t.Errorf("Connection string does not contain expected username '%s'\nGot: %s\nExpected to contain: %s", - tt.wantUsername, sqlServerConn.connStr, expectedPrefix) + tt.wantUsername, sqlServerConn.User, tt.wantUsername) } }) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/engine/engine.go new/trufflehog-3.92.4/pkg/engine/engine.go --- old/trufflehog-3.92.3/pkg/engine/engine.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/engine/engine.go 2025-12-19 16:15:11.000000000 +0100 @@ -1266,6 +1266,7 @@ switch sourceType { case sourcespb.SourceType_SOURCE_TYPE_GIT, sourcespb.SourceType_SOURCE_TYPE_GITHUB, + sourcespb.SourceType_SOURCE_TYPE_GITHUB_REALTIME, sourcespb.SourceType_SOURCE_TYPE_GITLAB, sourcespb.SourceType_SOURCE_TYPE_BITBUCKET, sourcespb.SourceType_SOURCE_TYPE_GERRIT, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/sources/docker/docker_test.go new/trufflehog-3.92.4/pkg/sources/docker/docker_test.go --- old/trufflehog-3.92.3/pkg/sources/docker/docker_test.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/sources/docker/docker_test.go 2025-12-19 16:15:11.000000000 +0100 @@ -71,7 +71,7 @@ Credential: &sourcespb.Docker_Unauthenticated{ Unauthenticated: &credentialspb.Unauthenticated{}, }, - Images: []string{"quay.io/prometheus/busybox"}, // https://quay.io/repository/prometheus/busybox + Images: []string{"quay.io/prometheus/node-exporter@sha256:337ff1d356b68d39cef853e8c6345de11ce7556bb34cda8bd205bcf2ed30b565"}, } conn := &anypb.Any{} @@ -109,9 +109,9 @@ close(chunksChan) wg.Wait() - assert.Equal(t, 944, chunkCounter) - assert.Equal(t, 941, layerCounter) - assert.Equal(t, 3, historyCounter) + assert.Equal(t, 1302, chunkCounter) + assert.Equal(t, 1291, layerCounter) + assert.Equal(t, 11, historyCounter) } func TestGHCRRegistry(t *testing.T) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/sources/docker/registries.go new/trufflehog-3.92.4/pkg/sources/docker/registries.go --- old/trufflehog-3.92.3/pkg/sources/docker/registries.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/sources/docker/registries.go 2025-12-19 16:15:11.000000000 +0100 @@ -9,11 +9,21 @@ "net/url" "path" "strings" - "time" + + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + + "golang.org/x/time/rate" ) // defaultHTTPClient defines a shared HTTP client with timeout for all registry requests. -var defaultHTTPClient = &http.Client{Timeout: 10 * time.Second} +var defaultHTTPClient = common.RetryableHTTPClientTimeout(10) + +// registryRateLimiter limits how quickly we make registry API calls across all registries. +// We allow roughly 1 event every 1.5s, with a burst of 2 as a simple safeguard against overloading upstream APIs. +var registryRateLimiter = rate.NewLimiter(rate.Limit(2.0/3.0), 2) + +// maxRegistryPageSize defines the maximum number of images to request per page from a registry API. +const maxRegistryPageSize = 100 // Image represents a container image or repository entry in a registry API response. type Image struct { @@ -22,10 +32,10 @@ // Registry is an interface for any Docker/OCI registry implementation that can list all images under a given namespace. type Registry interface { - Name() string // return name of the registry - WithRegistryToken(registryToken string) // set token for registry - // TODO: Handle pagination and rate limits for list images API Call + Name() string // return name of the registry + WithRegistryToken(registryToken string) // set token for registry ListImages(ctx context.Context, namespace string) ([]string, error) // list all images + WithClient(client *http.Client) // return the HTTP client to use } // MakeRegistryFromNamespace returns a Registry implementation @@ -45,11 +55,12 @@ return registry } -// === DockerHub registry === +// === Docker Hub Registry === -// DockerHub implements the Registry interface for Docker Hub. +// DockerHub implements the Registry interface for hub.docker.com. type DockerHub struct { - Token string + Token string + Client *http.Client } // dockerhubResp models Docker Hub's /v2/namespaces/<ns>/repositories API response. @@ -66,67 +77,101 @@ d.Token = registryToken } +func (d *DockerHub) WithClient(client *http.Client) { + d.Client = client +} + // ListImages lists all images under a Docker Hub namespace using Docker Hub's API. +// +// We fetch images in fixed-size pages and keep following the "next" link until there +// are no more pages. A shared rate limiter is used so we don't accidentally hammer +// the Docker Hub API. func (d *DockerHub) ListImages(ctx context.Context, namespace string) ([]string, error) { - url := &url.URL{ + baseURL := &url.URL{ Scheme: "https", Host: "hub.docker.com", Path: path.Join("v2", "namespaces", namespace, "repositories"), } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), http.NoBody) - if err != nil { - return nil, err - } + query := baseURL.Query() + query.Set("page_size", fmt.Sprint(maxRegistryPageSize)) + baseURL.RawQuery = query.Encode() - if d.Token != "" { - req.Header.Set("Authorization", "Bearer "+d.Token) - } + allImages := []string{} + nextURL := baseURL.String() - resp, err := defaultHTTPClient.Do(req) - if err != nil { - return nil, err - } + for { + if err := registryRateLimiter.Wait(ctx); err != nil { + return nil, err + } - defer func() { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - }() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, nextURL, http.NoBody) + if err != nil { + return nil, err + } - responseBodyByte, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } + if d.Token != "" { + req.Header.Set("Authorization", "Bearer "+d.Token) + } + + client := d.Client + if client == nil { + client = defaultHTTPClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + discardBody(resp) + if err != nil { + return nil, err + } - switch resp.StatusCode { - case http.StatusOK: - var allImages = make([]string, 0) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list dockerhub images: unexpected status code: %d", resp.StatusCode) + } - var listImagesResp dockerhubResp - if err := json.Unmarshal(responseBodyByte, &listImagesResp); err != nil { + var page dockerhubResp + if err := json.Unmarshal(body, &page); err != nil { return nil, err } - for _, image := range listImagesResp.Results { + for _, image := range page.Results { allImages = append(allImages, fmt.Sprintf("%s/%s", namespace, image.Name)) // <namespace>/<image_name> } - return allImages, nil - default: - return nil, fmt.Errorf("failed to list dockerhub images: unexpected status code: %d", resp.StatusCode) + if page.Next == "" { + break + } + + // Docker Hub sometimes returns an absolute "next" URL and sometimes a + // relative one. ResolveReference cleans that up for us and turns whatever + // they send into a proper URL we can call. + next, err := url.Parse(page.Next) + if err != nil { + return nil, err + } + nextURL = baseURL.ResolveReference(next).String() } + + return allImages, nil } // === Red Hat Quay Registry === // Quay implements the Registry interface for Quay.io. type Quay struct { - Token string + Token string + Client *http.Client } // quayResp models the JSON structure returned by Quay's /api/v1/repository endpoint. type quayResp struct { - Repositories []Image `json:"repositories"` + Repositories []Image `json:"repositories"` + HasAdditional bool `json:"has_additional"` + NextPage string `json:"next_page"` } func (q *Quay) Name() string { @@ -137,69 +182,96 @@ q.Token = registryToken } +func (q *Quay) WithClient(client *http.Client) { + q.Client = client +} + // ListImages lists all images under a Quay namespace. +// API reference: +// +// GET https://quay.io/api/v1/repository?namespace=<namespace>&public=true&private=true&next_page=<token> +// +// We keep following next_page while has_additional is true. func (q *Quay) ListImages(ctx context.Context, namespace string) ([]string, error) { quayNamespace := path.Base(namespace) // quay.io/<namespace> -> namespace - url := &url.URL{ + baseURL := &url.URL{ Scheme: "https", Host: "quay.io", Path: path.Join("api", "v1", "repository"), } - query := url.Query() - query.Set("namespace", quayNamespace) - query.Set("public", "true") - query.Set("private", "true") - url.RawQuery = query.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), http.NoBody) - if err != nil { - return nil, err - } + allImages := []string{} + nextPageToken := "" - if q.Token != "" { - req.Header.Set("Authorization", "Bearer "+q.Token) - } + for { + if err := registryRateLimiter.Wait(ctx); err != nil { + return nil, err + } - resp, err := defaultHTTPClient.Do(req) - if err != nil { - return nil, err - } + u := *baseURL + query := u.Query() + query.Set("namespace", quayNamespace) + query.Set("public", "true") + query.Set("private", "true") + + // Quay's API controls page size internally; we just fetch page by page. + if nextPageToken != "" { + query.Set("next_page", nextPageToken) + } + u.RawQuery = query.Encode() - defer func() { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - }() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), http.NoBody) + if err != nil { + return nil, err + } - responseBodyByte, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } + if q.Token != "" { + req.Header.Set("Authorization", "Bearer "+q.Token) + } + + client := q.Client + if client == nil { + client = defaultHTTPClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + discardBody(resp) + if err != nil { + return nil, err + } - switch resp.StatusCode { - case http.StatusOK: - var allImages = make([]string, 0) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list quay images: unexpected status code: %d", resp.StatusCode) + } - var listImagesResp quayResp - if err := json.Unmarshal(responseBodyByte, &listImagesResp); err != nil { + var page quayResp + if err := json.Unmarshal(body, &page); err != nil { return nil, err } - for _, image := range listImagesResp.Repositories { + for _, image := range page.Repositories { allImages = append(allImages, fmt.Sprintf("%s/%s", namespace, image.Name)) // quay.io/<namespace>/<image_name> } - return allImages, nil - default: - return nil, fmt.Errorf("failed to list quay images: unexpected status code: %d", resp.StatusCode) + if !page.HasAdditional || page.NextPage == "" { + break + } + nextPageToken = page.NextPage } + + return allImages, nil } // === GHCR Registry === // GHCR implements the Registry interface for GHCR.io. type GHCR struct { - Token string // https://github.com/github/roadmap/issues/558 + Token string // https://github.com/github/roadmap/issues/558 + Client *http.Client } func (g *GHCR) Name() string { @@ -210,60 +282,134 @@ g.Token = registryToken } -// ListImages lists all images under a Quay namespace. +func (g *GHCR) WithClient(client *http.Client) { + g.Client = client +} + +// GHCR paginates results and includes pagination links in the HTTP Link header. +// The Link header contains URLs for "next", "prev", "first", and "last" pages. +// Example Link header: +// +// <https://api.github.com/user/abc/packages?package_type=container&per_page=100&page=2>; rel="next", +// <https://api.github.com/user/abc/packages?package_type=container&per_page=100&page=5>; rel="last" +func parseNextLinkURL(linkHeader string) string { + if linkHeader == "" { + return "" + } + + parts := strings.Split(linkHeader, ",") + for _, part := range parts { + section := strings.Split(strings.TrimSpace(part), ";") + if len(section) < 2 { + continue + } + + linkPart := strings.TrimSpace(section[0]) + if !strings.HasPrefix(linkPart, "<") || !strings.HasSuffix(linkPart, ">") { + continue + } + urlStr := strings.Trim(linkPart, "<>") + + rel := "" + for _, attr := range section[1:] { + attr = strings.TrimSpace(attr) + if strings.HasPrefix(attr, "rel=") { + rel = strings.Trim(strings.TrimPrefix(attr, "rel="), "\"") + break + } + } + + if rel == "next" { + return urlStr + } + } + + return "" +} + +// ListImages lists all images under a GHCR namespace. +// For GitHub Container Registry the package listing endpoint is: +// +// GET https://api.github.com/users/{namespace}/packages?package_type=container&per_page=100 +// +// The GitHub API is paginated via the Link response header. func (g *GHCR) ListImages(ctx context.Context, namespace string) ([]string, error) { ghcrNamespace := path.Base(namespace) // ghcr.io/<namespace> -> namespace - url := &url.URL{ + baseURL := &url.URL{ Scheme: "https", Host: "api.github.com", Path: path.Join("users", ghcrNamespace, "packages"), } - query := url.Query() - query.Set("package_type", "container") - url.RawQuery = query.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), http.NoBody) - if err != nil { - return nil, err - } + allImages := []string{} + nextURL := func() string { + u := *baseURL + q := u.Query() + q.Set("package_type", "container") + q.Set("per_page", fmt.Sprint(maxRegistryPageSize)) // fetch images in batches of 100 per page + u.RawQuery = q.Encode() + return u.String() + }() - // https://stackoverflow.com/questions/72732582/using-github-packages-without-personal-access-token - if g.Token != "" { - req.Header.Set("Authorization", "Bearer "+g.Token) - } + for nextURL != "" { + if err := registryRateLimiter.Wait(ctx); err != nil { + return nil, err + } - resp, err := defaultHTTPClient.Do(req) - if err != nil { - return nil, err - } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, nextURL, http.NoBody) + if err != nil { + return nil, err + } - defer func() { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - }() + // https://stackoverflow.com/questions/72732582/using-github-packages-without-personal-access-token + if g.Token != "" { + req.Header.Set("Authorization", "Bearer "+g.Token) + } + // GitHub recommends explicitly sending the v3 media type. + req.Header.Set("Accept", "application/vnd.github+json") - responseBodyByte, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } + client := g.Client + if client == nil { + client = defaultHTTPClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + discardBody(resp) + if err != nil { + return nil, err + } - switch resp.StatusCode { - case http.StatusOK: - var allImages = make([]string, 0) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list ghcr images: unexpected status code: %d", resp.StatusCode) + } - var listImagesResp []Image - if err := json.Unmarshal(responseBodyByte, &listImagesResp); err != nil { + // The GHCR packages list returns an array of package objects. We only + // care about the "name" field at this layer, so reuse the Image struct. + var page []Image + if err := json.Unmarshal(body, &page); err != nil { return nil, err } - for _, image := range listImagesResp { + for _, image := range page { allImages = append(allImages, fmt.Sprintf("%s/%s", namespace, image.Name)) // ghcr.io/<namespace>/<image_name> } - return allImages, nil - default: - return nil, fmt.Errorf("failed to list ghcr images: unexpected status code: %d", resp.StatusCode) + // Determine if there's another page via the Link header. + nextURL = parseNextLinkURL(resp.Header.Get("Link")) + } + + return allImages, nil +} + +// Function to discard response body +func discardBody(resp *http.Response) { + if resp != nil && resp.Body != nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/sources/docker/registries_test.go new/trufflehog-3.92.4/pkg/sources/docker/registries_test.go --- old/trufflehog-3.92.3/pkg/sources/docker/registries_test.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/sources/docker/registries_test.go 2025-12-19 16:15:11.000000000 +0100 @@ -2,6 +2,7 @@ import ( "fmt" + "net/http" "slices" "testing" @@ -60,3 +61,42 @@ assert.Equal(t, ghcrImages, []string{"ghcr.io/mongodb/kingfisher"}) } + +func TestDockerHubListImages_RateLimitError(t *testing.T) { + t.Parallel() + + // Dockerhub registry + dockerhub := MakeRegistryFromNamespace("trufflesecurity") // no authentication + + // Cast dockerhub to *DockerHub registry to override the HTTP client + dockerhub.WithClient(common.ConstantResponseHttpClient(http.StatusTooManyRequests, "{}")) + dockerImages, err := dockerhub.ListImages(context.Background(), "trufflesecurity") // namespace without any prefix defaults to dockerhub registry + assert.Error(t, err) + assert.Nil(t, dockerImages) +} + +func TestQuayListImages_RateLimitError(t *testing.T) { + t.Parallel() + + // Quay.io registry + quay := MakeRegistryFromNamespace("quay.io/truffledockerman") // no authentication + // Cast quay to *Quay registry to override the HTTP client + quay.WithClient(common.ConstantResponseHttpClient(http.StatusTooManyRequests, "{}")) + + quayImages, err := quay.ListImages(context.Background(), "quay.io/truffledockerman") + assert.Error(t, err) + assert.Nil(t, quayImages) +} + +func TestGHCRListImages_RateLimitError(t *testing.T) { + t.Parallel() + + // GHCR registry + ghcr := MakeRegistryFromNamespace("ghcr.io/mongodb") // no authentication + // Cast ghcr to *GHCR registry to override the HTTP client + ghcr.WithClient(common.ConstantResponseHttpClient(http.StatusTooManyRequests, "{}")) + + ghcrImages, err := ghcr.ListImages(context.Background(), "ghcr.io/mongodb") + assert.Error(t, err) + assert.Nil(t, ghcrImages) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/sources/s3/checkpointer.go new/trufflehog-3.92.4/pkg/sources/s3/checkpointer.go --- old/trufflehog-3.92.3/pkg/sources/s3/checkpointer.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/sources/s3/checkpointer.go 2025-12-19 16:15:11.000000000 +0100 @@ -98,6 +98,7 @@ type ResumeInfo struct { CurrentBucket string `json:"current_bucket"` // Current bucket being scanned StartAfter string `json:"start_after"` // Last processed object key + Role string `json:"role"` // Role used for scanning } // ResumePoint retrieves the last saved checkpoint state if one exists. @@ -121,7 +122,7 @@ return resume, nil } - return ResumeInfo{CurrentBucket: resumeInfo.CurrentBucket, StartAfter: resumeInfo.StartAfter}, nil + return ResumeInfo{CurrentBucket: resumeInfo.CurrentBucket, StartAfter: resumeInfo.StartAfter, Role: resumeInfo.Role}, nil } // Complete marks the entire scanning operation as finished and clears the resume state. @@ -215,7 +216,7 @@ return nil } - encoded, err := json.Marshal(&ResumeInfo{CurrentBucket: bucket, StartAfter: lastKey}) + encoded, err := json.Marshal(&ResumeInfo{CurrentBucket: bucket, StartAfter: lastKey, Role: role}) if err != nil { return fmt.Errorf("failed to encode resume info: %w", err) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/sources/s3/checkpointer_test.go new/trufflehog-3.92.4/pkg/sources/s3/checkpointer_test.go --- old/trufflehog-3.92.3/pkg/sources/s3/checkpointer_test.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/sources/s3/checkpointer_test.go 2025-12-19 16:15:11.000000000 +0100 @@ -61,6 +61,56 @@ assert.Equal(t, "key-11", finalResumeInfo.StartAfter) } +func TestCheckpointerResumptionWithRole(t *testing.T) { + ctx := context.Background() + + // First scan - process 6 objects then interrupt. + initialProgress := &sources.Progress{} + tracker := NewCheckpointer(ctx, initialProgress) + role := "test-role" + + firstPage := &s3.ListObjectsV2Output{ + Contents: make([]s3types.Object, 12), // Total of 12 objects + } + for i := range 12 { + key := fmt.Sprintf("key-%d", i) + firstPage.Contents[i] = s3types.Object{Key: &key} + } + + // Process first 6 objects. + for i := range 6 { + err := tracker.UpdateObjectCompletion(ctx, i, "test-bucket", role, firstPage.Contents) + assert.NoError(t, err) + } + + // Verify resume info is set correctly. + resumeInfo, err := tracker.ResumePoint(ctx) + require.NoError(t, err) + assert.Equal(t, "test-bucket", resumeInfo.CurrentBucket) + assert.Equal(t, "key-5", resumeInfo.StartAfter) + assert.Equal(t, role, resumeInfo.Role) + + // Resume scan with existing progress. + resumeTracker := NewCheckpointer(ctx, initialProgress) + + resumePage := &s3.ListObjectsV2Output{ + Contents: firstPage.Contents[6:], // Remaining 6 objects + } + + // Process remaining objects. + for i := range len(resumePage.Contents) { + err := resumeTracker.UpdateObjectCompletion(ctx, i, "test-bucket", role, resumePage.Contents) + assert.NoError(t, err) + } + + // Verify final resume info. + finalResumeInfo, err := resumeTracker.ResumePoint(ctx) + require.NoError(t, err) + assert.Equal(t, "test-bucket", finalResumeInfo.CurrentBucket) + assert.Equal(t, "key-11", finalResumeInfo.StartAfter) + assert.Equal(t, role, finalResumeInfo.Role) +} + func TestCheckpointerReset(t *testing.T) { tests := []struct { name string @@ -112,6 +162,13 @@ expectedResumeInfo: ResumeInfo{CurrentBucket: "test-bucket", StartAfter: "test-key"}, }, { + name: "valid resume info with role", + progress: &sources.Progress{ + EncodedResumeInfo: `{"current_bucket":"test-bucket","start_after":"test-key","role":"test-role"}`, + }, + expectedResumeInfo: ResumeInfo{CurrentBucket: "test-bucket", StartAfter: "test-key", Role: "test-role"}, + }, + { name: "empty encoded resume info", progress: &sources.Progress{EncodedResumeInfo: ""}, }, @@ -122,6 +179,13 @@ }, }, { + name: "no role in resume info", + progress: &sources.Progress{ + EncodedResumeInfo: `{"current_bucket":"test-bucket","start_after":"test-key"}`, + }, + expectedResumeInfo: ResumeInfo{CurrentBucket: "test-bucket", StartAfter: "test-key", Role: ""}, + }, + { name: "unmarshal error", progress: &sources.Progress{ EncodedResumeInfo: `{"current_bucket":123,"start_after":"test-key"}`, // Invalid JSON @@ -254,6 +318,122 @@ assert.Equal(t, tt.expectedLowestIncomplete, tracker.lowestIncompleteIdx, "Incorrect lowest incomplete index") + }) + } +} +func TestCheckpointerUpdateWithRole(t *testing.T) { + role := "test-role" + tests := []struct { + name string + description string + completedIdx int + pageSize int + preCompleted []int + expectedKey string + expectedRole string + expectedLowestIncomplete int + }{ + { + name: "first object completed", + description: "Basic case - completing first object", + completedIdx: 0, + pageSize: 3, + expectedKey: "key-0", + expectedRole: role, + expectedLowestIncomplete: 1, + }, + { + name: "completing missing middle", + description: "Completing object when previous is done", + completedIdx: 1, + pageSize: 3, + preCompleted: []int{0}, + expectedKey: "key-1", + expectedRole: role, + expectedLowestIncomplete: 2, + }, + { + name: "all objects completed in order", + description: "Completing final object in sequence", + completedIdx: 2, + pageSize: 3, + preCompleted: []int{0, 1}, + expectedKey: "key-2", + expectedRole: role, + expectedLowestIncomplete: 3, + }, + { + name: "out of order completion before lowest", + description: "Completing object before current lowest incomplete - should not affect checkpoint", + completedIdx: 1, + pageSize: 4, + preCompleted: []int{0, 2, 3}, + expectedKey: "key-3", + expectedRole: role, + expectedLowestIncomplete: 4, + }, + { + name: "last index in max page", + description: "Edge case - maximum page size boundary", + completedIdx: 999, + pageSize: 1000, + preCompleted: func() []int { + indices := make([]int, 999) + for i := range indices { + indices[i] = i + } + return indices + }(), + expectedKey: "key-999", + expectedRole: role, + expectedLowestIncomplete: 1000, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + progress := new(sources.Progress) + tracker := &Checkpointer{ + progress: progress, + completedObjects: make([]bool, tt.pageSize), + completionOrder: make([]int, 0, tt.pageSize), + lowestIncompleteIdx: 0, + } + + page := &s3.ListObjectsV2Output{Contents: make([]s3types.Object, tt.pageSize)} + for i := range tt.pageSize { + key := fmt.Sprintf("key-%d", i) + page.Contents[i] = s3types.Object{Key: &key} + } + + // Setup pre-completed objects. + for _, idx := range tt.preCompleted { + tracker.completedObjects[idx] = true + tracker.completionOrder = append(tracker.completionOrder, idx) + } + + // Find the correct lowest incomplete index after pre-completion. + for i := range tt.pageSize { + if !tracker.completedObjects[i] { + tracker.lowestIncompleteIdx = i + break + } + } + + err := tracker.UpdateObjectCompletion(ctx, tt.completedIdx, "test-bucket", role, page.Contents) + assert.NoError(t, err, "Unexpected error updating progress") + + var info ResumeInfo + err = json.Unmarshal([]byte(progress.EncodedResumeInfo), &info) + assert.NoError(t, err, "Failed to decode resume info") + assert.Equal(t, tt.expectedKey, info.StartAfter, "Incorrect resume point") + assert.Equal(t, tt.expectedRole, info.Role, "Incorrect role") + + assert.Equal(t, tt.expectedLowestIncomplete, tracker.lowestIncompleteIdx, + "Incorrect lowest incomplete index") }) } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/sources/s3/s3.go new/trufflehog-3.92.4/pkg/sources/s3/s3.go --- old/trufflehog-3.92.3/pkg/sources/s3/s3.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/sources/s3/s3.go 2025-12-19 16:15:11.000000000 +0100 @@ -245,6 +245,7 @@ startAfter string // The last processed object key within the bucket isNewScan bool // True if we're starting a fresh scan exactMatch bool // True if we found the exact bucket we were previously processing + role string // The role used during the previous scan } // determineResumePosition calculates where to resume scanning from based on the last saved checkpoint @@ -282,6 +283,7 @@ startAfter: resumePoint.StartAfter, index: startIdx, exactMatch: found, + role: resumePoint.Role, } } @@ -306,12 +308,14 @@ "Resume bucket no longer available, starting from closest position", "original_bucket", pos.bucket, "position", pos.index, + "role", pos.role, ) default: ctx.Logger().Info( "Resuming scan from previous scan's bucket", "bucket", pos.bucket, "position", pos.index, + "role", pos.role, ) } @@ -327,7 +331,7 @@ ) var startAfter *string - if bucket == pos.bucket && pos.startAfter != "" { + if bucket == pos.bucket && pos.startAfter != "" && role == pos.role { startAfter = &pos.startAfter ctx.Logger().V(3).Info( "Resuming bucket scan", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.92.3/pkg/sources/s3/s3_integration_test.go new/trufflehog-3.92.4/pkg/sources/s3/s3_integration_test.go --- old/trufflehog-3.92.3/pkg/sources/s3/s3_integration_test.go 2025-12-11 12:40:06.000000000 +0100 +++ new/trufflehog-3.92.4/pkg/sources/s3/s3_integration_test.go 2025-12-19 16:15:11.000000000 +0100 @@ -584,3 +584,63 @@ assert.Equal(t, wantCount, gotCount, "Chunk count mismatch for bucket %s", bucket) } } + +func TestSourceChunksResumptionWithRole(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + secret, err := common.GetTestSecret(ctx) + if err != nil { + t.Fatal(fmt.Errorf("failed to access secret: %v", err)) + } + + s3key := secret.MustGetField("AWS_S3_KEY") + s3secret := secret.MustGetField("AWS_S3_SECRET") + + src := new(Source) + src.Progress = sources.Progress{ + Message: "Bucket: integration-resumption-tests", + EncodedResumeInfo: "{\"current_bucket\":\"integration-resumption-tests\",\"start_after\":\"test-dir/\",\"role\":\"arn:aws:iam::619888638459:role/s3-test-assume-role\"}", + SectionsCompleted: 0, + SectionsRemaining: 1, + } + connection := &sourcespb.S3{ + Credential: &sourcespb.S3_AccessKey{ + AccessKey: &credentialspb.KeySecret{ + Key: s3key, + Secret: s3secret, + }, + }, + Buckets: []string{"integration-resumption-tests"}, + Roles: []string{"arn:aws:iam::619888638459:role/s3-test-assume-role"}, + EnableResumption: true, + } + conn, err := anypb.New(connection) + require.NoError(t, err) + + err = src.Init(ctx, "test name", 0, 0, false, conn, 2) + require.NoError(t, err) + + chunksCh := make(chan *sources.Chunk) + var count int + + cancelCtx, ctxCancel := context.WithCancel(ctx) + defer ctxCancel() + + go func() { + defer close(chunksCh) + err = src.Chunks(cancelCtx, chunksCh) + assert.NoError(t, err, "Should not error during scan") + }() + + for range chunksCh { + count++ + } + + // Verify that we processed all remaining data on resume. + // Also verify that we processed less than the total number of chunks for the source. + sourceTotalChunkCount := 19787 + assert.Equal(t, 9638, count, "Should have processed all remaining data on resume") + assert.Less(t, count, sourceTotalChunkCount, "Should have processed less than total chunks on resume") +} ++++++ trufflehog.obsinfo ++++++ --- /var/tmp/diff_new_pack.4WIDSh/_old 2025-12-27 11:29:22.972870311 +0100 +++ /var/tmp/diff_new_pack.4WIDSh/_new 2025-12-27 11:29:22.976870474 +0100 @@ -1,5 +1,5 @@ name: trufflehog -version: 3.92.3 -mtime: 1765453206 -commit: 05cccb53bc9e13bc6d17997db5a6bcc3df44bf2f +version: 3.92.4 +mtime: 1766157311 +commit: ef6e76c3c4023279497fab4721ffa071a722fd05 ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/trufflehog/vendor.tar.gz /work/SRC/openSUSE:Factory/.trufflehog.new.1928/vendor.tar.gz differ: char 117, line 2
