diff --git a/go.mod b/go.mod index a06b80b..00f79f7 100644 --- a/go.mod +++ b/go.mod @@ -25,11 +25,13 @@ require ( require ( github.com/PaesslerAG/gval v1.2.4 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.14.3 // indirect github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect @@ -47,6 +49,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nlnwa/whatwg-url v0.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -64,6 +67,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.17.0 // indirect golang.org/x/crypto v0.38.0 // indirect + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/net v0.40.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.25.0 // indirect diff --git a/go.sum b/go.sum index 04cb2fb..3840ab4 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,9 @@ github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEs github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= 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/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.14.3 h1:Gd2c8lSNf9pKXom5JtD7AaKO8o7fGQ2LtFj1436qilA= +github.com/bits-and-blooms/bitset v1.14.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -21,6 +24,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1 h1:RW22Y3QjGrb97NUA8yupdFcaqg//+hMI2fZrETBvQ4s= +github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1/go.mod h1:mnVcdqOeYg0HvT6veRo7wINa1mJ+lC/R4ig2lWcapSI= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -83,6 +88,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY 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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nlnwa/whatwg-url v0.5.0 h1:l71cqfqG44+VCQZQX3wD4bwheFWicPxuwaCimLEfpDo= +github.com/nlnwa/whatwg-url v0.5.0/go.mod h1:X/ejnFFVbaOWdSul+cnlsSHviCzGZJdvPkgc9zD8IY8= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -138,24 +145,70 @@ github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifj github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/whuang8/redactrus v1.0.2 h1:F6h9zpN/eJDAkFSZmCT97m52Cr0r7FnDwSw1Y2wRLsA= github.com/whuang8/redactrus v1.0.2/go.mod h1:/QqU95wNV2zWg3nD5/uatl9Uz0cJUROT4Svx4PoT78Q= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= diff --git a/pkg/allowlist.go b/pkg/allowlist.go index 6a9eca9..c17c93a 100644 --- a/pkg/allowlist.go +++ b/pkg/allowlist.go @@ -3,7 +3,8 @@ package pkg import ( "net/url" - "github.com/ucarion/urlpath" + "github.com/dunglas/go-urlpattern" + log "github.com/sirupsen/logrus" ) func (config AllowlistItem) Matches(method string, url *url.URL) bool { @@ -12,21 +13,13 @@ func (config AllowlistItem) Matches(method string, url *url.URL) bool { return false } - parsedUrl, err := url.Parse(config.URL) + pattern, err := urlpattern.New(config.URL, "", nil) if err != nil { + log.WithError(err).WithField("url", config.URL).Error("failed to compile url pattern") return false } - if parsedUrl.Scheme != url.Scheme || parsedUrl.Host != url.Host { - return false - } - - matcher := urlpath.New(parsedUrl.EscapedPath()) - if _, matches := matcher.Match(url.EscapedPath()); matches { - return true - } - - return false + return pattern.Test(url.String(), config.URL) } func (allowlist Allowlist) FindMatch(method string, url *url.URL) (*AllowlistItem, bool) { diff --git a/pkg/allowlist_test.go b/pkg/allowlist_test.go index c06857c..6473bfe 100644 --- a/pkg/allowlist_test.go +++ b/pkg/allowlist_test.go @@ -3,6 +3,7 @@ package pkg import ( "net/url" "testing" + "time" ) func urlMustParse(rawURL string) *url.URL { @@ -139,3 +140,149 @@ func TestAllowlistEncodedPathMatch(t *testing.T) { assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/api/v4/projects/test-group%2Ftest-project/repository/files/path/to/file", true) assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/api/v4/projects/test-group/test-project/repository/files/path/to/file", false) } + +func TestAllowlistWildcardMatch(t *testing.T) { + allowlist := &Allowlist{ + AllowlistItem{ + URL: "https://gitlab.example.com/*/:repo/info/refs", + Methods: ParseHttpMethods([]string{"GET"}), + }, + AllowlistItem{ + URL: "https://gitlab.example.com/api/v3/*", + Methods: ParseHttpMethods([]string{"GET"}), + }, + AllowlistItem{ + URL: "https://gitlab.example.com/api/v4*", + Methods: ParseHttpMethods([]string{"GET"}), + }, + } + + // Test leading wildcard matches + assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/user/repo/info/refs", true) + assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/group/subgroup/repo/info/refs", true) + assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/endpoint?path=/info/refs", false) + assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/endpoint#repo/info/refs", false) + + // Test trailing wildcard matches + assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/*", false) + + assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/api/v3", false) + assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/api/v3/", true) + assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/api/v3/projects/123", true) + + assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/api/v4/projects/123", true) +} + +func TestAllowlistParamMatch(t *testing.T) { + allowlist := &Allowlist{ + AllowlistItem{ + URL: "https://gitlab.example.com/api/v4/projects/:group/repository/files/:file_path", + Methods: ParseHttpMethods([]string{"GET"}), + }, + } + + assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/api/v4/projects/123/repository/files/path%2Fto%2Ffile", true) + assertAllowlistMatch(t, allowlist, "GET", "https://gitlab.example.com/api/v4/projects/123/repository/files/path/to/file", false) +} + +func createCombinedAllowlist() *Allowlist { + config := &Config{ + Inbound: InboundProxyConfig{ + GitHub: &GitHub{ + BaseURL: "https://api.github.com", + AllowCodeAccess: true, + }, + GitLab: &GitLab{ + BaseURL: "https://gitlab.com/api/v4", + AllowCodeAccess: true, + }, + BitBucket: &BitBucket{ + BaseURL: "https://bitbucket.org/rest/api/1.0", + AllowCodeAccess: true, + }, + AzureDevOps: &AzureDevOps{ + BaseURL: "https://dev.azure.com", + AllowCodeAccess: true, + }, + Allowlist: Allowlist{}, + }, + } + + err := PopulateAllowLists(config) + if err != nil { + panic(err) + } + + return &config.Inbound.Allowlist +} + +func TestAllowlistFindMatchPerformance(t *testing.T) { + const maxAllowedDurationPerFindMatch = 100 // 100 milliseconds + + allowlist := createCombinedAllowlist() + testUrls := []struct { + method string + url string + name string + }{ + {"GET", "https://api.github.com/repos/testorg/testrepo", "GitHub_RepoInfo"}, + {"POST", "https://api.github.com/repos/testorg/testrepo/pulls/123/comments", "GitHub_PRComments"}, + {"GET", "https://api.github.com/orgs/testorg/hooks", "GitHub_OrgHooks"}, + + {"GET", "https://gitlab.com/api/v4/projects/123", "GitLab_Projects"}, + {"POST", "https://gitlab.com/api/v4/projects/123/merge_requests/456/discussions", "GitLab_MRDiscussions"}, + {"GET", "https://gitlab.com/api/v4/projects/123/repository/branches", "GitLab_Branches"}, + + {"GET", "https://bitbucket.org/rest/api/1.0/application-properties", "BitBucket_AppProperties"}, + {"POST", "https://bitbucket.org/rest/api/1.0/projects/TEST/repos/testrepo/pull-requests/123/comments", "BitBucket_PRComments"}, + {"GET", "https://bitbucket.org/rest/api/1.0/projects/TEST/webhooks", "BitBucket_Webhooks"}, + + {"GET", "https://dev.azure.com/testorg/_apis/connectionData", "AzureDevOps_ConnectionData"}, + {"GET", "https://dev.azure.com/testorg/testproject/_apis/git/repositories", "AzureDevOps_Repositories"}, + {"GET", "https://dev.azure.com/testorg/testproject/_apis/git/repositories/testrepo/pullRequests", "AzureDevOps_PullRequests"}, + + // Include some no-match scenarios (worst case performance) + {"GET", "https://unknown.com/some/random/endpoint", "NoMatch_UnknownDomain"}, + {"GET", "https://api.github.com/nonexistent/endpoint", "NoMatch_WrongPath"}, + {"GET", "https://api.github.com/nonexistent/endpoint/with/terribly/many/path/segments/to/ensure/url/parsing/is/sufficiently/performant", "NoMatch_WrongLongPath"}, + } + + t.Logf("Testing combined allowlist with %d items against %d URLs", len(*allowlist), len(testUrls)) + t.Logf("Budget: %dms per call to find match", maxAllowedDurationPerFindMatch) + + var totalDuration time.Duration + matches := 0 + + // Test that evaluating each URL against the allowlist is within the budget + for _, testCase := range testUrls { + t.Run(testCase.name, func(t *testing.T) { + testURL := urlMustParse(testCase.url) + + // Measure time for this specific URL lookup + start := time.Now() + _, match := allowlist.FindMatch(testCase.method, testURL) + duration := time.Since(start) + + totalDuration += duration + if match { + matches++ + } + + durationMillis := float64(duration.Nanoseconds()) / 1_000_000 + t.Logf("%s: %.1fms (match: %v)", testCase.name, durationMillis, match) + + // Check if this URL exceeded the per-URL budget + if durationMillis > float64(maxAllowedDurationPerFindMatch) { + t.Errorf("%s took %.1fms, exceeds budget of %dms", + testCase.name, + durationMillis, + maxAllowedDurationPerFindMatch) + } + }) + } + + // Report summary + avgDurationMillis := float64(totalDuration.Nanoseconds()) / float64(len(testUrls)) / 1_000_000 + t.Logf("Summary: %d matches out of %d URLs", matches, len(testUrls)) + t.Logf("Average time per lookup: %.1fms", avgDurationMillis) +}