diff --git a/go.mod b/go.mod index c8a4eeb6..6f72a2ec 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-playground/validator/v10 v10.28.0 github.com/go-viper/mapstructure/v2 v2.4.0 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/go-containerregistry v0.21.2 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 github.com/joho/godotenv v1.5.1 @@ -48,7 +49,6 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.1 // indirect - github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -74,6 +74,8 @@ require ( github.com/buger/jsonparser v1.1.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v1.0.0-rc.2 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect @@ -82,7 +84,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v28.0.1+incompatible // indirect + github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.8.2 // indirect @@ -110,7 +112,6 @@ require ( github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/gohugoio/hugo v0.163.3 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect @@ -121,7 +122,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect @@ -139,9 +140,10 @@ require ( github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/patternmatcher v0.6.0 // indirect - github.com/moby/sys/sequential v0.6.0 // indirect - github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.1 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect diff --git a/go.sum b/go.sum index fdd9b457..1b463bac 100644 --- a/go.sum +++ b/go.sum @@ -118,6 +118,10 @@ github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+Urai github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4= @@ -150,8 +154,8 @@ github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= -github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -241,8 +245,6 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gohugoio/gift v0.2.0 h1:vA31pP0rTVmBxBrhpY3WEt+4zM4g+1sDqYeemwsYeqc= github.com/gohugoio/gift v0.2.0/go.mod h1:1Mrm5CjF33KpD749Dwj+UAjWZ3LC6cBXGuTMa5XwoP4= github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 h1:pxlAea9eRwuAnt/zKbGqlFO2ZszpIe24YpOVLf+N+4I= @@ -272,6 +274,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.2 h1:vYaMU4nU55JJGFC9JR/s8NZcTjbE9DBBbvusTW9NeS0= +github.com/google/go-containerregistry v0.21.2/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -308,10 +312,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -379,12 +381,16 @@ github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374 github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= -github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= -github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= +github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= @@ -557,8 +563,6 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGC github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= -github.com/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/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= @@ -599,8 +603,6 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= @@ -609,16 +611,11 @@ golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+h golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY= golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= -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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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-20201021035429-f5854403a974/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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -628,16 +625,12 @@ golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= 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-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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -669,16 +662,12 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= @@ -708,8 +697,8 @@ gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= gorm.io/gorm v1.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s= gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/pkg/agentconfig/cron.go b/pkg/agentconfig/cron.go new file mode 100644 index 00000000..8d560504 --- /dev/null +++ b/pkg/agentconfig/cron.go @@ -0,0 +1,31 @@ +package agentconfig + +import ( + "errors" + "fmt" + "strings" + + "github.com/robfig/cron/v3" +) + +var scheduleParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) + +// ParseSchedule parses a plugin schedule the way the agent does: standard 5-field cron or a +// descriptor such as "@hourly". It is NOT the API's 6-field internal scheduler format. +// +// robfig/cron v3.0.1 panics on a TZ= or CRON_TZ= prefix with no space after it (it slices +// past the end of the spec), so that form is rejected first, and any other parser panic is +// returned as an error. +func ParseSchedule(expr string) (sched cron.Schedule, err error) { + if strings.HasPrefix(expr, "TZ=") || strings.HasPrefix(expr, "CRON_TZ=") { + if !strings.Contains(expr, " ") { + return nil, errors.New("a time zone prefix must be followed by a space and a schedule") + } + } + defer func() { + if r := recover(); r != nil { + sched, err = nil, fmt.Errorf("unparseable schedule: %v", r) + } + }() + return scheduleParser.Parse(expr) +} diff --git a/pkg/agentconfig/diff.go b/pkg/agentconfig/diff.go new file mode 100644 index 00000000..23809125 --- /dev/null +++ b/pkg/agentconfig/diff.go @@ -0,0 +1,102 @@ +package agentconfig + +import ( + "encoding/json" + "fmt" + "slices" + "strings" +) + +// Diff operations. +const ( + DiffOpAdd = "add" + DiffOpRemove = "remove" + DiffOpReplace = "replace" +) + +// DiffEntry is one difference between two JSON documents. Path is an RFC 6901 pointer. +type DiffEntry struct { + Path string `json:"path"` + Op string `json:"op"` // add | remove | replace + From json.RawMessage `json:"from,omitempty" swaggertype:"object"` + To json.RawMessage `json:"to,omitempty" swaggertype:"object"` +} + +// DiffJSON compares two JSON documents. Objects recurse; arrays and scalars are leaves. A key +// present on one side only is an add or a remove of the whole value; a value that differs +// (including a type change) is a replace. Empty input is treated as null. The result is +// sorted by Path. +func DiffJSON(a, b []byte) ([]DiffEntry, error) { + va, err := decodeAny(a) + if err != nil { + return nil, fmt.Errorf("diff: decode first document: %w", err) + } + vb, err := decodeAny(b) + if err != nil { + return nil, fmt.Errorf("diff: decode second document: %w", err) + } + var out []DiffEntry + if err := diffValues("", va, vb, true, true, &out); err != nil { + return nil, err + } + slices.SortFunc(out, func(x, y DiffEntry) int { return strings.Compare(x.Path, y.Path) }) + return out, nil +} + +func diffValues(path string, a, b any, hasA, hasB bool, out *[]DiffEntry) error { + switch { + case hasA && !hasB: + from, err := encodeCanonical(a) + if err != nil { + return err + } + *out = append(*out, DiffEntry{Path: path, Op: DiffOpRemove, From: from}) + return nil + case !hasA && hasB: + to, err := encodeCanonical(b) + if err != nil { + return err + } + *out = append(*out, DiffEntry{Path: path, Op: DiffOpAdd, To: to}) + return nil + } + objA, okA := a.(map[string]any) + objB, okB := b.(map[string]any) + if okA && okB { + for _, k := range unionKeys(objA, objB) { + va, inA := objA[k] + vb, inB := objB[k] + if err := diffValues(appendPointer(path, k), va, vb, inA, inB, out); err != nil { + return err + } + } + return nil + } + if jsonEqual(a, b) { + return nil + } + from, err := encodeCanonical(a) + if err != nil { + return err + } + to, err := encodeCanonical(b) + if err != nil { + return err + } + *out = append(*out, DiffEntry{Path: path, Op: DiffOpReplace, From: from, To: to}) + return nil +} + +func unionKeys(a, b map[string]any) []string { + keys := make([]string, 0, len(a)+len(b)) + for k := range a { + keys = append(keys, k) + } + for k := range b { + if _, ok := a[k]; !ok { + keys = append(keys, k) + } + } + slices.Sort(keys) + return keys +} diff --git a/pkg/agentconfig/diff_test.go b/pkg/agentconfig/diff_test.go new file mode 100644 index 00000000..52690c9c --- /dev/null +++ b/pkg/agentconfig/diff_test.go @@ -0,0 +1,133 @@ +package agentconfig + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPointer(t *testing.T) { + tests := []struct { + segments []string + want string + }{ + {nil, ""}, + {[]string{""}, "/"}, + {[]string{"plugins", "x", "config", "port"}, "/plugins/x/config/port"}, + {[]string{"plugins", "x", "config", "a/b"}, "/plugins/x/config/a~1b"}, + {[]string{"k~ey"}, "/k~0ey"}, + {[]string{"~/", "/~"}, "/~0~1/~1~0"}, + {[]string{"~1"}, "/~01"}, + {[]string{"plugins", "ssh", "policy_data", "sub/key"}, "/plugins/ssh/policy_data/sub~1key"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := Pointer(tt.segments...) + assert.Equal(t, tt.want, got) + if len(tt.segments) > 0 { + assert.Equal(t, tt.segments, SplitPointer(got), "round trip") + } else { + assert.Nil(t, SplitPointer(got)) + } + }) + } +} + +func TestEscapePointerToken(t *testing.T) { + for _, s := range []string{"", "a", "a/b", "a~b", "~1", "~0", "/~/~", "~~//"} { + esc := EscapePointerToken(s) + assert.NotContains(t, esc, "/", s) + assert.Equal(t, s, UnescapePointerToken(esc), s) + } + assert.Equal(t, "~01", EscapePointerToken("~1")) + assert.Equal(t, "~1", UnescapePointerToken("~01"), "~01 unescapes to ~1, not /") +} + +func TestDiffJSON(t *testing.T) { + tests := []struct { + name string + a, b string + want []DiffEntry + }{ + {name: "equal", a: `{"a":1,"b":[1,2]}`, b: `{"b":[1,2],"a":1}`, want: nil}, + {name: "equal numbers by text", a: `{"n":12345678901234567890}`, b: `{"n":12345678901234567890}`, want: nil}, + { + name: "add remove replace", + a: `{"a":1,"b":"x","c":true}`, + b: `{"a":2,"c":true,"d":null}`, + want: []DiffEntry{ + {Path: "/a", Op: DiffOpReplace, From: json.RawMessage(`1`), To: json.RawMessage(`2`)}, + {Path: "/b", Op: DiffOpRemove, From: json.RawMessage(`"x"`)}, + {Path: "/d", Op: DiffOpAdd, To: json.RawMessage(`null`)}, + }, + }, + { + name: "nested objects recurse", + a: `{"plugins":{"x":{"config":{"port":"22","host":"h"}}}}`, + b: `{"plugins":{"x":{"config":{"port":"2222","host":"h"}},"y":{"source":"s"}}}`, + want: []DiffEntry{ + {Path: "/plugins/x/config/port", Op: DiffOpReplace, From: json.RawMessage(`"22"`), To: json.RawMessage(`"2222"`)}, + {Path: "/plugins/y", Op: DiffOpAdd, To: json.RawMessage(`{"source":"s"}`)}, + }, + }, + { + name: "arrays are leaves", + a: `{"p":["a","b"]}`, + b: `{"p":["a","c"]}`, + want: []DiffEntry{{Path: "/p", Op: DiffOpReplace, From: json.RawMessage(`["a","b"]`), To: json.RawMessage(`["a","c"]`)}}, + }, + { + name: "type change object to scalar", + a: `{"x":{"y":1}}`, + b: `{"x":null}`, + want: []DiffEntry{{Path: "/x", Op: DiffOpReplace, From: json.RawMessage(`{"y":1}`), To: json.RawMessage(`null`)}}, + }, + { + name: "keys with / and ~ are escaped", + a: `{"m":{"a/b":1,"c~d":1}}`, + b: `{"m":{"a/b":2}}`, + want: []DiffEntry{ + {Path: "/m/a~1b", Op: DiffOpReplace, From: json.RawMessage(`1`), To: json.RawMessage(`2`)}, + {Path: "/m/c~0d", Op: DiffOpRemove, From: json.RawMessage(`1`)}, + }, + }, + { + name: "root replace", + a: `[1]`, + b: `{"a":1}`, + want: []DiffEntry{{Path: "", Op: DiffOpReplace, From: json.RawMessage(`[1]`), To: json.RawMessage(`{"a":1}`)}}, + }, + { + name: "empty input is null", + a: ``, + b: `null`, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DiffJSON([]byte(tt.a), []byte(tt.b)) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestDiffJSONSortedByPath(t *testing.T) { + got, err := DiffJSON([]byte(`{"z":1,"a":{"c":1,"b":1},"m":1}`), []byte(`{"z":2,"a":{"c":2,"b":2},"m":2}`)) + require.NoError(t, err) + var paths []string + for _, d := range got { + paths = append(paths, d.Path) + } + assert.Equal(t, []string{"/a/b", "/a/c", "/m", "/z"}, paths) +} + +func TestDiffJSONErrors(t *testing.T) { + _, err := DiffJSON([]byte(`{`), []byte(`{}`)) + assert.Error(t, err) + _, err = DiffJSON([]byte(`{}`), []byte(`nope`)) + assert.Error(t, err) +} diff --git a/pkg/agentconfig/envref.go b/pkg/agentconfig/envref.go new file mode 100644 index 00000000..a9102ea3 --- /dev/null +++ b/pkg/agentconfig/envref.go @@ -0,0 +1,87 @@ +package agentconfig + +import ( + "errors" + "fmt" + "regexp" + "slices" + "strings" +) + +// EnvRefPattern matches a ${env:NAME} placeholder. Placeholders are resolved only in +// plugins.*.config values (whole or embedded), in the file and in the overlay (R24). +var EnvRefPattern = regexp.MustCompile(`\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}`) + +var ( + // ErrEnvMissing is returned by ResolveEnv when a referenced variable is unset. + ErrEnvMissing = errors.New("environment variable is not set") + // ErrEnvForbidden is returned by ResolveEnv for a forbidden variable name. + ErrEnvForbidden = errors.New("environment variable may not be referenced") +) + +// forbiddenEnvPrefix protects the agent's own API credentials. +const forbiddenEnvPrefix = "CCF_API_AUTH_" + +// EnvRefs returns the variable names referenced in s, in order of first appearance and +// deduplicated. +func EnvRefs(s string) []string { + matches := EnvRefPattern.FindAllStringSubmatch(s, -1) + if len(matches) == 0 { + return nil + } + out := make([]string, 0, len(matches)) + for _, m := range matches { + if !slices.Contains(out, m[1]) { + out = append(out, m[1]) + } + } + return out +} + +// IsForbiddenEnvName reports whether a variable may never be referenced (CCF_API_AUTH_*, +// case-insensitive). +func IsForbiddenEnvName(name string) bool { + return strings.HasPrefix(strings.ToUpper(name), forbiddenEnvPrefix) +} + +// ResolveEnv returns a copy of c with ${env:NAME} placeholders in plugins.*.config values +// replaced by lookup(NAME). Nothing else is resolved. An unset variable yields an error +// wrapping ErrEnvMissing; a forbidden name yields an error wrapping ErrEnvForbidden. There is +// no escaping syntax in v1. Agent only: reports, redaction and digests use the unresolved +// config. +func ResolveEnv(c Config, lookup func(string) (string, bool)) (Config, error) { + out := c.clone() + for _, pluginName := range sortedKeys(out.Plugins) { + p := out.Plugins[pluginName] + if p == nil { + continue + } + for _, key := range sortedKeys(p.Config) { + value := p.Config[key] + names := EnvRefs(value) + if len(names) == 0 { + continue + } + ptr := Pointer("plugins", pluginName, "config", key) + for _, n := range names { + if IsForbiddenEnvName(n) { + return Config{}, fmt.Errorf("%w: ${env:%s} at %s", ErrEnvForbidden, n, ptr) + } + } + var missing string + resolved := EnvRefPattern.ReplaceAllStringFunc(value, func(ref string) string { + n := EnvRefPattern.FindStringSubmatch(ref)[1] + v, ok := lookup(n) + if !ok && missing == "" { + missing = n + } + return v + }) + if missing != "" { + return Config{}, fmt.Errorf("%w: ${env:%s} at %s", ErrEnvMissing, missing, ptr) + } + p.Config[key] = resolved + } + } + return out, nil +} diff --git a/pkg/agentconfig/envref_test.go b/pkg/agentconfig/envref_test.go new file mode 100644 index 00000000..1affdd4e --- /dev/null +++ b/pkg/agentconfig/envref_test.go @@ -0,0 +1,122 @@ +package agentconfig + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnvRefs(t *testing.T) { + tests := []struct { + in string + want []string + }{ + {"", nil}, + {"plain", nil}, + {"${env:A}", []string{"A"}}, + {"x-${env:B}-${env:A}-${env:B}-${env:A}", []string{"B", "A"}}, + {"${env:lower_case1}", []string{"lower_case1"}}, + {"${env:_X}", []string{"_X"}}, + {"${env:1A}", nil}, + {"${env:}", nil}, + {"$env:A", nil}, + {"${ENV:A}", nil}, + {"${env:A-B}", nil}, + {"${env:A", nil}, + {"$${env:A}", []string{"A"}}, // no escaping syntax in v1 + } + for _, tt := range tests { + assert.Equal(t, tt.want, EnvRefs(tt.in), tt.in) + } +} + +func TestIsForbiddenEnvName(t *testing.T) { + for _, n := range []string{"CCF_API_AUTH_CLIENT_SECRET", "CCF_API_AUTH_CLIENT_ID", "ccf_api_auth_client_secret", "Ccf_Api_Auth_X", "CCF_API_AUTH_"} { + assert.True(t, IsForbiddenEnvName(n), n) + } + for _, n := range []string{"CCF_API_URL", "CCF_API_AUTH", "X_CCF_API_AUTH_Y", "HOME", ""} { + assert.False(t, IsForbiddenEnvName(n), n) + } +} + +func envConfig() Config { + return Config{ + Plugins: map[string]*Plugin{ + "p": { + Source: "ghcr.io/x/${env:ORG}:v1", + Config: map[string]string{ + "password": "${env:PW}", + "dsn": "user=${env:USER}&pw=${env:PW}&again=${env:USER}", + "plain": "no refs", + }, + Labels: map[string]string{"l": "${env:PW}"}, + PolicyData: map[string]any{"d": "${env:PW}"}, + }, + "nil": nil, + }, + } +} + +func TestResolveEnv(t *testing.T) { + env := map[string]string{"PW": "hunter2", "USER": "root", "ORG": "acme"} + lookup := func(n string) (string, bool) { v, ok := env[n]; return v, ok } + + in := envConfig() + out, err := ResolveEnv(in, lookup) + require.NoError(t, err) + + p := out.Plugins["p"] + assert.Equal(t, "hunter2", p.Config["password"], "whole value") + assert.Equal(t, "user=root&pw=hunter2&again=root", p.Config["dsn"], "embedded values") + assert.Equal(t, "no refs", p.Config["plain"]) + assert.Equal(t, "ghcr.io/x/${env:ORG}:v1", p.Source, "only plugins.*.config is resolved") + assert.Equal(t, "${env:PW}", p.Labels["l"]) + assert.Equal(t, "${env:PW}", p.PolicyData["d"]) + assert.Nil(t, out.Plugins["nil"]) + + assert.Equal(t, envConfig(), in, "input not mutated") +} + +func TestResolveEnvEmptyValue(t *testing.T) { + c := Config{Plugins: map[string]*Plugin{"p": {Config: map[string]string{"k": "a${env:E}b"}}}} + out, err := ResolveEnv(c, func(string) (string, bool) { return "", true }) + require.NoError(t, err) + assert.Equal(t, "ab", out.Plugins["p"].Config["k"], "set but empty is not missing") +} + +func TestResolveEnvErrors(t *testing.T) { + t.Run("missing", func(t *testing.T) { + c := envConfig() + _, err := ResolveEnv(c, func(n string) (string, bool) { return "x", n != "USER" }) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrEnvMissing)) + assert.False(t, errors.Is(err, ErrEnvForbidden)) + assert.Contains(t, err.Error(), "USER") + assert.Contains(t, err.Error(), "/plugins/p/config/dsn") + assert.Equal(t, envConfig(), c, "input not mutated") + }) + t.Run("forbidden", func(t *testing.T) { + c := Config{Plugins: map[string]*Plugin{"p": {Config: map[string]string{"s": "${env:ccf_api_auth_client_secret}"}}}} + called := false + _, err := ResolveEnv(c, func(string) (string, bool) { called = true; return "leak", true }) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrEnvForbidden)) + assert.False(t, called, "a forbidden variable is never looked up") + assert.Equal(t, "${env:ccf_api_auth_client_secret}", c.Plugins["p"].Config["s"]) + }) + t.Run("forbidden alongside a set variable", func(t *testing.T) { + c := Config{Plugins: map[string]*Plugin{"p": {Config: map[string]string{"s": "${env:A}${env:CCF_API_AUTH_CLIENT_ID}"}}}} + _, err := ResolveEnv(c, func(string) (string, bool) { return "x", true }) + assert.True(t, errors.Is(err, ErrEnvForbidden)) + }) +} + +func TestResolveEnvKeepsFreeFormNumbers(t *testing.T) { + c := Config{Plugins: map[string]*Plugin{"p": {PolicyData: map[string]any{"n": json.Number("12345678901234567890")}}}} + out, err := ResolveEnv(c, func(string) (string, bool) { return "", false }) + require.NoError(t, err) + assert.Equal(t, json.Number("12345678901234567890"), out.Plugins["p"].PolicyData["n"]) +} diff --git a/pkg/agentconfig/etag.go b/pkg/agentconfig/etag.go new file mode 100644 index 00000000..037adb9c --- /dev/null +++ b/pkg/agentconfig/etag.go @@ -0,0 +1,90 @@ +package agentconfig + +import ( + "fmt" + "strconv" + "strings" + + "github.com/google/uuid" +) + +// ETagForRevision returns the agent-facing opaque ETag (R7), including the quotes: +// `"r-"` for rev >= 1 and `"r0-"` for rev 0, so a DB reset, +// re-registration or agent re-creation never yields a false 304. Server-side only: clients +// store the raw ETag they received and send it back verbatim. +func ETagForRevision(rev int64, revisionRowID uuid.UUID, agentID uuid.UUID) string { + if rev <= 0 { + return fmt.Sprintf(`"r0-%s"`, agentID) + } + return fmt.Sprintf(`"r%d-%s"`, rev, revisionRowID) +} + +// normalizeETag strips a weak prefix, surrounding whitespace and quotes. +func normalizeETag(tag string) string { + t := strings.TrimSpace(tag) + t = strings.TrimPrefix(t, "W/") + t = strings.TrimSpace(t) + if len(t) >= 2 && strings.HasPrefix(t, `"`) && strings.HasSuffix(t, `"`) { + t = t[1 : len(t)-1] + } + return t +} + +// MatchIfNoneMatch reports whether any entry of an If-None-Match header (strong, W/, bare, +// a comma-separated list, or "*") equals the CURRENT opaque tag. Entries are compared as text +// after stripping W/ and quotes. An empty header never matches. +func MatchIfNoneMatch(header, current string) bool { + want := normalizeETag(current) + if want == "" { + return false + } + for _, entry := range strings.Split(header, ",") { + e := strings.TrimSpace(entry) + if e == "" { + continue + } + if e == "*" || normalizeETag(e) == want { + return true + } + } + return false +} + +// AdminETag returns the admin-facing ETag for a revision: the plain revision number, quoted +// (`"7"`) (R7). +func AdminETag(rev int64) string { + return fmt.Sprintf(`"%d"`, rev) +} + +// ParseRevisionIfMatch parses an admin If-Match header holding a plain revision number: +// `"7"`, `W/"7"` or `7`. Exactly one non-negative value in canonical decimal form is +// accepted; anything else (a list, "*", a sign such as "+7", leading zeros such as "07") +// yields ok=false. +func ParseRevisionIfMatch(header string) (rev int64, ok bool) { + h := strings.TrimSpace(header) + if h == "" || strings.Contains(h, ",") { + return 0, false + } + t := normalizeETag(h) + if !isCanonicalDecimal(t) { + return 0, false + } + r, err := strconv.ParseInt(t, 10, 64) + if err != nil || r < 0 { + return 0, false + } + return r, true +} + +// isCanonicalDecimal reports whether s is "0" or a digit string without a leading zero. +func isCanonicalDecimal(s string) bool { + if s == "" || (len(s) > 1 && s[0] == '0') { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} diff --git a/pkg/agentconfig/etag_test.go b/pkg/agentconfig/etag_test.go new file mode 100644 index 00000000..6a0e8dba --- /dev/null +++ b/pkg/agentconfig/etag_test.go @@ -0,0 +1,106 @@ +package agentconfig + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +var ( + etagRowID = uuid.MustParse("11111111-2222-3333-4444-555555555555") + etagAgentID = uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + etagOtherID = uuid.MustParse("99999999-2222-3333-4444-555555555555") +) + +func TestETagForRevision(t *testing.T) { + assert.Equal(t, `"r0-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"`, ETagForRevision(0, etagRowID, etagAgentID)) + assert.Equal(t, `"r0-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"`, ETagForRevision(0, uuid.Nil, etagAgentID)) + assert.Equal(t, `"r7-11111111-2222-3333-4444-555555555555"`, ETagForRevision(7, etagRowID, etagAgentID)) + assert.Equal(t, `"r1-11111111-2222-3333-4444-555555555555"`, ETagForRevision(1, etagRowID, etagAgentID)) +} + +func TestMatchIfNoneMatch(t *testing.T) { + current := ETagForRevision(7, etagRowID, etagAgentID) + other := ETagForRevision(7, etagOtherID, etagAgentID) + older := ETagForRevision(6, etagRowID, etagAgentID) + tests := []struct { + name string + header string + want bool + }{ + {name: "strong", header: current, want: true}, + {name: "weak", header: "W/" + current, want: true}, + {name: "bare", header: "r7-11111111-2222-3333-4444-555555555555", want: true}, + {name: "list containing", header: older + ", " + "W/" + current, want: true}, + {name: "list without spaces", header: older + "," + current, want: true}, + {name: "star", header: "*", want: true}, + {name: "star in list", header: older + ", *", want: true}, + {name: "mismatching row uuid", header: other, want: false}, + {name: "older revision", header: older, want: false}, + {name: "list not containing", header: older + ", " + other, want: false}, + {name: "empty", header: "", want: false}, + {name: "whitespace", header: " ", want: false}, + {name: "empty list entries", header: " , ,", want: false}, + {name: "case sensitive", header: `"R7-11111111-2222-3333-4444-555555555555"`, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, MatchIfNoneMatch(tt.header, current)) + }) + } + assert.False(t, MatchIfNoneMatch("*", ""), "no current tag never matches") + assert.False(t, MatchIfNoneMatch(`""`, `""`)) +} + +func TestAdminETag(t *testing.T) { + assert.Equal(t, `"7"`, AdminETag(7)) + rev, ok := ParseRevisionIfMatch(AdminETag(7)) + assert.True(t, ok) + assert.Equal(t, int64(7), rev) +} + +func TestParseRevisionIfMatch(t *testing.T) { + tests := []struct { + header string + wantRev int64 + wantOK bool + }{ + {header: `"7"`, wantRev: 7, wantOK: true}, + {header: `W/"7"`, wantRev: 7, wantOK: true}, + {header: `7`, wantRev: 7, wantOK: true}, + {header: ` "0" `, wantRev: 0, wantOK: true}, + {header: `"123456789012"`, wantRev: 123456789012, wantOK: true}, + {header: ``}, + {header: ` `}, + {header: `*`}, + {header: `"*"`}, + {header: `"7", "8"`}, + {header: `7,8`}, + {header: `"-1"`}, + {header: `-1`}, + {header: `"abc"`}, + {header: `"7`}, + {header: `""`}, + {header: `"""7"""`}, + {header: `"r7-11111111-2222-3333-4444-555555555555"`}, + {header: `"7.0"`}, + {header: `"+7"`}, + {header: `+7`}, + {header: `W/"+7"`}, + {header: `"07"`}, + {header: `"00"`}, + {header: `" 7"`}, + {header: `"7 "`}, + {header: `"0x7"`}, + {header: `"1e3"`}, + {header: `"99999999999999999999"`}, + } + for _, tt := range tests { + t.Run(tt.header, func(t *testing.T) { + rev, ok := ParseRevisionIfMatch(tt.header) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantRev, rev) + }) + } +} diff --git a/pkg/agentconfig/jsonutil.go b/pkg/agentconfig/jsonutil.go index 32c9cd99..4ab718f9 100644 --- a/pkg/agentconfig/jsonutil.go +++ b/pkg/agentconfig/jsonutil.go @@ -2,6 +2,7 @@ package agentconfig import ( "bytes" + "cmp" "encoding/json" "errors" "fmt" @@ -364,3 +365,23 @@ func deepCopyAny(v any) any { return v } } + +// jsonEqual reports whether two decoded JSON values are equal (by canonical encoding). +func jsonEqual(a, b any) bool { + ea, errA := encodeCanonical(a) + eb, errB := encodeCanonical(b) + if errA != nil || errB != nil { + return false + } + return bytes.Equal(ea, eb) +} + +// sortedKeys returns the keys of m in ascending order. +func sortedKeys[K cmp.Ordered, V any](m map[K]V) []K { + keys := make([]K, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} diff --git a/pkg/agentconfig/pointer.go b/pkg/agentconfig/pointer.go index 648181f9..1524f7a7 100644 --- a/pkg/agentconfig/pointer.go +++ b/pkg/agentconfig/pointer.go @@ -44,3 +44,8 @@ func SplitPointer(ptr string) []string { } return parts } + +// appendPointer appends one unescaped segment to an existing pointer. +func appendPointer(ptr string, segment string) string { + return ptr + "/" + EscapePointerToken(segment) +} diff --git a/pkg/agentconfig/remoteconfig.go b/pkg/agentconfig/remoteconfig.go new file mode 100644 index 00000000..2d9285c8 --- /dev/null +++ b/pkg/agentconfig/remoteconfig.go @@ -0,0 +1,78 @@ +package agentconfig + +import ( + "path" + "strings" +) + +// Normalize applies the remote_config defaults (R29): +// - Mode "" becomes report with auth (the agent reports but never applies a revision +// until the operator opts in with apply_safe or apply_all), off without; no auth +// always forces off; +// - PollInterval "" becomes "60s"; +// - nil TrustedSources / OverridableConfigFlags become []; +// - AllowLocalSources stays false unless set. +func (rc RemoteConfig) Normalize(hasAuth bool) RemoteConfig { + out := rc + switch { + case !hasAuth: + out.Mode = ModeOff + case out.Mode == "": + out.Mode = ModeReport + } + if out.PollInterval == "" { + out.PollInterval = "60s" // DefaultPollInterval, in the form operators write + } + out.TrustedSources = cloneStrings(rc.TrustedSources) + if out.TrustedSources == nil { + out.TrustedSources = []string{} + } + out.OverridableConfigFlags = cloneStrings(rc.OverridableConfigFlags) + if out.OverridableConfigFlags == nil { + out.OverridableConfigFlags = []string{} + } + return out +} + +// EffectiveRemoteConfig returns the normalized remote_config block of c, using c.API to +// decide whether the agent has credentials. +func (c Config) EffectiveRemoteConfig() RemoteConfig { + var rc RemoteConfig + if c.RemoteConfig != nil { + rc = *c.RemoteConfig + } + return rc.Normalize(c.API.HasAuth()) +} + +// MatchTrustedSource reports whether source matches one of rc.TrustedSources using +// path.Match semantics: case-sensitive, and '*' does not cross '/'. +func MatchTrustedSource(rc RemoteConfig, source string) bool { + for _, pattern := range rc.TrustedSources { + if ok, err := path.Match(pattern, source); err == nil && ok { + return true + } + } + return false +} + +// MatchOverridableConfigFlag reports whether plugins..config. may be changed +// remotely. An entry containing ':' is ":" (split at the first ':'); +// otherwise it is "" for any plugin. Matching is path.Match, case-sensitive; base +// keys are lowercased by viper (R28). +func MatchOverridableConfigFlag(rc RemoteConfig, plugin, key string) bool { + for _, entry := range rc.OverridableConfigFlags { + pluginGlob, keyGlob, scoped := strings.Cut(entry, ":") + if !scoped { + keyGlob = entry + pluginGlob = "*" + } + pluginOK, err := path.Match(pluginGlob, plugin) + if err != nil || !pluginOK { + continue + } + if keyOK, err := path.Match(keyGlob, key); err == nil && keyOK { + return true + } + } + return false +} diff --git a/pkg/agentconfig/sources.go b/pkg/agentconfig/sources.go new file mode 100644 index 00000000..416b88e9 --- /dev/null +++ b/pkg/agentconfig/sources.go @@ -0,0 +1,28 @@ +package agentconfig + +import ( + "github.com/google/go-containerregistry/pkg/name" +) + +// SourceKind classifies a plugin source or policy entry. +type SourceKind string + +const ( + SourceKindOCI SourceKind = "oci" + SourceKindLocal SourceKind = "local" +) + +// IsOCISource reports whether s parses as an OCI tag with strict validation, which is what +// the agent's downloader supports. The agent's internal.IsOCI delegates here (R3). +func IsOCISource(s string) bool { + _, err := name.NewTag(s, name.StrictValidation) + return err == nil +} + +// KindOf classifies s: OCI (strict tag) or, otherwise, a local path. +func KindOf(s string) SourceKind { + if IsOCISource(s) { + return SourceKindOCI + } + return SourceKindLocal +} diff --git a/pkg/agentconfig/sources_test.go b/pkg/agentconfig/sources_test.go new file mode 100644 index 00000000..9d2843cb --- /dev/null +++ b/pkg/agentconfig/sources_test.go @@ -0,0 +1,43 @@ +package agentconfig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKindOfAndIsOCISource(t *testing.T) { + tests := []struct { + source string + kind SourceKind + }{ + {"ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", SourceKindOCI}, + {"ghcr.io/compliance-framework/plugin-local-ssh-policies:latest", SourceKindOCI}, + {"docker.io/library/alpine:3.20", SourceKindOCI}, + {"localhost:5000/plugin:v1", SourceKindOCI}, + {"registry.example.com:5000/a/b/c:1.2.3", SourceKindOCI}, + {"ghcr.io/x/y", SourceKindLocal}, // strict validation requires an explicit tag + {"ghcr.io/X/Y:v1", SourceKindLocal}, + {"ghcr.io/x/y:", SourceKindLocal}, + {"./plugins/foo", SourceKindLocal}, + {"/opt/plugin", SourceKindLocal}, + {"plugin", SourceKindLocal}, + {"", SourceKindLocal}, + {"inline:ssh", SourceKindLocal}, // no special meaning: a local path + } + for _, tt := range tests { + t.Run(tt.source, func(t *testing.T) { + assert.Equal(t, tt.kind, KindOf(tt.source)) + assert.Equal(t, tt.kind == SourceKindOCI, IsOCISource(tt.source)) + }) + } +} + +func TestNamePatterns(t *testing.T) { + for _, ok := range []string{"a", "0", "local-ssh", "ssh_tuned", "a" + string(make([]byte, 0)), "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijk"} { + assert.True(t, PluginNamePattern.MatchString(ok), ok) + } + for _, bad := range []string{"", "GitHub", "Ssh.Tuned", "-a", "_a", "a.b", "a b", "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl"} { + assert.False(t, PluginNamePattern.MatchString(bad), bad) + } +}