├── .github └── workflows │ └── release.yaml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── authz ├── logic.go ├── logic_test.go ├── server.go └── server_test.go ├── charts └── acjs-k8s-local │ ├── .helmignore │ ├── Chart.yaml │ ├── templates │ ├── _helpers.tpl │ ├── configmap.yaml │ ├── deployment.yaml │ ├── mutatingwebhookconfig.yaml │ └── service.yaml │ └── values.yaml ├── common ├── authz.go ├── config.go ├── config_test.go ├── listener.go └── listener_test.go ├── flags.go ├── go.mod ├── go.sum ├── main.go ├── simple-ac.yaml └── slsa ├── googlecloud.go ├── googlecloud_test.go ├── provenance.go ├── provenance_test.go ├── repo.go └── repo_test.go /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Acjs release 16 | on: 17 | workflow_dispatch: 18 | 19 | jobs: 20 | Release: 21 | runs-on: ubuntu-latest 22 | 23 | permissions: 24 | contents: read 25 | packages: write 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v3 30 | 31 | - name: Setup Docker Buildx 32 | uses: docker/setup-buildx-action@v2 33 | 34 | - name: Login to GitHub Container Registry 35 | uses: docker/login-action@v2 36 | with: 37 | registry: ghcr.io 38 | username: ${{ github.actor }} 39 | password: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | - name: Build and push 42 | uses: docker/build-push-action@v2 43 | with: 44 | context: . 45 | platforms: linux/amd64 46 | push: ${{ github.event_name != 'pull_request' }} 47 | tags: | 48 | ghcr.io/${{ github.repository }}:latest 49 | labels: ${{ steps.meta.outputs.labels }} 50 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement (CLA). You (or your employer) retain the copyright to your 10 | contribution; this simply gives us permission to use and redistribute your 11 | contributions as part of the project. Head over to 12 | to see your current agreements on file or 13 | to sign a new one. 14 | 15 | You generally only need to submit a CLA once, so if you've already submitted one 16 | (even if it was for a different project), you probably don't need to do it 17 | again. 18 | 19 | ## Code Reviews 20 | 21 | All submissions, including submissions by project members, require review. We 22 | use GitHub pull requests for this purpose. Consult 23 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 24 | information on using pull requests. 25 | 26 | ## Community Guidelines 27 | 28 | This project follows 29 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This is a [distroless](https://github.com/GoogleContainerTools/distroless?tab=readme-ov-file#examples-with-docker) 2 | # container image that contains acjs and slsa-verifier 3 | 4 | FROM golang:bookworm AS build 5 | 6 | ENV CGO_ENABLED=0 7 | ENV GOOS=linux 8 | 9 | RUN go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@v2.4.1 10 | 11 | WORKDIR /app 12 | COPY go.mod go.sum ./ 13 | RUN go mod download 14 | COPY . . 15 | RUN go build -o acjs . 16 | 17 | 18 | FROM gcr.io/distroless/static-debian12 19 | 20 | WORKDIR /usr/local/bin 21 | COPY --from=build /app/acjs . 22 | COPY --from=build /go/bin/slsa-verifier . 23 | ENTRYPOINT ["/usr/local/bin/acjs"] 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Admission control with Javascript policies 2 | 3 | **This is not an officially supported Google product.** 4 | 5 | Kubernetes supports the concept of admission control - which is effectively a 6 | webhook that is triggered upon certain action (e.g. pod creation). The admission 7 | controller can then inspect the request and decide whether the action is to be 8 | allowed or rejected. 9 | 10 | ## acjs 11 | 12 | The project `acjs` is a Kubernetes admission controller that allows inspecting 13 | the requests with policies written in JavaScript, making it flexible enough to 14 | cover both typical any atypical needs. It was developed to support the [ctrdac](https://github.com/google/ctrdac) 15 | project in non-Kubernetes context, but it can be used in standard Kubernetes 16 | clusters as well. `ctrdac` can be connected to `acjs`; to facilitate the 17 | process, `acjs` can be listening on a unix domain socket and `ctrdac` can 18 | forward the requests to that UDS. This setup allows enforcing policies and 19 | verifying images "offline" (on the same host). 20 | 21 | Acjs can be used both as "validating webhook" and "mutating webhook". 22 | 23 | ### acjs setup in a Kubernetes cluster 24 | 25 | It should be as easy as running: 26 | 27 | ``` 28 | $ git clone https://github.com/google/acjs.git 29 | $ helm install --create-namespace --namespace acjs-namespace -f /path/to/policies.yaml acjs-installation acjs/charts/acjs-k8s-local 30 | ``` 31 | 32 | Where policies.yaml contains your acjs policies to enforce: 33 | 34 | ``` 35 | policies: 36 | - name: some policy 37 | ... 38 | ``` 39 | 40 | ### acjs setup with ctrdac 41 | 42 | Create a `yaml` configuration file for `acjs`. Setup the listener and the 43 | policies. You can refer to the sample config `simple-ac.yaml`. Start `acjs`: 44 | 45 | ``` 46 | ./acjs -config-file ./simple-ac.yaml 47 | ``` 48 | 49 | You may execute `ctrdac` with these options: 50 | 51 | ``` 52 | ./ctrdac -containerd-socket /tmp/ctrdac.sock -validating-webhook '/tmp/acjs.sock' 53 | ``` 54 | 55 | ### acjs policies 56 | 57 | A policy is a piece of javascript code. The admission decision is made based on 58 | the return value: 59 | 60 | - boolean true: evaluating the policies is terminated and the admission 61 | request is accepted 62 | - boolean false: evaluating the policies is terminated and the admission 63 | request is rejected 64 | - string: evaluating the policies is terminated and the admission request is 65 | rejected with the returned string as the admission response message 66 | - exception was thrown: evaluating the policies is terminated and the 67 | admission request is rejected with the exception message as the admission 68 | response message 69 | - undef (or no explicit return), the engine proceeds to evaluating the next 70 | policy. If there are no more policies, the decision is made based on the 71 | `defaultAction` config option (it defaults to reject). 72 | 73 | The following global variables are available in the context of a policy: 74 | 75 | - `ac.Timestamp`: The timestamp when `acjs` received the current admission 76 | request 77 | 78 | - `ac.User`: Information about the entity that sent the current admission 79 | request. In the case of the UDS listener, this will be a dictionary about 80 | the process that connected to the `acjs` socket, e.g. `ctrdac`. Subfields: 81 | `Pid`, `Uid`, `Gid`, `Username`, `Group` In the case of the mTLS listener, 82 | this will be a dictionary with subject of the client certificate presented 83 | during the handshake. You may access it like this: `ac.User.CommonName` 84 | 85 | - `ac.UserAuthNMethod`: A string indicating the type of authentication (e.g. 86 | `mTLS` or `unix-domain-socket`) 87 | 88 | - `ac.RequestPeerCertificates` a slice of x509.Certificates; this is the peer 89 | certificate when mTLS is used 90 | 91 | - `ac.HTTPRequest` this is the raw, incoming net/http.Request. You can use it 92 | in advanced rules to inspect the request path (`ac.HTTPRequest.RequestURI`) 93 | or the headers (e.g. ac.HTTPRequest.Header.Get("X-Something")`)). In a 94 | ctrdac setup, you can also use it to check which containerd interface the 95 | request was originally submitted to: 96 | ``` 97 | ac.HTTPRequest.Header.Get("X-Ctrdac-RequestUri") == "/containerd.services.containers.v1.Container/Create" 98 | ``` 99 | 100 | In a Kubernetes context, you can use the RequestUri to serve multiple 101 | webhookconfigurations with the same acjs process. Example: 102 | 103 | ``` 104 | if (ac.HTTPRequest.RequestUri == "/ac1") { 105 | // ... some logic here 106 | } 107 | if (ac.HTTPRequest.RequestUri == "/ac2") { 108 | // ... some logic here 109 | } 110 | ``` 111 | 112 | - `ac.GlobalContext` a dictionary that is available through the whole 113 | lifecycle of tha authz configuration. You can use it to save some kind of 114 | state information, if needed. 115 | 116 | - `req`: this is the incoming 117 | [AdmissionRequest](https://pkg.go.dev/k8s.io/api/admission/v1#AdmissionRequest). 118 | As such, you may access info like this (in line with the `json=""`): 119 | `req.namespace` or `req.name` 120 | 121 | - `object`: this is the json parsed `req.Object` (which is just raw bytes). 122 | This is typically a [Pod](https://pkg.go.dev/k8s.io/api/core/v1#Pod) As 123 | such, you may access info like this (in line with the `json=""`): 124 | `object.spec.containers[0].name` or `object.spec.containers[0].image` 125 | 126 | - `console`: this is the Javascript standard console object that you may use 127 | for logging. 128 | 129 | - `cosignVerify(keyPath)`: built-in function to verify cosign signatures on 130 | the images present in the request This function relies on the `cosign` 131 | binary in the PATH. It returns a map indexed by the image references where 132 | the value is the JSON output of cosign for further processing or throws an 133 | error when the verification was unsuccessful. Example: 134 | 135 | ``` 136 | {"some/image":[{"Critical":{...}}]} 137 | ``` 138 | 139 | - `slsaVerify({"SourceURI": "github.com/irsl/gcb-tests", "BuilderID": 140 | "https://cloudbuild.googleapis.com/GoogleHostedWorker", "ProvenancePath": 141 | "/home/user/provenance-github.json"})`: built-in function to verify SLSA 142 | provenance. It returns a boolean. This function relies on the 143 | `slsa-verifier` binary in the PATH. It returns boolean true or an error 144 | string. 145 | 146 | - `slsaEnsureComingFrom(repos)`: built-in helper function to verify SLSA 147 | provenance, it supports looking up image provenance on the fly. This 148 | function relies on the `slsa-verifier` binary in the PATH. It returns a 149 | boolean indicating whether the image was built at one of the provided 150 | repositories. 151 | 152 | - `forwardToAdmissionController(acUrl)`: built-in function to forward the 153 | current admission review request to another admission controller. 154 | 155 | - `atob` helper function to decode a base64 string 156 | 157 | - `btoa` helper function to encode something into base64 158 | 159 | `acjs` can work as a mutating webhook: if the policy makes changes on the 160 | `object` and returns true, then a JSON Patch is calculated and included in the 161 | admission review response. 162 | 163 | ### An example policy 164 | 165 | ``` 166 | policies: 167 | - name: some name of the policy 168 | code: | 169 | console.log("hello!", ac.User.Username, "x", req.UID, "x", object.spec.containers[0].name, "x", object.spec.containers[0].image) 170 | 171 | if (object.spec.containers[0].name.includes("apple")) 172 | return "please choose a different fruit" 173 | ``` 174 | 175 | The corresponding response may look like this: 176 | 177 | ``` 178 | $ curl --unix-socket /tmp/acjs.sock http:/images/ -d @/home/user/ac1-apple.json 179 | {"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1","response":{"uid":"2bb7b8e5-3cd4-47ea-9b4e-ee8c98dc00ed","allowed":false,"status":{"metadata":{},"status":"Failure","message":"some name of the policy: please choose a different fruit","reason":"VIOLATES_POLICY"}} 180 | ``` 181 | 182 | ### Example to mutate a Pod - default k8s conversion 183 | 184 | ``` 185 | policies: 186 | - name: replace the parameters 187 | code: | 188 | object.spec.containers[0].command = ["/bin/echo", "hello :) sorry, this is probably not what you expected."] 189 | object.spec.containers[0].args = [] 190 | return true 191 | ``` 192 | 193 | ### Example to mutate the runc spec - k8s conversion is disabled 194 | 195 | ``` 196 | policies: 197 | - name: replace the parameters 198 | code: | 199 | runcSpec = JSON.parse(atob(object.container.spec.value)) 200 | runcSpec.process.env.push("SOMETHING=debug") 201 | object.container.spec.value = btoa(JSON.stringify(runcSpec)) 202 | ``` 203 | 204 | ### Example to forward the request 205 | 206 | ``` 207 | policies: 208 | - name: example-forward 209 | code: | 210 | if (object.spec.containers[0].name.includes("apple")) 211 | // apple containers are to be evaluated by this another admission controller: 212 | return forwardToAdmissionController('https://my-awesome-ac.com/v1/projects/user-test/policy/locations/europe-west4-b/clusters/cluster-1:admissionReview?timeout=10s') 213 | ``` 214 | 215 | ### Example to verify SLSA: 216 | 217 | ``` 218 | policies: 219 | - name: example-slsa 220 | code: | 221 | var trustedSourceRepos = ["github.com/irsl/gcb-tests"] 222 | if (!slsaEnsureComingFrom(trustedSourceRepos)) 223 | return "SLSA verification of the image failed. Trusted repos are: "+(trustedSourceRepos.join(", ")) 224 | ``` 225 | 226 | Example rejection: 227 | 228 | ``` 229 | $ docker run --rm -it us-west2-docker.pkg.dev/user-test/quickstart-docker-repo/quickstart-image:tag3 230 | docker: Error response from daemon: VIOLATES_POLICY: some name of the policy: SLSA verification of the image failed. Trusted repos are: github.com/irsl/gcb-tests: invalid argument. 231 | ``` 232 | 233 | Example success: 234 | 235 | ``` 236 | $ docker run --rm -it us-west2-docker.pkg.dev/user-test/quickstart-docker-repo/quickstart-image:v41 237 | user: Hello! The time is Thu Mar 23 10:47:47 UTC 2023. 238 | ``` 239 | 240 | ### Example to secure deprecated gitRepo volumes: 241 | 242 | Kubernetes's gitRepo volume type is vulnerable to privilege escalation attacks 243 | (code execution in the context of `kubelet`). 244 | The following mutating webhook policy secures the configuration on the fly by 245 | offloading the git operation into init containers: 246 | 247 | ``` 248 | policies: 249 | - name: securing gitrepo volumes 250 | code: | 251 | for(var i = 0; i < object.spec.volumes.length; i++) { 252 | var volume = object.spec.volumes[i] 253 | if(!volume.gitRepo) continue 254 | var gitRepoCfg = volume.gitRepo 255 | var gitCmd = [ 256 | "git", 257 | "clone", 258 | ] 259 | if (gitRepoCfg.revision) { 260 | gitCmd.push("--branch", gitRepoCfg.revision) 261 | } 262 | gitCmd.push("--", gitRepoCfg.repository) 263 | object.spec.initContainers = [ 264 | ...(object.spec.initContainers || []), 265 | { 266 | name: "gitrepo-init-"+i, 267 | image: "bitnami/git", 268 | workingDir: "/repo-volume", 269 | command: gitCmd, 270 | volumeMounts: [{ 271 | mountPath: "/repo-volume", 272 | name: volume.name 273 | }] 274 | } 275 | ] 276 | volume.emptyDir = {} 277 | delete volume.gitRepo 278 | } 279 | return true 280 | ``` 281 | 282 | 283 | 284 | -------------------------------------------------------------------------------- /authz/logic.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package authz includes the core implementation of the acjs application: policy evaluation. 16 | package authz 17 | 18 | import ( 19 | "encoding/base64" 20 | "encoding/json" 21 | "fmt" 22 | "io/fs" 23 | "io/ioutil" 24 | "os" 25 | "os/exec" 26 | "strings" 27 | 28 | log "github.com/golang/glog" 29 | "github.com/google/acjs/common" 30 | "github.com/google/acjs/slsa" 31 | "github.com/google/ctrdac/lookup" 32 | "github.com/dop251/goja" 33 | "github.com/mattbaird/jsonpatch" 34 | 35 | ctrdachttp "github.com/google/ctrdac/http" 36 | k8sac "k8s.io/api/admission/v1" 37 | k8smeta "k8s.io/apimachinery/pkg/apis/meta/v1" 38 | ) 39 | 40 | type authDecision int 41 | 42 | const ( 43 | reject authDecision = 0 44 | allow authDecision = 1 45 | ) 46 | 47 | type compiledPolicy struct { 48 | name string 49 | script *goja.Program 50 | } 51 | 52 | // CompiledPolicies is the type the authorization logic operates on. 53 | type CompiledPolicies struct { 54 | config *common.ConfigFile 55 | globalContext map[string]any 56 | compiledPolicies []compiledPolicy 57 | defaultAction authDecision 58 | } 59 | 60 | func consoleLog(v ...any) { 61 | log.Infoln(v...) 62 | } 63 | 64 | func convertDefaultAction(action string, def authDecision) authDecision { 65 | switch strings.ToLower(action) { 66 | case "reject": 67 | return reject 68 | case "allow": 69 | return allow 70 | default: 71 | return def 72 | } 73 | } 74 | 75 | func cosignVerify(ar *k8sac.AdmissionRequest, object any, publicKeyPath string) (any, error) { 76 | imagesRef, err := getImagesFromRequest(ar, object) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | // TODO(imrer): integrate this with the golang library instead, once it is released 82 | 83 | re := map[string]any{} 84 | 85 | for _, imageRef := range imagesRef { 86 | log.V(2).Infof("verifying cosign on image: %v", imageRef) 87 | stdout, err := callCosignVerify(imageRef, publicKeyPath) 88 | if err != nil { 89 | if strings.HasPrefix(err.Error(), "exit status 1") { 90 | // just no matching signatures, nothing "exceptional" 91 | return nil, nil 92 | } 93 | 94 | return nil, fmt.Errorf("invoking the cosign cli failed: %v", err) 95 | } 96 | var m []any 97 | if err := json.Unmarshal(stdout, &m); err != nil { 98 | return nil, fmt.Errorf("unable to parse Cosign response: %v", err) 99 | } 100 | 101 | re[imageRef] = m 102 | } 103 | 104 | return re, nil 105 | } 106 | 107 | // SlsaParams describes the arguments expected by the verifySlsa function in the context of an 108 | // acjs policy. 109 | type SlsaParams struct { 110 | BuilderID string 111 | ProvenancePath string 112 | SourceURI string 113 | } 114 | 115 | func slsaVerify(ar *k8sac.AdmissionRequest, object any, slsaParams SlsaParams) (bool, error) { 116 | imagesRef, err := getImagesFromRequest(ar, object) 117 | if err != nil { 118 | return false, err 119 | } 120 | 121 | log.V(2).Infof("slsaVerify, %v, params: %+v", imagesRef, slsaParams) 122 | 123 | for _, imageRef := range imagesRef { 124 | // TODO(imrer): refactor this to use the library behind 125 | output, err := callSlsaVerifier(imageRef, slsaParams.ProvenancePath, slsaParams.BuilderID, slsaParams.SourceURI) 126 | if err != nil { 127 | return false, fmt.Errorf("invoking the slsa-verifier cli failed: %v %v", string(output), err) 128 | } 129 | } 130 | 131 | return true, nil 132 | } 133 | 134 | func getImagesFromRequest(ar *k8sac.AdmissionRequest, object any) ([]string, error) { 135 | kind := lookup.Lookup(object, "kind", "") 136 | if kind != "Pod" { 137 | // it is not a k8s Pod, but may be a runc spec. 138 | image := lookup.Lookup(object, "container.image", "") 139 | if image != "" { 140 | return []string{image}, nil 141 | } 142 | 143 | return nil, fmt.Errorf("unable to extract image reference from object of kind: %s", kind) 144 | } 145 | var re []string 146 | var containers []any 147 | for _, path := range []string{"spec.containers", "spec.initContainers", "spec.ephemeralContainers"} { 148 | containers = append(containers, lookup.Lookup[[]any](object, path, nil)...) 149 | } 150 | for _, container := range containers { 151 | image := lookup.Lookup(container, "image", "") 152 | if image == "" { 153 | return nil, fmt.Errorf("image attribute is not present for container %v", lookup.Lookup(container, "name", "")) 154 | } 155 | 156 | re = append(re, image) 157 | } 158 | return re, nil 159 | } 160 | 161 | func slsaEnsureComingFromImage(imageRef string, slsaTrustedRepos []*slsa.Repo) (bool, error) { 162 | slsaTrustedRepoMatches := slsa.FindMatchingRepos(imageRef, slsaTrustedRepos) 163 | if len(slsaTrustedRepoMatches) == 0 { 164 | // makes no sense to fetch provenance, no idea which repo this image belongs to 165 | return false, nil 166 | } 167 | 168 | provenanceMeta, err := callSlsaObtainProvenance(imageRef) 169 | if err != nil { 170 | return false, err 171 | } 172 | file, err := ioutil.TempFile("/tmp", "slsa-provenance") 173 | if err != nil { 174 | return false, err 175 | } 176 | tmpProvenancePath := file.Name() 177 | defer os.Remove(tmpProvenancePath) 178 | permissions := 0644 // or whatever you need 179 | err = os.WriteFile(tmpProvenancePath, provenanceMeta, fs.FileMode(permissions)) 180 | if err != nil { 181 | return false, err 182 | } 183 | 184 | for _, slsaTrustedRepo := range slsaTrustedRepoMatches { 185 | 186 | output, err := callSlsaVerifier(imageRef, tmpProvenancePath, slsaTrustedRepo.BuilderID, slsaTrustedRepo.Repo) 187 | 188 | if err != nil { 189 | // it just didn't match 190 | log.V(2).Infof("SLSA verification output: %s", string(output)) 191 | continue 192 | } 193 | 194 | // match! 195 | return true, nil 196 | } 197 | 198 | // it didn't match any of the trusted repos 199 | return false, nil 200 | } 201 | 202 | func slsaEnsureComingFrom(ar *k8sac.AdmissionRequest, object any, trustedRepos []string) (bool, error) { 203 | imageRefs, err := getImagesFromRequest(ar, object) 204 | if err != nil { 205 | return false, err 206 | } 207 | 208 | log.V(2).Infof("slsaEnsureComingFrom: %v, params: %+v", imageRefs, trustedRepos) 209 | 210 | slsaTrustedRepos, err := callSlsaResolver(trustedRepos...) 211 | if err != nil { 212 | return false, err 213 | } 214 | for _, imageRef := range imageRefs { 215 | a, err := slsaEnsureComingFromImage(imageRef, slsaTrustedRepos) 216 | if err != nil { 217 | return false, err 218 | } 219 | if !a { 220 | return a, nil 221 | } 222 | } 223 | 224 | // all images matched 225 | return true, nil 226 | } 227 | 228 | // CompilePolicies processes the yaml parsed configuration file and does some preparation steps. 229 | // Most importantly, it compiles the string policies present in the configuration file 230 | // and turns them into a goja.Program so executing them later on will be faster 231 | func CompilePolicies(config *common.ConfigFile) (*CompiledPolicies, error) { 232 | re := CompiledPolicies{} 233 | re.config = config 234 | re.defaultAction = convertDefaultAction(config.DefaultAction, reject) 235 | 236 | re.globalContext = make(map[string]any) 237 | seenAlready := map[string]bool{} 238 | for _, policy := range config.Policies { 239 | if seenAlready[policy.Name] { 240 | return nil, fmt.Errorf("duplicate policy name: %s", policy.Name) 241 | } 242 | seenAlready[policy.Name] = true 243 | var cp compiledPolicy 244 | 245 | cp.name = policy.Name 246 | code := config.Globals + "\n\nfunction policyHandler() {\n" + policy.Code + "\n}\npolicyHandler()\n" 247 | script, err := goja.Compile("", code, false) 248 | if err != nil { 249 | return nil, fmt.Errorf("unable to compile policy %s: %v", policy.Name, err) 250 | } 251 | cp.script = script 252 | re.compiledPolicies = append(re.compiledPolicies, cp) 253 | } 254 | return &re, nil 255 | } 256 | 257 | func jsWrapper(avm *goja.Runtime, callback func() (any, error)) any { 258 | cs, err := callback() 259 | if err != nil { 260 | panic(avm.ToValue(err.Error())) 261 | } 262 | return cs 263 | } 264 | 265 | // this will return either: 266 | // - boolean true at successful validation 267 | // - string message at policy violation 268 | func forwardToAdmissionController(acURL string, ar *k8sac.AdmissionRequest) (any, error) { 269 | areq := k8sac.AdmissionReview{ 270 | TypeMeta: k8smeta.TypeMeta{ 271 | Kind: "AdmissionReview", 272 | APIVersion: "admission.k8s.io/v1", 273 | }, 274 | Request: ar, 275 | } 276 | ares, err := callValidatingWebhook(acURL, areq) 277 | if err != nil { 278 | return nil, err 279 | } 280 | if ares.Response.Allowed { 281 | return true, nil 282 | } 283 | 284 | // else it is not allowed 285 | return ares.Response.Result.Message, nil 286 | } 287 | 288 | func createPatch(orgObjBytes []byte, newObj any) ([]byte, error) { 289 | newObjBytes, err := json.Marshal(newObj) 290 | if err != nil { 291 | return nil, err 292 | } 293 | 294 | patches, err := jsonpatch.CreatePatch(orgObjBytes, newObjBytes) 295 | if err != nil { 296 | return nil, err 297 | } 298 | 299 | return json.Marshal(patches) 300 | } 301 | 302 | func (cp *CompiledPolicies) doEvaluate(rc *common.AdmissionControllerRequest, areq *k8sac.AdmissionRequest) *k8sac.AdmissionResponse { 303 | var re k8sac.AdmissionResponse 304 | re.UID = areq.UID 305 | re.Allowed = false 306 | 307 | rc.GlobalContext = cp.globalContext 308 | 309 | allowed := false 310 | reason := "" 311 | message := "" 312 | decided := false 313 | 314 | var patch []byte 315 | var object any 316 | err := json.Unmarshal(areq.Object.Raw, &object) 317 | if err == nil { 318 | avm := goja.New() 319 | 320 | avm.Set("console", map[string](func(...any)){ 321 | "log": consoleLog, 322 | }) 323 | avm.Set("atob", func(b64 string) string { 324 | return jsWrapper(avm, func() (any, error) { 325 | bytes, err := base64.StdEncoding.DecodeString(b64) 326 | if err != nil { 327 | return nil, err 328 | } 329 | return string(bytes), nil 330 | }).(string) 331 | }) 332 | avm.Set("btoa", func(in string) string { 333 | return base64.StdEncoding.EncodeToString([]byte(in)) 334 | }) 335 | avm.Set("cosignVerify", func(publicKeyPath string) any { 336 | return jsWrapper(avm, func() (any, error) { 337 | return cosignVerify(areq, object, publicKeyPath) 338 | }) 339 | }) 340 | avm.Set("slsaVerify", func(params SlsaParams) any { 341 | r, err := slsaVerify(areq, object, params) 342 | if err != nil { 343 | return err.Error() 344 | } 345 | return r 346 | }) 347 | avm.Set("slsaEnsureComingFrom", func(repos []string) any { 348 | return jsWrapper(avm, func() (any, error) { 349 | return slsaEnsureComingFrom(areq, object, repos) 350 | }) 351 | }) 352 | avm.Set("forwardToAdmissionController", func(acUrl string) any { 353 | return jsWrapper(avm, func() (any, error) { 354 | return forwardToAdmissionController(acUrl, areq) 355 | }) 356 | }) 357 | 358 | avm.Set("ac", rc) 359 | avm.Set("req", areq) 360 | avm.Set("object", object) 361 | 362 | for _, p := range cp.compiledPolicies { 363 | 364 | value, err := avm.RunProgram(p.script) 365 | if err != nil { 366 | allowed = false 367 | reason = "INTERNAL_ERROR" 368 | message = p.name + ": unable to evaluate policy:" + err.Error() 369 | 370 | decided = true 371 | break 372 | 373 | } else if !goja.IsUndefined(value) { 374 | 375 | switch govalue := value.Export().(type) { 376 | case bool: 377 | if govalue { 378 | allowed = true 379 | } else { 380 | allowed = false 381 | reason = "VIOLATES_POLICY" 382 | message = p.name + ": request denied by policy" 383 | } 384 | case string: 385 | allowed = false 386 | reason = "VIOLATES_POLICY" 387 | message = p.name + ": " + govalue 388 | default: 389 | allowed = false 390 | reason = "INTERNAL_ERROR" 391 | message = p.name + fmt.Sprintf(": unknown return value from policy (%T: %v)", govalue, govalue) 392 | } 393 | 394 | decided = true 395 | break 396 | 397 | } 398 | 399 | } 400 | } else { 401 | allowed = false 402 | reason = "INTERNAL_ERROR" 403 | message = "unable to unmarshal object of the request:" + err.Error() 404 | decided = true 405 | } 406 | 407 | if !decided { 408 | // default decision 409 | allowed = cp.defaultAction == allow 410 | } 411 | 412 | if allowed { 413 | // need to compare the object 414 | patch, err = createPatch(areq.Object.Raw, object) 415 | 416 | if err != nil { 417 | allowed = false 418 | reason = "INTERNAL_ERROR" 419 | message = "unable to calculate the object diff:" + err.Error() 420 | } else if patch != nil && len(patch) != 2 { // 2 bytes patch is '[]' 421 | patchType := k8sac.PatchTypeJSONPatch 422 | re.PatchType = &patchType 423 | re.Patch = patch 424 | } 425 | } 426 | 427 | re.Allowed = allowed 428 | re.Result = &k8smeta.Status{ 429 | Reason: k8smeta.StatusReason(reason), 430 | Message: message, 431 | } 432 | 433 | if re.Result == nil { 434 | re.Result = &k8smeta.Status{} 435 | if !re.Allowed { 436 | re.Result.Reason = "VIOLATES_POLICY" 437 | re.Result.Message = "Default decision" 438 | } 439 | } 440 | if re.Allowed { 441 | re.Result.Status = "Success" 442 | } else { 443 | re.Result.Status = "Failure" 444 | } 445 | 446 | return &re 447 | } 448 | 449 | // LogEntry is the type that will be turned into a JSON string and will be emitted for each request 450 | // to the standard output. 451 | type LogEntry struct { 452 | Ts string 453 | UserAuthNMethod string 454 | User any 455 | Request any 456 | Response *k8sac.AdmissionResponse 457 | } 458 | 459 | // Evaluate iterates over the configured policies and evaluates the incoming admission review request. 460 | func (cp *CompiledPolicies) Evaluate(rc *common.AdmissionControllerRequest, areq *k8sac.AdmissionRequest) *k8sac.AdmissionResponse { 461 | res := cp.doEvaluate(rc, areq) 462 | 463 | l := LogEntry{ 464 | Ts: rc.Timestamp, 465 | Request: areq, 466 | Response: res, 467 | UserAuthNMethod: rc.UserAuthNMethod, 468 | User: rc.User, 469 | } 470 | 471 | logBytes, err := json.Marshal(l) 472 | if err == nil { 473 | fmt.Printf("%s\n", logBytes) 474 | } 475 | 476 | return res 477 | } 478 | 479 | var callValidatingWebhook = func(url string, ar k8sac.AdmissionReview) (*k8sac.AdmissionReview, error) { 480 | ar, err := ctrdachttp.Post[k8sac.AdmissionReview](url, ar, nil) 481 | if err != nil { 482 | return nil, err 483 | } 484 | return &ar, nil 485 | } 486 | 487 | var callCosignVerify = func(imageRef, publicKeyPath string) ([]byte, error) { 488 | return exec.Command("cosign", "verify", "--key", publicKeyPath, imageRef).Output() 489 | } 490 | 491 | var callSlsaResolver = func(trustedRepos ...string) ([]*slsa.Repo, error) { 492 | resolver := slsa.RepoResolver{} 493 | return resolver.Resolve(trustedRepos...) 494 | } 495 | 496 | var callSlsaObtainProvenance = func(imageRef string) ([]byte, error) { 497 | return slsa.ObtainProvenance(imageRef) 498 | } 499 | 500 | var callSlsaVerifier = func(imageRef, tmpProvenancePath, builderID, repo string) ([]byte, error) { 501 | // TODO(imrer): refactor this to use the library behind 502 | params := []string{"verify-image", imageRef, "--provenance-path", tmpProvenancePath, "--builder-id", builderID, "--source-uri", repo} 503 | log.V(2).Infof("executing command: slsa-verifier %v", params) 504 | return exec.Command("slsa-verifier", params...).CombinedOutput() 505 | } 506 | -------------------------------------------------------------------------------- /authz/logic_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package authz 16 | 17 | import ( 18 | "encoding/json" 19 | "fmt" 20 | "io" 21 | "net/http" 22 | "os" 23 | "reflect" 24 | "strconv" 25 | "testing" 26 | 27 | "github.com/google/acjs/common" 28 | "github.com/google/acjs/slsa" 29 | "k8s.io/apimachinery/pkg/runtime" 30 | "k8s.io/apimachinery/pkg/types" 31 | 32 | k8sac "k8s.io/api/admission/v1" 33 | k8smeta "k8s.io/apimachinery/pkg/apis/meta/v1" 34 | ) 35 | 36 | func TestFundamentals(t *testing.T) { 37 | config := &common.ConfigFile{ 38 | Globals: ` 39 | function addNumbers(n1, n2) { 40 | return n1 + n2 41 | } 42 | `, 43 | Policies: []common.ConfigPolicy{ 44 | { 45 | // this step does not return anything 46 | Name: "logging", 47 | Code: ` 48 | console.log("hello!", ac.User.Username, "x", req.UID, "x", object) 49 | `, 50 | }, 51 | { 52 | // this step returns a boolean false 53 | Name: "user restriction", 54 | Code: ` 55 | if (ac.User.Username == "restricteduser") 56 | return false 57 | `, 58 | }, 59 | { 60 | // returns a string 61 | Name: "access to the object", 62 | Code: ` 63 | if (object.foo == "bar") 64 | return "you should not send "+object.foo 65 | `, 66 | }, 67 | { 68 | // access to the AdmissionRequest 69 | Name: "admissionrequest", 70 | Code: ` 71 | if (req.UID == "3") 72 | return "req.UID is 3" 73 | `, 74 | }, 75 | { 76 | // base64 functions 77 | Name: "atob", 78 | Code: ` 79 | if (object.atob == atob("aGVsbG8=")) 80 | return "atob" 81 | `, 82 | }, 83 | { 84 | // base64 functions 85 | Name: "btoa", 86 | Code: ` 87 | if (object.btoa == btoa("hello")) 88 | return "btoa" 89 | `, 90 | }, 91 | { 92 | Name: "true-terminates1", 93 | Code: ` 94 | if (ac.User.Username == "trueuser") 95 | return true 96 | `, 97 | }, 98 | { 99 | Name: "true-terminates2", 100 | Code: ` 101 | if (ac.User.Username == "trueuser") 102 | return "should not get here" 103 | `, 104 | }, 105 | { 106 | Name: "patch", 107 | Code: ` 108 | if (ac.User.Username == "patchuser") { 109 | object.attr = "modified" 110 | return true 111 | } 112 | `, 113 | }, 114 | { 115 | // this step relies on functions defined as globals 116 | Name: "global functions", 117 | Code: ` 118 | if (ac.User.Username == "globalfunctions") 119 | return "addNumbers(1,2)=" + addNumbers(1,2) 120 | `, 121 | }, 122 | { 123 | // these two steps rely on the global context 124 | Name: "global context1", 125 | Code: ` 126 | ac.GlobalContext[req.UID] = "value-set-in-previous-step" 127 | `, 128 | }, 129 | { 130 | Name: "global context2", 131 | Code: ` 132 | if (ac.User.Username == "globalcontext") 133 | return ac.GlobalContext[req.UID] 134 | `, 135 | }, 136 | { 137 | Name: "http request path", 138 | Code: ` 139 | if (ac.HTTPRequest.RequestURI + "/" + ac.User.Username == "/some/path/pathtest") 140 | return "i dont like this path" 141 | `, 142 | }, 143 | { 144 | Name: "http request header", 145 | Code: ` 146 | if (ac.User.Username == "headertest" && ac.HTTPRequest.Header.Get('X-Something-Cool') == "hello") 147 | return "i dont like cool headers" 148 | `, 149 | }, 150 | }, 151 | DefaultAction: "Allow", 152 | } 153 | 154 | cp, err := CompilePolicies(config) 155 | if err != nil { 156 | t.Fatal(err) 157 | } 158 | 159 | type testcase struct { 160 | user string 161 | object any 162 | expectedAllowed bool 163 | expectedMessage string 164 | expectedPatch string 165 | } 166 | 167 | testcases := []testcase{ 168 | { 169 | user: "someone", 170 | object: map[string]any{}, 171 | expectedAllowed: true, 172 | }, 173 | { 174 | user: "restricteduser", 175 | object: map[string]any{}, 176 | expectedAllowed: false, 177 | expectedMessage: "user restriction: request denied by policy", 178 | }, 179 | { 180 | user: "someone", 181 | object: map[string]any{"foo": "bar"}, 182 | expectedAllowed: false, 183 | expectedMessage: "access to the object: you should not send bar", 184 | }, 185 | { 186 | user: "someone", 187 | object: map[string]any{}, 188 | expectedAllowed: false, 189 | expectedMessage: "admissionrequest: req.UID is 3", 190 | }, 191 | { 192 | user: "someone", 193 | object: map[string]any{"atob": "hello"}, 194 | expectedAllowed: false, 195 | expectedMessage: "atob: atob", 196 | }, 197 | { 198 | user: "someone", 199 | object: map[string]any{"btoa": "aGVsbG8="}, 200 | expectedAllowed: false, 201 | expectedMessage: "btoa: btoa", 202 | }, 203 | { 204 | user: "trueuser", 205 | object: map[string]any{}, 206 | expectedAllowed: true, 207 | }, 208 | { 209 | user: "patchuser", 210 | object: map[string]any{"attr": "original"}, 211 | expectedAllowed: true, 212 | expectedPatch: `[{"op":"replace","path":"/attr","value":"modified"}]`, 213 | }, 214 | { 215 | user: "globalfunctions", 216 | object: map[string]any{}, 217 | expectedAllowed: false, 218 | expectedMessage: "global functions: addNumbers(1,2)=3", 219 | }, 220 | { 221 | user: "globalcontext", 222 | object: map[string]any{}, 223 | expectedAllowed: false, 224 | expectedMessage: "global context2: value-set-in-previous-step", 225 | }, 226 | { 227 | user: "pathtest", 228 | object: map[string]any{}, 229 | expectedAllowed: false, 230 | expectedMessage: "http request path: i dont like this path", 231 | }, 232 | { 233 | user: "headertest", 234 | object: map[string]any{}, 235 | expectedAllowed: false, 236 | expectedMessage: "http request header: i dont like cool headers", 237 | }, 238 | } 239 | 240 | for i, tc := range testcases { 241 | objectBytes, err := json.Marshal(tc.object) 242 | if err != nil { 243 | t.Fatal(err) 244 | } 245 | rc := &common.AdmissionControllerRequest{ 246 | User: map[string]any{"Username": tc.user}, 247 | HTTPRequest: &http.Request{ 248 | Method: "POST", 249 | RequestURI: "/some/path", 250 | Header: http.Header{ 251 | "X-Something-Cool": {"hello"}, 252 | }, 253 | }, 254 | } 255 | areq := &k8sac.AdmissionRequest{ 256 | UID: types.UID(strconv.Itoa(i)), 257 | Object: runtime.RawExtension{ 258 | Raw: objectBytes, 259 | }, 260 | } 261 | aresp := cp.Evaluate(rc, areq) 262 | 263 | if aresp.Allowed != tc.expectedAllowed { 264 | t.Errorf("subtest %d failed; allowed %v vs %v", i, aresp.Allowed, tc.expectedAllowed) 265 | } 266 | if aresp.Result.Message != tc.expectedMessage { 267 | t.Errorf("subtest %d failed; message %v vs %v", i, aresp.Result.Message, tc.expectedMessage) 268 | } 269 | if string(aresp.Patch) != tc.expectedPatch { 270 | t.Errorf("subtest %d failed; patch %v vs %v", i, string(aresp.Patch), tc.expectedPatch) 271 | } 272 | } 273 | } 274 | 275 | func TestConvertDefaultAction(t *testing.T) { 276 | if allow != convertDefaultAction("Allow", reject) { 277 | t.Errorf("convertDefaultAction(Allow) failed") 278 | } 279 | if reject != convertDefaultAction("Reject", allow) { 280 | t.Errorf("convertDefaultAction(Reject) failed") 281 | } 282 | if allow != convertDefaultAction("foobar", allow) { 283 | t.Errorf("convertDefaultAction(foobar) failed") 284 | } 285 | } 286 | 287 | func TestImageExtractionK8s(t *testing.T) { 288 | k8sobject := map[string]any{"kind": "Pod", "spec": map[string]any{ 289 | "containers": []any{map[string]any{"image": "image1"}}, 290 | "initContainers": []any{map[string]any{"image": "image2"}}, 291 | "ephemeralContainers": []any{map[string]any{"image": "image3"}}, 292 | }} 293 | is, err := getImagesFromRequest(nil, k8sobject) 294 | if err != nil { 295 | t.Fatal(err) 296 | } 297 | if !reflect.DeepEqual(is, []string{"image1", "image2", "image3"}) { 298 | t.Errorf("getImagesFromRequest invalid response for k8s input: %+v", is) 299 | } 300 | } 301 | 302 | func TestImageExtractionRunc(t *testing.T) { 303 | runcobject := map[string]any{"container": map[string]any{"image": "imagename"}} 304 | is, err := getImagesFromRequest(nil, runcobject) 305 | if err != nil { 306 | t.Fatal(err) 307 | } 308 | if !reflect.DeepEqual(is, []string{"imagename"}) { 309 | t.Errorf("getImagesFromRequest invalid response for runc input: %+v", is) 310 | } 311 | } 312 | 313 | func TestFowrardToAdmissionController(t *testing.T) { 314 | origCallValidatingWebhook := callValidatingWebhook 315 | defer func() { callValidatingWebhook = origCallValidatingWebhook }() 316 | callValidatingWebhook = func(url string, ar k8sac.AdmissionReview) (*k8sac.AdmissionReview, error) { 317 | if url != "https://some.tld/ac" { 318 | return nil, fmt.Errorf("unexpected ac url: %v", url) 319 | } 320 | if ar.Request.UID != "uid" { 321 | return nil, fmt.Errorf("unexpected ac payload: %v", url) 322 | } 323 | aresp := &k8sac.AdmissionReview{ 324 | Response: &k8sac.AdmissionResponse{ 325 | Allowed: false, 326 | Result: &k8smeta.Status{ 327 | Message: "external rejection", 328 | }, 329 | }, 330 | } 331 | 332 | return aresp, nil 333 | } 334 | config := &common.ConfigFile{ 335 | Policies: []common.ConfigPolicy{ 336 | { 337 | Name: "forward", 338 | Code: ` 339 | return forwardToAdmissionController("https://some.tld/ac") 340 | `, 341 | }, 342 | }, 343 | DefaultAction: "Allow", 344 | } 345 | 346 | cp, err := CompilePolicies(config) 347 | if err != nil { 348 | t.Fatal(err) 349 | } 350 | 351 | rc := &common.AdmissionControllerRequest{ 352 | User: map[string]any{"Username": "whatever"}, 353 | } 354 | areq := &k8sac.AdmissionRequest{ 355 | UID: types.UID("uid"), 356 | Object: runtime.RawExtension{ 357 | Raw: []byte(`{}`), 358 | }, 359 | } 360 | aresp := cp.Evaluate(rc, areq) 361 | 362 | if aresp.Allowed != false { 363 | t.Errorf("should have been rejected") 364 | } 365 | if aresp.Result.Message != "forward: external rejection" { 366 | t.Errorf("unexpected rejection message: %v", aresp.Result.Message) 367 | } 368 | } 369 | 370 | func TestCosignVerify(t *testing.T) { 371 | origCallCosignVerify := callCosignVerify 372 | defer func() { callCosignVerify = origCallCosignVerify }() 373 | callCosignVerify = func(imageRef, publicKeyPath string) ([]byte, error) { 374 | 375 | if imageRef != "some/image" { 376 | return nil, fmt.Errorf("unexpected imageRef: %v", imageRef) 377 | } 378 | if publicKeyPath != "/path/to/key.pub" { 379 | return nil, fmt.Errorf("unexpected publicKeyPath: %v", publicKeyPath) 380 | } 381 | 382 | return []byte(`[{"Critical":{"Identity":{"docker-reference":""},"Image":{"Docker-manifest-digest":"87ef60f558bad79beea6425a3b28989f01dd417164150ab3baab98dcbf04def8"},"Type":"cosign container image signature"},"Optional":null}]`), nil 383 | } 384 | config := &common.ConfigFile{ 385 | Policies: []common.ConfigPolicy{ 386 | { 387 | Name: "cosign", 388 | Code: ` 389 | var x = cosignVerify("/path/to/key.pub") 390 | return x["some/image"][0].Critical.Type 391 | `, 392 | }, 393 | }, 394 | DefaultAction: "Allow", 395 | } 396 | 397 | cp, err := CompilePolicies(config) 398 | if err != nil { 399 | t.Fatal(err) 400 | } 401 | 402 | rc := &common.AdmissionControllerRequest{ 403 | User: map[string]any{"Username": "whatever"}, 404 | } 405 | areq := &k8sac.AdmissionRequest{ 406 | UID: types.UID("uid"), 407 | Object: runtime.RawExtension{ 408 | Raw: []byte(`{"container":{"image":"some/image"}}`), 409 | }, 410 | } 411 | aresp := cp.Evaluate(rc, areq) 412 | 413 | if aresp.Allowed != false { 414 | t.Errorf("should have been rejected") 415 | } 416 | if aresp.Result.Message != "cosign: cosign container image signature" { 417 | t.Errorf("unexpected rejection message: %v", aresp.Result.Message) 418 | } 419 | } 420 | 421 | func TestSlsaEnsureComingFrom(t *testing.T) { 422 | origCallSlsaResolver := callSlsaResolver 423 | defer func() { callSlsaResolver = origCallSlsaResolver }() 424 | callSlsaResolver = func(trustedRepos ...string) ([]*slsa.Repo, error) { 425 | if len(trustedRepos) != 1 || trustedRepos[0] != "github.com/irsl/gcb-tests" { 426 | return nil, fmt.Errorf("unexpected trustedRepos: %v", trustedRepos) 427 | } 428 | return []*slsa.Repo{{ 429 | BuilderID: "cloud-build", 430 | Images: []string{"us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image"}, 431 | Repo: trustedRepos[0], 432 | }}, nil 433 | } 434 | origCallSlsaObtainProvenance := callSlsaObtainProvenance 435 | defer func() { callSlsaObtainProvenance = origCallSlsaObtainProvenance }() 436 | callSlsaObtainProvenance = func(imageRef string) ([]byte, error) { 437 | if imageRef != "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529" { 438 | return nil, fmt.Errorf("unexpected imageRef for callSlsaObtainProvenance: %v", imageRef) 439 | } 440 | return []byte(`some-provenance-data`), nil 441 | } 442 | 443 | origCallSlsaVerifier := callSlsaVerifier 444 | defer func() { callSlsaVerifier = origCallSlsaVerifier }() 445 | callSlsaVerifier = func(imageRef, tmpProvenancePath, builderID, repo string) ([]byte, error) { 446 | if imageRef != "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529" { 447 | return nil, fmt.Errorf("unexpected imageRef for callSlsaVerifier: %v", imageRef) 448 | } 449 | if builderID != "cloud-build" { 450 | return nil, fmt.Errorf("unexpected builderID for callSlsaVerifier: %v", builderID) 451 | } 452 | if repo != "github.com/irsl/gcb-tests" { 453 | return nil, fmt.Errorf("unexpected repo for callSlsaVerifier: %v", repo) 454 | } 455 | f, err := os.Open(tmpProvenancePath) 456 | if err != nil { 457 | return nil, err 458 | } 459 | bytes, err := io.ReadAll(f) 460 | if err != nil { 461 | return nil, err 462 | } 463 | f.Close() 464 | if string(bytes) != "some-provenance-data" { 465 | return nil, fmt.Errorf("unexpected provenance data: %v", string(bytes)) 466 | } 467 | return []byte("success"), nil 468 | } 469 | 470 | config := &common.ConfigFile{ 471 | Policies: []common.ConfigPolicy{ 472 | { 473 | Name: "slsaEnsureComingFrom", 474 | Code: ` 475 | if (!slsaEnsureComingFrom(["github.com/irsl/gcb-tests"])) 476 | return "according to slsaEnsureComingFrom this image is not coming from one of the trusted repositories" 477 | `, 478 | }, 479 | }, 480 | DefaultAction: "Allow", 481 | } 482 | 483 | cp, err := CompilePolicies(config) 484 | if err != nil { 485 | t.Fatal(err) 486 | } 487 | 488 | rc := &common.AdmissionControllerRequest{ 489 | User: map[string]any{"Username": "whatever"}, 490 | } 491 | areq := &k8sac.AdmissionRequest{ 492 | UID: types.UID("uid"), 493 | Object: runtime.RawExtension{ 494 | Raw: []byte(`{"container":{"image":"some/image"}}`), 495 | }, 496 | } 497 | aresp := cp.Evaluate(rc, areq) 498 | 499 | if aresp.Allowed != false { 500 | t.Errorf("should have been rejected") 501 | } 502 | if aresp.Result.Message != "slsaEnsureComingFrom: according to slsaEnsureComingFrom this image is not coming from one of the trusted repositories" { 503 | t.Errorf("unexpected rejection message: %v", aresp.Result.Message) 504 | } 505 | 506 | // and repeating with a successful verification 507 | areq = &k8sac.AdmissionRequest{ 508 | UID: types.UID("uid"), 509 | Object: runtime.RawExtension{ 510 | Raw: []byte(`{"container":{"image":"us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529"}}`), 511 | }, 512 | } 513 | aresp = cp.Evaluate(rc, areq) 514 | if aresp.Allowed != true { 515 | t.Errorf("should have been accepted") 516 | } 517 | } 518 | 519 | func TestSlsaVerify(t *testing.T) { 520 | origCallSlsaVerifier := callSlsaVerifier 521 | defer func() { callSlsaVerifier = origCallSlsaVerifier }() 522 | callSlsaVerifier = func(imageRef, tmpProvenancePath, builderID, repo string) ([]byte, error) { 523 | if imageRef != "some/image" { 524 | return nil, fmt.Errorf("unexpected imageRef for callSlsaVerifier: %v", imageRef) 525 | } 526 | if builderID != "https://cloudbuild.googleapis.com/GoogleHostedWorker" { 527 | return nil, fmt.Errorf("unexpected builderID for callSlsaVerifier: %v", builderID) 528 | } 529 | if repo != "github.com/irsl/gcb-tests" { 530 | return nil, fmt.Errorf("unexpected repo for callSlsaVerifier: %v", repo) 531 | } 532 | if tmpProvenancePath != "/home/imrer/provenance-github.json" { 533 | return nil, fmt.Errorf("unexpected provenance path for callSlsaVerifier: %v", tmpProvenancePath) 534 | } 535 | 536 | return []byte("stdout"), fmt.Errorf("slsa-verifier didn't like this image") 537 | } 538 | 539 | config := &common.ConfigFile{ 540 | Policies: []common.ConfigPolicy{ 541 | { 542 | Name: "slsaVerify", 543 | Code: ` 544 | return slsaVerify({"SourceURI": "github.com/irsl/gcb-tests", "BuilderID": "https://cloudbuild.googleapis.com/GoogleHostedWorker", "ProvenancePath": "/home/imrer/provenance-github.json"}) 545 | `, 546 | }, 547 | }, 548 | DefaultAction: "Reject", 549 | } 550 | 551 | cp, err := CompilePolicies(config) 552 | if err != nil { 553 | t.Fatal(err) 554 | } 555 | 556 | rc := &common.AdmissionControllerRequest{ 557 | User: map[string]any{"Username": "whatever"}, 558 | } 559 | areq := &k8sac.AdmissionRequest{ 560 | UID: types.UID("uid"), 561 | Object: runtime.RawExtension{ 562 | Raw: []byte(`{"container":{"image":"some/image"}}`), 563 | }, 564 | } 565 | aresp := cp.Evaluate(rc, areq) 566 | 567 | if aresp.Allowed != false { 568 | t.Errorf("should have been rejected") 569 | } 570 | if aresp.Result.Message != "slsaVerify: invoking the slsa-verifier cli failed: stdout slsa-verifier didn't like this image" { 571 | t.Errorf("unexpected rejection message: %v", aresp.Result.Message) 572 | } 573 | 574 | } 575 | -------------------------------------------------------------------------------- /authz/server.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package authz 16 | 17 | import ( 18 | "encoding/json" 19 | "errors" 20 | "io" 21 | "net/http" 22 | "time" 23 | 24 | "github.com/google/acjs/common" 25 | 26 | k8sac "k8s.io/api/admission/v1" 27 | ) 28 | 29 | // AdmissionControllerServer is the root type of an acjs server instance 30 | type AdmissionControllerServer struct { 31 | policies common.CompiledPolicies 32 | listener common.Listener 33 | } 34 | 35 | // UsePolicies can swap the policies on the fly for a running acjs instance. This can be useful 36 | // e.g. at SIGHUP. 37 | func (pl *AdmissionControllerServer) UsePolicies(cp common.CompiledPolicies) { 38 | pl.policies = cp 39 | } 40 | 41 | func (pl *AdmissionControllerServer) handleRequest(w http.ResponseWriter, r *http.Request) error { 42 | bodyBytes, err := io.ReadAll(r.Body) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | var areq k8sac.AdmissionReview 48 | err = json.Unmarshal(bodyBytes, &areq) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | az := common.AdmissionControllerRequest{} 54 | az.HTTPRequest = r 55 | az.Timestamp = time.Now().Format(time.RFC3339) 56 | err = pl.listener.PopulateRequest(&az, r) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | aresp := pl.policies.Evaluate(&az, areq.Request) 62 | 63 | ar := k8sac.AdmissionReview{ 64 | TypeMeta: areq.TypeMeta, 65 | Response: aresp, 66 | } 67 | 68 | respBytes, err := json.Marshal(&ar) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | w.WriteHeader(http.StatusOK) 74 | w.Write(respBytes) 75 | 76 | return nil 77 | } 78 | 79 | // Serve starts accepting and serving requests. It returns an error immediately if it is unable to. 80 | func (pl *AdmissionControllerServer) Serve() error { 81 | 82 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 83 | err := pl.handleRequest(w, r) 84 | if err != nil { 85 | w.WriteHeader(http.StatusInternalServerError) 86 | w.Write([]byte(err.Error())) 87 | return 88 | } 89 | 90 | }) 91 | s := http.Server{} 92 | s.Handler = handler 93 | if err := pl.listener.ConfigureHooks(&s); err != nil { 94 | return err 95 | } 96 | return s.Serve(pl.listener.GetListener()) 97 | } 98 | 99 | // NewServer creates a new acjs server instance based on the configuration provided. 100 | // It returns a common.AuthzServer interface. 101 | func NewServer(c *common.ConfigFile) (common.AuthzServer, error) { 102 | 103 | listeners := 0 104 | if c.Listener != nil { 105 | if c.Listener.Mtls != nil { 106 | listeners++ 107 | } 108 | if c.Listener.TLS != nil { 109 | listeners++ 110 | } 111 | if c.Listener.Path != nil { 112 | listeners++ 113 | } 114 | } 115 | if listeners > 1 { 116 | return nil, errors.New("only one listener can be configured") 117 | } 118 | 119 | var err error 120 | var listener common.Listener 121 | switch { 122 | case c.Listener.TLS != nil: 123 | listener, err = common.NewTLSListener(c.Listener.TLS) 124 | case c.Listener.Mtls != nil: 125 | listener, err = common.NewMtlsListener(c.Listener.Mtls) 126 | case c.Listener.Path != nil: 127 | listener, err = common.NewPathListener(c.Listener.Path) 128 | } 129 | 130 | if err != nil { 131 | return nil, err 132 | } 133 | 134 | return &AdmissionControllerServer{ 135 | listener: listener, 136 | }, nil 137 | } 138 | -------------------------------------------------------------------------------- /authz/server_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package authz 16 | 17 | import ( 18 | "path" 19 | "testing" 20 | 21 | "github.com/google/acjs/common" 22 | "github.com/google/ctrdac/http" 23 | "k8s.io/apimachinery/pkg/runtime" 24 | "k8s.io/apimachinery/pkg/types" 25 | 26 | k8sac "k8s.io/api/admission/v1" 27 | ) 28 | 29 | func TestEnd2End(t *testing.T) { 30 | tempdir := t.TempDir() 31 | udsPath := path.Join(tempdir, "acjs.sock") 32 | config := &common.ConfigFile{ 33 | Listener: &common.ConfigListener{ 34 | Path: &common.ConfigPathListener{ 35 | SocketPath: udsPath, 36 | }, 37 | }, 38 | Policies: []common.ConfigPolicy{ 39 | { 40 | Name: "logging", 41 | Code: ` 42 | console.log("hello!", ac.User.Username, "x", req.UID, "x", object) 43 | `, 44 | }, 45 | { 46 | Name: "some name of the policy", 47 | Code: ` 48 | return "rejection: "+req.UID+" "+object.foo+" "+ac.HTTPRequest.Header.Get("Accept-Encoding") 49 | `, 50 | }, 51 | }, 52 | DefaultAction: "Allow", 53 | } 54 | cp, err := CompilePolicies(config) 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | 59 | server, err := NewServer(config) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | server.UsePolicies(cp) 64 | go server.Serve() 65 | 66 | uid := "deadbeef" 67 | areview := k8sac.AdmissionReview{ 68 | Request: &k8sac.AdmissionRequest{ 69 | UID: types.UID(uid), 70 | Object: runtime.RawExtension{ 71 | Raw: []byte(`{"foo":"bar"}`), 72 | }, 73 | }, 74 | } 75 | re, err := http.Post[k8sac.AdmissionReview](udsPath, areview, nil) 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | if re.Response.Allowed { 80 | t.Errorf("should have been rejected") 81 | } 82 | eMessage := "some name of the policy: rejection: " + uid + " bar gzip" 83 | if re.Response.Result.Message != eMessage { 84 | t.Errorf("unexpeceted rejection message: %v vs %v", re.Response.Result.Message, eMessage) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /charts/acjs-k8s-local/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /charts/acjs-k8s-local/Chart.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: v2 16 | name: acjs 17 | description: A Helm chart for AcJs 18 | type: application 19 | version: 1.0.0 20 | appVersion: "1.0.0" 21 | -------------------------------------------------------------------------------- /charts/acjs-k8s-local/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "acjs.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "acjs.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "acjs.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "acjs.labels" -}} 37 | helm.sh/chart: {{ include "acjs.chart" . }} 38 | {{ include "acjs.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "acjs.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "acjs.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | -------------------------------------------------------------------------------- /charts/acjs-k8s-local/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: v1 16 | kind: ConfigMap 17 | metadata: 18 | name: {{ include "acjs.fullname" . }}-config 19 | labels: 20 | {{- include "acjs.labels" . | nindent 4 }} 21 | data: 22 | config.yaml: | 23 | listener: 24 | tls: 25 | privateKeyPath: "/etc/acjs-tls/acjs.key" 26 | certificatePath: "/etc/acjs-tls/acjs.crt" 27 | listenOn: ":8443" 28 | policies: 29 | {{- .Values.policies | toYaml | nindent 6 }} 30 | -------------------------------------------------------------------------------- /charts/acjs-k8s-local/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: apps/v1 16 | kind: Deployment 17 | metadata: 18 | name: {{ include "acjs.fullname" . }} 19 | labels: 20 | {{- include "acjs.labels" . | nindent 4 }} 21 | spec: 22 | replicas: {{ .Values.replicaCount }} 23 | selector: 24 | matchLabels: 25 | {{- include "acjs.selectorLabels" . | nindent 6 }} 26 | template: 27 | metadata: 28 | labels: 29 | {{- include "acjs.selectorLabels" . | nindent 8 }} 30 | spec: 31 | volumes: 32 | - name: tls-layer 33 | secret: 34 | secretName: {{ include "acjs.fullname" . }}-secret 35 | - name: config 36 | configMap: 37 | name: {{ include "acjs.fullname" . }}-config 38 | containers: 39 | - name: {{ .Chart.Name }} 40 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 41 | imagePullPolicy: {{ .Values.image.pullPolicy }} 42 | securityContext: 43 | allowPrivilegeEscalation: false 44 | runAsNonRoot: true 45 | runAsUser: 1000 46 | args: 47 | - "-config-file" 48 | - /etc/acjs/config.yaml 49 | ports: 50 | - name: acjs-listener 51 | containerPort: 8443 52 | protocol: TCP 53 | volumeMounts: 54 | - name: tls-layer 55 | readOnly: true 56 | mountPath: "/etc/acjs-tls" 57 | - name: config 58 | readOnly: true 59 | mountPath: "/etc/acjs" 60 | -------------------------------------------------------------------------------- /charts/acjs-k8s-local/templates/mutatingwebhookconfig.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | {{- $name:= printf "%s-svc.%s.svc" (include "acjs.fullname" .) $.Release.Namespace -}} 16 | {{- $cert := genSelfSignedCert ( include "acjs.fullname" $ ) nil ( list $name ) 14600 -}} 17 | 18 | apiVersion: v1 19 | kind: Secret 20 | metadata: 21 | name: {{ include "acjs.fullname" . }}-secret 22 | labels: 23 | {{- include "acjs.labels" . | nindent 4 }} 24 | data: 25 | acjs.crt: {{ $cert.Cert | b64enc }} 26 | acjs.key: {{ $cert.Key | b64enc }} 27 | 28 | --- 29 | 30 | apiVersion: admissionregistration.k8s.io/v1 31 | kind: MutatingWebhookConfiguration 32 | metadata: 33 | name: "{{ $name }}" 34 | webhooks: 35 | - name: "{{ $name }}" 36 | namespaceSelector: 37 | matchExpressions: 38 | - key: name 39 | operator: NotIn 40 | values: 41 | - {{ .Release.Namespace }} 42 | rules: 43 | - apiGroups: [""] 44 | apiVersions: ["v1"] 45 | operations: ["CREATE"] 46 | resources: ["pods"] 47 | scope: "Namespaced" 48 | clientConfig: 49 | service: 50 | namespace: {{ $.Release.Namespace }} 51 | name: {{ include "acjs.fullname" . }}-svc 52 | caBundle: {{ $cert.Cert | b64enc }} 53 | admissionReviewVersions: ["v1"] 54 | sideEffects: None 55 | timeoutSeconds: 15 56 | -------------------------------------------------------------------------------- /charts/acjs-k8s-local/templates/service.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: v1 16 | kind: Service 17 | metadata: 18 | name: {{ include "acjs.fullname" . }}-svc 19 | labels: 20 | {{- include "acjs.labels" . | nindent 4 }} 21 | spec: 22 | type: {{ .Values.service.type }} 23 | ports: 24 | - port: {{ .Values.service.port }} 25 | targetPort: 8443 26 | protocol: TCP 27 | name: acjs-listener 28 | selector: 29 | {{- include "acjs.selectorLabels" . | nindent 4 }} 30 | -------------------------------------------------------------------------------- /charts/acjs-k8s-local/values.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | replicaCount: 1 16 | 17 | image: 18 | repository: ghcr.io/google/acjs 19 | tag: "latest" 20 | pullPolicy: IfNotPresent 21 | 22 | nameOverride: "" 23 | fullnameOverride: "" 24 | 25 | service: 26 | type: ClusterIP 27 | port: 443 28 | -------------------------------------------------------------------------------- /common/authz.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package common 16 | 17 | import ( 18 | "crypto/x509" 19 | "net/http" 20 | 21 | k8sac "k8s.io/api/admission/v1" 22 | ) 23 | 24 | // CompiledPolicies is an interface to evaluate admission requests 25 | type CompiledPolicies interface { 26 | Evaluate(rc *AdmissionControllerRequest, ar *k8sac.AdmissionRequest) *k8sac.AdmissionResponse 27 | } 28 | 29 | // AuthzServer type represents the acjs admission controller 30 | type AuthzServer interface { 31 | Serve() error 32 | UsePolicies(CompiledPolicies) 33 | } 34 | 35 | // AdmissionControllerRequest holds data required for authZ plugins 36 | type AdmissionControllerRequest struct { 37 | // timestamp of the operation (when the request or response was received by the plugin, before starting the policy evaluation) 38 | Timestamp string 39 | 40 | // User holds the user extracted by AuthN mechanism 41 | User any `json:"User,omitempty"` 42 | 43 | // UserAuthNMethod holds the mechanism used to extract user details (e.g., krb) 44 | UserAuthNMethod string `json:"UserAuthNMethod,omitempty"` 45 | 46 | // RequestPeerCertificates stores the request's TLS peer certificates in PEM format 47 | RequestPeerCertificates []*x509.Certificate `json:"RequestPeerCertificates,omitempty"` 48 | 49 | // HTTPRequest is the raw incoming HTTP request, allowing access to RequestURI or Headers 50 | HTTPRequest *http.Request `json:"HttpRequest,omitempty"` 51 | 52 | // a dictionary that is available through the whole lifecycle of tha authz configuration 53 | GlobalContext map[string]any 54 | } 55 | -------------------------------------------------------------------------------- /common/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package common 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | 21 | "gopkg.in/yaml.v2" 22 | ) 23 | 24 | // ConfigPathListener holds the settings for an UDS listener 25 | type ConfigPathListener struct { 26 | SocketPath string `yaml:"socketPath"` 27 | Params string // according to pathListener's documentation 28 | } 29 | 30 | // ConfigMtlsListener holds the settings for an mTLS listener 31 | type ConfigMtlsListener struct { 32 | PrivateKeyPath string `yaml:"privateKeyPath"` 33 | CertificatePath string `yaml:"certificatePath"` 34 | ClientCAsPath string `yaml:"clientCAsPath"` 35 | ListenOn string `yaml:"listenOn"` // e.g. ":8080" 36 | } 37 | 38 | // ConfigTLSListener holds the settings for a TLS listener 39 | // If acjs is meant to be used in a cluster-local setup, you can generate a CA cert and load it with 40 | // this listener, like this: 41 | // ``` 42 | // openssl req -x509 -sha256 -new -nodes -newkey rsa:2048 -keyout ca_acjs.key -days 14600 -out ca_acjs.pem -subj "/C=NL/ST=Zuid Holland/L=Rotterdam/O=ACME Corp/OU=IT Dept/CN=Acjs" -addext "subjectAltName = DNS:acjs-svc.my-namespace.svc.cluster.local" 43 | // ``` 44 | type ConfigTLSListener struct { 45 | PrivateKeyPath string `yaml:"privateKeyPath"` 46 | CertificatePath string `yaml:"certificatePath"` 47 | ListenOn string `yaml:"listenOn"` // e.g. ":8080" 48 | } 49 | 50 | // ConfigPolicy represents a policy to be evaluated and make authz decisions based on 51 | type ConfigPolicy struct { 52 | Name string 53 | Code string 54 | } 55 | 56 | // ConfigListener represents the listener options for acjs 57 | type ConfigListener struct { 58 | Path *ConfigPathListener 59 | Mtls *ConfigMtlsListener 60 | TLS *ConfigTLSListener `yaml:"tls"` 61 | } 62 | 63 | // ConfigFile type represents the configuration for acjs 64 | type ConfigFile struct { 65 | Listener *ConfigListener 66 | 67 | Globals string 68 | Policies []ConfigPolicy 69 | 70 | DefaultAction string `yaml:"defaultAction"` 71 | } 72 | 73 | // ReadConfigFile parses the specified file into a ConfigFile structure 74 | func ReadConfigFile(filename string) (*ConfigFile, error) { 75 | f, err := os.ReadFile(filename) 76 | if err != nil { 77 | return nil, fmt.Errorf("unable to read config file: %v", err) 78 | } 79 | 80 | var c ConfigFile 81 | 82 | // Unmarshal our input YAML file into empty Car (var c) 83 | if err := yaml.Unmarshal(f, &c); err != nil { 84 | return nil, fmt.Errorf("unable to parse config file %s: %v", filename, err) 85 | } 86 | // fmt.Printf("%+v", c.Proxy.Listener.UDS) 87 | 88 | return &c, nil 89 | } 90 | -------------------------------------------------------------------------------- /common/config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package common 16 | 17 | import ( 18 | "io" 19 | "os" 20 | "path" 21 | "testing" 22 | 23 | "github.com/google/go-cmp/cmp" 24 | ) 25 | 26 | func TestReadConfigFile(t *testing.T) { 27 | tempdir := t.TempDir() 28 | configFilePath := path.Join(tempdir, "config.yaml") 29 | f, err := os.Create(configFilePath) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | io.WriteString(f, ` 34 | listener: 35 | path: 36 | socketPath: /tmp/acjs.sock 37 | params: 0660:-:- 38 | mtls: 39 | privateKeyPath: /priv 40 | certificatePath: /cert 41 | clientCAsPath: /cas 42 | listenOn: ":8080" 43 | 44 | globals: | 45 | hello 46 | 47 | policies: 48 | - name: some name of the policy 49 | code: | 50 | console.log("hello!", ac.User.Username, "x", req.UID, "x", object) 51 | 52 | defaultAction: Allow 53 | `) 54 | f.Close() 55 | 56 | c, err := ReadConfigFile(configFilePath) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | e := &ConfigFile{ 62 | Listener: &ConfigListener{ 63 | Path: &ConfigPathListener{ 64 | SocketPath: "/tmp/acjs.sock", 65 | Params: "0660:-:-", 66 | }, 67 | Mtls: &ConfigMtlsListener{ 68 | PrivateKeyPath: "/priv", 69 | CertificatePath: "/cert", 70 | ClientCAsPath: "/cas", 71 | ListenOn: ":8080", 72 | }, 73 | }, 74 | Globals: "hello\n", 75 | Policies: []ConfigPolicy{ 76 | {Name: "some name of the policy", Code: `console.log("hello!", ac.User.Username, "x", req.UID, "x", object)` + "\n"}, 77 | }, 78 | DefaultAction: "Allow", 79 | } 80 | 81 | diff := cmp.Diff(c, e) 82 | if diff != "" { 83 | t.Errorf("config file was not parsed as expected: %v", diff) 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /common/listener.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package common provides a bunch of common types and functions for the acjs project 16 | package common 17 | 18 | import ( 19 | "context" 20 | "crypto/tls" 21 | "crypto/x509" 22 | "errors" 23 | "fmt" 24 | "io/ioutil" 25 | "net" 26 | "net/http" 27 | 28 | "github.com/google/ctrdac/common" 29 | "github.com/google/ctrdac/pathsocket" 30 | ) 31 | 32 | // ContextKey is the root type we use for populating context 33 | type ContextKey struct { 34 | Name string 35 | } 36 | 37 | var ( 38 | // MtlsConn is the ContextKey that can be used to retrieve mTLS related info from an 39 | // http.Requests's context 40 | MtlsConn = &ContextKey{Name: "mtls-conn"} 41 | ) 42 | 43 | // Listener is the common interface of the unix domain socket, named pipes and the mTLS listeners 44 | type Listener interface { 45 | GetListener() net.Listener 46 | ConfigureHooks(server *http.Server) error 47 | 48 | // this is to set the listener specific attributes on authzReq, e.g. 49 | // the client certificates in the case of mTLS or the remote uid in the case of UDS 50 | PopulateRequest(authzReq *AdmissionControllerRequest, dReq *http.Request) error 51 | } 52 | 53 | // NewPathListener creates a new Listener for unix domain socket / named pipe connections 54 | func NewPathListener(config *ConfigPathListener) (*PathListener, error) { 55 | 56 | ulsnr, err := pathsocket.NewListener(config.SocketPath, config.Params) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return &PathListener{ulsnr}, nil 62 | } 63 | 64 | // PathListener implements the Listener interface for connections over unix domain socket 65 | type PathListener struct { 66 | listener common.Listener 67 | } 68 | 69 | // GetListener returns the raw golang net.Listener 70 | func (ul *PathListener) GetListener() net.Listener { 71 | return ul.listener.GetListener() 72 | } 73 | 74 | // ConfigureHooks configures an http.Server so that authentication related info can be retrieved 75 | // in the business logic via the context 76 | func (ul *PathListener) ConfigureHooks(server *http.Server) error { 77 | return ul.listener.ConfigureHooks(server) 78 | } 79 | 80 | // PopulateRequest fills the authentication related fields of AdmissionControllerRequest based on 81 | // the incoming dReq 82 | func (ul *PathListener) PopulateRequest(authzReq *AdmissionControllerRequest, dReq *http.Request) error { 83 | authzReq.User = dReq.Context().Value(pathsocket.PathCred) 84 | authzReq.UserAuthNMethod = "path-listener" 85 | return nil 86 | } 87 | 88 | // MtlsListener implements the Listener interface for mTLS connections 89 | type MtlsListener struct { 90 | listener net.Listener 91 | } 92 | 93 | // NewMtlsListener returns a new mTLS listener implementing the Listener interface 94 | func NewMtlsListener(config *ConfigMtlsListener) (*MtlsListener, error) { 95 | 96 | cer, err := tls.LoadX509KeyPair(config.CertificatePath, config.PrivateKeyPath) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | clientCaBytes, err := ioutil.ReadFile(config.ClientCAsPath) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | clientCAs := x509.NewCertPool() 107 | if !clientCAs.AppendCertsFromPEM(clientCaBytes) { 108 | return nil, fmt.Errorf("unable to parse %s as PEM certificate(s)", config.ClientCAsPath) 109 | } 110 | 111 | tlsConfig := &tls.Config{ 112 | ClientCAs: clientCAs, 113 | ClientAuth: tls.RequireAndVerifyClientCert, 114 | Certificates: []tls.Certificate{cer}, 115 | } 116 | listener, err := tls.Listen("tcp", config.ListenOn, tlsConfig) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | return &MtlsListener{listener}, nil 122 | } 123 | 124 | // PopulateRequest fills the authentication related fields of AdmissionControllerRequest based on 125 | // the incoming dReq 126 | func (ml *MtlsListener) PopulateRequest(authzReq *AdmissionControllerRequest, dReq *http.Request) error { 127 | var conn *tls.Conn 128 | var ok bool 129 | if conn, ok = dReq.Context().Value(MtlsConn).(*tls.Conn); !ok { 130 | return errors.New("unable to extract caller process'es tls connection") 131 | } 132 | 133 | state := conn.ConnectionState() 134 | 135 | var peercerts []*x509.Certificate 136 | 137 | for _, cert := range state.PeerCertificates { 138 | if authzReq.User == nil { 139 | authzReq.User = cert.Subject 140 | } 141 | 142 | peercerts = append(peercerts, cert) 143 | } 144 | 145 | authzReq.UserAuthNMethod = "mTLS" 146 | authzReq.RequestPeerCertificates = peercerts 147 | 148 | return nil 149 | } 150 | 151 | // GetListener returns the raw golang net.Listener 152 | func (ml *MtlsListener) GetListener() net.Listener { 153 | return ml.listener 154 | } 155 | 156 | // ConfigureHooks configures an http.Server so that authentication related info can be retrieved 157 | // in the business logic via the context 158 | func (ml *MtlsListener) ConfigureHooks(server *http.Server) error { 159 | server.ConnContext = func(ctx context.Context, c net.Conn) context.Context { 160 | conn, ok := c.(*tls.Conn) 161 | if !ok { 162 | return ctx 163 | } 164 | 165 | // we are saving the connection handle here, 166 | // as handshake was not yet processed and we can't return an error in this callback 167 | return context.WithValue(ctx, MtlsConn, conn) 168 | } 169 | return nil 170 | } 171 | 172 | // TLSListener implements the Listener interface for TLS connections 173 | type TLSListener struct { 174 | listener net.Listener 175 | } 176 | 177 | // NewTLSListener returns a new mTLS listener implementing the Listener interface 178 | func NewTLSListener(config *ConfigTLSListener) (*TLSListener, error) { 179 | 180 | cer, err := tls.LoadX509KeyPair(config.CertificatePath, config.PrivateKeyPath) 181 | if err != nil { 182 | return nil, err 183 | } 184 | 185 | tlsConfig := &tls.Config{ 186 | Certificates: []tls.Certificate{cer}, 187 | } 188 | listener, err := tls.Listen("tcp", config.ListenOn, tlsConfig) 189 | if err != nil { 190 | return nil, err 191 | } 192 | 193 | return &TLSListener{listener}, nil 194 | } 195 | 196 | // PopulateRequest fills the authentication related fields of AdmissionControllerRequest 197 | func (ml *TLSListener) PopulateRequest(authzReq *AdmissionControllerRequest, dReq *http.Request) error { 198 | return nil 199 | } 200 | 201 | // GetListener returns the raw golang net.Listener 202 | func (ml *TLSListener) GetListener() net.Listener { 203 | return ml.listener 204 | } 205 | 206 | // ConfigureHooks configures an http.Server so that authentication related info can be retrieved 207 | // in the business logic via the context 208 | func (ml *TLSListener) ConfigureHooks(server *http.Server) error { 209 | return nil 210 | } 211 | -------------------------------------------------------------------------------- /common/listener_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package common 16 | 17 | import ( 18 | "context" 19 | "crypto/rand" 20 | "crypto/rsa" 21 | "crypto/tls" 22 | "crypto/x509" 23 | "crypto/x509/pkix" 24 | "encoding/json" 25 | "encoding/pem" 26 | "io" 27 | "io/ioutil" 28 | "math/big" 29 | "net" 30 | "net/http" 31 | "os" 32 | "path" 33 | "strings" 34 | "testing" 35 | "time" 36 | 37 | log "github.com/golang/glog" 38 | "github.com/google/ctrdac/lookup" 39 | ) 40 | 41 | type myHandler struct { 42 | listener Listener 43 | } 44 | 45 | func (h myHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 46 | re := AdmissionControllerRequest{} 47 | h.listener.PopulateRequest(&re, r) 48 | re.RequestPeerCertificates = nil 49 | bytes, err := json.Marshal(re) 50 | if err != nil { 51 | w.WriteHeader(http.StatusInternalServerError) 52 | return 53 | } 54 | w.Write(bytes) 55 | } 56 | 57 | func TestUdsListener(t *testing.T) { 58 | testdir := t.TempDir() 59 | udsPath := path.Join(testdir, "uds") 60 | cus := ConfigPathListener{ 61 | SocketPath: udsPath, 62 | } 63 | listener, err := NewPathListener(&cus) 64 | if err != nil { 65 | t.Fatal(err) 66 | } 67 | 68 | server := http.Server{ 69 | Handler: myHandler{listener}, 70 | } 71 | err = listener.ConfigureHooks(&server) 72 | if err != nil { 73 | t.Fatal(err) 74 | } 75 | go server.Serve(listener.GetListener()) 76 | 77 | client := http.Client{ 78 | Transport: &http.Transport{ 79 | DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { 80 | return net.Dial("unix", udsPath) 81 | }, 82 | }, 83 | } 84 | resp, err := client.Get("http://unix/") 85 | if err != nil { 86 | t.Fatal(err) 87 | } 88 | bytes, err := io.ReadAll(resp.Body) 89 | if err != nil { 90 | t.Fatal(err) 91 | } 92 | var rc AdmissionControllerRequest 93 | err = json.Unmarshal(bytes, &rc) 94 | if err != nil { 95 | t.Fatal(err) 96 | } 97 | 98 | aUID := lookup.Lookup[float64](rc.User, "Uid", 0) 99 | eUID := os.Getuid() 100 | if eUID != int(aUID) { 101 | t.Errorf("User was not populated correctly. %v vs %v (in %v)", eUID, aUID, rc.User) 102 | } 103 | } 104 | 105 | func generateSelfSignedCert(certPath, privKeyPath string) error { 106 | priv, err := rsa.GenerateKey(rand.Reader, 2048) 107 | if err != nil { 108 | return err 109 | } 110 | template := x509.Certificate{ 111 | SerialNumber: big.NewInt(1), 112 | Subject: pkix.Name{ 113 | Organization: []string{"Acme Co"}, 114 | CommonName: "hello:)", 115 | }, 116 | NotBefore: time.Now(), 117 | NotAfter: time.Now().Add(time.Hour * 24 * 180), 118 | 119 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 120 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, 121 | BasicConstraintsValid: true, 122 | } 123 | 124 | ip := net.ParseIP("127.0.0.1") 125 | template.IPAddresses = []net.IP{ip} 126 | template.DNSNames = []string{"localhost"} 127 | 128 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) 129 | if err != nil { 130 | log.Fatalf("Failed to create certificate: %s", err) 131 | } 132 | 133 | f, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE, 0600) 134 | if err != nil { 135 | return err 136 | } 137 | pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) 138 | f.Close() 139 | 140 | f, err = os.OpenFile(privKeyPath, os.O_WRONLY|os.O_CREATE, 0600) 141 | if err != nil { 142 | return err 143 | } 144 | pem.Encode(f, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) 145 | f.Close() 146 | return nil 147 | } 148 | 149 | func TestMtlsListener(t *testing.T) { 150 | testdir := t.TempDir() 151 | privKeyPath := path.Join(testdir, "privkey.pem") 152 | certPath := path.Join(testdir, "cert.pem") 153 | 154 | err := generateSelfSignedCert(certPath, privKeyPath) 155 | if err != nil { 156 | t.Fatal(err) 157 | } 158 | 159 | cus := ConfigMtlsListener{ 160 | PrivateKeyPath: privKeyPath, 161 | CertificatePath: certPath, 162 | ClientCAsPath: certPath, 163 | ListenOn: ":0", // random free port 164 | } 165 | listener, err := NewMtlsListener(&cus) 166 | if err != nil { 167 | t.Fatal(err) 168 | } 169 | 170 | server := http.Server{ 171 | Handler: myHandler{listener}, 172 | } 173 | err = listener.ConfigureHooks(&server) 174 | if err != nil { 175 | t.Fatal(err) 176 | } 177 | go server.Serve(listener.GetListener()) 178 | 179 | caCert, err := ioutil.ReadFile(certPath) 180 | if err != nil { 181 | log.Fatal(err) 182 | } 183 | caCertPool := x509.NewCertPool() 184 | caCertPool.AppendCertsFromPEM(caCert) 185 | 186 | cer, err := tls.LoadX509KeyPair(certPath, privKeyPath) 187 | if err != nil { 188 | t.Fatal(err) 189 | } 190 | client := &http.Client{ 191 | Transport: &http.Transport{ 192 | TLSClientConfig: &tls.Config{ 193 | RootCAs: caCertPool, 194 | Certificates: []tls.Certificate{cer}, 195 | }, 196 | }, 197 | } 198 | upstreamAddress := strings.Replace(listener.GetListener().Addr().String(), "[::]", "localhost", -1) 199 | url := "https://" + upstreamAddress + "/" 200 | 201 | resp, err := client.Get(url) 202 | if err != nil { 203 | t.Fatal(err) 204 | } 205 | bytes, err := io.ReadAll(resp.Body) 206 | if err != nil { 207 | t.Fatal(err) 208 | } 209 | var rc AdmissionControllerRequest 210 | err = json.Unmarshal(bytes, &rc) 211 | if err != nil { 212 | t.Fatal(err) 213 | } 214 | 215 | commonName := lookup.Lookup(rc.User, "CommonName", "") 216 | if commonName != "hello:)" { 217 | t.Errorf("User was not populated correctly. %v", rc.User) 218 | } 219 | } 220 | 221 | func TestTlsListener(t *testing.T) { 222 | testdir := t.TempDir() 223 | privKeyPath := path.Join(testdir, "privkey.pem") 224 | certPath := path.Join(testdir, "cert.pem") 225 | 226 | err := generateSelfSignedCert(certPath, privKeyPath) 227 | if err != nil { 228 | t.Fatal(err) 229 | } 230 | 231 | cus := ConfigTLSListener{ 232 | PrivateKeyPath: privKeyPath, 233 | CertificatePath: certPath, 234 | ListenOn: ":0", // random free port 235 | } 236 | listener, err := NewTLSListener(&cus) 237 | if err != nil { 238 | t.Fatal(err) 239 | } 240 | 241 | server := http.Server{ 242 | Handler: myHandler{listener}, 243 | } 244 | err = listener.ConfigureHooks(&server) 245 | if err != nil { 246 | t.Fatal(err) 247 | } 248 | go server.Serve(listener.GetListener()) 249 | 250 | caCert, err := ioutil.ReadFile(certPath) 251 | if err != nil { 252 | log.Fatal(err) 253 | } 254 | caCertPool := x509.NewCertPool() 255 | caCertPool.AppendCertsFromPEM(caCert) 256 | 257 | client := &http.Client{ 258 | Transport: &http.Transport{ 259 | TLSClientConfig: &tls.Config{ 260 | RootCAs: caCertPool, 261 | }, 262 | }, 263 | } 264 | upstreamAddress := strings.Replace(listener.GetListener().Addr().String(), "[::]", "localhost", -1) 265 | url := "https://" + upstreamAddress + "/" 266 | 267 | resp, err := client.Get(url) 268 | if err != nil { 269 | t.Fatal(err) 270 | } 271 | bytes, err := io.ReadAll(resp.Body) 272 | if err != nil { 273 | t.Fatal(err) 274 | } 275 | var rc AdmissionControllerRequest 276 | err = json.Unmarshal(bytes, &rc) 277 | if err != nil { 278 | t.Fatal(err) 279 | } 280 | 281 | // note: User is not populated here as no authentication happens 282 | if rc.UserAuthNMethod != "" || rc.User != nil { 283 | t.Errorf("User was not populated correctly. %v - %v", rc.UserAuthNMethod, rc.User) 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /flags.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "flag" 19 | ) 20 | 21 | var ( 22 | configFilePath = flag.String("config-file", "/etc/acjs.yaml", "sets the path to the configuration file") 23 | ) 24 | 25 | func init() { 26 | flag.Set("alsologtostderr", "true") 27 | } 28 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/google/acjs 2 | 3 | go 1.21 4 | 5 | require ( 6 | cloud.google.com/go/compute v1.23.3 // indirect 7 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 8 | github.com/Microsoft/go-winio v0.6.2 // indirect 9 | github.com/dlclark/regexp2 v1.7.0 // indirect 10 | github.com/dop251/goja v0.0.0-20230304130813-e2f543bf4b4c // indirect 11 | github.com/dop251/goja_nodejs v0.0.0-20230322100729-2550c7b6c124 // indirect 12 | github.com/evanphx/json-patch v5.9.0+incompatible // indirect 13 | github.com/go-logr/logr v1.4.1 // indirect 14 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect 15 | github.com/gogo/protobuf v1.3.2 // indirect 16 | github.com/golang/glog v1.2.0 // indirect 17 | github.com/golang/protobuf v1.5.3 // indirect 18 | github.com/google/ctrdac v0.0.0-20240320113845-57c9ba25547a // indirect 19 | github.com/google/gofuzz v1.2.0 // indirect 20 | github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect 21 | github.com/json-iterator/go v1.1.12 // indirect 22 | github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 // indirect 23 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 24 | github.com/modern-go/reflect2 v1.0.2 // indirect 25 | github.com/pkg/errors v0.9.1 // indirect 26 | golang.org/x/net v0.22.0 // indirect 27 | golang.org/x/oauth2 v0.18.0 // indirect 28 | golang.org/x/sys v0.18.0 // indirect 29 | golang.org/x/text v0.14.0 // indirect 30 | google.golang.org/appengine v1.6.8 // indirect 31 | google.golang.org/protobuf v1.32.0 // indirect 32 | gopkg.in/inf.v0 v0.9.1 // indirect 33 | gopkg.in/yaml.v2 v2.4.0 // indirect 34 | gopkg.in/yaml.v3 v3.0.1 // indirect 35 | k8s.io/api v0.29.2 // indirect 36 | k8s.io/apimachinery v0.29.2 // indirect 37 | k8s.io/klog/v2 v2.110.1 // indirect 38 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect 39 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 40 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 41 | toolman.org/net/peercred v0.6.1 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= 2 | cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= 3 | cloud.google.com/go/compute/metadata v0.2.0 h1:nBbNSZyDpkNlo3DepaaLKVuO7ClyifSAmNloSCZrHnQ= 4 | cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 5 | cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= 6 | cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= 7 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 8 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 9 | github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= 10 | github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= 11 | github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 12 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 16 | github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= 17 | github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 18 | github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= 19 | github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86/go.mod h1:yRkwfj0CBpOGre+TwBsqPV0IH0Pk73e4PXJOeNDboGs= 20 | github.com/dop251/goja v0.0.0-20230304130813-e2f543bf4b4c h1:/utv6nmTctV6OVgfk5+O6lEMEWL+6KJy4h9NZ5fnkQQ= 21 | github.com/dop251/goja v0.0.0-20230304130813-e2f543bf4b4c/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= 22 | github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= 23 | github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= 24 | github.com/dop251/goja_nodejs v0.0.0-20230322100729-2550c7b6c124 h1:QDuDMgEkC/lnmvk0d/fZfcUUml18uUbS9TY5QtbdFhs= 25 | github.com/dop251/goja_nodejs v0.0.0-20230322100729-2550c7b6c124/go.mod h1:0tlktQL7yHfYEtjcRGi/eiOkbDR5XF7gyFFvbC5//E0= 26 | github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= 27 | github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= 28 | github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 29 | github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= 30 | github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 31 | github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 32 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 33 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 34 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= 35 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 36 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 37 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 38 | github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68= 39 | github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= 40 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 41 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 42 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 43 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 44 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 45 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 46 | github.com/google/ctrdac v0.0.0-20240320113845-57c9ba25547a h1:002chw6tGxDZDNnXmYDEugwKHQVuxbY+o+9qMWyTi4g= 47 | github.com/google/ctrdac v0.0.0-20240320113845-57c9ba25547a/go.mod h1:cIaY2Iz2FL0TjDNmCb701Db8Pi0U/pftLXgMMgEJDRU= 48 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 49 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 50 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 51 | github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= 52 | github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 53 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 54 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 55 | github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= 56 | github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= 57 | github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= 58 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 59 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 60 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 61 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 62 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 63 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 64 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 65 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 66 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 67 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 68 | github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 h1:hQWBtNqRYrI7CWIaUSXXtNKR90KzcUA5uiuxFVWw7sU= 69 | github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38/go.mod h1:M1qoD/MqPgTZIk0EWKB38wE28ACRfVcn+cU08jyArI0= 70 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 71 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 72 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 73 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 74 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 75 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 76 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 77 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 78 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 79 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 80 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 81 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 82 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 83 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 84 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 85 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 86 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 87 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 88 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 89 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 90 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 91 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 92 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 93 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 94 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 95 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 96 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 97 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 98 | golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 99 | golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= 100 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 101 | golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= 102 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 103 | golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= 104 | golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 105 | golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= 106 | golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= 107 | golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= 108 | golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= 109 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 110 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 111 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 112 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 113 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 114 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 115 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 116 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 117 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 118 | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 119 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 120 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 121 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 122 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 123 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 124 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 125 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 126 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 127 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 128 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 129 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 130 | golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 131 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 132 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 133 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 134 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 135 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 136 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 137 | golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 138 | golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= 139 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 140 | golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= 141 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 142 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 143 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 144 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 145 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 146 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 147 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 148 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 149 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 150 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 151 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 152 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 153 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 154 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 155 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 156 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 157 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 158 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 159 | google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= 160 | google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 161 | google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= 162 | google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 163 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 164 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 165 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 166 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 167 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 168 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 169 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 170 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 171 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 172 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 173 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 174 | k8s.io/api v0.26.3 h1:emf74GIQMTik01Aum9dPP0gAypL8JTLl/lHa4V9RFSU= 175 | k8s.io/api v0.26.3/go.mod h1:PXsqwPMXBSBcL1lJ9CYDKy7kIReUydukS5JiRlxC3qE= 176 | k8s.io/api v0.29.2 h1:hBC7B9+MU+ptchxEqTNW2DkUosJpp1P+Wn6YncZ474A= 177 | k8s.io/api v0.29.2/go.mod h1:sdIaaKuU7P44aoyyLlikSLayT6Vb7bvJNCX105xZXY0= 178 | k8s.io/apimachinery v0.26.3 h1:dQx6PNETJ7nODU3XPtrwkfuubs6w7sX0M8n61zHIV/k= 179 | k8s.io/apimachinery v0.26.3/go.mod h1:ats7nN1LExKHvJ9TmwootT00Yz05MuYqPXEXaVeOy5I= 180 | k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8= 181 | k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU= 182 | k8s.io/klog/v2 v2.80.1 h1:atnLQ121W371wYYFawwYx1aEY2eUfs4l3J72wtgAwV4= 183 | k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= 184 | k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= 185 | k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= 186 | k8s.io/utils v0.0.0-20221107191617-1a15be271d1d h1:0Smp/HP1OH4Rvhe+4B8nWGERtlqAGSftbSbbmm45oFs= 187 | k8s.io/utils v0.0.0-20221107191617-1a15be271d1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 188 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= 189 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 190 | sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 h1:iXTIw73aPyC+oRdyqqvVJuloN1p0AC/kzH07hu3NE+k= 191 | sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= 192 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= 193 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= 194 | sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= 195 | sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= 196 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= 197 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= 198 | toolman.org/net/peercred v0.6.1 h1:xAjw6yxNJRO2asnmqMPfbzOwKpb1wUJF3iKoTvqH0zk= 199 | toolman.org/net/peercred v0.6.1/go.mod h1:soGaSNwoDm9E75fpgElzOOMDapKLnrwWeDdMUKbsUmo= 200 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package main is the cli of the acjs application. 16 | package main 17 | 18 | import ( 19 | "fmt" 20 | "os" 21 | "os/signal" 22 | "syscall" 23 | 24 | "flag" 25 | log "github.com/golang/glog" 26 | "github.com/google/acjs/authz" 27 | "github.com/google/acjs/common" 28 | ) 29 | 30 | var ( 31 | server common.AuthzServer 32 | ) 33 | 34 | func reReadConfigFile(configFilePath string) (*common.ConfigFile, *authz.CompiledPolicies, error) { 35 | config, err := common.ReadConfigFile(configFilePath) 36 | if err != nil { 37 | return nil, nil, fmt.Errorf("parsing the config file failed: %v", err) 38 | } 39 | cp, err := authz.CompilePolicies(config) 40 | if err != nil { 41 | return nil, nil, fmt.Errorf("compiling policies failed: %v", err) 42 | } 43 | 44 | return config, cp, nil 45 | } 46 | 47 | func main() { 48 | if !flag.Parsed() { 49 | flag.Parse() 50 | } 51 | 52 | config, policies, err := reReadConfigFile(*configFilePath) 53 | if err != nil { 54 | log.Info(err) 55 | return 56 | } 57 | 58 | server, err = authz.NewServer(config) 59 | if err != nil { 60 | log.Infof("Unable to start the server: %v", err) 61 | return 62 | } 63 | server.UsePolicies(policies) 64 | 65 | /* 66 | signal handler for HUP 67 | */ 68 | c := make(chan os.Signal, 1) 69 | signal.Notify(c, syscall.SIGHUP) 70 | go func() { 71 | <-c 72 | _, policies, err := reReadConfigFile(*configFilePath) 73 | if err != nil { 74 | log.Errorf("Unable to reread configuration: %v", err) 75 | server.UsePolicies(policies) 76 | } 77 | }() 78 | 79 | err = server.Serve() 80 | if err != nil { 81 | log.Errorf("Failed serving on server: %v", err) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /simple-ac.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | listener: 16 | path: 17 | socketPath: /tmp/acjs.sock 18 | permission: 0660 19 | user: "-" 20 | group: "-" 21 | 22 | policies: 23 | - name: some name of the policy 24 | code: | 25 | console.log("hello!", ac.User.Username, "x", req.UID, "x", object) 26 | 27 | /* 28 | // a dummy example 29 | if (object.spec.containers[0].name.includes("apple")) 30 | return "please choose a different fruit" 31 | */ 32 | 33 | /* 34 | // example to mutate the pod (when default k8s conversion is active - this is discouraged) 35 | object.spec.containers[0].command = ["/bin/echo", "hello :) sorry, this is probably not what you expected."] 36 | object.spec.containers[0].args = [] 37 | return true 38 | */ 39 | 40 | /* 41 | // example to mutate the runc spec (when k8s conversion is disabled) 42 | console.log("hello!", ac.User.Username, "x", req.UID, "x", object) 43 | runcSpec = JSON.parse(atob(object.container.spec.value)) 44 | // patch 45 | runcSpec.process.env.push("SOMETHING=debug") 46 | // repackaging 47 | object.container.spec.value = btoa(JSON.stringify(runcSpec)) 48 | */ 49 | 50 | /* 51 | // defering to a different admission controller to make the decision 52 | return forwardToAdmissionController('https://bcidcloudenforcer-pa.googleapis.com/v1/projects/imre-test/policy/locations/europe-west4-b/clusters/cluster-1:admissionReview?timeout=10s') 53 | */ 54 | 55 | /* 56 | // example how to verify SLSA provenance (high-level): 57 | var trustedSourceRepos = ["github.com/irsl/gcb-tests"] 58 | if (!slsaEnsureComingFrom(trustedSourceRepos)) 59 | return "SLSA verification of the image failed. Trusted repos are: "+(trustedSourceRepos.join(", ")) 60 | */ 61 | 62 | /* 63 | // verifying slsa provenance, fine tuning each parameter 64 | if (!slsaVerify({"SourceUri": "github.com/irsl/gcb-tests", "BuilderId": "https://cloudbuild.googleapis.com/GoogleHostedWorker", "ProvenancePath": "/home/imrer/provenance-github.json"})) 65 | return "SLSA verification of the image failed." 66 | */ 67 | 68 | /* 69 | // simply test a cosign signature 70 | var cosignResponse = verifyCosign("/home/imrer/cosign.pub") 71 | if(!cosignResponse) return "Cosign based signature verification of the image failed." 72 | console.log("cosign verification has succeeded", cosignResponse) 73 | */ 74 | 75 | 76 | defaultAction: Allow 77 | -------------------------------------------------------------------------------- /slsa/googlecloud.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package slsa 16 | 17 | import ( 18 | "encoding/json" 19 | "io" 20 | 21 | "errors" 22 | "fmt" 23 | "net/http" 24 | "net/url" 25 | "strings" 26 | 27 | "github.com/google/ctrdac/auth/google" 28 | ) 29 | 30 | type httpDoer interface { 31 | Do(*http.Request) (*http.Response, error) 32 | } 33 | 34 | func extractGcpProjectFromImageRef(imageRef string) string { 35 | cs := strings.SplitN(imageRef, "/", 3) 36 | return cs[1] 37 | } 38 | 39 | func getImageHash(fullyQualifiedDigest string) string { 40 | cs := strings.SplitN(fullyQualifiedDigest, "@", 2) 41 | if len(cs) == 1 { 42 | // it was not fully qualified after all... 43 | return "" 44 | } 45 | return cs[1] 46 | } 47 | 48 | // GcpImageSummary contains info about a container image 49 | type GcpImageSummary struct { 50 | Digest string `json:"digest"` 51 | FullyQualifiedDigest string `json:"fully_qualified_digest"` 52 | } 53 | 54 | // GcpProvenanceOccourence contains info about a container image 55 | type GcpProvenanceOccourence map[string]any 56 | 57 | // GcpProvenanceSummary is the main type with provenance info about a container image 58 | type GcpProvenanceSummary struct { 59 | Provenance []GcpProvenanceOccourence `json:"provenance"` 60 | } 61 | 62 | // GcpProvenance contains info about the container image along with provenance summary 63 | type GcpProvenance struct { 64 | ImageSummary GcpImageSummary `json:"image_summary"` 65 | ProvenanceSummary GcpProvenanceSummary `json:"provenance_summary"` 66 | } 67 | 68 | // GcpRawProvenanceOccourencesResponse is the type that holds the response of the containeranalysis API 69 | type GcpRawProvenanceOccourencesResponse struct { 70 | Occourances []GcpProvenanceOccourence `json:"occurrences"` 71 | } 72 | 73 | // ObtainGoogleCloudProvenance obtains SLSA provenance info about a container image that was built 74 | // on Google Cloud. 75 | func ObtainGoogleCloudProvenance(fullyQualifiedDigest string) ([]byte, error) { 76 | hashOnly := getImageHash(fullyQualifiedDigest) 77 | if hashOnly == "" { 78 | return nil, fmt.Errorf("immutable image specification is expected, %s is not", fullyQualifiedDigest) 79 | } 80 | 81 | imageWithoutTagsAndHash := GetImageRefWithoutTags(fullyQualifiedDigest) 82 | rebuiltFullyQualifiedDigest := imageWithoutTagsAndHash + "@" + hashOnly 83 | 84 | token, err := getGoogleAccessToken() 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | projectID := extractGcpProjectFromImageRef(fullyQualifiedDigest) 90 | filter := `((kind = "BUILD") OR (kind = "DSSE_ATTESTATION")) AND (resourceUrl = "https://` + rebuiltFullyQualifiedDigest + `")` 91 | 92 | requestURL := fmt.Sprintf("https://containeranalysis.googleapis.com/v1/projects/%s/occurrences?alt=json&filter=%s&pageSize=10", url.QueryEscape(projectID), url.QueryEscape(filter)) 93 | req, err := http.NewRequest(http.MethodGet, requestURL, nil) 94 | if err != nil { 95 | return nil, fmt.Errorf("client: could not create request: %v", err) 96 | } 97 | req.Header.Add("Authorization", "Bearer "+token) 98 | 99 | httpDoer := getHTTPDoer() 100 | res, err := httpDoer.Do(req) 101 | if err != nil { 102 | return nil, fmt.Errorf("client: error making http request: %v", err) 103 | } 104 | bytes, err := io.ReadAll(res.Body) 105 | if err != nil { 106 | return nil, fmt.Errorf("unable to read the response: %v", err) 107 | } 108 | 109 | var ocs GcpRawProvenanceOccourencesResponse 110 | err = json.Unmarshal(bytes, &ocs) 111 | if err != nil { 112 | return nil, fmt.Errorf("unable to parse the response: %v", err) 113 | } 114 | 115 | hTime := "" 116 | var prov *GcpProvenanceOccourence 117 | for _, o := range ocs.Occourances { 118 | ct, ok := o["createTime"].(string) 119 | if !ok { 120 | return nil, fmt.Errorf("invalid response, creationTime not present") 121 | } 122 | if hTime == "" || hTime < ct { 123 | hTime = ct 124 | prov = &o 125 | } 126 | } 127 | 128 | if prov == nil { 129 | return nil, errors.New("no provenance occourances") 130 | } 131 | 132 | re := GcpProvenance{ 133 | ImageSummary: GcpImageSummary{ 134 | Digest: hashOnly, 135 | FullyQualifiedDigest: rebuiltFullyQualifiedDigest, 136 | }, 137 | ProvenanceSummary: GcpProvenanceSummary{ 138 | Provenance: []GcpProvenanceOccourence{ 139 | *prov, 140 | }, 141 | }, 142 | } 143 | 144 | bytes, err = json.Marshal(re) 145 | if err != nil { 146 | return nil, err 147 | } 148 | // log.Printf("latest prov: %s", string(bytes)) 149 | 150 | return bytes, nil 151 | } 152 | 153 | var getGoogleAccessToken = func() (string, error) { return google.GetAccessToken() } 154 | var getHTTPDoer = func() httpDoer { return http.DefaultClient } 155 | -------------------------------------------------------------------------------- /slsa/googlecloud_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package slsa 16 | 17 | import ( 18 | "bytes" 19 | "encoding/json" 20 | "fmt" 21 | "io" 22 | "net/http" 23 | "testing" 24 | ) 25 | 26 | var containerAnalysisResponse = []byte(` 27 | { 28 | "occurrences": [ 29 | { 30 | "name": "projects/imre-test/occurrences/3af2d63d-518a-40af-b39c-fc2a7f75bfee", 31 | "resourceUri": "https://us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529", 32 | "noteName": "projects/verified-builder/notes/1dbbe813-4f3d-4c8e-b2d3-776c91481bb8", 33 | "kind": "BUILD", 34 | "createTime": "2023-02-17T09:56:57.174925Z", 35 | "updateTime": "2023-02-17T09:56:57.174925Z", 36 | "build": { 37 | "provenance": { 38 | "id": "1dbbe813-4f3d-4c8e-b2d3-776c91481bb8", 39 | "projectId": "imre-test", 40 | "commands": [ 41 | { 42 | "name": "gcr.io/cloud-builders/docker", 43 | "args": [ 44 | "build", 45 | "-t", 46 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:v41", 47 | "." 48 | ] 49 | } 50 | ], 51 | "builtArtifacts": [ 52 | { 53 | "checksum": "sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529", 54 | "id": "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529", 55 | "names": [ 56 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:v41" 57 | ] 58 | } 59 | ], 60 | "createTime": "2023-02-17T09:56:40.446566Z", 61 | "startTime": "2023-02-17T09:56:41.000101282Z", 62 | "endTime": "2023-02-17T09:56:56.113063Z", 63 | "creator": "79219187776@cloudbuild.gserviceaccount.com", 64 | "logsUri": "gs://79219187776.cloudbuild-logs.googleusercontent.com", 65 | "sourceProvenance": { 66 | "context": { 67 | "git": { 68 | "url": "https://github.com/irsl/gcb-tests/commit/6da093fbd4f14b31e6895154d070fb0113bc3aa8", 69 | "revisionId": "6da093fbd4f14b31e6895154d070fb0113bc3aa8" 70 | } 71 | } 72 | }, 73 | "triggerId": "714d3c68-b4fd-4d8e-ad80-f1ac66d6c4f0", 74 | "buildOptions": { 75 | "VerifyOption": "VERIFIED" 76 | }, 77 | "builderVersion": "508019100" 78 | }, 79 | "provenanceBytes": "eyJpZCI6IjFkYmJlODEzLTRmM2QtNGM4ZS1iMmQzLTc3NmM5MTQ4MWJiOCIsInByb2plY3RJZCI6ImltcmUtdGVzdCIsImNvbW1hbmRzIjpbeyJuYW1lIjoiZ2NyLmlvL2Nsb3VkLWJ1aWxkZXJzL2RvY2tlciIsImFyZ3MiOlsiYnVpbGQiLCItdCIsInVzLXdlc3QyLWRvY2tlci5wa2cuZGV2L2ltcmUtdGVzdC9xdWlja3N0YXJ0LWRvY2tlci1yZXBvL3F1aWNrc3RhcnQtaW1hZ2U6djQxIiwiLiJdfV0sImJ1aWx0QXJ0aWZhY3RzIjpbeyJjaGVja3N1bSI6InNoYTI1Njo0MWNiNGI1ZTMyZTQxN2I4NmMyYjIyMjlkMDU4MWI3MmY3ZGZmZDFjYzZiMGU1ODZhYjJjZWZkYjdhNTI3NTI5IiwiaWQiOiJ1cy13ZXN0Mi1kb2NrZXIucGtnLmRldi9pbXJlLXRlc3QvcXVpY2tzdGFydC1kb2NrZXItcmVwby9xdWlja3N0YXJ0LWltYWdlQHNoYTI1Njo0MWNiNGI1ZTMyZTQxN2I4NmMyYjIyMjlkMDU4MWI3MmY3ZGZmZDFjYzZiMGU1ODZhYjJjZWZkYjdhNTI3NTI5IiwibmFtZXMiOlsidXMtd2VzdDItZG9ja2VyLnBrZy5kZXYvaW1yZS10ZXN0L3F1aWNrc3RhcnQtZG9ja2VyLXJlcG8vcXVpY2tzdGFydC1pbWFnZTp2NDEiXX1dLCJjcmVhdGVUaW1lIjoiMjAyMy0wMi0xN1QwOTo1Njo0MC40NDY1NjZaIiwic3RhcnRUaW1lIjoiMjAyMy0wMi0xN1QwOTo1Njo0MS4wMDAxMDEyODJaIiwiZW5kVGltZSI6IjIwMjMtMDItMTdUMDk6NTY6NTYuMTEzMDYzWiIsImNyZWF0b3IiOiI3OTIxOTE4Nzc3NkBjbG91ZGJ1aWxkLmdzZXJ2aWNlYWNjb3VudC5jb20iLCJsb2dzVXJpIjoiZ3M6Ly83OTIxOTE4Nzc3Ni5jbG91ZGJ1aWxkLWxvZ3MuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwic291cmNlUHJvdmVuYW5jZSI6eyJjb250ZXh0Ijp7ImdpdCI6eyJ1cmwiOiJodHRwczovL2dpdGh1Yi5jb20vaXJzbC9nY2ItdGVzdHMvY29tbWl0LzZkYTA5M2ZiZDRmMTRiMzFlNjg5NTE1NGQwNzBmYjAxMTNiYzNhYTgiLCJyZXZpc2lvbklkIjoiNmRhMDkzZmJkNGYxNGIzMWU2ODk1MTU0ZDA3MGZiMDExM2JjM2FhOCJ9fX0sInRyaWdnZXJJZCI6IjcxNGQzYzY4LWI0ZmQtNGQ4ZS1hZDgwLWYxYWM2NmQ2YzRmMCIsImJ1aWxkT3B0aW9ucyI6eyJWZXJpZnlPcHRpb24iOiJWRVJJRklFRCJ9LCJidWlsZGVyVmVyc2lvbiI6IjUwODAxOTEwMCJ9" 80 | } 81 | }, 82 | { 83 | "name": "projects/imre-test/occurrences/42b57797-96c2-4031-95ac-f67250fa6d28", 84 | "resourceUri": "https://us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529", 85 | "noteName": "projects/verified-builder/notes/intoto_1dbbe813-4f3d-4c8e-b2d3-776c91481bb8", 86 | "kind": "BUILD", 87 | "createTime": "2023-02-17T09:56:58.259006Z", 88 | "updateTime": "2023-02-17T09:56:58.259006Z", 89 | "envelope": { 90 | "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZSI6eyJidWlsZGVyIjp7ImlkIjoiaHR0cHM6Ly9jbG91ZGJ1aWxkLmdvb2dsZWFwaXMuY29tL0dvb2dsZUhvc3RlZFdvcmtlckB2MC4zIn0sIm1hdGVyaWFscyI6W3siZGlnZXN0Ijp7InNoYTEiOiI2ZGEwOTNmYmQ0ZjE0YjMxZTY4OTUxNTRkMDcwZmIwMTEzYmMzYWE4In0sInVyaSI6Imh0dHBzOi8vZ2l0aHViLmNvbS9pcnNsL2djYi10ZXN0cyJ9XSwibWV0YWRhdGEiOnsiYnVpbGRGaW5pc2hlZE9uIjoiMjAyMy0wMi0xN1QwOTo1Njo1Ni4xMTMwNjNaIiwiYnVpbGRJbnZvY2F0aW9uSWQiOiIxZGJiZTgxMy00ZjNkLTRjOGUtYjJkMy03NzZjOTE0ODFiYjgiLCJidWlsZFN0YXJ0ZWRPbiI6IjIwMjMtMDItMTdUMDk6NTY6NDEuMDAwMTAxMjgyWiJ9LCJyZWNpcGUiOnsiYXJndW1lbnRzIjp7IkB0eXBlIjoidHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUuZGV2dG9vbHMuY2xvdWRidWlsZC52MS5CdWlsZCIsImlkIjoiMWRiYmU4MTMtNGYzZC00YzhlLWIyZDMtNzc2YzkxNDgxYmI4IiwibmFtZSI6InByb2plY3RzLzc5MjE5MTg3Nzc2L2xvY2F0aW9ucy91cy13ZXN0Mi9idWlsZHMvMWRiYmU4MTMtNGYzZC00YzhlLWIyZDMtNzc2YzkxNDgxYmI4Iiwib3B0aW9ucyI6eyJkeW5hbWljU3Vic3RpdHV0aW9ucyI6dHJ1ZSwibG9nZ2luZyI6IkxFR0FDWSIsInBvb2wiOnt9LCJyZXF1ZXN0ZWRWZXJpZnlPcHRpb24iOiJWRVJJRklFRCIsInN1YnN0aXR1dGlvbk9wdGlvbiI6IkFMTE9XX0xPT1NFIn0sInNvdXJjZVByb3ZlbmFuY2UiOnt9LCJzdGVwcyI6W3siYXJncyI6WyJidWlsZCIsIi10IiwidXMtd2VzdDItZG9ja2VyLnBrZy5kZXYvaW1yZS10ZXN0L3F1aWNrc3RhcnQtZG9ja2VyLXJlcG8vcXVpY2tzdGFydC1pbWFnZTp2NDEiLCIuIl0sIm5hbWUiOiJnY3IuaW8vY2xvdWQtYnVpbGRlcnMvZG9ja2VyIiwicHVsbFRpbWluZyI6eyJlbmRUaW1lIjoiMjAyMy0wMi0xN1QwOTo1Njo0NC4zMjQ3NDU0ODlaIiwic3RhcnRUaW1lIjoiMjAyMy0wMi0xN1QwOTo1Njo0NC4zMjA2NjE3MThaIn0sInN0YXR1cyI6IlNVQ0NFU1MiLCJ0aW1pbmciOnsiZW5kVGltZSI6IjIwMjMtMDItMTdUMDk6NTY6NTAuNTk4NTA4NzU4WiIsInN0YXJ0VGltZSI6IjIwMjMtMDItMTdUMDk6NTY6NDQuMzIwNjYxNzE4WiJ9fV0sInN1YnN0aXR1dGlvbnMiOnsiQlJBTkNIX05BTUUiOiJtYWluIiwiQ09NTUlUX1NIQSI6IjZkYTA5M2ZiZDRmMTRiMzFlNjg5NTE1NGQwNzBmYjAxMTNiYzNhYTgiLCJSRUZfTkFNRSI6Im1haW4iLCJSRVBPX05BTUUiOiJnY2ItdGVzdHMiLCJSRVZJU0lPTl9JRCI6IjZkYTA5M2ZiZDRmMTRiMzFlNjg5NTE1NGQwNzBmYjAxMTNiYzNhYTgiLCJTSE9SVF9TSEEiOiI2ZGEwOTNmIiwiVFJJR0dFUl9CVUlMRF9DT05GSUdfUEFUSCI6ImNsb3VkYnVpbGQueWFtbCIsIlRSSUdHRVJfTkFNRSI6InRyaWdnZXIifX0sImVudHJ5UG9pbnQiOiJjbG91ZGJ1aWxkLnlhbWwiLCJ0eXBlIjoiaHR0cHM6Ly9jbG91ZGJ1aWxkLmdvb2dsZWFwaXMuY29tL0Nsb3VkQnVpbGRZYW1sQHYwLjEifX0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3Nsc2EuZGV2L3Byb3ZlbmFuY2UvdjAuMSIsInNsc2FQcm92ZW5hbmNlIjp7ImJ1aWxkZXIiOnsiaWQiOiJodHRwczovL2Nsb3VkYnVpbGQuZ29vZ2xlYXBpcy5jb20vR29vZ2xlSG9zdGVkV29ya2VyQHYwLjMifSwibWF0ZXJpYWxzIjpbeyJkaWdlc3QiOnsic2hhMSI6IjZkYTA5M2ZiZDRmMTRiMzFlNjg5NTE1NGQwNzBmYjAxMTNiYzNhYTgifSwidXJpIjoiaHR0cHM6Ly9naXRodWIuY29tL2lyc2wvZ2NiLXRlc3RzIn1dLCJtZXRhZGF0YSI6eyJidWlsZEZpbmlzaGVkT24iOiIyMDIzLTAyLTE3VDA5OjU2OjU2LjExMzA2M1oiLCJidWlsZEludm9jYXRpb25JZCI6IjFkYmJlODEzLTRmM2QtNGM4ZS1iMmQzLTc3NmM5MTQ4MWJiOCIsImJ1aWxkU3RhcnRlZE9uIjoiMjAyMy0wMi0xN1QwOTo1Njo0MS4wMDAxMDEyODJaIn0sInJlY2lwZSI6eyJhcmd1bWVudHMiOnsiQHR5cGUiOiJ0eXBlLmdvb2dsZWFwaXMuY29tL2dvb2dsZS5kZXZ0b29scy5jbG91ZGJ1aWxkLnYxLkJ1aWxkIiwiaWQiOiIxZGJiZTgxMy00ZjNkLTRjOGUtYjJkMy03NzZjOTE0ODFiYjgiLCJuYW1lIjoicHJvamVjdHMvNzkyMTkxODc3NzYvbG9jYXRpb25zL3VzLXdlc3QyL2J1aWxkcy8xZGJiZTgxMy00ZjNkLTRjOGUtYjJkMy03NzZjOTE0ODFiYjgiLCJvcHRpb25zIjp7ImR5bmFtaWNTdWJzdGl0dXRpb25zIjp0cnVlLCJsb2dnaW5nIjoiTEVHQUNZIiwicG9vbCI6e30sInJlcXVlc3RlZFZlcmlmeU9wdGlvbiI6IlZFUklGSUVEIiwic3Vic3RpdHV0aW9uT3B0aW9uIjoiQUxMT1dfTE9PU0UifSwic291cmNlUHJvdmVuYW5jZSI6e30sInN0ZXBzIjpbeyJhcmdzIjpbImJ1aWxkIiwiLXQiLCJ1cy13ZXN0Mi1kb2NrZXIucGtnLmRldi9pbXJlLXRlc3QvcXVpY2tzdGFydC1kb2NrZXItcmVwby9xdWlja3N0YXJ0LWltYWdlOnY0MSIsIi4iXSwibmFtZSI6Imdjci5pby9jbG91ZC1idWlsZGVycy9kb2NrZXIiLCJwdWxsVGltaW5nIjp7ImVuZFRpbWUiOiIyMDIzLTAyLTE3VDA5OjU2OjQ0LjMyNDc0NTQ4OVoiLCJzdGFydFRpbWUiOiIyMDIzLTAyLTE3VDA5OjU2OjQ0LjMyMDY2MTcxOFoifSwic3RhdHVzIjoiU1VDQ0VTUyIsInRpbWluZyI6eyJlbmRUaW1lIjoiMjAyMy0wMi0xN1QwOTo1Njo1MC41OTg1MDg3NThaIiwic3RhcnRUaW1lIjoiMjAyMy0wMi0xN1QwOTo1Njo0NC4zMjA2NjE3MThaIn19XSwic3Vic3RpdHV0aW9ucyI6eyJCUkFOQ0hfTkFNRSI6Im1haW4iLCJDT01NSVRfU0hBIjoiNmRhMDkzZmJkNGYxNGIzMWU2ODk1MTU0ZDA3MGZiMDExM2JjM2FhOCIsIlJFRl9OQU1FIjoibWFpbiIsIlJFUE9fTkFNRSI6ImdjYi10ZXN0cyIsIlJFVklTSU9OX0lEIjoiNmRhMDkzZmJkNGYxNGIzMWU2ODk1MTU0ZDA3MGZiMDExM2JjM2FhOCIsIlNIT1JUX1NIQSI6IjZkYTA5M2YiLCJUUklHR0VSX0JVSUxEX0NPTkZJR19QQVRIIjoiY2xvdWRidWlsZC55YW1sIiwiVFJJR0dFUl9OQU1FIjoidHJpZ2dlciJ9fSwiZW50cnlQb2ludCI6ImNsb3VkYnVpbGQueWFtbCIsInR5cGUiOiJodHRwczovL2Nsb3VkYnVpbGQuZ29vZ2xlYXBpcy5jb20vQ2xvdWRCdWlsZFlhbWxAdjAuMSJ9fSwic3ViamVjdCI6W3siZGlnZXN0Ijp7InNoYTI1NiI6IjQxY2I0YjVlMzJlNDE3Yjg2YzJiMjIyOWQwNTgxYjcyZjdkZmZkMWNjNmIwZTU4NmFiMmNlZmRiN2E1Mjc1MjkifSwibmFtZSI6Imh0dHBzOi8vdXMtd2VzdDItZG9ja2VyLnBrZy5kZXYvaW1yZS10ZXN0L3F1aWNrc3RhcnQtZG9ja2VyLXJlcG8vcXVpY2tzdGFydC1pbWFnZTp2NDEifV19", 91 | "payloadType": "application/vnd.in-toto+json", 92 | "signatures": [ 93 | { 94 | "sig": "MEUCIQCi3Nul7fQCdJBFzWTOl+nnsBuhfx26Wc8LDeWDuxxAewIgV4TsY0iEMgU/JXf+RuhHTVT6u3TbvhMdYtIzVhHxw/w=", 95 | "keyid": "projects/verified-builder/locations/us-west2/keyRings/attestor/cryptoKeys/builtByGCB/cryptoKeyVersions/1" 96 | } 97 | ] 98 | }, 99 | "build": { 100 | "intotoStatement": { 101 | "_type": "https://in-toto.io/Statement/v0.1", 102 | "subject": [ 103 | { 104 | "name": "https://us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:v41", 105 | "digest": { 106 | "sha256": "41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529" 107 | } 108 | } 109 | ], 110 | "predicateType": "https://slsa.dev/provenance/v0.1", 111 | "slsaProvenance": { 112 | "builder": { 113 | "id": "https://cloudbuild.googleapis.com/GoogleHostedWorker@v0.3" 114 | }, 115 | "recipe": { 116 | "type": "https://cloudbuild.googleapis.com/CloudBuildYaml@v0.1", 117 | "entryPoint": "cloudbuild.yaml", 118 | "arguments": { 119 | "@type": "type.googleapis.com/google.devtools.cloudbuild.v1.Build", 120 | "id": "1dbbe813-4f3d-4c8e-b2d3-776c91481bb8", 121 | "steps": [ 122 | { 123 | "name": "gcr.io/cloud-builders/docker", 124 | "args": [ 125 | "build", 126 | "-t", 127 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:v41", 128 | "." 129 | ], 130 | "timing": { 131 | "startTime": "2023-02-17T09:56:44.320661718Z", 132 | "endTime": "2023-02-17T09:56:50.598508758Z" 133 | }, 134 | "status": "SUCCESS", 135 | "pullTiming": { 136 | "startTime": "2023-02-17T09:56:44.320661718Z", 137 | "endTime": "2023-02-17T09:56:44.324745489Z" 138 | } 139 | } 140 | ], 141 | "sourceProvenance": {}, 142 | "options": { 143 | "requestedVerifyOption": "VERIFIED", 144 | "substitutionOption": "ALLOW_LOOSE", 145 | "logging": "LEGACY", 146 | "dynamicSubstitutions": true, 147 | "pool": {} 148 | }, 149 | "substitutions": { 150 | "COMMIT_SHA": "6da093fbd4f14b31e6895154d070fb0113bc3aa8", 151 | "SHORT_SHA": "6da093f", 152 | "BRANCH_NAME": "main", 153 | "REF_NAME": "main", 154 | "TRIGGER_NAME": "trigger", 155 | "TRIGGER_BUILD_CONFIG_PATH": "cloudbuild.yaml", 156 | "REPO_NAME": "gcb-tests", 157 | "REVISION_ID": "6da093fbd4f14b31e6895154d070fb0113bc3aa8" 158 | }, 159 | "name": "projects/79219187776/locations/us-west2/builds/1dbbe813-4f3d-4c8e-b2d3-776c91481bb8" 160 | } 161 | }, 162 | "metadata": { 163 | "buildInvocationId": "1dbbe813-4f3d-4c8e-b2d3-776c91481bb8", 164 | "buildStartedOn": "2023-02-17T09:56:41.000101282Z", 165 | "buildFinishedOn": "2023-02-17T09:56:56.113063Z" 166 | }, 167 | "materials": [ 168 | { 169 | "uri": "https://github.com/irsl/gcb-tests", 170 | "digest": { 171 | "sha1": "6da093fbd4f14b31e6895154d070fb0113bc3aa8" 172 | } 173 | } 174 | ] 175 | } 176 | } 177 | } 178 | } 179 | ] 180 | } 181 | `) 182 | 183 | func TestExtractGcpProjectFromImageRef(t *testing.T) { 184 | testCases := map[string]string{ 185 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529": "imre-test", 186 | } 187 | for input, want := range testCases { 188 | got := extractGcpProjectFromImageRef(input) 189 | if got != want { 190 | t.Errorf("extractGcpProjectFromImageRef(%q) = %q, want %q", input, got, want) 191 | } 192 | } 193 | } 194 | 195 | func TestGetImageHash(t *testing.T) { 196 | testCases := map[string]string{ 197 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529": "sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529", 198 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag": "", 199 | } 200 | for input, want := range testCases { 201 | got := getImageHash(input) 202 | if got != want { 203 | t.Errorf("getImageHash(%q) = %q, want %q", input, got, want) 204 | } 205 | } 206 | } 207 | 208 | type mockedHTTPDoer struct { 209 | expectedAuthorizationHeader string 210 | expectedURL string 211 | } 212 | 213 | func (m mockedHTTPDoer) Do(req *http.Request) (*http.Response, error) { 214 | if req.URL.String() != m.expectedURL { 215 | return nil, fmt.Errorf("unexpected URL: %v vs %v", req.URL, m.expectedURL) 216 | } 217 | if req.Header.Get("Authorization") != m.expectedAuthorizationHeader { 218 | return nil, fmt.Errorf("unexpected authorization: %v vs %v", req.Header.Get("Authorization"), m.expectedAuthorizationHeader) 219 | } 220 | return &http.Response{ 221 | StatusCode: http.StatusOK, 222 | Body: io.NopCloser(bytes.NewReader(containerAnalysisResponse)), 223 | }, nil 224 | } 225 | 226 | func TestObtainGoogleCloudProvenance(t *testing.T) { 227 | origGetGoogleAccessToken := getGoogleAccessToken 228 | defer func() { getGoogleAccessToken = origGetGoogleAccessToken }() 229 | getGoogleAccessToken = func() (string, error) { return "specialtoken", nil } 230 | 231 | origGetHTTPDoer := getHTTPDoer 232 | defer func() { getHTTPDoer = origGetHTTPDoer }() 233 | myMockedHTTPDoer := mockedHTTPDoer{ 234 | expectedAuthorizationHeader: "Bearer specialtoken", 235 | // note, the :v41 tag is stripped! 236 | expectedURL: "https://containeranalysis.googleapis.com/v1/projects/imre-test/occurrences?alt=json&filter=%28%28kind+%3D+%22BUILD%22%29+OR+%28kind+%3D+%22DSSE_ATTESTATION%22%29%29+AND+%28resourceUrl+%3D+%22https%3A%2F%2Fus-west2-docker.pkg.dev%2Fimre-test%2Fquickstart-docker-repo%2Fquickstart-image%40sha256%3A41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529%22%29&pageSize=10", 237 | } 238 | getHTTPDoer = func() httpDoer { return myMockedHTTPDoer } 239 | 240 | provBytes, err := ObtainGoogleCloudProvenance("us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:v41@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529") 241 | if err != nil { 242 | t.Fatal(err) 243 | } 244 | 245 | var gp GcpProvenance 246 | err = json.Unmarshal(provBytes, &gp) 247 | if err != nil { 248 | t.Fatal(err) 249 | } 250 | expectedRebuiltFullyQualified := "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529" 251 | if gp.ImageSummary.FullyQualifiedDigest != expectedRebuiltFullyQualified { 252 | t.Errorf("sanity check on GcpProvenance failed: %v vs %v", gp.ImageSummary.FullyQualifiedDigest, expectedRebuiltFullyQualified) 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /slsa/provenance.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package slsa provides functions to resolve SLSA related info about code repositories and to 16 | // obtain SLSA provenance information about images. 17 | package slsa 18 | 19 | import ( 20 | "fmt" 21 | "regexp" 22 | ) 23 | 24 | type platformResolver func(string) ([]byte, error) 25 | 26 | // registry about the supported platforms where we can automatically obtain provenance info from. 27 | var platformLookupMap = map[string]platformResolver{ 28 | "^([a-z0-9-]+-)?docker.pkg.dev/": ObtainGoogleCloudProvenance, 29 | } 30 | 31 | func findPlatform(fullyQualifiedDigest string) platformResolver { 32 | for platformRegexp, resolver := range platformLookupMap { 33 | match, err := regexp.MatchString(platformRegexp, fullyQualifiedDigest) 34 | if err != nil { 35 | // the regexes are always hard coded above, so no errors are ever expected 36 | continue 37 | } 38 | 39 | if match { 40 | return resolver 41 | } 42 | } 43 | return nil 44 | } 45 | 46 | func obtainProvenance(fullyQualifiedDigest string) ([]byte, error) { 47 | resolver := findPlatform(fullyQualifiedDigest) 48 | if resolver == nil { 49 | return nil, fmt.Errorf("automatically obtaining provenance for image %s is not supported", fullyQualifiedDigest) 50 | } 51 | 52 | return resolver(fullyQualifiedDigest) 53 | } 54 | 55 | // ObtainProvenance obtains SLSA provenance info about the specified container image. 56 | // The image reference is expected to be fully qualified (along with the sha hash). 57 | func ObtainProvenance(fullyQualifiedDigest string) ([]byte, error) { 58 | // TODO(imrer): add caching 59 | return obtainProvenance(fullyQualifiedDigest) 60 | } 61 | -------------------------------------------------------------------------------- /slsa/provenance_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package slsa 16 | 17 | import ( 18 | "fmt" 19 | "testing" 20 | ) 21 | 22 | func TestObtainProvenance(t *testing.T) { 23 | inputImage := "mocked-unit-tests.tld/some/image@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529" 24 | 25 | mockedPlatformObtainProvenance := func(fullyQalifiedImage string) ([]byte, error) { 26 | if fullyQalifiedImage != inputImage { 27 | return nil, fmt.Errorf("unexpected image ref: %v vs %v", fullyQalifiedImage, inputImage) 28 | } 29 | return []byte("hello:)"), nil 30 | } 31 | 32 | regexp := "^mocked-unit-tests\\.tld/" 33 | platformLookupMap[regexp] = mockedPlatformObtainProvenance 34 | defer delete(platformLookupMap, regexp) 35 | re, err := ObtainProvenance(inputImage) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | sre := string(re) 40 | ere := "hello:)" 41 | if sre != ere { 42 | t.Errorf("unexpected response from ObtainProvenance: %v vs %v", sre, ere) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /slsa/repo.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package slsa 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "io" 21 | "net/http" 22 | "regexp" 23 | "strings" 24 | 25 | "github.com/google/ctrdac/lookup" 26 | "gopkg.in/yaml.v2" 27 | ) 28 | 29 | const ( 30 | // BuilderIDGoogleCloudBuild is the ID slsa-verifier expects for the Cloud Build builder 31 | BuilderIDGoogleCloudBuild = "https://cloudbuild.googleapis.com/GoogleHostedWorker" 32 | 33 | maxBytesToRead = 100 * 1024 // 100kbyte 34 | ) 35 | 36 | type httpGetter interface { 37 | Get(string) (*http.Response, error) 38 | } 39 | 40 | // RepoResolver is the type that features resolving SLSA info for a repository 41 | type RepoResolver struct { 42 | client httpGetter 43 | } 44 | 45 | // Repo contains SLSA related info about a code repository 46 | type Repo struct { 47 | BuilderID string 48 | Images []string 49 | Repo string 50 | } 51 | 52 | // fetchURL is a helper function to slurp a url to bytes. Read is limited to maxBytesToRead. 53 | func (s RepoResolver) fetchURL(url string) ([]byte, error) { 54 | client := s.client 55 | if client == nil { 56 | client = http.DefaultClient 57 | } 58 | resp, err := client.Get(url) 59 | if err != nil { 60 | return nil, err 61 | } 62 | limitedReader := &io.LimitedReader{R: resp.Body, N: maxBytesToRead} 63 | return io.ReadAll(limitedReader) 64 | } 65 | 66 | func githubURLProducer(repo string, file string) string { 67 | // https://raw.githubusercontent.com/irsl/gcb-tests/main/cloudbuild.yaml 68 | // repo is just the repo under github.com, like irsl/something 69 | return "https://raw.githubusercontent.com/" + repo + "/main/" + file 70 | } 71 | 72 | func (s RepoResolver) doResolveHTTPCloudBuild(cloudbuild []byte) (*Repo, error) { 73 | // Unmarshal our input YAML file into empty Car (var c) 74 | var c map[string]any 75 | if err := yaml.Unmarshal(cloudbuild, &c); err != nil { 76 | return nil, fmt.Errorf("unable to parse cloudbuild.yaml: %v", err) 77 | } 78 | // fmt.Printf("%+v", c.Proxy.Listener.UDS) 79 | 80 | repoImages, err := lookup.ToStrSlice(lookup.Lookup[[]any](c, "images", nil)) 81 | if err != nil || len(repoImages) == 0 { 82 | return nil, errors.New("images field not present in cloudbuild.yaml") 83 | } 84 | var sImages []string 85 | for _, image := range repoImages { 86 | sImages = append(sImages, GetImageRefWithoutTags(image)) 87 | } 88 | 89 | return &Repo{Images: sImages, BuilderID: BuilderIDGoogleCloudBuild}, nil 90 | } 91 | 92 | func (s RepoResolver) doResolveHTTP(repo string, urlProducer func(repo string, file string) string) (*Repo, error) { 93 | 94 | type HTTPResolver struct { 95 | logic func([]byte) (*Repo, error) 96 | files []string 97 | } 98 | 99 | for _, resolver := range []HTTPResolver{ 100 | { 101 | logic: s.doResolveHTTPCloudBuild, 102 | files: []string{"cloudbuild.yaml", "cloudbuild.yml"}, 103 | }, 104 | } { 105 | for _, file := range resolver.files { 106 | url := urlProducer(repo, file) 107 | body, err := s.fetchURL(url) 108 | if err != nil { 109 | continue 110 | } 111 | 112 | return resolver.logic(body) 113 | } 114 | } 115 | 116 | return nil, errors.New("unable to parse repo") 117 | } 118 | 119 | func (s RepoResolver) doResolvePartially(repo string) (*Repo, error) { 120 | 121 | if strings.HasPrefix(repo, "github.com/") { 122 | cs := strings.SplitN(repo, "/", 2) 123 | 124 | return s.doResolveHTTP(cs[1], githubURLProducer) 125 | } 126 | 127 | return nil, errors.New("the specified repo is not supported") 128 | } 129 | 130 | func (s RepoResolver) doResolve(repo string) (*Repo, error) { 131 | 132 | as, err := s.doResolvePartially(repo) 133 | if err != nil { 134 | return nil, err 135 | } 136 | as.Repo = repo 137 | return as, nil 138 | } 139 | 140 | // ResolveRepo attempts to obtain SLSA related info about the specified code repository 141 | func (s RepoResolver) ResolveRepo(repo string) (*Repo, error) { 142 | // TODO(imrer): add some caching 143 | r, e := s.doResolve(repo) 144 | if e != nil { 145 | return nil, fmt.Errorf("error while resolving %s: %v", repo, s) 146 | } 147 | return r, nil 148 | } 149 | 150 | // Resolve attempts to obtain SLSA related info about the specified code repositories 151 | func (s RepoResolver) Resolve(repos ...string) ([]*Repo, error) { 152 | // TODO(imrer): add some caching 153 | var re []*Repo 154 | for _, repo := range repos { 155 | s, err := s.ResolveRepo(repo) 156 | if err != nil { 157 | return nil, err 158 | } 159 | re = append(re, s) 160 | } 161 | return re, nil 162 | } 163 | 164 | // GetImageRefWithoutTags returns the name of a container image reference without tags and hash 165 | func GetImageRefWithoutTags(imageRef string) string { 166 | // an imageref may look like this: 167 | // us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529 168 | // or 169 | // us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529 170 | // or 171 | // us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag 172 | imageRefSplit := strings.SplitN(imageRef, "@", 2) 173 | imageRef = imageRefSplit[0] 174 | imageRefSplit = strings.SplitN(imageRef, ":", 2) 175 | return imageRefSplit[0] 176 | } 177 | 178 | // IsImageMatch is a helper function to compare a container image reference to a reference present 179 | // in a code repo - this latter may have $PROJECT_ID reference inside. 180 | func IsImageMatch(imageRef string, imageFromRepo string) bool { 181 | imageRef = GetImageRefWithoutTags(imageRef) 182 | 183 | // TODO(imrer): some nicer simple wildcard based solution 184 | repoPattern := strings.ReplaceAll(imageFromRepo, ".", "\\.") 185 | repoPattern = strings.ReplaceAll(repoPattern, "$PROJECT_ID", ".*") 186 | repoPattern = "^" + repoPattern + "$" 187 | matched, err := regexp.MatchString(repoPattern, imageRef) 188 | if err != nil { 189 | return false 190 | } 191 | return matched 192 | } 193 | 194 | // FindMatchingRepos filters the provided code repo slice based on the image reference. 195 | func FindMatchingRepos(imageRef string, repos []*Repo) []*Repo { 196 | var re []*Repo 197 | for _, repo := range repos { 198 | for _, image := range repo.Images { 199 | if IsImageMatch(imageRef, image) { 200 | re = append(re, repo) 201 | } 202 | } 203 | } 204 | return re 205 | } 206 | -------------------------------------------------------------------------------- /slsa/repo_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package slsa 16 | 17 | import ( 18 | "bytes" 19 | "fmt" 20 | "io" 21 | "net/http" 22 | "reflect" 23 | "testing" 24 | ) 25 | 26 | var cloudbuildYaml = []byte(` 27 | steps: 28 | - name: 'gcr.io/cloud-builders/docker' 29 | args: [ 'build', '-t', 'us-west2-docker.pkg.dev/$PROJECT_ID/quickstart-docker-repo/quickstart-image:v41', '.' ] 30 | images: [ 'us-west2-docker.pkg.dev/$PROJECT_ID/quickstart-docker-repo/quickstart-image:v41' ] 31 | options: 32 | requestedVerifyOption: VERIFIED 33 | `) 34 | 35 | func TestGetImageRefWithoutTags(t *testing.T) { 36 | testCases := map[string]string{ 37 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529": "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image", 38 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529": "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image", 39 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag": "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image", 40 | } 41 | for input, expected := range testCases { 42 | actual := GetImageRefWithoutTags(input) 43 | if actual != expected { 44 | t.Errorf("GetImageRefWithoutTags(%q) = %q, want %q", input, actual, expected) 45 | } 46 | } 47 | } 48 | 49 | func TestIsImageMatch(t *testing.T) { 50 | matchingPairs := map[string]string{ 51 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag@sha256:41cb4b5e32e417b86c2b2229d0581b72f7dffd1cc6b0e586ab2cefdb7a527529": "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image", 52 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag": "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image", 53 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag2": "us-west2-docker.pkg.dev/$PROJECT_ID/quickstart-docker-repo/quickstart-image", 54 | } 55 | for v1, v2 := range matchingPairs { 56 | if !IsImageMatch(v1, v2) { 57 | t.Errorf("IsImageMatch(%q, %q) did not match", v1, v2) 58 | } 59 | } 60 | } 61 | 62 | func TestIsImageMismatch(t *testing.T) { 63 | mismatchingPairs := map[string]string{ 64 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag1": "us-west2-docker.pkg.dev/xxximre-test/quickstart-docker-repo/quickstart-image", 65 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag2": "us-west2-docker.pkg.dev/imre-test/xxxquickstart-docker-repo/quickstart-image", 66 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag3": "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/xxxquickstart-image", 67 | "us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag4": "xx-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image", 68 | } 69 | for v1, v2 := range mismatchingPairs { 70 | if IsImageMatch(v1, v2) { 71 | t.Errorf("IsImageMatch(%q, %q) should not match", v1, v2) 72 | } 73 | } 74 | } 75 | 76 | func TestFindMatchingRepos(t *testing.T) { 77 | repos := []*Repo{ 78 | {Images: []string{"xx-west2-docker.pkg.dev/$PROJECT_ID/quickstart-docker-repo/quickstart-image"}}, 79 | {Images: []string{"us-west2-docker.pkg.dev/$PROJECT_ID/quickstart-docker-repo/quickstart-image"}}, 80 | } 81 | frepos := FindMatchingRepos("us-west2-docker.pkg.dev/imre-test/quickstart-docker-repo/quickstart-image:sometag", repos) 82 | if len(frepos) != 1 { 83 | t.Fatalf("unexpected number of FindMatchingRepos matches: %d", len(frepos)) 84 | } 85 | if frepos[0].Images[0] != "us-west2-docker.pkg.dev/$PROJECT_ID/quickstart-docker-repo/quickstart-image" { 86 | t.Errorf("FindMatchingRepos did not return the expected repo") 87 | } 88 | } 89 | 90 | func TestGithubUrl(t *testing.T) { 91 | eURL := "https://raw.githubusercontent.com/imre/test/main/cloudbuild.yaml" 92 | aURL := githubURLProducer("imre/test", "cloudbuild.yaml") 93 | if aURL != eURL { 94 | t.Errorf("githubURLProducer: got %v wanted %v", aURL, eURL) 95 | } 96 | } 97 | 98 | type mockedClient struct{} 99 | 100 | func (m mockedClient) Get(url string) (*http.Response, error) { 101 | switch url { 102 | case "https://raw.githubusercontent.com/irsl/gcb-tests/main/cloudbuild.yaml": 103 | return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(cloudbuildYaml))}, nil 104 | default: 105 | return nil, fmt.Errorf("unexpected url: %v", url) 106 | } 107 | } 108 | 109 | func TestResolver(t *testing.T) { 110 | 111 | inputRepo := "github.com/irsl/gcb-tests" 112 | 113 | rr := RepoResolver{} 114 | rr.client = mockedClient{} 115 | repos, err := rr.Resolve(inputRepo) 116 | if err != nil { 117 | t.Fatal(err) 118 | } 119 | if len(repos) != 1 { 120 | t.Fatalf("unexpected number of repos: %d", len(repos)) 121 | } 122 | if repos[0].BuilderID != BuilderIDGoogleCloudBuild { 123 | t.Errorf("should be cloud build: %v vs %v", repos[0].BuilderID, BuilderIDGoogleCloudBuild) 124 | } 125 | expectedImages := []string{"us-west2-docker.pkg.dev/$PROJECT_ID/quickstart-docker-repo/quickstart-image"} 126 | if !reflect.DeepEqual(repos[0].Images, expectedImages) { 127 | t.Errorf("images incorrect: %v vs %v", repos[0].Images, expectedImages) 128 | } 129 | if repos[0].Repo != inputRepo { 130 | t.Errorf("repo invalid: %v vs %v", repos[0].Repo, inputRepo) 131 | } 132 | } 133 | --------------------------------------------------------------------------------