Skip to content

Commit aaae31a

Browse files
authored
Merge pull request #2130 from fluxcd/backport-2126-to-release/v1.9.x
[release/v1.9.x] fix: restrict GCS static auth to SA keys
2 parents 6b6b3ee + 66b3951 commit aaae31a

5 files changed

Lines changed: 249 additions & 35 deletions

File tree

‎docs/spec/v1/buckets.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,13 @@ Where the (base64 decoded) value of `.data.serviceaccount` looks like this:
750750
}
751751
```
752752

753+
Only service account keys are accepted, i.e. the `type` field must be set to
754+
`service_account`. Other Google credential configurations, such as
755+
`external_account` (workload identity federation), are rejected. To authenticate
756+
without a static key, use Workload Identity as shown in the
757+
[GCP Controller-Level](#gcp-controller-level-workload-identity-example) and
758+
[GCP Object-Level](#gcp-object-level-workload-identity-example) examples.
759+
753760
### Interval
754761

755762
`.spec.interval` is a required field that specifies the interval which the

‎go.mod‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ replace github.com/fluxcd/source-controller/api => ./api
99
replace github.com/opencontainers/go-digest => github.com/opencontainers/go-digest v1.0.1-0.20220411205349-bde1400a84be
1010

1111
require (
12+
cloud.google.com/go/auth v0.20.0
1213
cloud.google.com/go/compute/metadata v0.9.0
1314
cloud.google.com/go/storage v1.62.3
1415
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6
@@ -64,7 +65,6 @@ require (
6465
github.com/sirupsen/logrus v1.9.4
6566
github.com/spf13/pflag v1.0.10
6667
golang.org/x/crypto v0.53.0
67-
golang.org/x/oauth2 v0.36.0
6868
golang.org/x/sync v0.21.0
6969
google.golang.org/api v0.283.0
7070
helm.sh/helm/v4 v4.2.2
@@ -80,7 +80,6 @@ require (
8080
require (
8181
cel.dev/expr v0.25.1 // indirect
8282
cloud.google.com/go v0.123.0 // indirect
83-
cloud.google.com/go/auth v0.20.0 // indirect
8483
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
8584
cloud.google.com/go/iam v1.11.0 // indirect
8685
cloud.google.com/go/monitoring v1.25.0 // indirect
@@ -396,6 +395,7 @@ require (
396395
go.yaml.in/yaml/v3 v3.0.4 // indirect
397396
golang.org/x/mod v0.36.0 // indirect
398397
golang.org/x/net v0.56.0 // indirect
398+
golang.org/x/oauth2 v0.36.0 // indirect
399399
golang.org/x/sys v0.46.0 // indirect
400400
golang.org/x/term v0.44.0 // indirect
401401
golang.org/x/text v0.38.0 // indirect

‎internal/bucket/gcp/gcp.go‎

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package gcp
1818

1919
import (
2020
"context"
21+
"encoding/json"
2122
"errors"
2223
"fmt"
2324
"io"
@@ -26,9 +27,9 @@ import (
2627
"os"
2728
"path/filepath"
2829

30+
"cloud.google.com/go/auth/credentials"
2931
gcpstorage "cloud.google.com/go/storage"
3032
"github.com/go-logr/logr"
31-
"golang.org/x/oauth2/google"
3233
"google.golang.org/api/iterator"
3334
"google.golang.org/api/option"
3435
htransport "google.golang.org/api/transport/http"
@@ -112,7 +113,20 @@ func NewClient(ctx context.Context, bucket *sourcev1.Bucket, opts ...Option) (*G
112113

113114
switch {
114115
case o.secret != nil && o.proxyURL == nil:
115-
clientOpts = append(clientOpts, option.WithCredentialsJSON(o.secret.Data["serviceaccount"]))
116+
if os.Getenv("STORAGE_EMULATOR_HOST") != "" {
117+
// The storage client runs unauthenticated when the emulator host is
118+
// set and rejects credential options passed alongside it. Validate
119+
// the secret, but let the client run unauthenticated.
120+
if err := ValidateSecret(o.secret); err != nil {
121+
return nil, err
122+
}
123+
} else {
124+
credsOpt, err := staticCredentialsOption(o.secret)
125+
if err != nil {
126+
return nil, err
127+
}
128+
clientOpts = append(clientOpts, credsOpt)
129+
}
116130
case o.secret == nil && o.proxyURL == nil:
117131
tokenSource := gcpauth.NewTokenSource(ctx, o.authOpts...)
118132
clientOpts = append(clientOpts, option.WithTokenSource(tokenSource))
@@ -142,15 +156,11 @@ func newHTTPClient(ctx context.Context, o *options) (*http.Client, error) {
142156
var opts []option.ClientOption
143157

144158
if o.secret != nil {
145-
// Here we can't use option.WithCredentialsJSON() because htransport.NewTransport()
146-
// won't know what scopes to use and yield a 400 Bad Request error when retrieving
147-
// the OAuth token. Instead we use google.CredentialsFromJSON(), which allows us to
148-
// specify the GCS read-only scope.
149-
creds, err := google.CredentialsFromJSON(ctx, o.secret.Data["serviceaccount"], gcpstorage.ScopeReadOnly)
159+
credsOpt, err := staticCredentialsOption(o.secret)
150160
if err != nil {
151-
return nil, fmt.Errorf("failed to create Google credentials from secret: %w", err)
161+
return nil, err
152162
}
153-
opts = append(opts, option.WithCredentials(creds))
163+
opts = append(opts, credsOpt)
154164
} else { // Workload Identity.
155165
tokenSource := gcpauth.NewTokenSource(ctx, o.authOpts...)
156166
opts = append(opts, option.WithTokenSource(tokenSource))
@@ -163,16 +173,53 @@ func newHTTPClient(ctx context.Context, o *options) (*http.Client, error) {
163173
return &http.Client{Transport: transport}, nil
164174
}
165175

176+
// staticCredentialsOption returns a client option holding type-checked Google
177+
// credentials built from the service account key in the given Secret,
178+
// restricted to the GCS read-only scope.
179+
func staticCredentialsOption(secret *corev1.Secret) (option.ClientOption, error) {
180+
key, err := serviceAccountKey(secret)
181+
if err != nil {
182+
return nil, err
183+
}
184+
creds, err := credentials.NewCredentialsFromJSON(credentials.ServiceAccount, key, &credentials.DetectOptions{
185+
Scopes: []string{gcpstorage.ScopeReadOnly},
186+
})
187+
if err != nil {
188+
return nil, fmt.Errorf("failed to create Google credentials from secret: %w", err)
189+
}
190+
return option.WithAuthCredentials(creds), nil
191+
}
192+
193+
// serviceAccountKey returns the service account key held in the 'serviceaccount'
194+
// field of the given Secret. Only Google service account keys are supported;
195+
// any other credential configuration type is rejected.
196+
func serviceAccountKey(secret *corev1.Secret) ([]byte, error) {
197+
key, exists := secret.Data["serviceaccount"]
198+
if !exists {
199+
return nil, fmt.Errorf("invalid '%s' secret data: required fields 'serviceaccount'", secret.Name)
200+
}
201+
var creds struct {
202+
Type string `json:"type"`
203+
}
204+
// The parser error is not wrapped, as it may quote the malformed input.
205+
if err := json.Unmarshal(key, &creds); err != nil {
206+
return nil, fmt.Errorf("invalid '%s' secret data: failed to parse 'serviceaccount' as JSON", secret.Name)
207+
}
208+
if creds.Type != string(credentials.ServiceAccount) {
209+
return nil, fmt.Errorf("invalid '%s' secret data: 'serviceaccount' must contain a service account key with 'type' set to '%s'",
210+
secret.Name, credentials.ServiceAccount)
211+
}
212+
return key, nil
213+
}
214+
166215
// ValidateSecret validates the credential secret. The provided Secret may
167216
// be nil.
168217
func ValidateSecret(secret *corev1.Secret) error {
169218
if secret == nil {
170219
return nil
171220
}
172-
if _, exists := secret.Data["serviceaccount"]; !exists {
173-
return fmt.Errorf("invalid '%s' secret data: required fields 'serviceaccount'", secret.Name)
174-
}
175-
return nil
221+
_, err := serviceAccountKey(secret)
222+
return err
176223
}
177224

178225
// BucketExists returns if an object storage bucket with the provided name

‎internal/bucket/gcp/gcp_test.go‎

Lines changed: 127 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ var (
6666
Namespace: "default",
6767
},
6868
Data: map[string][]byte{
69-
"serviceaccount": []byte("ewogICAgInR5cGUiOiAic2VydmljZV9hY2NvdW50IiwKICAgICJwcm9qZWN0X2lkIjogInBvZGluZm8iLAogICAgInByaXZhdGVfa2V5X2lkIjogIjI4cXdnaDNnZGY1aGozZ2I1ZmozZ3N1NXlmZ2gzNGY0NTMyNDU2OGh5MiIsCiAgICAicHJpdmF0ZV9rZXkiOiAiLS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tXG5Id2V0aGd5MTIzaHVnZ2hoaGJkY3U2MzU2ZGd5amhzdmd2R0ZESFlnY2RqYnZjZGhic3g2M2Ncbjc2dGd5Y2ZlaHVoVkdURllmdzZ0N3lkZ3lWZ3lkaGV5aHVnZ3ljdWhland5NnQzNWZ0aHl1aGVndmNldGZcblRGVUhHVHlnZ2h1Ymh4ZTY1eWd0NnRneWVkZ3kzMjZodWN5dnN1aGJoY3Zjc2poY3NqaGNzdmdkdEhGQ0dpXG5IY3llNnR5eWczZ2Z5dWhjaGNzYmh5Z2NpamRiaHl5VEY2NnR1aGNldnVoZGNiaHVoaHZmdGN1aGJoM3VoN3Q2eVxuZ2d2ZnRVSGJoNnQ1cmZ0aGh1R1ZSdGZqaGJmY3JkNXI2N3l1aHV2Z0ZUWWpndnRmeWdoYmZjZHJoeWpoYmZjdGZkZnlodmZnXG50Z3ZnZ3RmeWdodmZ0NnR1Z3ZURjVyNjZ0dWpoZ3ZmcnR5aGhnZmN0Nnk3eXRmcjVjdHZnaGJoaHZ0Z2hoanZjdHRmeWNmXG5mZnhmZ2hqYnZnY2d5dDY3dWpiZ3ZjdGZ5aFZDN3VodmdjeWp2aGhqdnl1amNcbmNnZ2hndmdjZmhnZzc2NTQ1NHRjZnRoaGdmdHloaHZ2eXZ2ZmZnZnJ5eXU3N3JlcmVkc3dmdGhoZ2ZjZnR5Y2ZkcnR0ZmhmL1xuLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLVxuIiwKICAgICJjbGllbnRfZW1haWwiOiAidGVzdEBwb2RpbmZvLmlhbS5nc2VydmljZWFjY291bnQuY29tIiwKICAgICJjbGllbnRfaWQiOiAiMzI2NTc2MzQ2Nzg3NjI1MzY3NDYiLAogICAgImF1dGhfdXJpIjogImh0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbS9vL29hdXRoMi9hdXRoIiwKICAgICJ0b2tlbl91cmkiOiAiaHR0cHM6Ly9vYXV0aDIuZ29vZ2xlYXBpcy5jb20vdG9rZW4iLAogICAgImF1dGhfcHJvdmlkZXJfeDUwOV9jZXJ0X3VybCI6ICJodHRwczovL3d3dy5nb29nbGVhcGlzLmNvbS9vYXV0aDIvdjEvY2VydHMiLAogICAgImNsaWVudF94NTA5X2NlcnRfdXJsIjogImh0dHBzOi8vd3d3Lmdvb2dsZWFwaXMuY29tL3JvYm90L3YxL21ldGFkYXRhL3g1MDkvdGVzdCU0MHBvZGluZm8uaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iCn0="),
69+
"serviceaccount": []byte(serviceAccountJSON),
7070
},
7171
Type: "Opaque",
7272
}
@@ -80,8 +80,57 @@ var (
8080
},
8181
Type: "Opaque",
8282
}
83+
malformedSecret = corev1.Secret{
84+
ObjectMeta: v1.ObjectMeta{
85+
Name: "gcp-secret",
86+
Namespace: "default",
87+
},
88+
Data: map[string][]byte{
89+
"serviceaccount": []byte("not-json"),
90+
},
91+
Type: "Opaque",
92+
}
93+
externalAccountSecret = corev1.Secret{
94+
ObjectMeta: v1.ObjectMeta{
95+
Name: "gcp-secret",
96+
Namespace: "default",
97+
},
98+
Data: map[string][]byte{
99+
"serviceaccount": []byte(externalAccountJSON),
100+
},
101+
Type: "Opaque",
102+
}
83103
)
84104

105+
// serviceAccountJSON is a dummy GCP service account key. The private key is a
106+
// throwaway RSA key generated for this test only; it does not belong to any
107+
// real service account and cannot be used to retrieve tokens, only to
108+
// exercise credential construction.
109+
const serviceAccountJSON = `{
110+
"type": "service_account",
111+
"project_id": "podinfo",
112+
"private_key_id": "28qwgh3gdf5hj3gb5fj3gsu5yfgh34f45324568hy2",
113+
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC02OoAWFKPVfE2\nTVYmdQKb8WRZo8ciQomAYb7WluGqa6avPu/9z7+q4K7hZtN/9Q2t0EvvBuKXTL8+\nV+AUV2N+oi4nY7T2yq3Ebc66R/c21o9sNvYJog6S8HWtOOd0tvotrvrjFQnfcCX5\nzC48HC9PgUjHAeR8kacYlpKbaEobRI1i9AfN5deuS5tsI0Di8Ag6lGf7G+0gHUpM\nLmUttJCleOltOl1Xwb57/zZCgGwB5VdkaBtkq6KW0xxwhOqF1RiFuGOUf+vRNjfj\nLPx58XSTL0TNtYa3M8koBLMaVznY5du6Pqpq1VBSUo3Yw0Z8DwxErZBICkvwbEsQ\njmQNpK+5AgMBAAECgf8l/OqQ5/yi3z3fI9LU55jrHBzx0QiJycnYWq/ocG+dIyYz\ngz6MsiowwSf7CpgJNaojhX2hCz4A474uxyBRJYotlfB1lbXA1KvSEL7Vom64T8zd\njmEtGApRRosLHmKAKDw+tfxwqhqvNLLFcuTDYg6nqyCYE22x1pnWMGR1AJMqGgFr\nSjfTl2wtSQzT705Cd3oNoItqPdYh4Ky6dxImfiHcj237mFXWy9e5x6l0N7ShJzqS\nH3t8s5wnxjt9jAK7NBqCFSxvYyKSO7MTLBlOLUdM8KuzjsYgKw0e2j+D4LhXKc/N\n1nXY2hUgq4NAOnYoK4HUAkOcN34wLjxTvp6wlwECgYEA7pkh0/QPrGVZ9bjAExq6\nCY9Z+gejUDOHWl4aWQeZA36g3FvlPgHTFTB7hMbmABbVEjCbLrnSFe9aMQsYm0O2\n/4MAUAqXX9bV134YK3kbtYH2qXX/60oGjACcbx0CIzOAO8prF7h0MKol065POaK8\nLGupztnn1fP/cH3GXF/cVLkCgYEAwgmD874uAYrXGPZ25PjH+J+L2IGj7WIa173O\ni+WJe/5Lp/A/fp9zr/ln5x2t5Pg7btysayGz1e8TGfrEBJ8ADzIm9z9ale9XHRz5\nVIBqO+bh3PW+iBs2ocZkfMtXigDCkIBP/lutvzvcFN/fsvGw1DF8PCmN6no6/gC5\nwzNjswECgYEAmbzp4xx7jOWxVXc5rBWokchgfY62WFMbf8rqxzryCSJqnBJKX+3l\nCN44eJGAWcZcfF/9Xdo12BRl1PwFWuYC4BiU9v4cE5DmMPf6suhSRl37ha2WvRDx\nrvwl0CKs4empUt1Wq+4aT9ESlpbWTZjiDu1AeRxHGcEicmVYjuTln2ECgYEAkciC\naiQN/ryoxSmPxJKh88szT7R/TD/0OPlzcKpBdHZns0KPAfyc967kAMHMwAY86RtF\nM6x7qBVafZ9pnKs1aTVeD097KMFM6yO0tGdS6bSbJ98+ipYfosYjA5vnJllR1S2C\nbHHHBbHctZZKRPDP0W1okO8LoAq7vdEfwGgg1QECgYBBuHECqDcz+buKSZRAQqRm\ngqt4hdcu+qRMqleZY4WPNHAZoPna9hU+7EFM+D1qsB6iZxzsUXzKF9gmJfB+Zvcn\nmLQq0YXDeXBt2KWeQSyQLM2bBW0J3aFIpilaP3VRcXUPEF7FstT0DC+SkydbANIf\n5IiZW7E1qU/WBIWMkD4WuA==\n-----END PRIVATE KEY-----\n",
114+
"client_email": "test@podinfo.iam.gserviceaccount.com",
115+
"client_id": "32657634678762536746",
116+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
117+
"token_uri": "https://oauth2.googleapis.com/token",
118+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
119+
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test%40podinfo.iam.gserviceaccount.com"
120+
}`
121+
122+
// externalAccountJSON is a workload identity federation configuration, which
123+
// is not a supported credential type for the 'serviceaccount' secret field.
124+
const externalAccountJSON = `{
125+
"type": "external_account",
126+
"audience": "//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/p/providers/v",
127+
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
128+
"token_url": "https://sts.googleapis.com/v1/token",
129+
"credential_source": {
130+
"file": "/var/run/service-account/token"
131+
}
132+
}`
133+
85134
// createTestBucket creates a test bucket for testing purposes
86135
func createTestBucket() *sourcev1.Bucket {
87136
return &sourcev1.Bucket{
@@ -162,14 +211,48 @@ func TestMain(m *testing.M) {
162211
os.Exit(run)
163212
}
164213

165-
func TestNewClientWithSecretErr(t *testing.T) {
166-
bucket := createTestBucket()
167-
gcpClient, err := NewClient(context.Background(), bucket, WithSecret(secret.DeepCopy()))
168-
t.Log(err)
169-
g := NewWithT(t)
170-
g.Expect(err).To(HaveOccurred())
171-
g.Expect(err.Error()).To(Equal("dialing: invalid character 'e' looking for beginning of value"))
172-
g.Expect(gcpClient).To(BeNil())
214+
func TestNewClientWithSecret(t *testing.T) {
215+
tests := []struct {
216+
name string
217+
secret *corev1.Secret
218+
wantErr string
219+
}{
220+
{
221+
name: "service account key",
222+
secret: secret.DeepCopy(),
223+
},
224+
{
225+
name: "missing serviceaccount field",
226+
secret: badSecret.DeepCopy(),
227+
wantErr: "invalid 'gcp-secret' secret data: required fields 'serviceaccount'",
228+
},
229+
{
230+
name: "serviceaccount is not JSON",
231+
secret: malformedSecret.DeepCopy(),
232+
wantErr: "invalid 'gcp-secret' secret data: failed to parse 'serviceaccount' as JSON",
233+
},
234+
{
235+
name: "unsupported credential type",
236+
secret: externalAccountSecret.DeepCopy(),
237+
wantErr: "invalid 'gcp-secret' secret data: 'serviceaccount' must contain a service account key with 'type' set to 'service_account'",
238+
},
239+
}
240+
241+
for _, tt := range tests {
242+
t.Run(tt.name, func(t *testing.T) {
243+
g := NewWithT(t)
244+
gcpClient, err := NewClient(context.Background(), createTestBucket(), WithSecret(tt.secret))
245+
if tt.wantErr != "" {
246+
g.Expect(err).To(HaveOccurred())
247+
g.Expect(err.Error()).To(Equal(tt.wantErr))
248+
g.Expect(gcpClient).To(BeNil())
249+
return
250+
}
251+
g.Expect(err).NotTo(HaveOccurred())
252+
g.Expect(gcpClient).NotTo(BeNil())
253+
gcpClient.Close(context.Background())
254+
})
255+
}
173256
}
174257

175258
func TestNewClientWithProxyErr(t *testing.T) {
@@ -184,9 +267,20 @@ func TestNewClientWithProxyErr(t *testing.T) {
184267
gcpClient, err := NewClient(context.Background(), bucket,
185268
WithProxyURL(&url.URL{}),
186269
WithSecret(secret.DeepCopy()))
270+
g.Expect(err).NotTo(HaveOccurred())
271+
g.Expect(gcpClient).NotTo(BeNil())
272+
gcpClient.Close(context.Background())
273+
})
274+
275+
t.Run("with unsupported credential type", func(t *testing.T) {
276+
g := NewWithT(t)
277+
bucket := createTestBucket()
278+
gcpClient, err := NewClient(context.Background(), bucket,
279+
WithProxyURL(&url.URL{}),
280+
WithSecret(externalAccountSecret.DeepCopy()))
187281
g.Expect(err).To(HaveOccurred())
188282
g.Expect(gcpClient).To(BeNil())
189-
g.Expect(err.Error()).To(Equal("failed to create Google credentials from secret: invalid character 'e' looking for beginning of value"))
283+
g.Expect(err.Error()).To(Equal("invalid 'gcp-secret' secret data: 'serviceaccount' must contain a service account key with 'type' set to 'service_account'"))
190284
})
191285

192286
t.Run("without secret", func(t *testing.T) {
@@ -369,18 +463,32 @@ func TestFGetObjectDirectoryIsFileName(t *testing.T) {
369463
func TestValidateSecret(t *testing.T) {
370464
t.Parallel()
371465
testCases := []struct {
372-
name string
373-
secret *corev1.Secret
374-
error bool
466+
name string
467+
secret *corev1.Secret
468+
wantErr string
375469
}{
376470
{
377-
name: "valid secret",
471+
name: "nil secret",
472+
secret: nil,
473+
},
474+
{
475+
name: "service account key",
378476
secret: secret.DeepCopy(),
379477
},
380478
{
381-
name: "invalid secret",
382-
secret: badSecret.DeepCopy(),
383-
error: true,
479+
name: "missing serviceaccount field",
480+
secret: badSecret.DeepCopy(),
481+
wantErr: "invalid 'gcp-secret' secret data: required fields 'serviceaccount'",
482+
},
483+
{
484+
name: "serviceaccount is not JSON",
485+
secret: malformedSecret.DeepCopy(),
486+
wantErr: "invalid 'gcp-secret' secret data: failed to parse 'serviceaccount' as JSON",
487+
},
488+
{
489+
name: "unsupported credential type",
490+
secret: externalAccountSecret.DeepCopy(),
491+
wantErr: "invalid 'gcp-secret' secret data: 'serviceaccount' must contain a service account key with 'type' set to 'service_account'",
384492
},
385493
}
386494
for _, testCase := range testCases {
@@ -389,9 +497,9 @@ func TestValidateSecret(t *testing.T) {
389497
t.Parallel()
390498
err := ValidateSecret(tt.secret)
391499
g := NewWithT(t)
392-
if tt.error {
500+
if tt.wantErr != "" {
393501
g.Expect(err).To(HaveOccurred())
394-
g.Expect(err.Error()).To(Equal(fmt.Sprintf("invalid '%v' secret data: required fields 'serviceaccount'", tt.secret.Name)))
502+
g.Expect(err.Error()).To(Equal(tt.wantErr))
395503
} else {
396504
g.Expect(err).NotTo(HaveOccurred())
397505
}

0 commit comments

Comments
 (0)