├── .editorconfig ├── .github └── workflows │ └── test.yaml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── docs └── policies.txt ├── example ├── conftest │ ├── Makefile │ └── data.yaml ├── inputs │ └── openapi.json ├── opa │ ├── Makefile │ ├── data.yaml │ └── server-configuration.yaml └── spectral │ ├── .gitignore │ ├── README.md │ ├── build.sh │ ├── build │ └── .gitkeep │ ├── example │ ├── .spectral.yaml │ └── openapi.json │ ├── functions │ └── opa.js │ ├── package-lock.json │ ├── package.json │ ├── policies.js │ ├── ruleset.js │ └── wasm.js └── src ├── .manifest └── openapi ├── lib └── lib.rego ├── main ├── conftest.rego ├── main.rego ├── main_test.rego ├── recommended.rego └── ruleset.rego └── policies ├── contact-properties ├── contact_properties.rego └── contact_properties_test.rego ├── duplicated-entry-in-enum ├── duplicated_entry_in_enum.rego └── duplicated_entry_in_enum_test.rego ├── info-contact ├── info_contact.rego └── info_contact_test.rego ├── info-description ├── info_description.rego └── info_description_test.rego ├── info-license ├── info_license.rego └── info_license_test.rego ├── license-url ├── license_url.rego └── license_url_test.rego ├── no-eval-in-markdown ├── no_eval_in_markdown.rego └── no_eval_in_markdown_test.rego ├── no-script-tags-in-markdown ├── no_script_tags_in_markdown.rego └── no_script_tags_in_markdown_test.rego ├── openapi-tags-uniqueness ├── openapi_tags_uniqueness.rego └── openapi_tags_uniqueness_test.rego ├── openapi-tags ├── openapi_tags.rego └── openapi_tags_test.rego ├── operation-description ├── operation_description.rego └── operation_description_test.rego ├── operation-operationId-unique ├── operation_operationid_unique.rego └── operation_operationid_unique_test.rego ├── operation-operationId-valid-in-url ├── operation_operationid_valid_in_url.rego └── operation_operationid_valid_in_url_test.rego ├── operation-operationId ├── operation_operationid.rego └── operation_operationid_test.rego ├── operation-parameters ├── operation_parameters.rego └── operation_parameters_test.rego ├── operation-singular-tag ├── operation_singular_tag.rego └── operation_singular_tag_test.rego ├── operation-success-response ├── operation_success_response.rego └── operation_success_response_test.rego ├── operation-tag-defined ├── operation_tag_defined.rego └── operation_tag_defined_test.rego ├── operation-tags ├── operation_tags.rego └── operation_tags_test.rego ├── path-declarations-must-exist ├── path_declarations_must_exist.rego └── path_declarations_must_exist_test.rego ├── path-keys-no-trailing-slash ├── path_keys_no_trailing_slash.rego └── path_keys_no_trailing_slash_test.rego ├── path-not-include-query ├── path_not_include_query.rego └── path_not_include_query_test.rego ├── path-params ├── lib │ ├── duplicate_path_param_definition_results.rego │ ├── duplicate_var_name_in_path_results.rego │ ├── lib.rego │ ├── param_asymmetry_results.rego │ ├── path_collision_results.rego │ ├── path_param_missing_required_results.rego │ └── path_regex.rego ├── path_params.rego └── path_params_test.rego └── tag-description ├── tag_description.rego └── tag_description_test.rego /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.rego] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: bundle 2 | on: 3 | push: 4 | branches: [main] 5 | 6 | jobs: 7 | test-policies: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: checkout repository 11 | uses: actions/checkout@v3 12 | - name: opa fmt 13 | uses: docker://openpolicyagent/opa:0.45.0 14 | with: 15 | args: fmt --list --fail ./src 16 | - name: opa check 17 | uses: docker://openpolicyagent/opa:0.45.0 18 | with: 19 | args: check --strict --bundle ./src 20 | - name: opa test 21 | uses: docker://openpolicyagent/opa:0.45.0 22 | with: 23 | args: test --bundle ./src --verbose 24 | build-and-push: 25 | needs: test-policies 26 | runs-on: ubuntu-latest 27 | permissions: 28 | packages: write 29 | steps: 30 | - name: checkout repository 31 | uses: actions/checkout@v3 32 | - name: build bundle 33 | run: tar -czvf bundle.tar.gz -C ./src . 34 | - name: oras login and push 35 | env: 36 | GITHUB_OWNER: ${{ github.repository_owner }} 37 | GITHUB_REPO: ${{ github.repository }} 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | run: | 40 | echo $GITHUB_TOKEN | oras login ghcr.io -u $GITHUB_OWNER --password-stdin 41 | echo '{}' > manifest-config.json 42 | oras push ghcr.io/$GITHUB_REPO:latest \ 43 | --config manifest-config.json:application/vnd.oci.image.config.v1+json \ 44 | bundle.tar.gz:application/vnd.oci.image.layer.v1.tar+gzip 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bundle.tar.gz 2 | example/conftest/policy 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["tsandall.opa"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "opa.checkOnSave": true, 3 | "opa.strictMode": true, 4 | "opa.roots": ["${workspaceFolder}/src"] 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | check: 2 | opa check --strict ./src 3 | fmt: 4 | opa fmt --list --fail ./src 5 | test: 6 | opa test -b ./src 7 | bundle: 8 | tar -czvf bundle.tar.gz -C ./src . 9 | push: 10 | oras push ghcr.io/kevinswiber/spego:latest --config manifest-config.json:application/vnd.oci.image.config.v1+json bundle.tar.gz:application/vnd.oci.image.layer.v1.tar+gzip 11 | docs: 12 | opa inspect -a ./src > docs/policies.txt 13 | clean: 14 | rm bundle.tar.gz 15 | 16 | .PHONY: check fmt test bundle push docs clean 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spego 2 | 3 | [![bundle](https://github.com/kevinswiber/spego/actions/workflows/test.yaml/badge.svg)](https://github.com/kevinswiber/spego/actions/workflows/test.yaml) 4 | 5 | A set of policies for Open Policy Agent to validate OpenAPI definitions. 6 | 7 | - Use `opa eval` to execute policies against OpenAPI documents 8 | - Built-in support for [Conftest](https://conftest.dev) 9 | - Based on the rules used by [Spectral](https://github.com/stoplightio/spectral). 10 | 11 | ## Usage 12 | 13 | Spego may be used as an Open Policy Agent (OPA) bundle. First, be sure to follow the [instructions to install OPA](https://www.openpolicyagent.org/docs/latest/#1-download-opa). 14 | 15 | ```sh 16 | git clone git@github.com:kevinswiber/spego.git 17 | opa eval \ 18 | --bundle ./spego/src \ 19 | --format pretty \ 20 | --input ./spego/example/inputs/openapi.json \ 21 | "data.openapi.main.results" 22 | ``` 23 | 24 | Spego can also be used with Conftest. First, be sure to follow the [instructions to download Conftest](https://www.conftest.dev/install/). 25 | 26 | ```sh 27 | conftest pull git::https://github.com/kevinswiber/spego//src 28 | conftest test -n "openapi.main" ./openapi.json 29 | ``` 30 | 31 | Note: Policy execution can be configured by adding a data file named `data.openapi.ruleset` as defined in the [Configuration](#configuration) section. 32 | 33 | ## Configuration 34 | 35 | - Supports a subset of Spectral custom rulesets, including [Changing Rule Severity](https://meta.stoplight.io/docs/spectral/e5b9616d6d50c-custom-rulesets#changing-rule-severity), [Recommended or All](https://meta.stoplight.io/docs/spectral/e5b9616d6d50c-custom-rulesets#recommended-or-all), [Disabling Rules](https://meta.stoplight.io/docs/spectral/e5b9616d6d50c-custom-rulesets#disabling-rules), and [Enabling Rules](https://meta.stoplight.io/docs/spectral/e5b9616d6d50c-custom-rulesets#enabling-rules). 36 | - Included as data in Open Policy Agent, under `data.openapi.ruleset`. 37 | - Configuration is optional. The recommended policies are executed by default. 38 | 39 | ### Example configuration 40 | 41 | ```yaml 42 | openapi: 43 | ruleset: 44 | extends: [['spego:oas', 'recommended']] 45 | rules: 46 | operation-success-response: error 47 | openapi-tags: true 48 | ``` 49 | 50 | ## Policies (Rules) 51 | 52 | ### contact-properties 53 | 54 | Contact object must have "name", "url" and "email". 55 | 56 | ### duplicated-entry-in-enum 57 | 58 | Recommended: true 59 | 60 | Enum values must not have duplicate entry. 61 | 62 | ### info-contact 63 | 64 | Recommended: true 65 | 66 | Info object must have "contact" object. 67 | 68 | ### info-description 69 | 70 | Recommended: true 71 | 72 | Info "description" must be present and non-empty string. 73 | 74 | ### info-license 75 | 76 | Info object must have "license" object. 77 | 78 | ### license-url 79 | 80 | License object must include "url". 81 | 82 | ### no-eval-in-markdown 83 | 84 | Recommended: true 85 | 86 | Markdown descriptions must not have "eval(". 87 | 88 | ### no-script-tags-in-markdown 89 | 90 | Recommended: true 91 | 92 | Markdown descriptions must not have " friend", 22 | "operationId": "createUser", 23 | "responses": { 24 | "400": {} 25 | }, 26 | "parameters": [ 27 | { "name": "a", "in": "body" }, 28 | { "name": "a", "in": "formData" } 29 | ], 30 | "tags": ["user"] 31 | }, 32 | "get": { 33 | "summary": "Sample endpoint: Returns details about a particular user", 34 | "operationId": "listUser", 35 | "tags": ["user"], 36 | "parameters": [ 37 | { 38 | "name": "id", 39 | "in": "query", 40 | "description": "ID of the user", 41 | "required": true, 42 | "schema": { 43 | "type": "integer", 44 | "format": "int32" 45 | } 46 | } 47 | ], 48 | "responses": { 49 | "400": { 50 | "description": "Sample response: Details about a user by ID", 51 | "headers": { 52 | "x-next": { 53 | "description": "A link to the next page of responses", 54 | "schema": { 55 | "type": "string" 56 | } 57 | } 58 | }, 59 | "content": { 60 | "application/json": { 61 | "schema": { 62 | "$ref": "#/components/schemas/Error" 63 | } 64 | } 65 | } 66 | }, 67 | "default": { 68 | "description": "Unexpected error", 69 | "content": { 70 | "application/json": { 71 | "schema": { 72 | "$ref": "#/components/schemas/Error" 73 | } 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | }, 81 | "components": { 82 | "schemas": { 83 | "User": { 84 | "type": "object", 85 | "properties": { 86 | "id": { 87 | "type": "string" 88 | } 89 | } 90 | }, 91 | "Error": { 92 | "type": "object", 93 | "required": ["code", "message"], 94 | "properties": { 95 | "_id": { 96 | "type": "string" 97 | }, 98 | "code": { 99 | "type": "integer", 100 | "format": "int32" 101 | }, 102 | "message": { 103 | "type": "string" 104 | } 105 | } 106 | } 107 | } 108 | }, 109 | "security": [ 110 | { 111 | "BasicAuth": [] 112 | } 113 | ] 114 | } 115 | -------------------------------------------------------------------------------- /example/opa/Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | opa eval -b ../../src -d ./data.yaml -i ../inputs/openapi.json -f pretty "data.openapi.main.problems" 3 | 4 | .PHONY: test 5 | -------------------------------------------------------------------------------- /example/opa/data.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: 3 | ruleset: 4 | extends: [spego:oas] 5 | rules: 6 | operation-success-response: warn 7 | -------------------------------------------------------------------------------- /example/opa/server-configuration.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | github-registry: 3 | url: https://ghcr.io 4 | type: oci 5 | credentials: 6 | bearer: 7 | schema: 'Bearer' 8 | token: '${PKG_PAT}' 9 | 10 | bundles: 11 | openapi: 12 | service: github-registry 13 | resource: ghcr.io/kevinswiber/spego:latest 14 | persist: true 15 | polling: 16 | min_delay_seconds: 30 17 | max_delay_seconds: 120 18 | -------------------------------------------------------------------------------- /example/spectral/.gitignore: -------------------------------------------------------------------------------- 1 | policy.wasm 2 | policies.json 3 | node_modules 4 | -------------------------------------------------------------------------------- /example/spectral/README.md: -------------------------------------------------------------------------------- 1 | # spego-spectral-example 2 | 3 | This is an example of using Spectral as a front-end for executing Spego policies. One benefit is that Spectral will resolve any JSON References and also point to line and character information showing where problems have occurred. 4 | 5 | Feel free to use this as a template for executing your own policies. 6 | 7 | ## Usage 8 | 9 | You can run this specific ruleset via a URL. 10 | 11 | ```sh 12 | spectral lint \ 13 | -r https://raw.githubusercontent.com/kevinswiber/spego/main/example/spectral/ruleset.js \ 14 | openapi.json 15 | ``` 16 | 17 | But more than likely, you'll want to bundle your own policies and can use this as a template. 18 | 19 | Be sure to follow the [Spectral install instructions](https://meta.stoplight.io/docs/spectral/b8391e051b7d8-installation). 20 | 21 | ```sh 22 | git clone git@github.com:kevinswiber/spego.git 23 | cd ./spego/example/spectral 24 | npm install 25 | npm run build 26 | spectral lint -r ./ruleset.js ../inputs/openapi.json 27 | ``` 28 | 29 | Furthermore, this ruleset can be extended. See the `example` directory. Example extended Spectral ruleset: 30 | 31 | ```yaml 32 | extends: [['../ruleset.js', 'recommended']] 33 | rules: 34 | no-script-tags-in-markdown: error 35 | no-eval-in-markdown: error 36 | path-params: false 37 | ``` 38 | -------------------------------------------------------------------------------- /example/spectral/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "1. Building OPA bundle..." 4 | opa build \ 5 | --target wasm \ 6 | --bundle ../../src \ 7 | --entrypoint "openapi/main/problems" \ 8 | --output ./build/bundle.tar.gz 9 | 10 | echo "2. Extracting policy.wasm from bundle..." 11 | tar -xzf ./build/bundle.tar.gz -C ./build /policy.wasm 2>&1 | grep -v "Removing leading" 12 | 13 | echo "3. Converting Wasm file into a base64-decoded string: ./wasm.js" 14 | wasm=$(base64 ./build/policy.wasm) 15 | echo "exports.policyWasmBuffer = Buffer.from(\`$wasm\`, 'base64');" >./wasm.js 16 | 17 | echo "4. Extracting annotations: ./policies.js" 18 | policies=$(opa inspect --annotations --format json ../../src) 19 | echo "module.exports = $policies;" >./policies.js 20 | 21 | echo "Done!" 22 | -------------------------------------------------------------------------------- /example/spectral/build/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinswiber/spego/5d9c41a4d3eb1d988a880377d7963cd09eb05f62/example/spectral/build/.gitkeep -------------------------------------------------------------------------------- /example/spectral/example/.spectral.yaml: -------------------------------------------------------------------------------- 1 | extends: [['../ruleset.js', 'recommended']] 2 | rules: 3 | no-script-tags-in-markdown: error 4 | no-eval-in-markdown: error 5 | path-params: false 6 | -------------------------------------------------------------------------------- /example/spectral/example/openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "abcd eval('hi') abcd", 5 | "version": "1.0.0", 6 | "description": "Echo Service", 7 | "license": { 8 | "name": "MIT" 9 | }, 10 | "contact": {} 11 | }, 12 | "servers": [ 13 | { 14 | "url": "localhost:3000" 15 | } 16 | ], 17 | "tags": [{ "name": "users" }], 18 | "paths": { 19 | "/user/{userId}": { 20 | "post": { 21 | "description": "hi friend", 22 | "operationId": "createUser", 23 | "responses": { 24 | "400": {} 25 | }, 26 | "parameters": [ 27 | { "name": "a", "in": "body" }, 28 | { "name": "a", "in": "formData" } 29 | ], 30 | "tags": ["user"] 31 | }, 32 | "get": { 33 | "summary": "Sample endpoint: Returns details about a particular user", 34 | "operationId": "listUser", 35 | "tags": ["user"], 36 | "parameters": [ 37 | { 38 | "name": "id", 39 | "in": "query", 40 | "description": "ID of the user", 41 | "required": true, 42 | "schema": { 43 | "type": "integer", 44 | "format": "int32" 45 | } 46 | } 47 | ], 48 | "responses": { 49 | "400": { 50 | "description": "Sample response: Details about a user by ID", 51 | "headers": { 52 | "x-next": { 53 | "description": "A link to the next page of responses", 54 | "schema": { 55 | "type": "string" 56 | } 57 | } 58 | }, 59 | "content": { 60 | "application/json": { 61 | "schema": { 62 | "$ref": "#/components/schemas/Error" 63 | } 64 | } 65 | } 66 | }, 67 | "default": { 68 | "description": "Unexpected error", 69 | "content": { 70 | "application/json": { 71 | "schema": { 72 | "$ref": "#/components/schemas/Error" 73 | } 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | }, 81 | "components": { 82 | "schemas": { 83 | "User": { 84 | "type": "object", 85 | "properties": { 86 | "id": { 87 | "type": "string" 88 | } 89 | } 90 | }, 91 | "Error": { 92 | "type": "object", 93 | "required": ["code", "message"], 94 | "properties": { 95 | "_id": { 96 | "type": "string" 97 | }, 98 | "code": { 99 | "type": "integer", 100 | "format": "int32" 101 | }, 102 | "message": { 103 | "type": "string" 104 | } 105 | } 106 | } 107 | } 108 | }, 109 | "security": [ 110 | { 111 | "BasicAuth": [] 112 | } 113 | ] 114 | } 115 | -------------------------------------------------------------------------------- /example/spectral/functions/opa.js: -------------------------------------------------------------------------------- 1 | export async function opa(input, { code, policy }) { 2 | policy.setData({ 3 | openapi: { 4 | ruleset: { 5 | extends: [['spego:oas', 'off']], 6 | rules: { [code]: true }, 7 | }, 8 | }, 9 | }); 10 | 11 | try { 12 | const result = policy.evaluate(input); 13 | return result[0].result; 14 | } catch (err) { 15 | console.error('Error executing OPA policy:', err); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /example/spectral/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spego-spectral-example", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "An example of using Spego Wasm bundles in Spectral", 6 | "main": "ruleset.js", 7 | "scripts": { 8 | "build": "./build.sh", 9 | "clean": "rimraf ./build/bundle.tar.gz ./build/policy.wasm" 10 | }, 11 | "keywords": [], 12 | "author": "Kevin Swiber ", 13 | "license": "Apache-2.0", 14 | "devDependencies": { 15 | "rimraf": "^3.0.2" 16 | }, 17 | "dependencies": { 18 | "@stoplight/spectral-formats": "^1.4.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /example/spectral/policies.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "manifest": { 3 | "revision": "0.1.0", 4 | "roots": [ 5 | "openapi/lib", 6 | "openapi/main", 7 | "openapi/policies" 8 | ] 9 | }, 10 | "signatures_config": {}, 11 | "namespaces": { 12 | "data.openapi.lib": [ 13 | "../../src/openapi/lib/lib.rego" 14 | ], 15 | "data.openapi.main": [ 16 | "../../src/openapi/main/conftest.rego", 17 | "../../src/openapi/main/main.rego", 18 | "../../src/openapi/main/main_test.rego", 19 | "../../src/openapi/main/recommended.rego", 20 | "../../src/openapi/main/ruleset.rego" 21 | ], 22 | "data.openapi.policies[\"contact-properties\"]": [ 23 | "../../src/openapi/policies/contact-properties/contact_properties.rego", 24 | "../../src/openapi/policies/contact-properties/contact_properties_test.rego" 25 | ], 26 | "data.openapi.policies[\"duplicated-entry-in-enum\"]": [ 27 | "../../src/openapi/policies/duplicated-entry-in-enum/duplicated_entry_in_enum.rego", 28 | "../../src/openapi/policies/duplicated-entry-in-enum/duplicated_entry_in_enum_test.rego" 29 | ], 30 | "data.openapi.policies[\"info-contact\"]": [ 31 | "../../src/openapi/policies/info-contact/info_contact.rego", 32 | "../../src/openapi/policies/info-contact/info_contact_test.rego" 33 | ], 34 | "data.openapi.policies[\"info-description\"]": [ 35 | "../../src/openapi/policies/info-description/info_description.rego", 36 | "../../src/openapi/policies/info-description/info_description_test.rego" 37 | ], 38 | "data.openapi.policies[\"info-license\"]": [ 39 | "../../src/openapi/policies/info-license/info_license.rego", 40 | "../../src/openapi/policies/info-license/info_license_test.rego" 41 | ], 42 | "data.openapi.policies[\"license-url\"]": [ 43 | "../../src/openapi/policies/license-url/license_url.rego", 44 | "../../src/openapi/policies/license-url/license_url_test.rego" 45 | ], 46 | "data.openapi.policies[\"no-eval-in-markdown\"]": [ 47 | "../../src/openapi/policies/no-eval-in-markdown/no_eval_in_markdown.rego", 48 | "../../src/openapi/policies/no-eval-in-markdown/no_eval_in_markdown_test.rego" 49 | ], 50 | "data.openapi.policies[\"no-script-tags-in-markdown\"]": [ 51 | "../../src/openapi/policies/no-script-tags-in-markdown/no_script_tags_in_markdown.rego", 52 | "../../src/openapi/policies/no-script-tags-in-markdown/no_script_tags_in_markdown_test.rego" 53 | ], 54 | "data.openapi.policies[\"openapi-tags\"]": [ 55 | "../../src/openapi/policies/openapi-tags/openapi_tags.rego", 56 | "../../src/openapi/policies/openapi-tags/openapi_tags_test.rego" 57 | ], 58 | "data.openapi.policies[\"openapi-tags-uniqueness\"]": [ 59 | "../../src/openapi/policies/openapi-tags-uniqueness/openapi_tags_uniqueness.rego", 60 | "../../src/openapi/policies/openapi-tags-uniqueness/openapi_tags_uniqueness_test.rego" 61 | ], 62 | "data.openapi.policies[\"operation-description\"]": [ 63 | "../../src/openapi/policies/operation-description/operation_description.rego", 64 | "../../src/openapi/policies/operation-description/operation_description_test.rego" 65 | ], 66 | "data.openapi.policies[\"operation-operationId\"]": [ 67 | "../../src/openapi/policies/operation-operationId/operation_operationid.rego", 68 | "../../src/openapi/policies/operation-operationId/operation_operationid_test.rego" 69 | ], 70 | "data.openapi.policies[\"operation-operationId-unique\"]": [ 71 | "../../src/openapi/policies/operation-operationId-unique/operation_operationid_unique.rego", 72 | "../../src/openapi/policies/operation-operationId-unique/operation_operationid_unique_test.rego" 73 | ], 74 | "data.openapi.policies[\"operation-operationId-valid-in-url\"]": [ 75 | "../../src/openapi/policies/operation-operationId-valid-in-url/operation_operationid_valid_in_url.rego", 76 | "../../src/openapi/policies/operation-operationId-valid-in-url/operation_operationid_valid_in_url_test.rego" 77 | ], 78 | "data.openapi.policies[\"operation-parameters\"]": [ 79 | "../../src/openapi/policies/operation-parameters/operation_parameters.rego", 80 | "../../src/openapi/policies/operation-parameters/operation_parameters_test.rego" 81 | ], 82 | "data.openapi.policies[\"operation-singular-tag\"]": [ 83 | "../../src/openapi/policies/operation-singular-tag/operation_singular_tag.rego", 84 | "../../src/openapi/policies/operation-singular-tag/operation_singular_tag_test.rego" 85 | ], 86 | "data.openapi.policies[\"operation-success-response\"]": [ 87 | "../../src/openapi/policies/operation-success-response/operation_success_response.rego", 88 | "../../src/openapi/policies/operation-success-response/operation_success_response_test.rego" 89 | ], 90 | "data.openapi.policies[\"operation-tag-defined\"]": [ 91 | "../../src/openapi/policies/operation-tag-defined/operation_tag_defined.rego", 92 | "../../src/openapi/policies/operation-tag-defined/operation_tag_defined_test.rego" 93 | ], 94 | "data.openapi.policies[\"operation-tags\"]": [ 95 | "../../src/openapi/policies/operation-tags/operation_tags.rego", 96 | "../../src/openapi/policies/operation-tags/operation_tags_test.rego" 97 | ], 98 | "data.openapi.policies[\"path-declarations-must-exist\"]": [ 99 | "../../src/openapi/policies/path-declarations-must-exist/path_declarations_must_exist.rego", 100 | "../../src/openapi/policies/path-declarations-must-exist/path_declarations_must_exist_test.rego" 101 | ], 102 | "data.openapi.policies[\"path-keys-no-trailing-slash\"]": [ 103 | "../../src/openapi/policies/path-keys-no-trailing-slash/path_keys_no_trailing_slash.rego", 104 | "../../src/openapi/policies/path-keys-no-trailing-slash/path_keys_no_trailing_slash_test.rego" 105 | ], 106 | "data.openapi.policies[\"path-not-include-query\"]": [ 107 | "../../src/openapi/policies/path-not-include-query/path_not_include_query.rego", 108 | "../../src/openapi/policies/path-not-include-query/path_not_include_query_test.rego" 109 | ], 110 | "data.openapi.policies[\"path-params\"]": [ 111 | "../../src/openapi/policies/path-params/path_params.rego", 112 | "../../src/openapi/policies/path-params/path_params_test.rego" 113 | ], 114 | "data.openapi.policies[\"path-params\"].lib": [ 115 | "../../src/openapi/policies/path-params/lib/duplicate_path_param_definition_results.rego", 116 | "../../src/openapi/policies/path-params/lib/duplicate_var_name_in_path_results.rego", 117 | "../../src/openapi/policies/path-params/lib/lib.rego", 118 | "../../src/openapi/policies/path-params/lib/param_asymmetry_results.rego", 119 | "../../src/openapi/policies/path-params/lib/path_collision_results.rego", 120 | "../../src/openapi/policies/path-params/lib/path_param_missing_required_results.rego", 121 | "../../src/openapi/policies/path-params/lib/path_regex.rego" 122 | ], 123 | "data.openapi.policies[\"tag-description\"]": [ 124 | "../../src/openapi/policies/tag-description/tag_description.rego", 125 | "../../src/openapi/policies/tag-description/tag_description_test.rego" 126 | ] 127 | }, 128 | "annotations": [ 129 | { 130 | "location": { 131 | "file": "../../src/openapi/main/main.rego", 132 | "row": 9, 133 | "col": 1 134 | }, 135 | "path": [ 136 | { 137 | "type": "var", 138 | "value": "data" 139 | }, 140 | { 141 | "type": "string", 142 | "value": "openapi" 143 | }, 144 | { 145 | "type": "string", 146 | "value": "main" 147 | }, 148 | { 149 | "type": "string", 150 | "value": "problems" 151 | } 152 | ], 153 | "annotations": { 154 | "scope": "rule", 155 | "title": "problems", 156 | "description": "Returns all non-successful rule validation results." 157 | } 158 | }, 159 | { 160 | "location": { 161 | "file": "../../src/openapi/main/main.rego", 162 | "row": 28, 163 | "col": 1 164 | }, 165 | "path": [ 166 | { 167 | "type": "var", 168 | "value": "data" 169 | }, 170 | { 171 | "type": "string", 172 | "value": "openapi" 173 | }, 174 | { 175 | "type": "string", 176 | "value": "main" 177 | }, 178 | { 179 | "type": "string", 180 | "value": "successes" 181 | } 182 | ], 183 | "annotations": { 184 | "scope": "rule", 185 | "title": "successes", 186 | "description": "Returns all successful rule validation results." 187 | } 188 | }, 189 | { 190 | "location": { 191 | "file": "../../src/openapi/main/conftest.rego", 192 | "row": 12, 193 | "col": 1 194 | }, 195 | "path": [ 196 | { 197 | "type": "var", 198 | "value": "data" 199 | }, 200 | { 201 | "type": "string", 202 | "value": "openapi" 203 | }, 204 | { 205 | "type": "string", 206 | "value": "main" 207 | }, 208 | { 209 | "type": "string", 210 | "value": "warn" 211 | } 212 | ], 213 | "annotations": { 214 | "scope": "rule", 215 | "title": "warn", 216 | "description": "Conftest-compatible warn rule." 217 | } 218 | }, 219 | { 220 | "location": { 221 | "file": "../../src/openapi/main/main.rego", 222 | "row": 39, 223 | "col": 1 224 | }, 225 | "path": [ 226 | { 227 | "type": "var", 228 | "value": "data" 229 | }, 230 | { 231 | "type": "string", 232 | "value": "openapi" 233 | }, 234 | { 235 | "type": "string", 236 | "value": "main" 237 | }, 238 | { 239 | "type": "string", 240 | "value": "results" 241 | } 242 | ], 243 | "annotations": { 244 | "scope": "rule", 245 | "title": "results", 246 | "description": "Returns all successful and non-successful rule validation results." 247 | } 248 | }, 249 | { 250 | "location": { 251 | "file": "../../src/openapi/main/conftest.rego", 252 | "row": 30, 253 | "col": 1 254 | }, 255 | "path": [ 256 | { 257 | "type": "var", 258 | "value": "data" 259 | }, 260 | { 261 | "type": "string", 262 | "value": "openapi" 263 | }, 264 | { 265 | "type": "string", 266 | "value": "main" 267 | }, 268 | { 269 | "type": "string", 270 | "value": "violation" 271 | } 272 | ], 273 | "annotations": { 274 | "scope": "rule", 275 | "title": "violation", 276 | "description": "Conftest-compatible violation rule." 277 | } 278 | }, 279 | { 280 | "location": { 281 | "file": "../../src/openapi/policies/contact-properties/contact_properties.rego", 282 | "row": 10, 283 | "col": 1 284 | }, 285 | "path": [ 286 | { 287 | "type": "var", 288 | "value": "data" 289 | }, 290 | { 291 | "type": "string", 292 | "value": "openapi" 293 | }, 294 | { 295 | "type": "string", 296 | "value": "policies" 297 | }, 298 | { 299 | "type": "string", 300 | "value": "contact-properties" 301 | }, 302 | { 303 | "type": "string", 304 | "value": "results" 305 | } 306 | ], 307 | "annotations": { 308 | "scope": "rule", 309 | "title": "contact-properties", 310 | "description": "Contact object must have \"name\", \"url\" and \"email\".", 311 | "custom": { 312 | "recommended": false 313 | } 314 | } 315 | }, 316 | { 317 | "location": { 318 | "file": "../../src/openapi/policies/duplicated-entry-in-enum/duplicated_entry_in_enum.rego", 319 | "row": 9, 320 | "col": 1 321 | }, 322 | "path": [ 323 | { 324 | "type": "var", 325 | "value": "data" 326 | }, 327 | { 328 | "type": "string", 329 | "value": "openapi" 330 | }, 331 | { 332 | "type": "string", 333 | "value": "policies" 334 | }, 335 | { 336 | "type": "string", 337 | "value": "duplicated-entry-in-enum" 338 | }, 339 | { 340 | "type": "string", 341 | "value": "results" 342 | } 343 | ], 344 | "annotations": { 345 | "scope": "rule", 346 | "title": "duplicated-entry-in-enum", 347 | "description": "Enum values must not have duplicate entry." 348 | } 349 | }, 350 | { 351 | "location": { 352 | "file": "../../src/openapi/policies/info-contact/info_contact.rego", 353 | "row": 8, 354 | "col": 1 355 | }, 356 | "path": [ 357 | { 358 | "type": "var", 359 | "value": "data" 360 | }, 361 | { 362 | "type": "string", 363 | "value": "openapi" 364 | }, 365 | { 366 | "type": "string", 367 | "value": "policies" 368 | }, 369 | { 370 | "type": "string", 371 | "value": "info-contact" 372 | }, 373 | { 374 | "type": "string", 375 | "value": "results" 376 | } 377 | ], 378 | "annotations": { 379 | "scope": "rule", 380 | "title": "info-contact", 381 | "description": "Info object must have \"contact\" object." 382 | } 383 | }, 384 | { 385 | "location": { 386 | "file": "../../src/openapi/policies/info-description/info_description.rego", 387 | "row": 8, 388 | "col": 1 389 | }, 390 | "path": [ 391 | { 392 | "type": "var", 393 | "value": "data" 394 | }, 395 | { 396 | "type": "string", 397 | "value": "openapi" 398 | }, 399 | { 400 | "type": "string", 401 | "value": "policies" 402 | }, 403 | { 404 | "type": "string", 405 | "value": "info-description" 406 | }, 407 | { 408 | "type": "string", 409 | "value": "results" 410 | } 411 | ], 412 | "annotations": { 413 | "scope": "rule", 414 | "title": "info-description", 415 | "description": "Info \"description\" must be present and non-empty string." 416 | } 417 | }, 418 | { 419 | "location": { 420 | "file": "../../src/openapi/policies/info-license/info_license.rego", 421 | "row": 10, 422 | "col": 1 423 | }, 424 | "path": [ 425 | { 426 | "type": "var", 427 | "value": "data" 428 | }, 429 | { 430 | "type": "string", 431 | "value": "openapi" 432 | }, 433 | { 434 | "type": "string", 435 | "value": "policies" 436 | }, 437 | { 438 | "type": "string", 439 | "value": "info-license" 440 | }, 441 | { 442 | "type": "string", 443 | "value": "results" 444 | } 445 | ], 446 | "annotations": { 447 | "scope": "rule", 448 | "title": "info-license", 449 | "description": "Info object must have \"license\" object.", 450 | "custom": { 451 | "recommended": false 452 | } 453 | } 454 | }, 455 | { 456 | "location": { 457 | "file": "../../src/openapi/policies/license-url/license_url.rego", 458 | "row": 10, 459 | "col": 1 460 | }, 461 | "path": [ 462 | { 463 | "type": "var", 464 | "value": "data" 465 | }, 466 | { 467 | "type": "string", 468 | "value": "openapi" 469 | }, 470 | { 471 | "type": "string", 472 | "value": "policies" 473 | }, 474 | { 475 | "type": "string", 476 | "value": "license-url" 477 | }, 478 | { 479 | "type": "string", 480 | "value": "results" 481 | } 482 | ], 483 | "annotations": { 484 | "scope": "rule", 485 | "title": "license-url", 486 | "description": "License object must include \"url\".", 487 | "custom": { 488 | "recommended": false 489 | } 490 | } 491 | }, 492 | { 493 | "location": { 494 | "file": "../../src/openapi/policies/no-eval-in-markdown/no_eval_in_markdown.rego", 495 | "row": 9, 496 | "col": 1 497 | }, 498 | "path": [ 499 | { 500 | "type": "var", 501 | "value": "data" 502 | }, 503 | { 504 | "type": "string", 505 | "value": "openapi" 506 | }, 507 | { 508 | "type": "string", 509 | "value": "policies" 510 | }, 511 | { 512 | "type": "string", 513 | "value": "no-eval-in-markdown" 514 | }, 515 | { 516 | "type": "string", 517 | "value": "results" 518 | } 519 | ], 520 | "annotations": { 521 | "scope": "rule", 522 | "title": "no-eval-in-markdown", 523 | "description": "Markdown descriptions must not have \"eval(\"." 524 | } 525 | }, 526 | { 527 | "location": { 528 | "file": "../../src/openapi/policies/no-script-tags-in-markdown/no_script_tags_in_markdown.rego", 529 | "row": 9, 530 | "col": 1 531 | }, 532 | "path": [ 533 | { 534 | "type": "var", 535 | "value": "data" 536 | }, 537 | { 538 | "type": "string", 539 | "value": "openapi" 540 | }, 541 | { 542 | "type": "string", 543 | "value": "policies" 544 | }, 545 | { 546 | "type": "string", 547 | "value": "no-script-tags-in-markdown" 548 | }, 549 | { 550 | "type": "string", 551 | "value": "results" 552 | } 553 | ], 554 | "annotations": { 555 | "scope": "rule", 556 | "title": "no-script-tags-in-markdown", 557 | "description": "Markdown descriptions must not have \"\u003cscript\u003e\" tags." 558 | } 559 | }, 560 | { 561 | "location": { 562 | "file": "../../src/openapi/policies/openapi-tags-uniqueness/openapi_tags_uniqueness.rego", 563 | "row": 9, 564 | "col": 1 565 | }, 566 | "path": [ 567 | { 568 | "type": "var", 569 | "value": "data" 570 | }, 571 | { 572 | "type": "string", 573 | "value": "openapi" 574 | }, 575 | { 576 | "type": "string", 577 | "value": "policies" 578 | }, 579 | { 580 | "type": "string", 581 | "value": "openapi-tags-uniqueness" 582 | }, 583 | { 584 | "type": "string", 585 | "value": "results" 586 | } 587 | ], 588 | "annotations": { 589 | "scope": "rule", 590 | "title": "openapi-tags-uniqueness", 591 | "description": "Each tag must have a unique name." 592 | } 593 | }, 594 | { 595 | "location": { 596 | "file": "../../src/openapi/policies/openapi-tags/openapi_tags.rego", 597 | "row": 10, 598 | "col": 1 599 | }, 600 | "path": [ 601 | { 602 | "type": "var", 603 | "value": "data" 604 | }, 605 | { 606 | "type": "string", 607 | "value": "openapi" 608 | }, 609 | { 610 | "type": "string", 611 | "value": "policies" 612 | }, 613 | { 614 | "type": "string", 615 | "value": "openapi-tags" 616 | }, 617 | { 618 | "type": "string", 619 | "value": "results" 620 | } 621 | ], 622 | "annotations": { 623 | "scope": "rule", 624 | "title": "openapi-tags", 625 | "description": "OpenAPI object must have non-empty \"tags\" array.", 626 | "custom": { 627 | "recommended": false 628 | } 629 | } 630 | }, 631 | { 632 | "location": { 633 | "file": "../../src/openapi/policies/operation-description/operation_description.rego", 634 | "row": 8, 635 | "col": 1 636 | }, 637 | "path": [ 638 | { 639 | "type": "var", 640 | "value": "data" 641 | }, 642 | { 643 | "type": "string", 644 | "value": "openapi" 645 | }, 646 | { 647 | "type": "string", 648 | "value": "policies" 649 | }, 650 | { 651 | "type": "string", 652 | "value": "operation-description" 653 | }, 654 | { 655 | "type": "string", 656 | "value": "results" 657 | } 658 | ], 659 | "annotations": { 660 | "scope": "rule", 661 | "title": "operation-description", 662 | "description": "Operation \"description\" must be present and non-empty string." 663 | } 664 | }, 665 | { 666 | "location": { 667 | "file": "../../src/openapi/policies/operation-operationId/operation_operationid.rego", 668 | "row": 8, 669 | "col": 1 670 | }, 671 | "path": [ 672 | { 673 | "type": "var", 674 | "value": "data" 675 | }, 676 | { 677 | "type": "string", 678 | "value": "openapi" 679 | }, 680 | { 681 | "type": "string", 682 | "value": "policies" 683 | }, 684 | { 685 | "type": "string", 686 | "value": "operation-operationId" 687 | }, 688 | { 689 | "type": "string", 690 | "value": "results" 691 | } 692 | ], 693 | "annotations": { 694 | "scope": "rule", 695 | "title": "operation-operationId", 696 | "description": "Operation must have \"operationId\"." 697 | } 698 | }, 699 | { 700 | "location": { 701 | "file": "../../src/openapi/policies/operation-operationId-unique/operation_operationid_unique.rego", 702 | "row": 15, 703 | "col": 1 704 | }, 705 | "path": [ 706 | { 707 | "type": "var", 708 | "value": "data" 709 | }, 710 | { 711 | "type": "string", 712 | "value": "openapi" 713 | }, 714 | { 715 | "type": "string", 716 | "value": "policies" 717 | }, 718 | { 719 | "type": "string", 720 | "value": "operation-operationId-unique" 721 | }, 722 | { 723 | "type": "string", 724 | "value": "results" 725 | } 726 | ], 727 | "annotations": { 728 | "scope": "rule", 729 | "title": "operation-operationId-unique", 730 | "description": "Every operation must have unique \"operationId\".", 731 | "custom": { 732 | "message": "operationId must be unique", 733 | "severity": "error" 734 | } 735 | } 736 | }, 737 | { 738 | "location": { 739 | "file": "../../src/openapi/policies/operation-operationId-valid-in-url/operation_operationid_valid_in_url.rego", 740 | "row": 8, 741 | "col": 1 742 | }, 743 | "path": [ 744 | { 745 | "type": "var", 746 | "value": "data" 747 | }, 748 | { 749 | "type": "string", 750 | "value": "openapi" 751 | }, 752 | { 753 | "type": "string", 754 | "value": "policies" 755 | }, 756 | { 757 | "type": "string", 758 | "value": "operation-operationId-valid-in-url" 759 | }, 760 | { 761 | "type": "string", 762 | "value": "results" 763 | } 764 | ], 765 | "annotations": { 766 | "scope": "rule", 767 | "title": "operation-operationId-valid-in-url", 768 | "description": "operationId must not have characters that are invalid when used in URL." 769 | } 770 | }, 771 | { 772 | "location": { 773 | "file": "../../src/openapi/policies/operation-parameters/operation_parameters.rego", 774 | "row": 8, 775 | "col": 1 776 | }, 777 | "path": [ 778 | { 779 | "type": "var", 780 | "value": "data" 781 | }, 782 | { 783 | "type": "string", 784 | "value": "openapi" 785 | }, 786 | { 787 | "type": "string", 788 | "value": "policies" 789 | }, 790 | { 791 | "type": "string", 792 | "value": "operation-parameters" 793 | }, 794 | { 795 | "type": "string", 796 | "value": "results" 797 | } 798 | ], 799 | "annotations": { 800 | "scope": "rule", 801 | "title": "operation-parameters", 802 | "description": "Operation parameters are unique and non-repeating." 803 | } 804 | }, 805 | { 806 | "location": { 807 | "file": "../../src/openapi/policies/operation-singular-tag/operation_singular_tag.rego", 808 | "row": 10, 809 | "col": 1 810 | }, 811 | "path": [ 812 | { 813 | "type": "var", 814 | "value": "data" 815 | }, 816 | { 817 | "type": "string", 818 | "value": "openapi" 819 | }, 820 | { 821 | "type": "string", 822 | "value": "policies" 823 | }, 824 | { 825 | "type": "string", 826 | "value": "operation-singular-tag" 827 | }, 828 | { 829 | "type": "string", 830 | "value": "results" 831 | } 832 | ], 833 | "annotations": { 834 | "scope": "rule", 835 | "title": "operation-singular-tag", 836 | "description": "Operation must not have more than a singular tag.", 837 | "custom": { 838 | "recommended": false 839 | } 840 | } 841 | }, 842 | { 843 | "location": { 844 | "file": "../../src/openapi/policies/operation-success-response/operation_success_response.rego", 845 | "row": 8, 846 | "col": 1 847 | }, 848 | "path": [ 849 | { 850 | "type": "var", 851 | "value": "data" 852 | }, 853 | { 854 | "type": "string", 855 | "value": "openapi" 856 | }, 857 | { 858 | "type": "string", 859 | "value": "policies" 860 | }, 861 | { 862 | "type": "string", 863 | "value": "operation-success-response" 864 | }, 865 | { 866 | "type": "string", 867 | "value": "results" 868 | } 869 | ], 870 | "annotations": { 871 | "scope": "rule", 872 | "title": "operation-success-response", 873 | "description": "Operation must have at least one \"2xx\" or \"3xx\" response." 874 | } 875 | }, 876 | { 877 | "location": { 878 | "file": "../../src/openapi/policies/operation-tag-defined/operation_tag_defined.rego", 879 | "row": 9, 880 | "col": 1 881 | }, 882 | "path": [ 883 | { 884 | "type": "var", 885 | "value": "data" 886 | }, 887 | { 888 | "type": "string", 889 | "value": "openapi" 890 | }, 891 | { 892 | "type": "string", 893 | "value": "policies" 894 | }, 895 | { 896 | "type": "string", 897 | "value": "operation-tag-defined" 898 | }, 899 | { 900 | "type": "string", 901 | "value": "results" 902 | } 903 | ], 904 | "annotations": { 905 | "scope": "rule", 906 | "title": "operation-tag-defined", 907 | "description": "Operation tags must be defined in global tags." 908 | } 909 | }, 910 | { 911 | "location": { 912 | "file": "../../src/openapi/policies/operation-tags/operation_tags.rego", 913 | "row": 8, 914 | "col": 1 915 | }, 916 | "path": [ 917 | { 918 | "type": "var", 919 | "value": "data" 920 | }, 921 | { 922 | "type": "string", 923 | "value": "openapi" 924 | }, 925 | { 926 | "type": "string", 927 | "value": "policies" 928 | }, 929 | { 930 | "type": "string", 931 | "value": "operation-tags" 932 | }, 933 | { 934 | "type": "string", 935 | "value": "results" 936 | } 937 | ], 938 | "annotations": { 939 | "scope": "rule", 940 | "title": "operation-tags", 941 | "description": "Operation must have non-empty \"tags\" array." 942 | } 943 | }, 944 | { 945 | "location": { 946 | "file": "../../src/openapi/policies/path-declarations-must-exist/path_declarations_must_exist.rego", 947 | "row": 8, 948 | "col": 1 949 | }, 950 | "path": [ 951 | { 952 | "type": "var", 953 | "value": "data" 954 | }, 955 | { 956 | "type": "string", 957 | "value": "openapi" 958 | }, 959 | { 960 | "type": "string", 961 | "value": "policies" 962 | }, 963 | { 964 | "type": "string", 965 | "value": "path-declarations-must-exist" 966 | }, 967 | { 968 | "type": "string", 969 | "value": "results" 970 | } 971 | ], 972 | "annotations": { 973 | "scope": "rule", 974 | "title": "path-declarations-must-exist", 975 | "description": "Path parameter declarations must not be empty, ex.\"/given/{}\" is invalid." 976 | } 977 | }, 978 | { 979 | "location": { 980 | "file": "../../src/openapi/policies/path-keys-no-trailing-slash/path_keys_no_trailing_slash.rego", 981 | "row": 8, 982 | "col": 1 983 | }, 984 | "path": [ 985 | { 986 | "type": "var", 987 | "value": "data" 988 | }, 989 | { 990 | "type": "string", 991 | "value": "openapi" 992 | }, 993 | { 994 | "type": "string", 995 | "value": "policies" 996 | }, 997 | { 998 | "type": "string", 999 | "value": "path-keys-no-trailing-slash" 1000 | }, 1001 | { 1002 | "type": "string", 1003 | "value": "results" 1004 | } 1005 | ], 1006 | "annotations": { 1007 | "scope": "rule", 1008 | "title": "path-keys-no-trailing-slash", 1009 | "description": "Path must not end with slash." 1010 | } 1011 | }, 1012 | { 1013 | "location": { 1014 | "file": "../../src/openapi/policies/path-not-include-query/path_not_include_query.rego", 1015 | "row": 8, 1016 | "col": 1 1017 | }, 1018 | "path": [ 1019 | { 1020 | "type": "var", 1021 | "value": "data" 1022 | }, 1023 | { 1024 | "type": "string", 1025 | "value": "openapi" 1026 | }, 1027 | { 1028 | "type": "string", 1029 | "value": "policies" 1030 | }, 1031 | { 1032 | "type": "string", 1033 | "value": "path-not-include-query" 1034 | }, 1035 | { 1036 | "type": "string", 1037 | "value": "results" 1038 | } 1039 | ], 1040 | "annotations": { 1041 | "scope": "rule", 1042 | "title": "path-not-include-query", 1043 | "description": "Path must not include query string." 1044 | } 1045 | }, 1046 | { 1047 | "location": { 1048 | "file": "../../src/openapi/policies/path-params/path_params.rego", 1049 | "row": 11, 1050 | "col": 1 1051 | }, 1052 | "path": [ 1053 | { 1054 | "type": "var", 1055 | "value": "data" 1056 | }, 1057 | { 1058 | "type": "string", 1059 | "value": "openapi" 1060 | }, 1061 | { 1062 | "type": "string", 1063 | "value": "policies" 1064 | }, 1065 | { 1066 | "type": "string", 1067 | "value": "path-params" 1068 | }, 1069 | { 1070 | "type": "string", 1071 | "value": "results" 1072 | } 1073 | ], 1074 | "annotations": { 1075 | "scope": "rule", 1076 | "title": "path-params", 1077 | "description": "Path parameters must be defined and valid.", 1078 | "custom": { 1079 | "severity": "error" 1080 | } 1081 | } 1082 | }, 1083 | { 1084 | "location": { 1085 | "file": "../../src/openapi/policies/tag-description/tag_description.rego", 1086 | "row": 10, 1087 | "col": 1 1088 | }, 1089 | "path": [ 1090 | { 1091 | "type": "var", 1092 | "value": "data" 1093 | }, 1094 | { 1095 | "type": "string", 1096 | "value": "openapi" 1097 | }, 1098 | { 1099 | "type": "string", 1100 | "value": "policies" 1101 | }, 1102 | { 1103 | "type": "string", 1104 | "value": "tag-description" 1105 | }, 1106 | { 1107 | "type": "string", 1108 | "value": "results" 1109 | } 1110 | ], 1111 | "annotations": { 1112 | "scope": "rule", 1113 | "title": "tag-description", 1114 | "description": "Tag object must have \"description\".", 1115 | "custom": { 1116 | "recommended": false 1117 | } 1118 | } 1119 | } 1120 | ] 1121 | }; 1122 | -------------------------------------------------------------------------------- /example/spectral/ruleset.js: -------------------------------------------------------------------------------- 1 | import { oas2, oas3 } from '@stoplight/spectral-formats'; 2 | import opaWasm from 'https://cdn.skypack.dev/@open-policy-agent/opa-wasm'; 3 | import { opa } from './functions/opa.js'; 4 | import { annotations } from './policies.js'; 5 | import { policyWasmBuffer } from './wasm.js'; 6 | 7 | const policy = opaWasm.loadPolicySync(policyWasmBuffer); 8 | 9 | const policyAnnotations = annotations 10 | .filter( 11 | (policy) => policy.location.file.includes('policies') && policy.annotations 12 | ) 13 | .map((policy) => policy.annotations); 14 | 15 | const rules = {}; 16 | 17 | for (const { title, description, custom } of policyAnnotations) { 18 | rules[title] = { 19 | description, 20 | severity: custom?.severity, 21 | recommended: custom?.recommended, 22 | formats: [oas2, oas3], 23 | given: '$', 24 | then: { 25 | function: opa, 26 | functionOptions: { 27 | code: title, 28 | policy, 29 | }, 30 | }, 31 | }; 32 | } 33 | 34 | export default { 35 | rules, 36 | }; 37 | -------------------------------------------------------------------------------- /src/.manifest: -------------------------------------------------------------------------------- 1 | { 2 | "revision": "0.1.0", 3 | "roots": [ 4 | "openapi/lib", 5 | "openapi/main", 6 | "openapi/policies" 7 | ], 8 | "required_builtins": [ 9 | "regex" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/openapi/lib/lib.rego: -------------------------------------------------------------------------------- 1 | package openapi.lib 2 | 3 | is_method_valid(method) { 4 | methods := {"get", "put", "post", "delete", "options", "head", "patch", "trace", "query"} 5 | methods[_] == method 6 | } 7 | 8 | format(meta, path) := result { 9 | result := merge_custom( 10 | meta, 11 | { 12 | "code": meta.title, 13 | "path": path, 14 | "message": extract_message(meta), 15 | }, 16 | ) 17 | } 18 | 19 | format_msg(meta, path, message) := result { 20 | result := merge_custom(meta, { 21 | "code": meta.title, 22 | "path": path, 23 | "message": message, 24 | }) 25 | } 26 | 27 | merge_custom(meta, obj) := merged { 28 | not meta.custom 29 | merged := obj 30 | } 31 | 32 | merge_custom(meta, obj) := merged { 33 | custom := object.remove(meta.custom, ["recommended"]) 34 | merged := object.union(custom, obj) 35 | } 36 | 37 | extract_message(meta) := msg { 38 | not meta.custom.message 39 | msg := meta.description 40 | } 41 | 42 | extract_message(meta) := msg { 43 | msg := meta.custom.message 44 | } 45 | 46 | msg(code, p_map) := msgs { 47 | p_map[code] 48 | msgs := {msg | 49 | p_map[code][result] 50 | msg := { 51 | "code": code, 52 | "message": result.message, 53 | "path": result.path, 54 | "severity": result.severity, 55 | "status": "failure", 56 | } 57 | } 58 | } 59 | 60 | msg(code, p_map) := {message} { 61 | not p_map[code] 62 | message := { 63 | "code": code, 64 | "status": "success", 65 | } 66 | } 67 | 68 | resolve_result_severity(result, _default) := severity { 69 | not result.severity 70 | severity := _default 71 | } 72 | 73 | resolve_result_severity(result, _default) := severity { 74 | severity := result.severity 75 | } 76 | 77 | resolve_rule_severity(policy_ref, _default, severity_overrides) := severity { 78 | not severity_overrides[policy_ref] 79 | severity := _default 80 | } 81 | 82 | resolve_rule_severity(policy_ref, _default, severity_overrides) := severity { 83 | severity := severity_overrides[policy_ref] 84 | } 85 | -------------------------------------------------------------------------------- /src/openapi/main/conftest.rego: -------------------------------------------------------------------------------- 1 | package openapi.main 2 | 3 | import future.keywords.in 4 | 5 | escape(s) := t { 6 | t := replace(s, "/", "~1") 7 | } 8 | 9 | # METADATA 10 | # title: warn 11 | # description: Conftest-compatible warn rule. 12 | warn[msg] { 13 | problems[result] 14 | result.severity == "warn" 15 | escaped := [segment | some s in result.path; segment := escape(s)] 16 | pointer := sprintf("%s", [concat("/", escaped)]) 17 | msg := { 18 | "msg": sprintf("%s - %s [%s]", [result.code, result.message, pointer]), 19 | "details": { 20 | "code": result.code, 21 | "severity": result.severity, 22 | "path": result.path, 23 | }, 24 | } 25 | } 26 | 27 | # METADATA 28 | # title: violation 29 | # description: Conftest-compatible violation rule. 30 | violation[msg] { 31 | problems[result] 32 | result.severity == "error" 33 | escaped := [segment | some s in result.path; segment := escape(s)] 34 | pointer := sprintf("%s", [concat("/", escaped)]) 35 | msg := { 36 | "msg": sprintf("%s - %s [%s]", [result.code, result.message, pointer]), 37 | "details": { 38 | "code": result.code, 39 | "severity": result.severity, 40 | "path": result.path, 41 | }, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/openapi/main/main.rego: -------------------------------------------------------------------------------- 1 | package openapi.main 2 | 3 | import data.openapi.lib 4 | import future.keywords.in 5 | 6 | # METADATA 7 | # title: problems 8 | # description: Returns all non-successful rule validation results. 9 | problems[msg] { 10 | policy_refs[policy_ref] 11 | data.openapi.policies[policy_ref].results[result] 12 | result.code == policy_ref 13 | 14 | severity_default := lib.resolve_result_severity(result, "warn") 15 | severity := lib.resolve_rule_severity(policy_ref, severity_default, severity_overrides) 16 | 17 | msg := { 18 | "code": result.code, 19 | "message": result.message, 20 | "path": result.path, 21 | "severity": severity, 22 | } 23 | } 24 | 25 | # METADATA 26 | # title: successes 27 | # description: Returns all successful rule validation results. 28 | successes[msg] { 29 | ref_codes := {code | some code in policy_refs} 30 | p_codes := {code | some problem in problems; code := problem.code} 31 | success_codes := ref_codes - p_codes 32 | success_codes[code] 33 | msg := {"code": code} 34 | } 35 | 36 | # METADATA 37 | # title: results 38 | # description: Returns all successful and non-successful rule validation results. 39 | results[message] { 40 | ref_codes := {code | some code in policy_refs} 41 | p_map := {code: results | 42 | some result in problems 43 | code := result.code 44 | results := {inner_result | 45 | problems[inner_result] 46 | inner_result.code == code 47 | } 48 | } 49 | 50 | ref_codes[code] 51 | messages := lib.msg(code, p_map) 52 | messages[message] 53 | } 54 | -------------------------------------------------------------------------------- /src/openapi/main/main_test.rego: -------------------------------------------------------------------------------- 1 | package openapi.main 2 | 3 | test_recommended_policies_execute_by_default { 4 | a := results with input as {} 5 | count(a) == count(recommended_policy_refs) 6 | } 7 | 8 | test_recommended_policies_with_ruleset_by_default { 9 | ruleset := {"extends": ["spego:oas"]} 10 | a := results with input as {} with data.openapi.ruleset as ruleset 11 | count(a) == count(recommended_policy_refs) 12 | } 13 | 14 | test_recommended_policies_with_ruleset { 15 | ruleset := {"extends": [["spego:oas", "recommended"]]} 16 | a := results with input as {} with data.openapi.ruleset as ruleset 17 | count(a) == count(recommended_policy_refs) 18 | } 19 | 20 | test_all_policies_with_ruleset { 21 | ruleset := {"extends": [["spego:oas", "all"]]} 22 | a := results with input as {} with data.openapi.ruleset as ruleset 23 | count(a) == count(all_policy_refs) 24 | } 25 | 26 | test_ruleset_specifier_is_spectral_compatible { 27 | ruleset := {"extends": [["spectral:oas", "all"]]} 28 | a := results with input as {} with data.openapi.ruleset as ruleset 29 | count(a) == count(all_policy_refs) 30 | } 31 | 32 | test_off_policies_with_ruleset { 33 | ruleset := {"extends": [["spego:oas", "off"]]} 34 | a := results with input as {} with data.openapi.ruleset as ruleset 35 | count(a) == 0 36 | } 37 | 38 | test_enabled_policies_with_ruleset { 39 | ruleset := { 40 | "extends": [["spego:oas", "off"]], 41 | "rules": {"openapi-tags": true}, 42 | } 43 | 44 | a := results with input as {} with data.openapi.ruleset as ruleset 45 | count(a) == 1 46 | } 47 | 48 | test_disabled_policies_with_ruleset { 49 | ruleset := { 50 | "extends": [["spego:oas", "all"]], 51 | "rules": {"openapi-tags": "off"}, 52 | } 53 | 54 | a := results with input as {} with data.openapi.ruleset as ruleset 55 | count(a) == count(all_policy_refs) - 1 56 | } 57 | 58 | test_enabled_and_disabled_policies_with_ruleset { 59 | ruleset := { 60 | "extends": [["spego:oas", "recommended"]], 61 | "rules": {"openapi-tags": true, "path-params": "off"}, 62 | } 63 | 64 | a := results with input as {} with data.openapi.ruleset as ruleset 65 | count(a) == count(recommended_policy_refs) 66 | } 67 | 68 | test_severity_override_with_ruleset { 69 | ruleset := { 70 | "extends": [["spego:oas", "all"]], 71 | "rules": {"openapi-tags": "info"}, 72 | } 73 | 74 | result := { 75 | "code": "openapi-tags", 76 | "path": ["tags"], 77 | "message": "OpenAPI object must have non-empty \"tags\" array.", 78 | "severity": "info", 79 | "status": "failure", 80 | } 81 | results[result] with input as {} with data.openapi.ruleset as ruleset 82 | } 83 | 84 | test_results_includes_both_successes_and_problems { 85 | mock_input := {"paths": {"/users": {"get": {"responses": {"400": {}}}}}} 86 | ruleset := {"extends": ["spego:oas"]} 87 | 88 | problem_count := count(problems) with input as mock_input with data.openapi.ruleset as ruleset 89 | success_count := count(successes) with input as mock_input with data.openapi.ruleset as ruleset 90 | results_count := count(results) with input as mock_input with data.openapi.ruleset as ruleset 91 | 92 | problem_count > 0 93 | success_count > 0 94 | results_count == problem_count + success_count 95 | } 96 | -------------------------------------------------------------------------------- /src/openapi/main/recommended.rego: -------------------------------------------------------------------------------- 1 | package openapi.main 2 | 3 | recommended_policy_refs := { 4 | "operation-success-response", 5 | "operation-operationId-unique", 6 | "operation-parameters", 7 | "operation-tag-defined", 8 | "path-params", 9 | "duplicated-entry-in-enum", 10 | "info-contact", 11 | "info-description", 12 | "no-eval-in-markdown", 13 | "no-script-tags-in-markdown", 14 | "openapi-tags-uniqueness", 15 | "operation-description", 16 | "operation-operationId", 17 | "operation-operationId-valid-in-url", 18 | "operation-tags", 19 | "path-declarations-must-exist", 20 | "path-keys-no-trailing-slash", 21 | "path-not-include-query", 22 | } 23 | -------------------------------------------------------------------------------- /src/openapi/main/ruleset.rego: -------------------------------------------------------------------------------- 1 | package openapi.main 2 | 3 | severity_list := {"error", "warn", "info", "hint"} 4 | 5 | all_policy_refs := {code | data.openapi.policies[code]} 6 | 7 | policy_refs := refs { 8 | not data.openapi.ruleset 9 | refs := recommended_policy_refs 10 | } 11 | 12 | policy_refs := refs { 13 | ruleset := data.openapi.ruleset 14 | refs := policy_refs_from_ruleset(ruleset) 15 | } 16 | 17 | ruleset_specifiers := {"spego:oas", "spectral:oas"} 18 | 19 | policy_refs_from_ruleset(ruleset) := refs { 20 | is_array(ruleset.extends) 21 | specifier := ruleset.extends[0] 22 | ruleset_specifiers[specifier] 23 | rec_refs := recommended_policy_refs 24 | enabled := enabled_ruleset_overrides(ruleset) 25 | disabled := disabled_ruleset_overrides(ruleset) 26 | refs := (rec_refs | enabled) - disabled 27 | } 28 | 29 | policy_refs_from_ruleset(ruleset) := refs { 30 | is_array(ruleset.extends) 31 | is_array(ruleset.extends[0]) 32 | [specifier, subset] := ruleset.extends[0] 33 | ruleset_specifiers[specifier] 34 | 35 | subset_map := { 36 | "recommended": recommended_policy_refs, 37 | "all": all_policy_refs, 38 | "off": set(), 39 | } 40 | 41 | subset_refs := subset_map[subset] 42 | enabled := enabled_ruleset_overrides(ruleset) 43 | disabled := disabled_ruleset_overrides(ruleset) 44 | refs := (subset_refs | enabled) - disabled 45 | } 46 | 47 | enabled_ruleset_overrides(ruleset) := policy_refs { 48 | not ruleset.rules 49 | policy_refs := set() 50 | } 51 | 52 | enabled_ruleset_overrides(ruleset) := policy_refs { 53 | rules := ruleset.rules 54 | valid_values := {true, "on"} 55 | policy_refs := {ref | 56 | v := rules[ref] 57 | valid_values[v] 58 | } 59 | } 60 | 61 | disabled_ruleset_overrides(ruleset) := policy_refs { 62 | not ruleset.rules 63 | policy_refs := set() 64 | } 65 | 66 | disabled_ruleset_overrides(ruleset) := policy_refs { 67 | rules := ruleset.rules 68 | valid_values := {false, "off"} 69 | policy_refs := {ref | 70 | v := rules[ref] 71 | valid_values[v] 72 | } 73 | } 74 | 75 | severity_overrides := {code: sev | 76 | rules := data.openapi.ruleset.rules 77 | sev := rules[code] 78 | severity_list[sev] 79 | } 80 | -------------------------------------------------------------------------------- /src/openapi/policies/contact-properties/contact_properties.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["contact-properties"] 2 | 3 | import data.openapi.lib 4 | 5 | # METADATA 6 | # title: contact-properties 7 | # description: Contact object must have "name", "url" and "email". 8 | # custom: 9 | # recommended: false 10 | results[lib.format(rego.metadata.rule(), path)] { 11 | path := ["info", "contact"] 12 | contact := input.info.contact 13 | 14 | required := {"name", "email", "url"} 15 | present := {x | required[x]; contact[x]} 16 | 17 | count(present) < count(required) 18 | } 19 | -------------------------------------------------------------------------------- /src/openapi/policies/contact-properties/contact_properties_test.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["contact-properties"] 2 | 3 | test_contact_missing_name_fails { 4 | result := { 5 | "code": "contact-properties", 6 | "path": ["info", "contact"], 7 | "message": "Contact object must have \"name\", \"url\" and \"email\".", 8 | } 9 | 10 | results[result] with input as {"info": {"contact": { 11 | "url": "https://example.org", 12 | "email": "web@example.org", 13 | }}} 14 | } 15 | 16 | test_contact_missing_email_fails { 17 | result := { 18 | "code": "contact-properties", 19 | "path": ["info", "contact"], 20 | "message": "Contact object must have \"name\", \"url\" and \"email\".", 21 | } 22 | 23 | results[result] with input as {"info": {"contact": { 24 | "name": "Example", 25 | "url": "https://example.org", 26 | }}} 27 | } 28 | 29 | test_contact_missing_url_fails { 30 | result := { 31 | "code": "contact-properties", 32 | "path": ["info", "contact"], 33 | "message": "Contact object must have \"name\", \"url\" and \"email\".", 34 | } 35 | 36 | results[result] with input as {"info": {"contact": { 37 | "name": "Example", 38 | "email": "web@example.org", 39 | }}} 40 | } 41 | 42 | test_contact_with_all_fields_succeeds { 43 | a := results with input as {"info": {"contact": { 44 | "name": "Example", 45 | "email": "web@example.org", 46 | "url": "https://example.com", 47 | }}} 48 | 49 | count(a) == 0 50 | } 51 | -------------------------------------------------------------------------------- /src/openapi/policies/duplicated-entry-in-enum/duplicated_entry_in_enum.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["duplicated-entry-in-enum"] 2 | 3 | import data.openapi.lib 4 | import future.keywords.in 5 | 6 | # METADATA 7 | # title: duplicated-entry-in-enum 8 | # description: Enum values must not have duplicate entry. 9 | results[lib.format_msg(rego.metadata.rule(), path, message)] { 10 | enums := {[current_path, enum] | 11 | some value 12 | [current_path, value] = walk(input) 13 | enum = value.enum 14 | } 15 | 16 | some [found_path, enum] in enums 17 | 18 | some i1, i2 19 | enum[i1] == enum[i2] 20 | 21 | not i1 == i2 22 | 23 | path := array.concat(found_path, ["enum", sprintf("%d", [i1])]) 24 | message := sprintf("Enum has duplicate value, \"%s\".", [enum[i1]]) 25 | } 26 | -------------------------------------------------------------------------------- /src/openapi/policies/duplicated-entry-in-enum/duplicated_entry_in_enum_test.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["duplicated-entry-in-enum"] 2 | 3 | test_duplicate_entry_in_enum_fails { 4 | result1 := { 5 | "code": "duplicated-entry-in-enum", 6 | "path": ["components", "schemas", "a", "properties", "b", "items", "enum", "0"], 7 | "message": "Enum has duplicate value, \"ALPHA\".", 8 | } 9 | 10 | result2 := { 11 | "code": "duplicated-entry-in-enum", 12 | "path": ["components", "schemas", "a", "properties", "b", "items", "enum", "2"], 13 | "message": "Enum has duplicate value, \"ALPHA\".", 14 | } 15 | 16 | res := results with input as {"components": {"schemas": {"a": { 17 | "type": "object", 18 | "properties": {"b": { 19 | "type": "array", 20 | "items": { 21 | "type": "string", 22 | "enum": ["ALPHA", "BETA", "ALPHA"], 23 | }, 24 | }}, 25 | }}}} 26 | 27 | res[result1] 28 | res[result2] 29 | } 30 | -------------------------------------------------------------------------------- /src/openapi/policies/info-contact/info_contact.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["info-contact"] 2 | 3 | import data.openapi.lib 4 | 5 | # METADATA 6 | # title: info-contact 7 | # description: Info object must have "contact" object. 8 | results[lib.format(rego.metadata.rule(), path)] { 9 | path := ["info", "contact"] 10 | contact := object.get(input, path, true) 11 | not is_object(contact) 12 | } 13 | -------------------------------------------------------------------------------- /src/openapi/policies/info-contact/info_contact_test.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["info-contact"] 2 | 3 | test_contact_missing_fails { 4 | result := { 5 | "code": "info-contact", 6 | "path": ["info", "contact"], 7 | "message": "Info object must have \"contact\" object.", 8 | } 9 | 10 | results[result] with input as {"info": {}} 11 | } 12 | 13 | test_contact_non_object_fails { 14 | result := { 15 | "code": "info-contact", 16 | "path": ["info", "contact"], 17 | "message": "Info object must have \"contact\" object.", 18 | } 19 | 20 | results[result] with input as {"info": {"contact": true}} 21 | } 22 | 23 | test_contact_exists_succeeds { 24 | a := results with input as {"info": {"contact": {}}} 25 | count(a) == 0 26 | } 27 | -------------------------------------------------------------------------------- /src/openapi/policies/info-description/info_description.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["info-description"] 2 | 3 | import data.openapi.lib 4 | 5 | # METADATA 6 | # title: info-description 7 | # description: Info "description" must be present and non-empty string. 8 | results[lib.format(rego.metadata.rule(), path)] { 9 | path := ["info", "description"] 10 | description := object.get(input, path, true) 11 | 12 | checks := { 13 | is_string(description) == false, 14 | description == "", 15 | } 16 | 17 | checks[true] 18 | } 19 | -------------------------------------------------------------------------------- /src/openapi/policies/info-description/info_description_test.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["info-description"] 2 | 3 | test_description_missing_fails { 4 | result := { 5 | "code": "info-description", 6 | "path": ["info", "description"], 7 | "message": "Info \"description\" must be present and non-empty string.", 8 | } 9 | 10 | results[result] with input as {"info": {}} 11 | } 12 | 13 | test_description_non_string_fails { 14 | result := { 15 | "code": "info-description", 16 | "path": ["info", "description"], 17 | "message": "Info \"description\" must be present and non-empty string.", 18 | } 19 | 20 | results[result] with input as {"info": {"description": true}} 21 | } 22 | 23 | test_description_empty_fails { 24 | result := { 25 | "code": "info-description", 26 | "path": ["info", "description"], 27 | "message": "Info \"description\" must be present and non-empty string.", 28 | } 29 | 30 | results[result] with input as {"info": {"description": ""}} 31 | } 32 | 33 | test_description_exists_succeeds { 34 | a := results with input as {"info": {"description": "hello"}} 35 | count(a) == 0 36 | } 37 | -------------------------------------------------------------------------------- /src/openapi/policies/info-license/info_license.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["info-license"] 2 | 3 | import data.openapi.lib 4 | 5 | # METADATA 6 | # title: info-license 7 | # description: Info object must have "license" object. 8 | # custom: 9 | # recommended: false 10 | results[lib.format(rego.metadata.rule(), path)] { 11 | path := ["info", "license"] 12 | license := object.get(input, path, true) 13 | not is_object(license) 14 | } 15 | -------------------------------------------------------------------------------- /src/openapi/policies/info-license/info_license_test.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["info-license"] 2 | 3 | test_license_missing_fails { 4 | result := { 5 | "code": "info-license", 6 | "path": ["info", "license"], 7 | "message": "Info object must have \"license\" object.", 8 | } 9 | 10 | results[result] with input as {"info": {}} 11 | } 12 | 13 | test_license_non_object_fails { 14 | result := { 15 | "code": "info-license", 16 | "path": ["info", "license"], 17 | "message": "Info object must have \"license\" object.", 18 | } 19 | 20 | results[result] with input as {"info": {"license": true}} 21 | } 22 | 23 | test_license_exists_succeeds { 24 | a := results with input as {"info": {"license": {}}} 25 | count(a) == 0 26 | } 27 | -------------------------------------------------------------------------------- /src/openapi/policies/license-url/license_url.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["license-url"] 2 | 3 | import data.openapi.lib 4 | 5 | # METADATA 6 | # title: license-url 7 | # description: License object must include "url". 8 | # custom: 9 | # recommended: false 10 | results[lib.format(rego.metadata.rule(), path)] { 11 | path := ["info", "license", "url"] 12 | url := object.get(input, path, true) 13 | 14 | checks := { 15 | is_string(url) == false, 16 | url == "", 17 | } 18 | 19 | checks[true] 20 | } 21 | -------------------------------------------------------------------------------- /src/openapi/policies/license-url/license_url_test.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["license-url"] 2 | 3 | test_url_missing_fails { 4 | result := { 5 | "code": "license-url", 6 | "path": ["info", "license", "url"], 7 | "message": "License object must include \"url\".", 8 | } 9 | 10 | results[result] with input as {"info": {"license": {}}} 11 | } 12 | 13 | test_url_non_string_fails { 14 | result := { 15 | "code": "license-url", 16 | "path": ["info", "license", "url"], 17 | "message": "License object must include \"url\".", 18 | } 19 | 20 | results[result] with input as {"info": {"license": {"url": true}}} 21 | } 22 | 23 | test_url_empty_fails { 24 | result := { 25 | "code": "license-url", 26 | "path": ["info", "license", "url"], 27 | "message": "License object must include \"url\".", 28 | } 29 | 30 | results[result] with input as {"info": {"license": {"url": ""}}} 31 | } 32 | 33 | test_url_exists_succeeds { 34 | a := results with input as {"info": {"license": {"url": "hello"}}} 35 | count(a) == 0 36 | } 37 | -------------------------------------------------------------------------------- /src/openapi/policies/no-eval-in-markdown/no_eval_in_markdown.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["no-eval-in-markdown"] 2 | 3 | import data.openapi.lib 4 | import future.keywords.in 5 | 6 | # METADATA 7 | # title: no-eval-in-markdown 8 | # description: Markdown descriptions must not have "eval(". 9 | results[lib.format(rego.metadata.rule(), path)] { 10 | valid_props := {"title", "description"} 11 | titles_and_descriptions := {[md_path, md_val] | 12 | [current_path, value] := walk(input) 13 | 14 | some key 15 | valid_props[key] 16 | 17 | md_val := value[key] 18 | md_path = array.concat(current_path, [key]) 19 | } 20 | 21 | some [path, val] in titles_and_descriptions 22 | contains(lower(val), "eval(") 23 | } 24 | -------------------------------------------------------------------------------- /src/openapi/policies/no-eval-in-markdown/no_eval_in_markdown_test.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["no-eval-in-markdown"] 2 | 3 | test_eval_in_markdown_title_fails { 4 | result := { 5 | "code": "no-eval-in-markdown", 6 | "path": ["info", "title"], 7 | "message": "Markdown descriptions must not have \"eval(\".", 8 | } 9 | 10 | results[result] with input as {"info": {"title": "hi eval(\"alert('hi')\") there"}} 11 | } 12 | 13 | test_eval_in_markdown_description_fails { 14 | result := { 15 | "code": "no-eval-in-markdown", 16 | "path": ["info", "description"], 17 | "message": "Markdown descriptions must not have \"eval(\".", 18 | } 19 | 20 | results[result] with input as {"info": {"description": "hi eval(\"alert('hi')\") there"}} 21 | } 22 | 23 | test_eval_in_markdown_title_and_description_fails { 24 | mock_input := {"info": { 25 | "title": "hi eval(\"alert('hi')\") there", 26 | "description": "hi eval(\"alert('hi')\") there", 27 | }} 28 | 29 | result1 := { 30 | "code": "no-eval-in-markdown", 31 | "path": ["info", "title"], 32 | "message": "Markdown descriptions must not have \"eval(\".", 33 | } 34 | 35 | result2 := { 36 | "code": "no-eval-in-markdown", 37 | "path": ["info", "description"], 38 | "message": "Markdown descriptions must not have \"eval(\".", 39 | } 40 | 41 | res := results with input as mock_input 42 | res[result1] 43 | res[result2] 44 | } 45 | 46 | test_no_eval_in_markdown_title_succeeds { 47 | a := results with input as {"info": {"title": "hi"}} 48 | count(a) == 0 49 | } 50 | 51 | test_no_eval_in_markdown_description_succeeds { 52 | a := results with input as {"info": {"description": "hi"}} 53 | count(a) == 0 54 | } 55 | -------------------------------------------------------------------------------- /src/openapi/policies/no-script-tags-in-markdown/no_script_tags_in_markdown.rego: -------------------------------------------------------------------------------- 1 | package openapi.policies["no-script-tags-in-markdown"] 2 | 3 | import data.openapi.lib 4 | import future.keywords.in 5 | 6 | # METADATA 7 | # title: no-script-tags-in-markdown 8 | # description: Markdown descriptions must not have " there"}} 11 | } 12 | 13 | test_script_tag_in_markdown_description_fails { 14 | result := { 15 | "code": "no-script-tags-in-markdown", 16 | "path": ["info", "description"], 17 | "message": "Markdown descriptions must not have \" there"}} 21 | } 22 | 23 | test_script_tag_in_markdown_title_and_description_fails { 24 | mock_input := {"info": { 25 | "title": "hi there", 26 | "description": "hi there", 27 | }} 28 | 29 | result1 := { 30 | "code": "no-script-tags-in-markdown", 31 | "path": ["info", "title"], 32 | "message": "Markdown descriptions must not have \"