├── .github └── CODEOWNERS ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── api.go ├── const.go ├── go.mod ├── go.sum ├── key_collection.go ├── keytype.go ├── main.go ├── sakey.go ├── sakey_x509.go └── util.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @allan-mercari @joaopenteado @hi120ki 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gcp-sa-key-checker 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Please read the CLA carefully before submitting your contribution to Mercari. 4 | Under any circumstances, by submitting your contribution, you are deemed to accept and agree to be bound by the terms and conditions of the CLA. 5 | 6 | https://www.mercari.com/cla 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Mercari, Inc. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Third Party GCP Service Account Key Checker 2 | 3 | This program implements a simple security checker for GCP Service Account Keys for any GCP Service Account using the [public x509 certificate endpoint](https://cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys#confidential-information). 4 | 5 | It is useful for auditing if GCP Service Accounts used by third party SaaS services are following [best pratices](https://cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys) before you grant them access to your environment. 6 | 7 | ## Background 8 | 9 | All Google Cloud Service Accounts have service account keys associated with them which they use for signing JWTs which can be used as idtokens or [exchanged for access tokens](https://developers.google.com/identity/protocols/oauth2/service-account#httprest). These are almost always 2048-bit RSA keys and are a foundational component of the GCP security model. 10 | 11 | These keys have attributes `keyOrigin` and `keyType`, which can be: 12 | 13 | - `keyOrigin` 14 | - `GOOGLE_PROVIDED` - key material was generated by Google 15 | - `USER_PROVIDED` - generated by the user 16 | - `keyType` 17 | - `SYSTEM_MANAGED` - key material is managed by GCP 18 | - `USER_MANAGED` - key material is managed by the user 19 | 20 | These can be in the following combinations: 21 | 22 | - `GOOGLE_PROVIDED`/`SYSTEM_MANAGED` these are the cloud platform internal SAs that are attached to every Service Account. These keys are used by the methods in the [Service Account Credentials REST API](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts) like [`SignJWT`](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt). 23 | - `GOOGLE_PROVIDED`/`SYSTEM_MANAGED` these are created by the [`projects.serviceAccounts.keys.create` API](https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts.keys/create) and then downloaded to get a "Service Account Key JSON". 24 | - `USER_PROVIDED`/`USER_MANAGED` these are created by the user and the certificate portion is uploaded using [`projects.serviceAccounts.keys.upload` API](https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts.keys/upload). Google Cloud never has access to these private keys. 25 | 26 | Note that `USER_PROVIDED`/`SYSTEM_MANAGED` doesn't exist because there's no way to import private key material into the cloud. 27 | 28 | According to [Best practices for managing service account keys](https://cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys) it is prefered to not have *any* `USER_MANAGED` keys. 29 | 30 | GCP does not directly make the information about what types of keys are attached to a service account public, however, it does provide several endpoints (documented [here](https://cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys#confidential-information) and [here](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt)) to see the public portions of the keys, to be used for verifying signatures. 31 | 32 | This tool takes the certificates from the public x509 endpoint, and uses heuristics to determine the key origin and type for each key. 33 | 34 | ## Usage 35 | 36 | Clone this repository and ensure you have golang installed. 37 | 38 | If you plan to use features requiring GCP authentication, ensure you run `gcloud auth login --update-adc`. 39 | 40 | You can run the tool with `go run ./... [args]` (or `go build` and then `./gcp-sa-key-checker [args]`). 41 | 42 | The list of Service Account emails to process can be provided in four different ways: 43 | 44 | - On the command line as individual positional arguments 45 | - with the `--in FILE` flag, pointing to a text file with one service account email on each line 46 | - with the `--project PROJECTID` flag, which will list all Service Accounts in the project using the [`projects.serviceAccounts.list` API](https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts/list) 47 | - with the `--scope SCOPE` flag, which will list all active service accounts using the [`searchAllResources` API](https://cloud.google.com/asset-inventory/docs/reference/rest/v1/TopLevel/searchAllResources). Supported scopes are: 48 | - `projects/{PROJECT_ID}` or `projects/{PROJECT_NUMBER}` (redundant with `--project` flag, but requires different permissions) 49 | - `folders/{FOLDER_NUMBER}` 50 | - `organizations/{ORGANIZATION_NUMBER}` 51 | 52 | The tool can be run in two different modes: 53 | 54 | - Normal: Default mode, only list keys that are likely not `GOOGLE_PROVIDED`/`SYSTEM_MANAGED` 55 | - Verbose: enabled with `--verbose`, it will output all the information about all keys seen. This could be useful for diffing and monitoring but is mostly for debugging. 56 | - Ground Truth: enabled with `--ground-truth`, it will use ADCs to pull the real status of the keys [from the IAM API](https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts.keys/list), and then compare that with the predicted `keyOrigin`/`keyType` from the public information, then it will report any descrepencies. This is useful for verifying the correctness of the heuristics. 57 | 58 | Additional flags: 59 | 60 | - `--out-dir DIR` - will write the PEM encoded x509 certificates for all scanned SAs to the output directory 61 | - `--quota-project PROJECT_ID` - will use the specified project for quota/billing purposes. Only really relevant for the `--ground-truth` which issues many IAM read calls. 62 | 63 | ## How it Works 64 | 65 | The certificate for each SA key is downloaded using the `https://www.googleapis.com/service_accounts/v1/metadata/x509/ACCOUNT_EMAIL` endpoint. Checks are run to gather "Signals" which are a guess towards a specific keyOrigin+keyType combination, and an explanation. The following checks are run, each of which were determined experimentally: 66 | 67 | - Validity period (`NotBefore`, `NotAfter`) 68 | - `16d12h15m` -> `GOOGLE_PROVIDED`/`SYSTEM_MANAGED` 69 | - ["two weeks"](https://github.com/googleapis/google-api-python-client/blob/84c3332759030a1b57a56bb3bd74a58b484253a0/docs/dyn/iam_v1.projects.serviceAccounts.keys.html#L237C475-L237C484) seems to be a hardcoded value 70 | - `3650d` (~10 years) -> `GOOGLE_PROVIDED`/`USER_MANAGED` 71 | - legacy user-created SA keys [had a 10 year validity](https://cloud.google.com/blog/products/containers-kubernetes/introducing-workload-identity-better-authentication-for-your-gke-applications#:~:text=but%20service%20account%20keys%20only%20expire%20every%2010%20years) 72 | - Valid between `730d` (~2 years) and `761d` (~2 years + 1 month) -> `GOOGLE_PROVIDED`/`SYSTEM_MANAGED` 73 | - This does not seem to be documented but was confirmed experimentally. Seems to have started rollout around February 2025 74 | - `NotAfter` date of `9999-12-31 23:59:59 +0000 UTC` -> `GOOGLE_PROVIDED`/`SYSTEM_MANAGED` 75 | - SA keys generated after around May 2021 seem to not expire at all 76 | - Any period in the [`iam.serviceAccountKeyExpiryHours` org constraint](https://cloud.google.com/resource-manager/docs/organization-policy/restricting-service-accounts#limit_key_expiry) -> `GOOGLE_PROVIDED`/`USER_MANAGED` 77 | - Any other period can not be generated by Google Cloud, so must be `USER_PROVIDED`/`USER_MANAGED` 78 | - Names (`Subject` and `Issuer`) 79 | - GAIA IDs -> `GOOGLE_PROVIDED`/`USER_MANAGED` 80 | - Experimentally determined user-generated keys always have GAIA IDs in these fields. 81 | - service account email or email truncated to 64 bytes -> `GOOGLE_PROVIDED`/`GOOGLE_MANAGED` 82 | - It is unclear when this truncation occurs, and seems to not be documented. 83 | - Anything else cannot be generated by GCP -> `USER_PROVIDED`/`USER_MANAGED` 84 | - Crypto settings: 85 | - 1024 bit `SHA1WithRSA` -> `GOOGLE_PROVIDED`/`USER_MANAGED` 86 | - not sure why anyone would do this, but [the API allows it](https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts.keys#ServiceAccountKeyAlgorithm) 87 | - anything else other than 2024 bit `SHA1WithRSA` -> `USER_PROVIDED`/`USER_MANAGED` 88 | - The extensions (key usage, etc) are also checked because these are very consistent from GCP, so if they differ they key must have been `USER_PROVIDED`. 89 | 90 | Finally, the signals are compiled, and ordered by precedence. The highest precedence finding wins. The prececdence order is `USER_PROVIDED/USER_MANAGED`, `GOOGLE_PROVIDED/USER_MANAGED` and finally `GOOGLE_PROVIDED/GOOGLE_MANAGED`. 91 | 92 | ## Findings 93 | 94 | This was run with `--ground-truth` across the main Mercari GCP organization which has existed for over 10 years and contains >20k service accounts, including some that have user-generated or user-managed keys. There were no disparities between the heuristic detection code in this script and the ground truth from the API. 95 | 96 | Additionally, we pulled data from [Wiz](https://app.wiz.io/) for external service accounts that are connected to our environment using the following advanced query: 97 | 98 | ```json 99 | { 100 | "select": true, 101 | "type": [ 102 | "SERVICE_ACCOUNT" 103 | ], 104 | "where": { 105 | "_partial": { 106 | "EQUALS": true 107 | }, 108 | "externalId": { 109 | "ENDS_WITH": [ 110 | ".gserviceaccount.com" 111 | ] 112 | } 113 | } 114 | } 115 | ``` 116 | 117 | This discovered that several of our SaaS services are potentially not following GCP [best practices for managing service account keys](https://cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys) and we plan to privately follow up with them. 118 | 119 | The `--out-dir` parameter is useful for running keys through [badkeys](https://github.com/badkeys/badkeys), however we found no examples of such keys in practice. A survey of SA keys looking for issues like duplicate moduli, [shared primes](https://factorable.net/resources.html) or other oddities could be interesting future work, particularly if combined with recon to gather a [large number of](https://sourcegraph.com/search) [SAs to scan](https://cloud.google.com/iam/docs/service-agents). 120 | 121 | ## Contribution 122 | 123 | If you want to submit a PR for bug fixes or documentation, please read the [CONTRIBUTING.md](CONTRIBUTING.md) and follow the instruction beforehand. 124 | 125 | ## License 126 | 127 | The gcp-sa-key-checker is released under the [MIT License](LICENSE). 128 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | asset "cloud.google.com/go/asset/apiv1" 9 | "cloud.google.com/go/asset/apiv1/assetpb" 10 | "google.golang.org/api/iam/v1" 11 | ) 12 | 13 | type ServiceAccountKeys map[string]*iam.ServiceAccountKey 14 | 15 | func getServiceAccountKeys(ctx context.Context, iamService *iam.Service, sa string) (ServiceAccountKeys, error) { 16 | keys, err := iamService.Projects.ServiceAccounts.Keys.List("projects/-/serviceAccounts/" + sa).Context(ctx).Do() 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | res := map[string]*iam.ServiceAccountKey{} 22 | num_internal := 0 23 | for _, key := range keys.Keys { 24 | id := strings.SplitAfter(key.Name, "keys/")[1] 25 | res[id] = key 26 | if key.KeyOrigin == "GOOGLE_PROVIDED" && key.KeyType == "SYSTEM_MANAGED" { 27 | num_internal++ 28 | } 29 | } 30 | 31 | if num_internal > 3 { 32 | fmt.Printf("Warning: More than 3 (%v) internal keys found for %v. Please file a bug report.", num_internal, sa) 33 | } 34 | 35 | return res, nil 36 | } 37 | 38 | // Note: we skip any service accounts that are disabled 39 | func getServiceAccountIDsInProject(ctx context.Context, iamService *iam.Service, project string) ([]string, error) { 40 | var serviceAccountIDs []string 41 | 42 | err := iamService.Projects.ServiceAccounts.List("projects/"+project).Pages(ctx, func(page *iam.ListServiceAccountsResponse) error { 43 | for _, serviceAccount := range page.Accounts { 44 | if serviceAccount.Disabled { 45 | continue 46 | } 47 | serviceAccountIDs = append(serviceAccountIDs, serviceAccount.Email) 48 | } 49 | return nil 50 | }) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return serviceAccountIDs, nil 56 | } 57 | 58 | func getServiceAccountIDsViaAssetInventory(ctx context.Context, c *asset.Client, scope string) ([]string, error) { 59 | var serviceAccountIDs []string 60 | for res, err := range c.SearchAllResources(ctx, &assetpb.SearchAllResourcesRequest{ 61 | Scope: scope, 62 | AssetTypes: []string{"iam.googleapis.com/ServiceAccount"}, 63 | Query: "state=ENABLED", 64 | PageSize: 500, // max, 65 | }).All() { 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | serviceAccountID := res.AdditionalAttributes.Fields["email"].GetStringValue() 71 | serviceAccountIDs = append(serviceAccountIDs, serviceAccountID) 72 | } 73 | 74 | return serviceAccountIDs, nil 75 | } 76 | -------------------------------------------------------------------------------- /const.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "regexp" 5 | "time" 6 | ) 7 | 8 | // This doesn't seem to be documented anywhere, but GCP internal SA keys are valid for exactly 396h15m 9 | const googleProvidedSystemManagedValidityV1 = time.Hour*396 + time.Minute*15 10 | 11 | // Newer keys seem to generate a key that has a random validity periiod between 2 years and 2 years and one month 12 | var googleProvidedSystemManagedValidityV2Min = time.Hour * 24 * 365 * 2 13 | var googleProvidedSystemManagedValidityV2Max = time.Hour * 24 * (365*2 + 31) 14 | 15 | // This is the default max value for a certificate that is google provided user managed 16 | var defaultMaxAfter = time.Date(9999, time.December, 31, 23, 59, 59, 0, time.UTC) 17 | 18 | // For old SA Keys, they seem to have have this as a validity period 19 | var legacyGoogleProvidedUserManagedValidity = 87600 * time.Hour 20 | 21 | // available validity periods for GOOGLE_PROVIDED+USER_MANAGED keys 22 | // https://cloud.google.com/resource-manager/docs/organization-policy/restricting-service-accounts#limit_key_expiry 23 | var serviceAccountKeyExpiryHours = []time.Duration{ 24 | time.Hour * 1, 25 | time.Hour * 8, 26 | time.Hour * 24, 27 | time.Hour * 168, 28 | time.Hour * 336, 29 | time.Hour * 720, 30 | time.Hour * 1440, 31 | time.Hour * 2160, 32 | } 33 | 34 | var GAIA_ID = regexp.MustCompile("^1[0-9]{20}$") 35 | 36 | var IAMReadRequestsPerMinutePerProjectMax = 5500 // really 6000, but leave some buffer 37 | const MaxInflightX509 = 64 // max requests to make at once for the x509 certs 38 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mercari/gcp-sa-key-checker 2 | 3 | go 1.23 4 | 5 | require ( 6 | cloud.google.com/go v0.116.0 // indirect 7 | cloud.google.com/go/accesscontextmanager v1.9.2 // indirect 8 | cloud.google.com/go/asset v1.20.4 // indirect 9 | cloud.google.com/go/auth v0.14.1 // indirect 10 | cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect 11 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 12 | cloud.google.com/go/iam v1.2.2 // indirect 13 | cloud.google.com/go/longrunning v0.6.2 // indirect 14 | cloud.google.com/go/orgpolicy v1.14.1 // indirect 15 | cloud.google.com/go/osconfig v1.14.2 // indirect 16 | github.com/felixge/httpsnoop v1.0.4 // indirect 17 | github.com/go-logr/logr v1.4.2 // indirect 18 | github.com/go-logr/stdr v1.2.2 // indirect 19 | github.com/google/s2a-go v0.1.9 // indirect 20 | github.com/google/uuid v1.6.0 // indirect 21 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 22 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 23 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 24 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 // indirect 25 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect 26 | go.opentelemetry.io/otel v1.34.0 // indirect 27 | go.opentelemetry.io/otel/metric v1.34.0 // indirect 28 | go.opentelemetry.io/otel/trace v1.34.0 // indirect 29 | golang.org/x/crypto v0.32.0 // indirect 30 | golang.org/x/net v0.34.0 // indirect 31 | golang.org/x/oauth2 v0.26.0 // indirect 32 | golang.org/x/sync v0.11.0 // indirect 33 | golang.org/x/sys v0.29.0 // indirect 34 | golang.org/x/text v0.21.0 // indirect 35 | golang.org/x/time v0.10.0 // indirect 36 | google.golang.org/api v0.220.0 // indirect 37 | google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect 38 | google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect 39 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 // indirect 40 | google.golang.org/grpc v1.70.0 // indirect 41 | google.golang.org/protobuf v1.36.4 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= 2 | cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= 3 | cloud.google.com/go/accesscontextmanager v1.9.1 h1:+C7HM05/h80znK+8VNu25wAimueda6/NGNdus+jxaHI= 4 | cloud.google.com/go/accesscontextmanager v1.9.1/go.mod h1:wUVSoz8HmG7m9miQTh6smbyYuNOJrvZukK5g6WxSOp0= 5 | cloud.google.com/go/accesscontextmanager v1.9.2 h1:P0uVixQft8aacbZ7VDZStNZdrftF24Hk8JkA3kfvfqI= 6 | cloud.google.com/go/accesscontextmanager v1.9.2/go.mod h1:T0Sw/PQPyzctnkw1pdmGAKb7XBA84BqQzH0fSU7wzJU= 7 | cloud.google.com/go/asset v1.20.1 h1:49lRdlLzNoevMrTyqDA1rnmX0eKVjCS8lN4tI1j1z3g= 8 | cloud.google.com/go/asset v1.20.1/go.mod h1:ZrTOIvEqKVZZB9CrGKuKgKJ+WNRrOwsO93GDcHznfec= 9 | cloud.google.com/go/asset v1.20.4 h1:6oNgjcs5KCPGBD71G0IccK6TfeFsEtBTyQ3Q+Dn09bs= 10 | cloud.google.com/go/asset v1.20.4/go.mod h1:DP09pZ+SoFWUZyPZx26xVroHk+6+9umnQv+01yfJxbM= 11 | cloud.google.com/go/auth v0.14.1 h1:AwoJbzUdxA/whv1qj3TLKwh3XX5sikny2fc40wUl+h0= 12 | cloud.google.com/go/auth v0.14.1/go.mod h1:4JHUxlGXisL0AW8kXPtUF6ztuOksyfUQNFjfsOCXkPM= 13 | cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= 14 | cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= 15 | cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= 16 | cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 17 | cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= 18 | cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= 19 | cloud.google.com/go/iam v1.2.1 h1:QFct02HRb7H12J/3utj0qf5tobFh9V4vR6h9eX5EBRU= 20 | cloud.google.com/go/iam v1.2.1/go.mod h1:3VUIJDPpwT6p/amXRC5GY8fCCh70lxPygguVtI0Z4/g= 21 | cloud.google.com/go/iam v1.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA= 22 | cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY= 23 | cloud.google.com/go/longrunning v0.6.1 h1:lOLTFxYpr8hcRtcwWir5ITh1PAKUD/sG2lKrTSYjyMc= 24 | cloud.google.com/go/longrunning v0.6.1/go.mod h1:nHISoOZpBcmlwbJmiVk5oDRz0qG/ZxPynEGs1iZ79s0= 25 | cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0MK+hc= 26 | cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI= 27 | cloud.google.com/go/orgpolicy v1.14.0 h1:UuLmi1+94lIS3tCoeuinuwx4oxdx58nECiAvfwCW0SM= 28 | cloud.google.com/go/orgpolicy v1.14.0/go.mod h1:S6Pveh1JOxpSbs6+2ToJG7h3HwqC6Uf1YQ6JYG7wdM8= 29 | cloud.google.com/go/orgpolicy v1.14.1 h1:c1QLoM5v8/aDKgYVCUaC039lD3GPvqAhTVOwsGhIoZQ= 30 | cloud.google.com/go/orgpolicy v1.14.1/go.mod h1:1z08Hsu1mkoH839X7C8JmnrqOkp2IZRSxiDw7W/Xpg4= 31 | cloud.google.com/go/osconfig v1.14.1 h1:67ISL0vZVfq0se+1cPRMYgwTjsES2k9vmSmn8ZS0O5g= 32 | cloud.google.com/go/osconfig v1.14.1/go.mod h1:Rk62nyQscgy8x4bICaTn0iWiip5EpwEfG2UCBa2TP/s= 33 | cloud.google.com/go/osconfig v1.14.2 h1:iBN87PQc+EGh5QqijM3CuxcibvDWmF+9k0eOJT27FO4= 34 | cloud.google.com/go/osconfig v1.14.2/go.mod h1:kHtsm0/j8ubyuzGciBsRxFlbWVjc4c7KdrwJw0+g+pQ= 35 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 36 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 37 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 38 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 39 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 40 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 41 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 42 | github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= 43 | github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 44 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 45 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 46 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= 47 | github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= 48 | github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= 49 | github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= 50 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 51 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 52 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc= 53 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0= 54 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= 55 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= 56 | go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= 57 | go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= 58 | go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= 59 | go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= 60 | go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= 61 | go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= 62 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 63 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 64 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 65 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 66 | golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= 67 | golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 68 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 69 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 70 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 71 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 72 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 73 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 74 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 75 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 76 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 77 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 78 | golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= 79 | golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 80 | google.golang.org/api v0.220.0 h1:3oMI4gdBgB72WFVwE1nerDD8W3HUOS4kypK6rRLbGns= 81 | google.golang.org/api v0.220.0/go.mod h1:26ZAlY6aN/8WgpCzjPNy18QpYaz7Zgg1h0qe1GkZEmY= 82 | google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU= 83 | google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4= 84 | google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= 85 | google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= 86 | google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= 87 | google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= 88 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 h1:J1H9f+LEdWAfHcez/4cvaVBox7cOYT+IU6rgqj5x++8= 89 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk= 90 | google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= 91 | google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= 92 | google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= 93 | google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 94 | -------------------------------------------------------------------------------- /key_collection.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/pem" 6 | "fmt" 7 | "os" 8 | "slices" 9 | "sync" 10 | 11 | "golang.org/x/sync/semaphore" 12 | "golang.org/x/time/rate" 13 | ) 14 | 15 | type KeyCollection struct { 16 | serviceAccountIDs []string 17 | observedKeys []ServiceAccountCerts 18 | groundTruthKeys []ServiceAccountKeys 19 | badSAsLock sync.Mutex 20 | badSAs []string 21 | } 22 | 23 | func NewKeyCollection(serviceAccountIDs []string) *KeyCollection { 24 | return &KeyCollection{ 25 | serviceAccountIDs: serviceAccountIDs, 26 | } 27 | } 28 | 29 | func (k *KeyCollection) FetchKeys(groundTruth bool, quotaProject string) error { 30 | err := k.FetchObservedKeys() 31 | if err != nil { 32 | return err 33 | } 34 | if groundTruth { 35 | err := k.FetchGroundTruthKeys() 36 | if err != nil { 37 | return err 38 | } 39 | } 40 | return nil 41 | } 42 | 43 | func (k *KeyCollection) FetchGroundTruthKeys() error { 44 | limiter := rate.NewLimiter(rate.Limit(IAMReadRequestsPerMinutePerProjectMax/60.0), 1) 45 | 46 | k.groundTruthKeys = make([]ServiceAccountKeys, len(k.serviceAccountIDs)) 47 | 48 | iam := iamService() 49 | 50 | res, err := parllelMap(k.serviceAccountIDs, func(sa string) (ServiceAccountKeys, error) { 51 | if k.isBadSA(sa) { 52 | return nil, nil 53 | } 54 | if err := limiter.Wait(context.Background()); err != nil { 55 | return nil, err 56 | } 57 | return getServiceAccountKeys(context.Background(), iam, sa) 58 | }) 59 | if err != nil { 60 | return fmt.Errorf("error getting keys from GCP API: %v", err) 61 | } 62 | k.groundTruthKeys = res 63 | return nil 64 | } 65 | 66 | func (k *KeyCollection) FetchObservedKeys() error { 67 | inflight := semaphore.NewWeighted(MaxInflightX509) 68 | 69 | k.observedKeys = make([]ServiceAccountCerts, len(k.serviceAccountIDs)) 70 | 71 | observedKeys, err := parllelMap(k.serviceAccountIDs, func(sa string) (ServiceAccountCerts, error) { 72 | if err := inflight.Acquire(context.Background(), 1); err != nil { 73 | return nil, err 74 | } 75 | defer inflight.Release(1) 76 | res, err := getServiceAccountKeyCerts(sa) 77 | if err != nil { 78 | fmt.Printf("Warning: error getting keys for service account %v: %v\n", sa, err) 79 | k.addBadSA(sa) 80 | return nil, nil 81 | } 82 | return res, nil 83 | }) 84 | if err != nil { 85 | return fmt.Errorf("error getting keys from GCP API: %v", err) 86 | } 87 | k.observedKeys = observedKeys 88 | return nil 89 | } 90 | 91 | func (k *KeyCollection) isBadSA(sa string) bool { 92 | k.badSAsLock.Lock() 93 | defer k.badSAsLock.Unlock() 94 | return slices.Contains(k.badSAs, sa) 95 | } 96 | 97 | func (k *KeyCollection) addBadSA(sa string) { 98 | k.badSAsLock.Lock() 99 | defer k.badSAsLock.Unlock() 100 | k.badSAs = append(k.badSAs, sa) 101 | } 102 | 103 | func (k *KeyCollection) WritePublicKeysToDir(s string) error { 104 | err := os.MkdirAll(s, 0755) 105 | if err != nil { 106 | return fmt.Errorf("error creating directory %v: %v", s, err) 107 | } 108 | 109 | for i, sa := range k.serviceAccountIDs { 110 | if k.isBadSA(sa) { 111 | continue 112 | } 113 | for keyID, cert := range k.observedKeys[i] { 114 | fname := fmt.Sprintf("%v/%v_%v.pem", s, sa, keyID) 115 | f, err := os.Create(fname) 116 | if err != nil { 117 | return fmt.Errorf("error creating file %v: %v", fname, err) 118 | } 119 | err = pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) 120 | if err != nil { 121 | return fmt.Errorf("error encoding PEM block: %v", err) 122 | } 123 | err = f.Close() 124 | if err != nil { 125 | return fmt.Errorf("error closing file %v: %v", fname, err) 126 | } 127 | } 128 | } 129 | 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /keytype.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "slices" 4 | 5 | // These are "muxed key kinds", which are combinations of key origin and key type 6 | // The google API separates these, but for the purposes of this program it's easier to combine them 7 | // To minimize ambiguity between the two, we call them a "kind" instead of a type 8 | const ( 9 | GOOGLE_PROVIDED_SYSTEM_MANAGED = "GOOGLE_PROVIDED/SYSTEM_MANAGED" 10 | GOOGLE_PROVIDED_USER_MANAGED = "GOOGLE_PROVIDED/USER_MANAGED" 11 | USER_PROVIDED_USER_MANAGED = "USER_PROVIDED/USER_MANAGED" 12 | ) 13 | 14 | // precendence order for key types based on the signals we see 15 | // signals for higher ones take precedence over signals for lower ones 16 | var keyKindPrecedence = []string{ 17 | USER_PROVIDED_USER_MANAGED, 18 | GOOGLE_PROVIDED_USER_MANAGED, 19 | GOOGLE_PROVIDED_SYSTEM_MANAGED, 20 | } 21 | 22 | func keyTypeAndOriginToMuxedKeyKind(keyType string, keyOrigin string) string { 23 | res := keyOrigin + "/" + keyType 24 | if slices.Index(keyKindPrecedence, res) == -1 { 25 | panic("Invalid key type and origin combination: " + res) 26 | } 27 | return res 28 | } 29 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "flag" 7 | "fmt" 8 | "os" 9 | "sync" 10 | 11 | asset "cloud.google.com/go/asset/apiv1" 12 | "google.golang.org/api/iam/v1" 13 | "google.golang.org/api/option" 14 | ) 15 | 16 | var groundTruth = flag.Bool("ground-truth", false, "If specified, will check against the GCP API for the ground truth") 17 | var verbose = flag.Bool("verbose", false, "If specified, will print verbose output") 18 | 19 | var project = flag.String("project", "", "The project to use for the GCP API, to list all service accounts (useful with -ground-truth)") 20 | var scope = flag.String("scope", "", "Use the cloud asset API to get SAs. Can be any cloud asset supported scope like organizations/{ORGANIZATION_NUMBER} or folders/{FOLDER_NUMBER} (useful with -ground-truth)") 21 | var inFile = flag.String("in", "", "Input file to read service accounts from, one per line") 22 | 23 | var outDir = flag.String("out-dir", "", "Output directory to write PEM x509 certificates to") 24 | var quotaProject = flag.String("quota-project", "", "Quota project to use for the GCP API. This is required if you are using a service account that is not in the same project as the service account you are trying to list keys for. This is also required if you are using the cloud asset API with --scope.") 25 | 26 | // output modes 27 | const ( 28 | OUTPUT_NORMAL = "normal" 29 | OUTPUT_VERBOSE = "verbose" 30 | OUTPUT_GROUND_TRUTH = "ground-truth" 31 | ) 32 | 33 | // return false if more than one of the flags is true 34 | func checkMultualExcluveFlags(flags []bool) bool { 35 | count := 0 36 | for _, f := range flags { 37 | if f { 38 | count++ 39 | } 40 | } 41 | return count <= 1 42 | } 43 | 44 | func decideOutputMode() (string, error) { 45 | if !checkMultualExcluveFlags([]bool{*groundTruth, *verbose}) { 46 | return "", fmt.Errorf("must specify one of --ground-truth, or --verbose") 47 | } 48 | if *groundTruth { 49 | return OUTPUT_GROUND_TRUTH, nil 50 | } 51 | if *verbose { 52 | return OUTPUT_VERBOSE, nil 53 | } 54 | return OUTPUT_NORMAL, nil 55 | } 56 | 57 | func gcpClientOptions() []option.ClientOption { 58 | var options []option.ClientOption 59 | if *quotaProject != "" { 60 | options = append(options, option.WithQuotaProject(*quotaProject)) 61 | } 62 | return options 63 | } 64 | 65 | var iamService = sync.OnceValue(func() *iam.Service { 66 | iamService, err := iam.NewService(context.Background(), gcpClientOptions()...) 67 | if err != nil { 68 | fmt.Println(err) 69 | os.Exit(1) 70 | } 71 | return iamService 72 | }) 73 | 74 | func getTargetServiceAccounts() ([]string, error) { 75 | if !checkMultualExcluveFlags([]bool{*inFile != "", flag.NArg() > 0, *scope != "", *project != ""}) { 76 | return nil, fmt.Errorf("must specify one of --scope, --project, --in, or service accounts as arguments") 77 | } 78 | 79 | if *scope != "" { 80 | c, err := asset.NewClient(context.Background(), gcpClientOptions()...) 81 | if err != nil { 82 | return nil, err 83 | } 84 | return getServiceAccountIDsViaAssetInventory(context.Background(), c, *scope) 85 | } else if *project != "" { 86 | return getServiceAccountIDsInProject(context.Background(), iamService(), *project) 87 | } else if *inFile != "" { 88 | return getServiceAccountsFromFile(*inFile) 89 | } else { 90 | return flag.Args(), nil 91 | } 92 | } 93 | 94 | func getServiceAccountsFromFile(s string) ([]string, error) { 95 | f, err := os.Open(s) 96 | if err != nil { 97 | return nil, err 98 | } 99 | defer f.Close() 100 | 101 | scanner := bufio.NewScanner(f) 102 | var res []string 103 | for scanner.Scan() { 104 | res = append(res, scanner.Text()) 105 | } 106 | if err := scanner.Err(); err != nil { 107 | return nil, err 108 | } 109 | 110 | return res, nil 111 | } 112 | 113 | func main() { 114 | flag.Parse() 115 | 116 | serviceAccountIDs, err := getTargetServiceAccounts() 117 | if err != nil { 118 | fmt.Println(err) 119 | os.Exit(1) 120 | } 121 | 122 | if len(serviceAccountIDs) == 0 { 123 | fmt.Println("No service accounts specified. Please specify one or more service accounts or use --project or --scope.") 124 | os.Exit(1) 125 | } 126 | 127 | outputMode, err := decideOutputMode() 128 | if err != nil { 129 | fmt.Println(err) 130 | os.Exit(1) 131 | } 132 | 133 | fmt.Printf("Analyzing %d service accounts\n", len(serviceAccountIDs)) 134 | 135 | keyCollection := NewKeyCollection(serviceAccountIDs) 136 | err = keyCollection.FetchKeys(*groundTruth, *quotaProject) 137 | if err != nil { 138 | fmt.Println(err) 139 | os.Exit(1) 140 | } 141 | 142 | if *outDir != "" { 143 | err = keyCollection.WritePublicKeysToDir(*outDir) 144 | if err != nil { 145 | fmt.Println(err) 146 | os.Exit(1) 147 | } 148 | } 149 | 150 | good := 0 151 | bad := 0 152 | 153 | for i, serviceAccountID := range serviceAccountIDs { 154 | if keyCollection.isBadSA(serviceAccountID) { 155 | continue 156 | } 157 | printedName := false 158 | if outputMode == OUTPUT_VERBOSE { 159 | fmt.Printf("Service Account: %v\n", serviceAccountID) 160 | } 161 | 162 | hasBadKeys := false 163 | for keyId, cert := range keyCollection.observedKeys[i] { 164 | key := NewSAKey(serviceAccountID, cert) 165 | keyKind := key.determineKeyKind() 166 | switch outputMode { 167 | case OUTPUT_NORMAL: 168 | if keyKind != GOOGLE_PROVIDED_SYSTEM_MANAGED { 169 | if !printedName { 170 | fmt.Printf("Service Account: %v\n", serviceAccountID) 171 | printedName = true 172 | } 173 | key.dump(" ", true) 174 | hasBadKeys = true 175 | } 176 | case OUTPUT_VERBOSE: 177 | key.dump(" ", true) 178 | if keyKind != GOOGLE_PROVIDED_SYSTEM_MANAGED { 179 | hasBadKeys = true 180 | } 181 | case OUTPUT_GROUND_TRUTH: 182 | realKey := keyCollection.groundTruthKeys[i][keyId] 183 | realKeyKind := keyTypeAndOriginToMuxedKeyKind(realKey.KeyType, realKey.KeyOrigin) 184 | if realKeyKind != keyKind { 185 | hasBadKeys = true 186 | if !printedName { 187 | fmt.Printf("Service Account: %v\n", serviceAccountID) 188 | printedName = true 189 | } 190 | fmt.Printf(" Key ID: %v - expected %v, got %v\n", key.cert.SerialNumber, realKeyKind, keyKind) 191 | key.dump(" ", true) 192 | } 193 | } 194 | } 195 | if hasBadKeys { 196 | bad++ 197 | } else { 198 | good++ 199 | } 200 | } 201 | 202 | fmt.Printf("Good SAs: %d, Bad SAs: %d\n", good, bad) 203 | 204 | if bad > 0 { 205 | os.Exit(1) 206 | } else { 207 | os.Exit(0) 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /sakey.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rsa" 5 | "crypto/x509" 6 | "fmt" 7 | "slices" 8 | "strings" 9 | ) 10 | 11 | type Signal struct { 12 | keyKind string 13 | explanation string 14 | } 15 | 16 | type SAKey struct { 17 | serviceAccount string 18 | cert *x509.Certificate 19 | signals []Signal 20 | keyKind string 21 | } 22 | 23 | func NewSAKey(serviceAccount string, cert *x509.Certificate) *SAKey { 24 | return &SAKey{ 25 | serviceAccount: serviceAccount, 26 | cert: cert, 27 | signals: []Signal{}, 28 | } 29 | } 30 | 31 | func (k *SAKey) CheckValidityPeriod() { 32 | validityWindow := k.cert.NotAfter.Sub(k.cert.NotBefore) 33 | 34 | if k.cert.NotAfter == defaultMaxAfter { 35 | k.signals = append(k.signals, Signal{ 36 | keyKind: GOOGLE_PROVIDED_USER_MANAGED, 37 | explanation: fmt.Sprintf("Certificate has a NotAfter date of %v", k.cert.NotAfter), 38 | }) 39 | } else if validityWindow == legacyGoogleProvidedUserManagedValidity { 40 | k.signals = append(k.signals, Signal{ 41 | keyKind: GOOGLE_PROVIDED_USER_MANAGED, 42 | explanation: fmt.Sprintf("Certificate has a legacy 10y validity period of %v", validityWindow), 43 | }) 44 | } else if validityWindow == googleProvidedSystemManagedValidityV1 { 45 | k.signals = append(k.signals, Signal{ 46 | keyKind: GOOGLE_PROVIDED_SYSTEM_MANAGED, 47 | explanation: fmt.Sprintf("Certificate has standard validity period of %v", validityWindow), 48 | }) 49 | } else if slices.Contains(serviceAccountKeyExpiryHours, validityWindow) { 50 | k.signals = append(k.signals, Signal{ 51 | keyKind: GOOGLE_PROVIDED_USER_MANAGED, 52 | explanation: fmt.Sprintf("Certificate has a validity period in constraints/iam.serviceAccountKeyExpiryHours of %v", validityWindow), 53 | }) 54 | } else if validityWindow > googleProvidedSystemManagedValidityV2Min && validityWindow < googleProvidedSystemManagedValidityV2Max { 55 | k.signals = append(k.signals, Signal{ 56 | keyKind: GOOGLE_PROVIDED_SYSTEM_MANAGED, 57 | explanation: fmt.Sprintf("Certificate has a validity period of %v which is between %v and %v", validityWindow, googleProvidedSystemManagedValidityV2Min, googleProvidedSystemManagedValidityV2Max), 58 | }) 59 | } else { 60 | k.signals = append(k.signals, Signal{ 61 | keyKind: USER_PROVIDED_USER_MANAGED, 62 | explanation: fmt.Sprintf("Certificate does not have a standard GCP validity window: %v (%v to %v)", validityWindow, k.cert.NotBefore, k.cert.NotAfter), 63 | }) 64 | } 65 | } 66 | 67 | func (k *SAKey) CheckExtensions() { 68 | if len(k.cert.ExtKeyUsage) != 1 || k.cert.ExtKeyUsage[0] != x509.ExtKeyUsageClientAuth { 69 | k.signals = append(k.signals, Signal{ 70 | keyKind: USER_PROVIDED_USER_MANAGED, 71 | explanation: fmt.Sprintf("Certificate has unexpected ExtendedKeyUsage: %v", k.cert.ExtKeyUsage), 72 | }) 73 | } 74 | 75 | if k.cert.KeyUsage != x509.KeyUsageDigitalSignature { 76 | k.signals = append(k.signals, Signal{ 77 | keyKind: USER_PROVIDED_USER_MANAGED, 78 | explanation: fmt.Sprintf("Certificate has unexpected KeyUsage: %v", k.cert.KeyUsage), 79 | }) 80 | } 81 | } 82 | 83 | func (k *SAKey) checkNames() { 84 | expectedName := strings.Replace(k.serviceAccount, "@", ".", 1) 85 | // 64 is the maximum length for a CN 86 | var truncatedName string 87 | if len(expectedName) >= 64 { 88 | truncatedName = expectedName[:64] 89 | } 90 | 91 | checkName := func(t, v string) { 92 | if GAIA_ID.MatchString(v) { 93 | k.signals = append(k.signals, Signal{ 94 | keyKind: GOOGLE_PROVIDED_USER_MANAGED, 95 | explanation: fmt.Sprintf("%v %v is a GAIA_ID", t, v), 96 | }) 97 | } else if v == expectedName { 98 | k.signals = append(k.signals, Signal{ 99 | keyKind: GOOGLE_PROVIDED_SYSTEM_MANAGED, 100 | explanation: fmt.Sprintf("%v %v matches expected name %v", t, v, expectedName), 101 | }) 102 | } else if truncatedName != "" && v == truncatedName { 103 | k.signals = append(k.signals, Signal{ 104 | keyKind: GOOGLE_PROVIDED_SYSTEM_MANAGED, 105 | explanation: fmt.Sprintf("%v %v matches expected truncated name %v", t, v, truncatedName), 106 | }) 107 | } else { 108 | k.signals = append(k.signals, Signal{ 109 | keyKind: USER_PROVIDED_USER_MANAGED, 110 | explanation: fmt.Sprintf("%v %v does not match any expected name %v", t, v, expectedName), 111 | }) 112 | } 113 | } 114 | 115 | checkName("SubjectCN", k.cert.Subject.CommonName) 116 | checkName("IssuerCN", k.cert.Issuer.CommonName) 117 | } 118 | 119 | // Note: we don't emit positive signals for google provided keys here on purpose, only negative signals 120 | // because a key using the same parameters as a google provided key is not necessarily a google provided key 121 | func (k *SAKey) checkCrypto() { 122 | if k.cert.PublicKeyAlgorithm != x509.RSA { 123 | k.signals = append(k.signals, Signal{ 124 | keyKind: USER_PROVIDED_USER_MANAGED, 125 | explanation: fmt.Sprintf("Public key algorithm %v is not RSA", k.cert.PublicKeyAlgorithm), 126 | }) 127 | } 128 | 129 | if k.cert.SignatureAlgorithm != x509.SHA1WithRSA { 130 | k.signals = append(k.signals, Signal{ 131 | keyKind: USER_PROVIDED_USER_MANAGED, 132 | explanation: fmt.Sprintf("Signature algorithm %v is not SHA1WithRSA", k.cert.SignatureAlgorithm), 133 | }) 134 | } 135 | 136 | if k.cert.PublicKey.(*rsa.PublicKey).N.BitLen() == 1024 { 137 | k.signals = append(k.signals, Signal{ 138 | keyKind: GOOGLE_PROVIDED_USER_MANAGED, 139 | explanation: "Public key length is 1024", 140 | }) 141 | } else if k.cert.PublicKey.(*rsa.PublicKey).N.BitLen() != 2048 { 142 | k.signals = append(k.signals, Signal{ 143 | keyKind: USER_PROVIDED_USER_MANAGED, 144 | explanation: fmt.Sprintf("Public key length %v is not 2048 or 1024", k.cert.PublicKey.(*rsa.PublicKey).N.BitLen()), 145 | }) 146 | } 147 | } 148 | 149 | func (k *SAKey) check() { 150 | k.checkNames() 151 | k.checkCrypto() 152 | k.CheckValidityPeriod() 153 | k.CheckExtensions() 154 | } 155 | 156 | // Returns the keyOrigin and keyType of the key 157 | // the precedence order is: 158 | // 1. USER_PROVIDED+USER_MANAGED 159 | // 2. GOOGLE_PROVIDED+USER_MANAGED 160 | // 3. GOOGLE_PROVIDED+SYSTEM_MANAGED 161 | // (note that GUSER_PROVIDED+SYSTEM_MANAGED is not possible) 162 | func (k *SAKey) determineKeyKind() (res string) { 163 | k.check() 164 | 165 | // There should always be at least one signal from the validity period checks 166 | if len(k.signals) == 0 { 167 | panic("No signals found for key") 168 | } 169 | 170 | for _, signal := range k.signals { 171 | if res == "" { 172 | res = signal.keyKind 173 | continue 174 | } 175 | 176 | // If the current signal is higher in precedence than the current result, replace the result 177 | if slices.Index(keyKindPrecedence, signal.keyKind) < slices.Index(keyKindPrecedence, res) { 178 | res = signal.keyKind 179 | } 180 | } 181 | 182 | k.keyKind = res 183 | return 184 | } 185 | 186 | func (k *SAKey) dump(indent string, includeSignals bool) { 187 | fmt.Printf("%vKey ID: %v - likely %v\n", indent, k.cert.SerialNumber, k.keyKind) 188 | if includeSignals { 189 | for _, signal := range k.signals { 190 | fmt.Printf("%v Signal for %v: %v\n", indent, signal.keyKind, signal.explanation) 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /sakey_x509.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/json" 6 | "encoding/pem" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | ) 11 | 12 | type ServiceAccountCerts map[string]*x509.Certificate 13 | 14 | func getServiceAccountKeyCerts(sa string) (ServiceAccountCerts, error) { 15 | resp, err := http.Get("https://www.googleapis.com/service_accounts/v1/metadata/x509/" + sa) 16 | if err != nil { 17 | return nil, fmt.Errorf("error making request: %v", err) 18 | } 19 | defer resp.Body.Close() 20 | 21 | if resp.StatusCode == http.StatusNotFound { 22 | return nil, fmt.Errorf("error: service account not found. Does it exist and is it enabled?") 23 | } 24 | 25 | if resp.StatusCode != http.StatusOK { 26 | return nil, fmt.Errorf("error: unexpected status code: %v. Check", resp.StatusCode) 27 | } 28 | 29 | body, err := io.ReadAll(resp.Body) 30 | if err != nil { 31 | return nil, fmt.Errorf("error reading response body: %v", err) 32 | } 33 | 34 | var keys map[string]string 35 | err = json.Unmarshal(body, &keys) 36 | if err != nil { 37 | return nil, fmt.Errorf("error unmarshaling JSON: %v", err) 38 | } 39 | 40 | certs := map[string]*x509.Certificate{} 41 | for keyId, v := range keys { 42 | block, rest := pem.Decode([]byte(v)) 43 | if block == nil { 44 | return nil, fmt.Errorf("error decoding PEM block") 45 | } 46 | if len(rest) > 0 { 47 | return nil, fmt.Errorf("error: Extra data after PEM block") 48 | } 49 | 50 | if block.Type != "CERTIFICATE" { 51 | return nil, fmt.Errorf("error: Unexpected PEM block type: %v. Expected CERTIFICATE", block.Type) 52 | } 53 | if len(block.Headers) > 0 { 54 | return nil, fmt.Errorf("error: unexpected headers in PEM block %v", block.Headers) 55 | } 56 | 57 | cert, err := x509.ParseCertificate(block.Bytes) 58 | if err != nil { 59 | return nil, fmt.Errorf("error parsing certificate: %v", err) 60 | } 61 | 62 | certs[keyId] = cert 63 | } 64 | 65 | return certs, nil 66 | } 67 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | ) 7 | 8 | // Why isn't this in the standard library...? 9 | func parllelMap[I any, O any](items []I, f func(I) (O, error)) ([]O, error) { 10 | res := make([]O, len(items)) 11 | errs := make([]error, len(items)) 12 | var wg sync.WaitGroup 13 | wg.Add(len(items)) 14 | for i, item := range items { 15 | i := i 16 | item := item 17 | go func() { 18 | defer wg.Done() 19 | r, err := f(item) 20 | res[i] = r 21 | errs[i] = err 22 | }() 23 | } 24 | wg.Wait() 25 | 26 | final_err := errors.Join(errs...) 27 | if final_err != nil { 28 | return nil, final_err 29 | } 30 | 31 | return res, nil 32 | } 33 | --------------------------------------------------------------------------------