├── .editorconfig ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── __tests__ ├── __helpers__ │ └── helper.ts ├── owasp-api1-2023-no-numeric-ids.test.ts ├── owasp-api2-2023-auth-insecure-schemes.test.ts ├── owasp-api2-2023-jwt-best-practices.test.ts ├── owasp-api2-2023-no-api-keys-in-url.test.ts ├── owasp-api2-2023-no-credentials-in-url.test.ts ├── owasp-api2-2023-no-http-basic.test.ts ├── owasp-api2-2023-short-lived-tokens.test.ts ├── owasp-api3-2023-constrained-additionalProperties.test.ts ├── owasp-api3-2023-constrained-unevaluatedProperties.test.ts ├── owasp-api3-2023-no-additionalProperties.test.ts ├── owasp-api3-2023-no-unevaluatedProperties.test.ts ├── owasp-api4-2023-array-limit.test.ts ├── owasp-api4-2023-integer-format.test.ts ├── owasp-api4-2023-integer-limit-legacy.test.ts ├── owasp-api4-2023-integer-limit.test.ts ├── owasp-api4-2023-rate-limit-responses-429.test.ts ├── owasp-api4-2023-rate-limit-retry-after.test.ts ├── owasp-api4-2023-rate-limit.test.ts ├── owasp-api4-2023-string-limit.test.ts ├── owasp-api4-2023-string-restricted.test.ts ├── owasp-api5-2023-admin-security-unique.test.ts ├── owasp-api7-2023-concerning-url-parameter.test.ts ├── owasp-api8-2023-define-cors-origin.test.ts ├── owasp-api8-2023-define-error-responses-401.test.ts ├── owasp-api8-2023-define-error-responses-500.test.ts ├── owasp-api8-2023-define-error-validation.test.ts ├── owasp-api8-2023-no-scheme-http.test.ts ├── owasp-api8-2023-no-server-http.test.ts ├── owasp-api9-2023-inventory-access.test.ts └── owasp-api9-2023-inventory-environment.test.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── functions │ ├── checkSecurity.ts │ └── differentSecuritySchemes.ts └── ruleset.ts ├── tsconfig.json └── tsup.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | # Unix-style newlines with a newline ending every file 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | 9 | [typescript] 10 | quote_type = single 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release npm package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | - run: npm ci 18 | - run: npm test 19 | - run: npm run type-check 20 | - run: npm run build 21 | - run: npx semantic-release --branches main 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | pull_request: 5 | # Only run tests when relevant files are changing 6 | paths-ignore: 7 | - "**.md" 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | cache: npm 19 | - run: npm ci 20 | - run: npm test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .tool-versions 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.0.0] - 2024-01-23 9 | 10 | ### Added 11 | 12 | - Added `owasp:api2:2023-short-lived-access-tokens` to error on OAuth 2.x flows which do not use a refresh token. 13 | - Added `owasp:api3:2023-no-unevaluatedProperties` (format `oas3_1` only.) 14 | - Added `owasp:api3:2023-constrained-unevaluatedProperties` (format `oas3_1` only.) 15 | - Added `owasp:api5:2023-admin-security-unique`. 16 | - Added `owasp:api7:2023-concerning-url-parameter` to keep an eye out for URLs being passed as parameters and warn about server-side request forgery. 17 | - Added `owasp:api8:2023-no-server-http` which supports `servers` having a `url` which is a relative path. 18 | - Added `owasp:api9:2023-inventory-access` to indicate intended audience of every server. 19 | - Added `owasp:api9:2023-inventory-environment` to declare intended environment for every server. 20 | 21 | ### Changed 22 | 23 | - Deleted `owasp:api2:2023-protection-global-unsafe` as it allowed for unprotected POST, PATCH, PUT, DELETE and that's always going to be an issue. Use the new `owasp:api2:2023-write-restricted` rule which does not allow these operations to ever disable security, or use [Spectral overrides](https://docs.stoplight.io/docs/spectral/e5b9616d6d50c-rulesets) if you have an edge case. 24 | - Renamed `owasp:api2:2019-protection-global-unsafe-strict` to `owasp:api2:2023-write-restricted`. 25 | - Renamed `owasp:api2:2019-protection-global-safe` to `owasp:api2:2023-read-restricted` and increased severity from `info` to `warn`. 26 | - Renamed `owasp:api2:2019-auth-insecure-schemes` to `owasp:api2:2023-auth-insecure-schemes`. 27 | - Renamed `owasp:api2:2019-jwt-best-practices` to `owasp:api2:2023-jwt-best-practices`. 28 | - Renamed `owasp:api2:2019-no-api-keys-in-url` to `owasp:api2:2023-no-api-keys-in-url`. 29 | - Renamed `owasp:api2:2019-no-credentials-in-url` to `owasp:api2:2023-no-credentials-in-url`. 30 | - Renamed `owasp:api2:2019-no-http-basic` to `owasp:api2:2023-no-http-basic`. 31 | - Renamed `owasp:api3:2019-define-error-validation` to `owasp:api8:2023-define-error-validation`. 32 | - Renamed `owasp:api3:2019-define-error-responses-401` to `owasp:api8:2023-define-error-responses-401`. 33 | - Renamed `owasp:api3:2019-define-error-responses-500` to `owasp:api8:2023-define-error-responses-500`. 34 | - Renamed `owasp:api4:2019-rate-limit` to `owasp:api4:2023-rate-limit` and added support for the singular `RateLimit` header in draft-ietf-httpapi-ratelimit-headers-07. 35 | - Renamed `owasp:api4:2019-rate-limit-retry-after` to `owasp:api4:2023-rate-limit-retry-after`. 36 | - Renamed `owasp:api4:2019-rate-limit-responses-429` to `owasp:api4:2023-rate-limit-responses-429`. 37 | - Renamed `owasp:api4:2019-array-limit` to `owasp:api4:2023-array-limit`. 38 | - Renamed `owasp:api4:2019-string-limit` to `owasp:api4:2023-string-limit`. 39 | - Renamed `owasp:api4:2019-string-restricted` to `owasp:api4:2023-string-restricted` and downgraded from `error` to `warn`. 40 | - Renamed `owasp:api4:2019-integer-limit` to `owasp:api4:2023-integer-limit`. 41 | - Renamed `owasp:api4:2019-integer-limit-legacy` to `owasp:api4:2023-integer-limit-legacy`. 42 | - Renamed `owasp:api4:2019-integer-format` to `owasp:api4:2023-integer-format`. 43 | - Renamed `owasp:api6:2019-no-additionalProperties` to `owasp:api3:2023-no-additionalProperties` and restricted rule to only run the `oas3_0` format. 44 | - Renamed `owasp:api6:2019-constrained-additionalProperties` to `owasp:api3:2023-constrained-additionalProperties` and restricted rule to only run the `oas3_0` format. 45 | - Renamed `owasp:api7:2023-security-hosts-https-oas2` to `owasp:api8:2023-no-scheme-http`. 46 | - Renamed `owasp:api7:2023-security-hosts-https-oas3` to `owasp:api8:2023-no-server-http`. 47 | 48 | ### Removed 49 | 50 | - Deleted `owasp:api2:2023-protection-global-unsafe` as it allowed for unprotected POST, PATCH, PUT, DELETE and that's always going to be an issue. Use the new `owasp:api2:2023-write-restricted` rule which does not allow these operations to ever disable security, or use [Spectral overrides](https://docs.stoplight.io/docs/spectral/e5b9616d6d50c-rulesets) if you have an edge case. 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spectral OWASP API Security 2 | 3 | [![NPM Downloads](https://img.shields.io/npm/dw/@stoplight/spectral-owasp-ruleset?color=blue)](https://www.npmjs.com/package/@stoplight/spectral-owasp-ruleset) [![Stoplight Forest](https://img.shields.io/ecologi/trees/stoplightinc)][stoplight_forest] 4 | 5 | Scan an [OpenAPI](https://spec.openapis.org/oas/v3.1.0) document to detect security issues. As OpenAPI is only describing the surface level of the API it cannot see what is happening in your code, but it can spot obvious issues and outdated standards being used. 6 | 7 | v2.x of this ruleset is based on the [OWASP API Security Top 10 2023 edition](https://owasp.org/API-Security/editions/2023/en/0x00-header/), but if you would like to use the [2019 edition](https://owasp.org/API-Security/editions/2019/en/0x00-header/) please use v1.x. 8 | 9 | ## Installation 10 | 11 | ```bash 12 | npm install --save -D @stoplight/spectral-owasp-ruleset@^2.0 13 | npm install --save -D @stoplight/spectral-cli 14 | ``` 15 | 16 | ## Usage 17 | 18 | Create a local ruleset that extends the ruleset. In its most basic form this just tells Spectral what ruleset you want to use, but it will allow you to customise things, add your own rules, turn bits off if its causing trouble. 19 | 20 | ``` 21 | cd ~/src/ 22 | 23 | echo 'extends: ["@stoplight/spectral-owasp-ruleset"]' > .spectral.yaml 24 | ``` 25 | 26 | _If you're using VS Code or Stoplight Studio then the NPM modules will not be available. Instead you can use the CDN hosted version:_ 27 | 28 | ``` 29 | echo 'extends: ["https://unpkg.com/@stoplight/spectral-owasp-ruleset/dist/ruleset.mjs"]' > .spectral.yaml 30 | ``` 31 | 32 | _**Note:** You need to use the full URL with CDN hosted rulesets because Spectral [cannot follow redirects through extends](https://github.com/stoplightio/spectral/issues/2266)._ 33 | 34 | Next, use Spectral CLI to lint against your OpenAPI description. Don't have any OpenAPI? [Record some HTTP traffic to make OpenAPI](https://apisyouwonthate.com/blog/creating-openapi-from-http-traffic) and then you can switch to API Design-First going forwards. 35 | 36 | ``` 37 | spectral lint api/openapi.yaml 38 | ``` 39 | 40 | You should see some output like this: 41 | 42 | ``` 43 | /Users/phil/src/protect-earth-api/api/openapi.yaml 44 | 4:5 error owasp:api8:2023-inventory-access Declare intended audience of every server by defining servers[0].x-internal as true/false. servers[0] 45 | 4:10 error owasp:api8:2023-no-server-http Server URLs must not use http://. https:// is highly recommended. servers[0].url 46 | 45:15 error owasp:api4:2023-rate-limit All 2XX and 4XX responses should define rate limiting headers. paths./upload.post.responses[201] 47 | 47:15 error owasp:api4:2023-rate-limit All 2XX and 4XX responses should define rate limiting headers. paths./upload.post.responses[401] 48 | 93:16 information owasp:api2:2023-read-restricted This operation is not protected by any security scheme. paths./sites.get.security 49 | 210:16 information owasp:api2:2023-read-restricted This operation is not protected by any security scheme. paths./species.get.security 50 | ``` 51 | 52 | Now you have some things to work on for your API. Thankfully these are only at the `warning` and `information` severity, and that is not going to [fail continuous integration](https://docs.stoplight.io/docs/spectral/ZG9jOjExNTMyOTAx-continuous-integration) (unless [you want them to](https://docs.stoplight.io/docs/spectral/ZG9jOjI1MTg1-spectral-cli#error-results)). 53 | 54 | There are [a bunch of other rulesets](https://github.com/stoplightio/spectral-rulesets) or [Stoplight API Stylebook](http://apistylebook.stoplight.io) you can use, or use for inspiration for your own rulesets and API Style Guides. 55 | 56 | ## 🎉 Thanks 57 | 58 | - [Andrzej](https://github.com/jerzyn) - Great rules contributed to the Adidas style guide. 59 | - [Roberto Polli](https://github.com/ioggstream) - Created lots of excellent Spectral rules for [API OAS Checker](https://github.com/italia/api-oas-checker/) which aligned with the OWASP API rules. 60 | 61 | ## 📜 License 62 | 63 | This repository is licensed under the MIT license. 64 | 65 | ## 🌲 Sponsor 66 | 67 | If you would like to thank us for creating Spectral, we ask that you [**buy the world a tree**][stoplight_forest]. 68 | 69 | [stoplight_forest]: https://ecologi.com/stoplightinc 70 | -------------------------------------------------------------------------------- /__tests__/__helpers__/helper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IRuleResult, 3 | Spectral, 4 | Document, 5 | Ruleset, 6 | RulesetDefinition, 7 | } from "@stoplight/spectral-core"; 8 | import { httpAndFileResolver } from "@stoplight/spectral-ref-resolver"; 9 | import sourceRuleset from "../../src/ruleset"; 10 | 11 | export type RuleName = keyof Ruleset["rules"]; 12 | 13 | type Scenario = ReadonlyArray< 14 | Readonly<{ 15 | name: string; 16 | document: Record | Document; 17 | errors: ReadonlyArray>; 18 | mocks?: Record>; 19 | }> 20 | >; 21 | 22 | export default (ruleName: RuleName, tests: Scenario): void => { 23 | describe(`Rule ${ruleName}`, () => { 24 | const concurrent = tests.every( 25 | (test) => test.mocks === void 0 || Object.keys(test.mocks).length === 0 26 | ); 27 | for (const testCase of tests) { 28 | (concurrent ? it.concurrent : it)(testCase.name, async () => { 29 | const s = createWithRules([ruleName]); 30 | const doc = 31 | testCase.document instanceof Document 32 | ? testCase.document 33 | : JSON.stringify(testCase.document); 34 | const errors = await s.run(doc); 35 | expect(errors.filter(({ code }) => code === ruleName)).toEqual( 36 | testCase.errors.map( 37 | (error) => expect.objectContaining(error) as unknown 38 | ) 39 | ); 40 | }); 41 | } 42 | }); 43 | }; 44 | 45 | export function createWithRules(rules: (keyof Ruleset["rules"])[]): Spectral { 46 | const s = new Spectral({ resolver: httpAndFileResolver }); 47 | 48 | s.setRuleset({ 49 | extends: [[sourceRuleset as RulesetDefinition, "off"]], 50 | rules: rules.reduce((obj: any, name) => { 51 | obj[name] = true; 52 | return obj; 53 | }, {}), 54 | }); 55 | 56 | return s; 57 | } 58 | -------------------------------------------------------------------------------- /__tests__/owasp-api1-2023-no-numeric-ids.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api1:2023-no-numeric-ids", [ 5 | { 6 | name: "valid case: uuid", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/foo/{id}": { 12 | get: { 13 | description: "get", 14 | parameters: [ 15 | { 16 | name: "id", 17 | in: "path", 18 | required: true, 19 | schema: { 20 | type: "string", 21 | format: "uuid", 22 | }, 23 | }, 24 | ], 25 | }, 26 | }, 27 | }, 28 | }, 29 | errors: [], 30 | }, 31 | 32 | { 33 | name: "valid case: ulid", 34 | document: { 35 | openapi: "3.1.0", 36 | info: { version: "1.0" }, 37 | paths: { 38 | "/foo/{id}": { 39 | get: { 40 | description: "get", 41 | parameters: [ 42 | { 43 | name: "id", 44 | in: "path", 45 | required: true, 46 | schema: { 47 | type: "string", 48 | format: "ulid", 49 | }, 50 | }, 51 | ], 52 | }, 53 | }, 54 | }, 55 | }, 56 | errors: [], 57 | }, 58 | 59 | { 60 | name: "valid case: random", 61 | document: { 62 | openapi: "3.1.0", 63 | info: { version: "1.0" }, 64 | paths: { 65 | "/foo/{id}": { 66 | get: { 67 | description: "get", 68 | parameters: [ 69 | { 70 | name: "id", 71 | in: "path", 72 | required: true, 73 | schema: { 74 | type: "string", 75 | example: "sfdjkhjk24kd9s", 76 | }, 77 | }, 78 | ], 79 | }, 80 | }, 81 | }, 82 | }, 83 | errors: [], 84 | }, 85 | 86 | { 87 | name: "invalid if its an integer", 88 | document: { 89 | openapi: "3.1.0", 90 | info: { version: "1.0" }, 91 | paths: { 92 | "/foo/{id}": { 93 | get: { 94 | description: "get", 95 | parameters: [ 96 | { 97 | name: "id", 98 | in: "path", 99 | required: true, 100 | schema: { 101 | type: "integer", 102 | }, 103 | }, 104 | { 105 | name: "notanid", 106 | in: "path", 107 | required: true, 108 | schema: { 109 | type: "integer", 110 | }, 111 | }, 112 | { 113 | name: "underscore_id", 114 | in: "path", 115 | required: true, 116 | schema: { 117 | type: "integer", 118 | }, 119 | }, 120 | { 121 | name: "hyphen-id", 122 | in: "path", 123 | required: true, 124 | schema: { 125 | type: "integer", 126 | format: "int32", 127 | }, 128 | }, 129 | { 130 | name: "camelId", 131 | in: "path", 132 | required: true, 133 | schema: { 134 | type: "integer", 135 | }, 136 | }, 137 | ], 138 | }, 139 | }, 140 | }, 141 | }, 142 | errors: [ 143 | { 144 | message: 145 | "Use random IDs that cannot be guessed. UUIDs are preferred but any other random string will do.", 146 | path: ["paths", "/foo/{id}", "get", "parameters", "0", "schema"], 147 | severity: DiagnosticSeverity.Error, 148 | }, 149 | { 150 | message: 151 | "Use random IDs that cannot be guessed. UUIDs are preferred but any other random string will do.", 152 | path: ["paths", "/foo/{id}", "get", "parameters", "2", "schema"], 153 | severity: DiagnosticSeverity.Error, 154 | }, 155 | { 156 | message: 157 | "Use random IDs that cannot be guessed. UUIDs are preferred but any other random string will do.", 158 | path: ["paths", "/foo/{id}", "get", "parameters", "3", "schema"], 159 | severity: DiagnosticSeverity.Error, 160 | }, 161 | { 162 | message: 163 | "Use random IDs that cannot be guessed. UUIDs are preferred but any other random string will do.", 164 | path: ["paths", "/foo/{id}", "get", "parameters", "4", "schema"], 165 | severity: DiagnosticSeverity.Error, 166 | }, 167 | ], 168 | }, 169 | ]); 170 | -------------------------------------------------------------------------------- /__tests__/owasp-api2-2023-auth-insecure-schemes.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api2:2023-auth-insecure-schemes", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | components: { 11 | securitySchemes: { 12 | "bearer is ok": { 13 | type: "http", 14 | scheme: "bearer", 15 | }, 16 | }, 17 | }, 18 | }, 19 | errors: [], 20 | }, 21 | 22 | { 23 | name: "invalid case", 24 | document: { 25 | openapi: "3.1.0", 26 | info: { version: "1.0" }, 27 | components: { 28 | securitySchemes: { 29 | "bad negotiate": { 30 | type: "http", 31 | scheme: "negotiate", 32 | }, 33 | "bad oauth": { 34 | type: "http", 35 | scheme: "oauth", 36 | }, 37 | }, 38 | }, 39 | }, 40 | errors: [ 41 | { 42 | message: 43 | "Authentication scheme is considered outdated or insecure: negotiate.", 44 | path: ["components", "securitySchemes", "bad negotiate", "scheme"], 45 | severity: DiagnosticSeverity.Error, 46 | }, 47 | { 48 | message: 49 | "Authentication scheme is considered outdated or insecure: oauth.", 50 | path: ["components", "securitySchemes", "bad oauth", "scheme"], 51 | severity: DiagnosticSeverity.Error, 52 | }, 53 | ], 54 | }, 55 | ]); 56 | -------------------------------------------------------------------------------- /__tests__/owasp-api2-2023-jwt-best-practices.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api2:2023-jwt-best-practices", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | components: { 11 | securitySchemes: { 12 | "bad oauth2": { 13 | type: "oauth2", 14 | description: "These JWTs use RFC8725.", 15 | }, 16 | "bad bearer jwt": { 17 | type: "http", 18 | bearerFormat: "jwt", 19 | description: "These JWTs use RFC8725.", 20 | }, 21 | }, 22 | }, 23 | }, 24 | errors: [], 25 | }, 26 | 27 | { 28 | name: "invalid case", 29 | document: { 30 | openapi: "3.1.0", 31 | info: { version: "1.0" }, 32 | components: { 33 | securitySchemes: { 34 | "bad oauth2": { 35 | type: "oauth2", 36 | description: 37 | "No way of knowing if these JWTs are following best practices.", 38 | }, 39 | "bad bearer jwt": { 40 | type: "http", 41 | bearerFormat: "jwt", 42 | description: 43 | "No way of knowing if these JWTs are following best practices.", 44 | }, 45 | }, 46 | }, 47 | }, 48 | errors: [ 49 | { 50 | message: 51 | "Security schemes using JWTs must explicitly declare support for RFC8725 in the description.", 52 | path: ["components", "securitySchemes", "bad oauth2", "description"], 53 | severity: DiagnosticSeverity.Error, 54 | }, 55 | { 56 | message: 57 | "Security schemes using JWTs must explicitly declare support for RFC8725 in the description.", 58 | path: [ 59 | "components", 60 | "securitySchemes", 61 | "bad bearer jwt", 62 | "description", 63 | ], 64 | severity: DiagnosticSeverity.Error, 65 | }, 66 | ], 67 | }, 68 | ]); 69 | -------------------------------------------------------------------------------- /__tests__/owasp-api2-2023-no-api-keys-in-url.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api2:2023-no-api-keys-in-url", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | components: { 11 | securitySchemes: { 12 | "API Key in URL": { 13 | type: "apiKey", 14 | in: "header", 15 | }, 16 | }, 17 | }, 18 | }, 19 | errors: [], 20 | }, 21 | 22 | { 23 | name: "invalid case", 24 | document: { 25 | openapi: "3.1.0", 26 | info: { version: "1.0" }, 27 | components: { 28 | securitySchemes: { 29 | "API Key in Query": { 30 | type: "apiKey", 31 | in: "query", 32 | }, 33 | "API Key in Path": { 34 | type: "apiKey", 35 | in: "path", 36 | }, 37 | }, 38 | }, 39 | }, 40 | errors: [ 41 | { 42 | message: 43 | 'API Key passed in URL: "query" must not match the pattern "^(path|query)$".', 44 | path: ["components", "securitySchemes", "API Key in Query", "in"], 45 | severity: DiagnosticSeverity.Error, 46 | }, 47 | { 48 | message: 49 | 'API Key passed in URL: "path" must not match the pattern "^(path|query)$".', 50 | path: ["components", "securitySchemes", "API Key in Path", "in"], 51 | severity: DiagnosticSeverity.Error, 52 | }, 53 | ], 54 | }, 55 | ]); 56 | -------------------------------------------------------------------------------- /__tests__/owasp-api2-2023-no-credentials-in-url.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api2:2023-no-credentials-in-url", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | "/foo/{id}": { 11 | get: { 12 | description: "get", 13 | parameters: [ 14 | { 15 | name: "id", 16 | in: "path", 17 | required: true, 18 | }, 19 | { 20 | name: "filter", 21 | in: "query", 22 | required: true, 23 | }, 24 | ], 25 | }, 26 | }, 27 | }, 28 | errors: [], 29 | }, 30 | 31 | { 32 | name: "invalid case", 33 | document: { 34 | openapi: "3.1.0", 35 | info: { version: "1.0" }, 36 | paths: { 37 | "/foo/{api-key}": { 38 | get: { 39 | description: "get", 40 | parameters: [ 41 | { 42 | name: "client_secret", 43 | in: "query", 44 | required: true, 45 | }, 46 | { 47 | name: "token", 48 | in: "query", 49 | required: true, 50 | }, 51 | { 52 | name: "refresh_token", 53 | in: "query", 54 | required: true, 55 | }, 56 | { 57 | name: "id_token", 58 | in: "query", 59 | required: true, 60 | }, 61 | { 62 | name: "password", 63 | in: "query", 64 | required: true, 65 | }, 66 | { 67 | name: "secret", 68 | in: "query", 69 | required: true, 70 | }, 71 | { 72 | name: "apikey", 73 | in: "query", 74 | required: true, 75 | }, 76 | { 77 | name: "api-key", 78 | in: "path", 79 | required: true, 80 | }, 81 | { 82 | name: "API-KEY", 83 | in: "query", 84 | required: true, 85 | }, 86 | ], 87 | }, 88 | }, 89 | }, 90 | }, 91 | errors: [ 92 | { 93 | message: 94 | "Security credentials detected in path parameter: client_secret.", 95 | severity: DiagnosticSeverity.Error, 96 | }, 97 | { 98 | message: "Security credentials detected in path parameter: token.", 99 | severity: DiagnosticSeverity.Error, 100 | }, 101 | { 102 | message: 103 | "Security credentials detected in path parameter: refresh_token.", 104 | severity: DiagnosticSeverity.Error, 105 | }, 106 | 107 | { 108 | message: "Security credentials detected in path parameter: id_token.", 109 | severity: DiagnosticSeverity.Error, 110 | }, 111 | { 112 | message: "Security credentials detected in path parameter: password.", 113 | severity: DiagnosticSeverity.Error, 114 | }, 115 | { 116 | message: "Security credentials detected in path parameter: secret.", 117 | severity: DiagnosticSeverity.Error, 118 | }, 119 | { 120 | message: "Security credentials detected in path parameter: apikey.", 121 | severity: DiagnosticSeverity.Error, 122 | }, 123 | { 124 | message: "Security credentials detected in path parameter: api-key.", 125 | severity: DiagnosticSeverity.Error, 126 | }, 127 | { 128 | message: "Security credentials detected in path parameter: API-KEY.", 129 | severity: DiagnosticSeverity.Error, 130 | }, 131 | ], 132 | }, 133 | ]); 134 | -------------------------------------------------------------------------------- /__tests__/owasp-api2-2023-no-http-basic.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api2:2023-no-http-basic", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | components: { 11 | securitySchemes: { 12 | "anything-else": { 13 | type: "http", 14 | scheme: "bearer", 15 | }, 16 | }, 17 | }, 18 | }, 19 | errors: [], 20 | }, 21 | 22 | { 23 | name: "invalid case", 24 | document: { 25 | openapi: "3.1.0", 26 | info: { version: "1.0" }, 27 | components: { 28 | securitySchemes: { 29 | "please-hack-me": { 30 | type: "http", 31 | scheme: "basic", 32 | }, 33 | }, 34 | }, 35 | }, 36 | errors: [ 37 | { 38 | message: 39 | "Security scheme uses HTTP Basic. Use a more secure authentication method, like OAuth 2, or OpenID.", 40 | path: ["components", "securitySchemes", "please-hack-me", "scheme"], 41 | severity: DiagnosticSeverity.Error, 42 | }, 43 | ], 44 | }, 45 | ]); 46 | -------------------------------------------------------------------------------- /__tests__/owasp-api2-2023-short-lived-tokens.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | const authorizationCodeFlow = { 5 | authorizationUrl: "https://example.com/oauth/authorize", 6 | tokenUrl: "https://example.com/oauth/token", 7 | scopes: { 8 | read_scope: "Read access to the protected resource", 9 | write_scope: "Write access to the protected resource", 10 | }, 11 | }; 12 | 13 | const oauth2SchemeWithRefreshUrl = { 14 | type: "oauth2", 15 | flows: { 16 | authorizationCode: { 17 | ...authorizationCodeFlow, 18 | refreshUrl: "https://example.com/oauth/refresh", 19 | }, 20 | }, 21 | }; 22 | 23 | const oauth2SchemeWithoutRefreshUrl = { 24 | type: "oauth2", 25 | flows: { 26 | authorizationCode: authorizationCodeFlow, 27 | }, 28 | }; 29 | 30 | testRule("owasp:api2:2023-short-lived-access-tokens", [ 31 | { 32 | name: "valid case", 33 | document: { 34 | openapi: "3.1.0", 35 | info: { version: "1.0" }, 36 | components: { 37 | securitySchemes: { 38 | oauth2: oauth2SchemeWithRefreshUrl, 39 | }, 40 | }, 41 | }, 42 | errors: [], 43 | }, 44 | 45 | { 46 | name: "invalid case", 47 | document: { 48 | openapi: "3.1.0", 49 | info: { version: "1.0" }, 50 | components: { 51 | securitySchemes: { 52 | oauth2: oauth2SchemeWithoutRefreshUrl, 53 | }, 54 | }, 55 | }, 56 | errors: [ 57 | { 58 | message: 59 | "Authentication scheme does not appear to support refresh tokens, meaning access tokens likely do not expire.", 60 | path: [ 61 | "components", 62 | "securitySchemes", 63 | "oauth2", 64 | "flows", 65 | "authorizationCode", 66 | ], 67 | severity: DiagnosticSeverity.Error, 68 | }, 69 | ], 70 | }, 71 | ]); 72 | -------------------------------------------------------------------------------- /__tests__/owasp-api3-2023-constrained-additionalProperties.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api3:2023-constrained-additionalProperties", [ 5 | { 6 | name: "valid case: disabled entirely (oas2)", 7 | document: { 8 | swagger: "2.0", 9 | info: { version: "1.0" }, 10 | definitions: { 11 | Foo: { 12 | type: "object", 13 | additionalProperties: false, 14 | }, 15 | }, 16 | }, 17 | errors: [], 18 | }, 19 | 20 | { 21 | name: "valid case: disabled entirely (oas3)", 22 | document: { 23 | openapi: "3.0.0", 24 | info: { version: "1.0" }, 25 | components: { 26 | schemas: { 27 | Foo: { 28 | type: "object", 29 | additionalProperties: false, 30 | }, 31 | }, 32 | }, 33 | }, 34 | errors: [], 35 | }, 36 | 37 | { 38 | name: "invalid case: constrained additionalProperties (oas3)", 39 | document: { 40 | openapi: "3.0.0", 41 | info: { version: "1.0" }, 42 | components: { 43 | schemas: { 44 | Foo: { 45 | type: "object", 46 | additionalProperties: { 47 | type: "string", 48 | }, 49 | }, 50 | }, 51 | }, 52 | }, 53 | errors: [ 54 | { 55 | message: "Objects should not allow unconstrained additionalProperties.", 56 | path: ["components", "schemas", "Foo"], 57 | severity: DiagnosticSeverity.Warning, 58 | }, 59 | ], 60 | }, 61 | 62 | { 63 | name: "valid case: constrained additionalProperties (oas3)", 64 | document: { 65 | openapi: "3.0.0", 66 | info: { version: "1.0" }, 67 | components: { 68 | schemas: { 69 | Foo: { 70 | type: "object", 71 | additionalProperties: { 72 | type: "string", 73 | }, 74 | maxProperties: 1, 75 | }, 76 | }, 77 | }, 78 | }, 79 | errors: [], 80 | }, 81 | ]); 82 | -------------------------------------------------------------------------------- /__tests__/owasp-api3-2023-constrained-unevaluatedProperties.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api3:2023-constrained-unevaluatedProperties", [ 5 | { 6 | name: "valid case: disabled entirely (oas3.1)", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | components: { 11 | schemas: { 12 | Foo: { 13 | type: "object", 14 | unevaluatedProperties: false, 15 | }, 16 | }, 17 | }, 18 | }, 19 | errors: [], 20 | }, 21 | 22 | { 23 | name: "invalid case: constrained unevaluatedProperties (oas3.1)", 24 | document: { 25 | openapi: "3.1.0", 26 | info: { version: "1.0" }, 27 | components: { 28 | schemas: { 29 | Foo: { 30 | type: "object", 31 | unevaluatedProperties: { 32 | type: "string", 33 | }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | errors: [ 39 | { 40 | message: 41 | "Objects should not allow unconstrained unevaluatedProperties.", 42 | path: ["components", "schemas", "Foo"], 43 | severity: DiagnosticSeverity.Warning, 44 | }, 45 | ], 46 | }, 47 | 48 | { 49 | name: "valid case: constrained unevaluatedProperties (oas3.1)", 50 | document: { 51 | openapi: "3.1.0", 52 | info: { version: "1.0" }, 53 | components: { 54 | schemas: { 55 | Foo: { 56 | type: "object", 57 | unevaluatedProperties: { 58 | type: "string", 59 | }, 60 | maxProperties: 1, 61 | }, 62 | }, 63 | }, 64 | }, 65 | errors: [], 66 | }, 67 | ]); 68 | -------------------------------------------------------------------------------- /__tests__/owasp-api3-2023-no-additionalProperties.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api3:2023-no-additionalProperties", [ 5 | { 6 | name: "valid case: oas2 does not allow additionalProperties by default so dont worry about it", 7 | document: { 8 | swagger: "2.0", 9 | info: { version: "1.0" }, 10 | definitions: { 11 | Foo: { 12 | type: "object", 13 | }, 14 | }, 15 | }, 16 | errors: [], 17 | }, 18 | 19 | { 20 | name: "valid case: oas2 can disable if it likes", 21 | document: { 22 | swagger: "2.0", 23 | info: { version: "1.0" }, 24 | definitions: { 25 | Foo: { 26 | type: "object", 27 | additionalProperties: false, 28 | }, 29 | }, 30 | }, 31 | errors: [], 32 | }, 33 | 34 | { 35 | name: "valid case: oas3", 36 | document: { 37 | openapi: "3.0.0", 38 | info: { version: "1.0" }, 39 | components: { 40 | schemas: { 41 | Foo: { 42 | type: "object", 43 | additionalProperties: false, 44 | }, 45 | }, 46 | }, 47 | }, 48 | errors: [], 49 | }, 50 | 51 | { 52 | name: "valid case: no additionalProperties defined (oas3", 53 | document: { 54 | openapi: "3.0.0", 55 | info: { version: "1.0" }, 56 | components: { 57 | schemas: { 58 | Foo: { 59 | type: "object", 60 | }, 61 | }, 62 | }, 63 | }, 64 | errors: [], 65 | }, 66 | 67 | { 68 | name: "valid case: additionalProperties set to false (oas3)", 69 | document: { 70 | openapi: "3.0.0", 71 | info: { version: "1.0" }, 72 | components: { 73 | schemas: { 74 | Foo: { 75 | type: "object", 76 | additionalProperties: false, 77 | }, 78 | }, 79 | }, 80 | }, 81 | errors: [{}], 82 | }, 83 | 84 | { 85 | name: "invalid case: additionalProperties set to true (oas3)", 86 | document: { 87 | openapi: "3.0.0", 88 | info: { version: "1.0" }, 89 | components: { 90 | schemas: { 91 | Foo: { 92 | type: "object", 93 | additionalProperties: true, 94 | }, 95 | }, 96 | }, 97 | }, 98 | errors: [ 99 | { 100 | message: 101 | "If the additionalProperties keyword is used it must be set to false.", 102 | path: ["components", "schemas", "Foo", "additionalProperties"], 103 | severity: DiagnosticSeverity.Warning, 104 | }, 105 | ], 106 | }, 107 | ]); 108 | -------------------------------------------------------------------------------- /__tests__/owasp-api3-2023-no-unevaluatedProperties.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api3:2023-no-unevaluatedProperties", [ 5 | { 6 | name: "valid case: oas3_1", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | components: { 11 | schemas: { 12 | Foo: { 13 | type: "object", 14 | unevaluatedProperties: false, 15 | }, 16 | }, 17 | }, 18 | }, 19 | errors: [], 20 | }, 21 | 22 | { 23 | name: "valid case: no unevaluatedProperties defined (oas3_1)", 24 | document: { 25 | openapi: "3.1.0", 26 | info: { version: "1.0" }, 27 | components: { 28 | schemas: { 29 | Foo: { 30 | type: "object", 31 | }, 32 | }, 33 | }, 34 | }, 35 | errors: [], 36 | }, 37 | 38 | { 39 | name: "valid case: unevaluatedProperties set to false (oas3_1)", 40 | document: { 41 | openapi: "3.1.0", 42 | info: { version: "1.0" }, 43 | components: { 44 | schemas: { 45 | Foo: { 46 | type: "object", 47 | unevaluatedProperties: false, 48 | }, 49 | }, 50 | }, 51 | }, 52 | errors: [], 53 | }, 54 | 55 | { 56 | name: "invalid case: unevaluatedProperties set to true (oas3_1)", 57 | document: { 58 | openapi: "3.1.0", 59 | info: { version: "1.0" }, 60 | components: { 61 | schemas: { 62 | Foo: { 63 | type: "object", 64 | unevaluatedProperties: true, 65 | }, 66 | }, 67 | }, 68 | }, 69 | errors: [ 70 | { 71 | message: 72 | "If the unevaluatedProperties keyword is used it must be set to false.", 73 | path: ["components", "schemas", "Foo", "unevaluatedProperties"], 74 | severity: DiagnosticSeverity.Warning, 75 | }, 76 | ], 77 | }, 78 | ]); 79 | -------------------------------------------------------------------------------- /__tests__/owasp-api4-2023-array-limit.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api4:2023-array-limit", [ 5 | { 6 | name: "valid case: oas2", 7 | document: { 8 | swagger: "2.0", 9 | info: { version: "1.0" }, 10 | definitions: { 11 | Foo: { 12 | type: "array", 13 | maxItems: 99, 14 | }, 15 | }, 16 | }, 17 | errors: [], 18 | }, 19 | 20 | { 21 | name: "valid case: oas3", 22 | document: { 23 | openapi: "3.0.0", 24 | info: { version: "1.0" }, 25 | components: { 26 | schemas: { 27 | Foo: { 28 | type: "array", 29 | maxItems: 99, 30 | }, 31 | }, 32 | }, 33 | }, 34 | errors: [], 35 | }, 36 | 37 | { 38 | name: "valid case: oas3.1", 39 | document: { 40 | openapi: "3.1.0", 41 | info: { version: "1.0" }, 42 | components: { 43 | schemas: { 44 | type: { 45 | type: "string", 46 | maxLength: 99, 47 | }, 48 | User: { 49 | type: "object", 50 | properties: { 51 | type: { 52 | enum: ["user", "admin"], 53 | }, 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | errors: [], 60 | }, 61 | 62 | { 63 | name: "invalid case: oas2 missing maxItems", 64 | document: { 65 | swagger: "2.0", 66 | info: { version: "1.0" }, 67 | definitions: { 68 | Foo: { 69 | type: "array", 70 | }, 71 | }, 72 | }, 73 | errors: [ 74 | { 75 | message: "Schema of type array must specify maxItems.", 76 | path: ["definitions", "Foo"], 77 | severity: DiagnosticSeverity.Error, 78 | }, 79 | ], 80 | }, 81 | 82 | { 83 | name: "invalid case: oas3 missing maxItems", 84 | document: { 85 | openapi: "3.0.0", 86 | info: { version: "1.0" }, 87 | components: { 88 | schemas: { 89 | Foo: { 90 | type: "array", 91 | }, 92 | }, 93 | }, 94 | }, 95 | errors: [ 96 | { 97 | message: "Schema of type array must specify maxItems.", 98 | path: ["components", "schemas", "Foo"], 99 | severity: DiagnosticSeverity.Error, 100 | }, 101 | ], 102 | }, 103 | ]); 104 | -------------------------------------------------------------------------------- /__tests__/owasp-api4-2023-integer-format.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api4:2023-integer-format", [ 5 | { 6 | name: "valid case: format - int32", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | components: { 11 | schemas: { 12 | Foo: { 13 | type: "integer", 14 | format: "int32", 15 | }, 16 | }, 17 | }, 18 | }, 19 | errors: [], 20 | }, 21 | 22 | { 23 | name: "valid case: format - int64", 24 | document: { 25 | openapi: "3.1.0", 26 | info: { version: "1.0" }, 27 | components: { 28 | schemas: { 29 | Foo: { 30 | type: "integer", 31 | format: "int64", 32 | }, 33 | }, 34 | }, 35 | }, 36 | errors: [], 37 | }, 38 | 39 | { 40 | name: "valid case: format - whatever", 41 | document: { 42 | openapi: "3.1.0", 43 | info: { version: "1.0" }, 44 | components: { 45 | schemas: { 46 | Foo: { 47 | type: "integer", 48 | format: "whatever", 49 | }, 50 | }, 51 | }, 52 | }, 53 | errors: [], 54 | }, 55 | 56 | { 57 | name: "invalid case: no format", 58 | document: { 59 | openapi: "3.1.0", 60 | info: { version: "1.0" }, 61 | components: { 62 | schemas: { 63 | Foo: { 64 | type: "integer", 65 | }, 66 | }, 67 | }, 68 | }, 69 | errors: [ 70 | { 71 | message: "Schema of type integer must specify format (int32 or int64).", 72 | path: ["components", "schemas", "Foo"], 73 | severity: DiagnosticSeverity.Error, 74 | }, 75 | ], 76 | }, 77 | ]); 78 | -------------------------------------------------------------------------------- /__tests__/owasp-api4-2023-integer-limit-legacy.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api4:2023-integer-limit-legacy", [ 5 | { 6 | name: "valid case: oas2", 7 | document: { 8 | swagger: "2.0", 9 | info: { version: "1.0" }, 10 | definitions: { 11 | Foo: { 12 | type: "integer", 13 | minimum: 1, 14 | maximum: 99, 15 | }, 16 | }, 17 | }, 18 | errors: [], 19 | }, 20 | 21 | { 22 | name: "valid case: oas3.0", 23 | document: { 24 | openapi: "3.0.0", 25 | info: { version: "1.0" }, 26 | components: { 27 | schemas: { 28 | Foo: { 29 | type: "integer", 30 | minimum: 1, 31 | maximum: 99, 32 | }, 33 | }, 34 | }, 35 | }, 36 | errors: [], 37 | }, 38 | 39 | { 40 | name: "invalid case: oas2 missing maximum", 41 | document: { 42 | swagger: "2.0", 43 | info: { version: "1.0" }, 44 | definitions: { 45 | Foo: { 46 | type: "integer", 47 | }, 48 | }, 49 | }, 50 | errors: [ 51 | { 52 | message: "Schema of type integer must specify minimum and maximum.", 53 | path: ["definitions", "Foo"], 54 | severity: DiagnosticSeverity.Error, 55 | }, 56 | ], 57 | }, 58 | 59 | { 60 | name: "invalid case: oas3.0 missing maximum", 61 | document: { 62 | openapi: "3.0.0", 63 | info: { version: "1.0" }, 64 | components: { 65 | schemas: { 66 | Foo: { 67 | type: "integer", 68 | }, 69 | }, 70 | }, 71 | }, 72 | errors: [ 73 | { 74 | message: "Schema of type integer must specify minimum and maximum.", 75 | path: ["components", "schemas", "Foo"], 76 | severity: DiagnosticSeverity.Error, 77 | }, 78 | ], 79 | }, 80 | 81 | { 82 | name: "invalid case: oas2 has maximum but missing minimum", 83 | document: { 84 | swagger: "2.0", 85 | info: { version: "1.0" }, 86 | definitions: { 87 | Foo: { 88 | type: "integer", 89 | maximum: 99, 90 | }, 91 | }, 92 | }, 93 | errors: [ 94 | { 95 | message: "Schema of type integer must specify minimum and maximum.", 96 | path: ["definitions", "Foo"], 97 | severity: DiagnosticSeverity.Error, 98 | }, 99 | ], 100 | }, 101 | 102 | { 103 | name: "invalid case: oas3.0 has maximum but missing minimum", 104 | document: { 105 | openapi: "3.0.0", 106 | info: { version: "1.0" }, 107 | components: { 108 | schemas: { 109 | Foo: { 110 | type: "integer", 111 | maximum: 99, 112 | }, 113 | }, 114 | }, 115 | }, 116 | errors: [ 117 | { 118 | message: "Schema of type integer must specify minimum and maximum.", 119 | path: ["components", "schemas", "Foo"], 120 | severity: DiagnosticSeverity.Error, 121 | }, 122 | ], 123 | }, 124 | ]); 125 | -------------------------------------------------------------------------------- /__tests__/owasp-api4-2023-integer-limit.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api4:2023-integer-limit", [ 5 | { 6 | name: "valid case: minimum and maximum", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | components: { 11 | schemas: { 12 | Foo: { 13 | type: "integer", 14 | minimum: 1, 15 | maximum: 99, 16 | }, 17 | }, 18 | }, 19 | }, 20 | errors: [], 21 | }, 22 | 23 | { 24 | name: "valid case: exclusiveMinimum and exclusiveMaximum", 25 | document: { 26 | openapi: "3.1.0", 27 | info: { version: "1.0" }, 28 | components: { 29 | schemas: { 30 | Foo: { 31 | type: "integer", 32 | exclusiveMinimum: 1, 33 | exclusiveMaximum: 99, 34 | }, 35 | }, 36 | }, 37 | }, 38 | errors: [], 39 | }, 40 | 41 | { 42 | name: "valid case: minimum and exclusiveMaximum", 43 | document: { 44 | openapi: "3.1.0", 45 | info: { version: "1.0" }, 46 | components: { 47 | schemas: { 48 | Foo: { 49 | type: "integer", 50 | minimum: 1, 51 | exclusiveMaximum: 99, 52 | }, 53 | }, 54 | }, 55 | }, 56 | errors: [], 57 | }, 58 | 59 | { 60 | name: "valid case: exclusiveMinimum and maximum", 61 | document: { 62 | openapi: "3.1.0", 63 | info: { version: "1.0" }, 64 | components: { 65 | schemas: { 66 | Foo: { 67 | type: "integer", 68 | exclusiveMinimum: 1, 69 | maximum: 99, 70 | }, 71 | }, 72 | }, 73 | }, 74 | errors: [], 75 | }, 76 | 77 | { 78 | name: "invalid case: only maximum", 79 | document: { 80 | openapi: "3.1.0", 81 | info: { version: "1.0" }, 82 | components: { 83 | schemas: { 84 | Foo: { 85 | type: "integer", 86 | maximum: 99, 87 | }, 88 | }, 89 | }, 90 | }, 91 | errors: [ 92 | { 93 | message: "Schema of type integer must specify minimum and maximum.", 94 | path: ["components", "schemas", "Foo"], 95 | severity: DiagnosticSeverity.Error, 96 | }, 97 | ], 98 | }, 99 | 100 | { 101 | name: "invalid case: only exclusiveMaximum", 102 | document: { 103 | openapi: "3.1.0", 104 | info: { version: "1.0" }, 105 | components: { 106 | schemas: { 107 | Foo: { 108 | type: "integer", 109 | exclusiveMaximum: 99, 110 | }, 111 | }, 112 | }, 113 | }, 114 | errors: [ 115 | { 116 | message: "Schema of type integer must specify minimum and maximum.", 117 | path: ["components", "schemas", "Foo"], 118 | severity: DiagnosticSeverity.Error, 119 | }, 120 | ], 121 | }, 122 | 123 | { 124 | name: "invalid case: only exclusiveMinimum", 125 | document: { 126 | openapi: "3.1.0", 127 | info: { version: "1.0" }, 128 | components: { 129 | schemas: { 130 | Foo: { 131 | type: "integer", 132 | exclusiveMinimum: 1, 133 | }, 134 | }, 135 | }, 136 | }, 137 | errors: [ 138 | { 139 | message: "Schema of type integer must specify minimum and maximum.", 140 | path: ["components", "schemas", "Foo"], 141 | severity: DiagnosticSeverity.Error, 142 | }, 143 | ], 144 | }, 145 | 146 | { 147 | name: "invalid case: only minimum", 148 | document: { 149 | openapi: "3.1.0", 150 | info: { version: "1.0" }, 151 | components: { 152 | schemas: { 153 | Foo: { 154 | type: "integer", 155 | minimum: 1, 156 | }, 157 | }, 158 | }, 159 | }, 160 | errors: [ 161 | { 162 | message: "Schema of type integer must specify minimum and maximum.", 163 | path: ["components", "schemas", "Foo"], 164 | severity: DiagnosticSeverity.Error, 165 | }, 166 | ], 167 | }, 168 | 169 | { 170 | name: "invalid case: both minimums and an exclusiveMaximum", 171 | document: { 172 | openapi: "3.1.0", 173 | info: { version: "1.0" }, 174 | components: { 175 | schemas: { 176 | Foo: { 177 | type: "integer", 178 | minimum: 1, 179 | exclusiveMinimum: 1, 180 | exclusiveMaximum: 4, 181 | }, 182 | }, 183 | }, 184 | }, 185 | errors: [ 186 | { 187 | message: "Schema of type integer must specify minimum and maximum.", 188 | path: ["components", "schemas", "Foo"], 189 | severity: DiagnosticSeverity.Error, 190 | }, 191 | ], 192 | }, 193 | ]); 194 | -------------------------------------------------------------------------------- /__tests__/owasp-api4-2023-rate-limit-responses-429.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api4:2023-rate-limit-responses-429", [ 5 | { 6 | name: "valid: defines a 429 response with content", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/": { 12 | get: { 13 | responses: { 14 | "429": { 15 | description: "ok", 16 | content: { 17 | "application/problem+json": {}, 18 | }, 19 | }, 20 | }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | errors: [], 26 | }, 27 | 28 | { 29 | name: "invalid: 429 is not defined at all", 30 | document: { 31 | openapi: "3.1.0", 32 | info: { version: "1.0" }, 33 | paths: { 34 | "/": { 35 | get: { 36 | responses: { 37 | "200": { 38 | description: "ok", 39 | content: { 40 | "application/json": {}, 41 | }, 42 | }, 43 | }, 44 | }, 45 | }, 46 | }, 47 | }, 48 | errors: [ 49 | { 50 | message: 51 | "Operation is missing rate limiting response in responses[429].", 52 | path: ["paths", "/", "get", "responses"], 53 | severity: DiagnosticSeverity.Warning, 54 | }, 55 | { 56 | message: 57 | "Operation is missing rate limiting response in responses[429].content.", 58 | path: ["paths", "/", "get", "responses"], 59 | severity: DiagnosticSeverity.Warning, 60 | }, 61 | ], 62 | }, 63 | 64 | { 65 | name: "invalid: 429 exists but content is missing", 66 | document: { 67 | openapi: "3.1.0", 68 | info: { version: "1.0" }, 69 | paths: { 70 | "/": { 71 | get: { 72 | responses: { 73 | "429": {}, 74 | }, 75 | }, 76 | }, 77 | }, 78 | }, 79 | errors: [ 80 | { 81 | message: 82 | "Operation is missing rate limiting response in [429].content.", 83 | path: ["paths", "/", "get", "responses", "429"], 84 | severity: DiagnosticSeverity.Warning, 85 | }, 86 | ], 87 | }, 88 | ]); 89 | -------------------------------------------------------------------------------- /__tests__/owasp-api4-2023-rate-limit-retry-after.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api4:2023-rate-limit-retry-after", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/": { 12 | get: { 13 | responses: { 14 | "429": { 15 | description: "ok", 16 | headers: { 17 | "Retry-After": { 18 | description: "standard retry header", 19 | schema: { 20 | type: "string", 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | errors: [], 31 | }, 32 | 33 | { 34 | name: "invalid case", 35 | document: { 36 | openapi: "3.1.0", 37 | info: { version: "1.0" }, 38 | paths: { 39 | "/": { 40 | get: { 41 | description: "get", 42 | responses: { 43 | "429": { 44 | description: "ok", 45 | headers: {}, 46 | }, 47 | }, 48 | }, 49 | }, 50 | }, 51 | }, 52 | errors: [ 53 | { 54 | message: "A 429 response should define a Retry-After header.", 55 | path: ["paths", "/", "get", "responses", "429", "headers"], 56 | severity: DiagnosticSeverity.Error, 57 | }, 58 | ], 59 | }, 60 | ]); 61 | -------------------------------------------------------------------------------- /__tests__/owasp-api4-2023-rate-limit.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api4:2023-rate-limit", [ 5 | { 6 | name: "valid use of IETF Draft HTTP RateLimit-* Headers", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/": { 12 | get: { 13 | responses: { 14 | "201": { 15 | description: "ok", 16 | headers: { 17 | "RateLimit-Limit": { 18 | schema: { 19 | type: "string", 20 | }, 21 | }, 22 | "RateLimit-Reset": { 23 | schema: { 24 | type: "string", 25 | }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | errors: [], 35 | }, 36 | 37 | { 38 | name: "valid use of IETF Draft HTTP RateLimit Headers", 39 | document: { 40 | openapi: "3.1.0", 41 | info: { version: "1.0" }, 42 | paths: { 43 | "/": { 44 | get: { 45 | responses: { 46 | "201": { 47 | description: "ok", 48 | headers: { 49 | RateLimit: { 50 | schema: { 51 | type: "string", 52 | }, 53 | }, 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | errors: [], 62 | }, 63 | 64 | { 65 | name: "valid use of Twitter-style Rate Limit Headers", 66 | document: { 67 | openapi: "3.1.0", 68 | info: { version: "1.0" }, 69 | paths: { 70 | "/": { 71 | get: { 72 | responses: { 73 | "201": { 74 | description: "ok", 75 | headers: { 76 | "X-Rate-Limit-Limit": { 77 | schema: { 78 | type: "string", 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | }, 85 | }, 86 | }, 87 | }, 88 | errors: [], 89 | }, 90 | 91 | { 92 | name: "valid use of GitHub-style Rate Limit Headers", 93 | document: { 94 | openapi: "3.1.0", 95 | info: { version: "1.0" }, 96 | paths: { 97 | "/": { 98 | get: { 99 | responses: { 100 | "201": { 101 | description: "ok", 102 | headers: { 103 | "X-RateLimit-Limit": { 104 | schema: { 105 | type: "string", 106 | }, 107 | }, 108 | }, 109 | }, 110 | }, 111 | }, 112 | }, 113 | }, 114 | }, 115 | errors: [], 116 | }, 117 | 118 | { 119 | name: "invalid case: no limit headers set", 120 | document: { 121 | openapi: "3.1.0", 122 | info: { version: "1.0" }, 123 | paths: { 124 | "/": { 125 | get: { 126 | description: "get", 127 | responses: { 128 | "201": { 129 | description: "ok", 130 | }, 131 | }, 132 | }, 133 | }, 134 | }, 135 | }, 136 | errors: [ 137 | { 138 | message: 139 | "All 2XX and 4XX responses should define rate limiting headers.", 140 | path: ["paths", "/", "get", "responses", "201"], 141 | severity: DiagnosticSeverity.Error, 142 | }, 143 | ], 144 | }, 145 | 146 | { 147 | name: "invalid case: no rate limit headers set", 148 | document: { 149 | openapi: "3.1.0", 150 | info: { version: "1.0" }, 151 | paths: { 152 | "/": { 153 | get: { 154 | description: "get", 155 | responses: { 156 | "201": { 157 | description: "ok", 158 | headers: { 159 | SomethingElse: { 160 | schema: { 161 | type: "string", 162 | }, 163 | }, 164 | }, 165 | }, 166 | }, 167 | }, 168 | }, 169 | }, 170 | }, 171 | errors: [ 172 | { 173 | message: 174 | "All 2XX and 4XX responses should define rate limiting headers.", 175 | path: ["paths", "/", "get", "responses", "201", "headers"], 176 | severity: DiagnosticSeverity.Error, 177 | }, 178 | ], 179 | }, 180 | ]); 181 | -------------------------------------------------------------------------------- /__tests__/owasp-api4-2023-string-limit.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api4:2023-string-limit", [ 5 | { 6 | name: "valid case: oas2", 7 | document: { 8 | swagger: "2.0", 9 | info: { version: "1.0" }, 10 | definitions: { 11 | Foo: { 12 | type: "string", 13 | maxLength: 99, 14 | }, 15 | }, 16 | }, 17 | errors: [], 18 | }, 19 | 20 | { 21 | name: "valid case: oas3.0", 22 | document: { 23 | openapi: "3.0.0", 24 | info: { version: "1.0" }, 25 | components: { 26 | schemas: { 27 | Foo: { 28 | type: "string", 29 | maxLength: 99, 30 | }, 31 | }, 32 | }, 33 | }, 34 | errors: [], 35 | }, 36 | 37 | { 38 | name: "valid case: oas3.1", 39 | document: { 40 | openapi: "3.1.0", 41 | info: { version: "1.0" }, 42 | components: { 43 | schemas: { 44 | Foo: { 45 | type: ["null", "string"], 46 | maxLength: 99, 47 | }, 48 | }, 49 | }, 50 | }, 51 | errors: [], 52 | }, 53 | 54 | { 55 | name: "valid case: oas3.0", 56 | document: { 57 | openapi: "3.0.0", 58 | info: { version: "1.0" }, 59 | components: { 60 | schemas: { 61 | Foo: { 62 | type: "string", 63 | enum: ["a", "b", "c"], 64 | }, 65 | }, 66 | }, 67 | }, 68 | errors: [], 69 | }, 70 | 71 | { 72 | name: "valid case: oas3.1", 73 | document: { 74 | openapi: "3.1.0", 75 | info: { version: "1.0" }, 76 | components: { 77 | schemas: { 78 | Foo: { 79 | type: "string", 80 | const: "constant", 81 | }, 82 | }, 83 | }, 84 | }, 85 | errors: [], 86 | }, 87 | 88 | { 89 | name: "valid case: oas3.1", 90 | document: { 91 | openapi: "3.1.0", 92 | info: { version: "1.0" }, 93 | components: { 94 | schemas: { 95 | Foo: { 96 | type: "string", 97 | const: "constant", 98 | }, 99 | }, 100 | }, 101 | }, 102 | errors: [], 103 | }, 104 | 105 | { 106 | name: "valid case: pattern and maxLength, oas3.1", 107 | document: { 108 | openapi: "3.1.0", 109 | info: { version: "1.0" }, 110 | components: { 111 | schemas: { 112 | Foo: { 113 | type: "string", 114 | format: "hex", 115 | pattern: "^[0-9a-fA-F]+$", 116 | maxLength: 16, 117 | }, 118 | }, 119 | }, 120 | }, 121 | errors: [], 122 | }, 123 | 124 | { 125 | name: "invalid case: oas2 missing maxLength", 126 | document: { 127 | swagger: "2.0", 128 | info: { version: "1.0" }, 129 | definitions: { 130 | Foo: { 131 | type: "string", 132 | }, 133 | }, 134 | }, 135 | errors: [ 136 | { 137 | message: 138 | "Schema of type string must specify maxLength, enum, or const.", 139 | path: ["definitions", "Foo"], 140 | severity: DiagnosticSeverity.Error, 141 | }, 142 | ], 143 | }, 144 | 145 | { 146 | name: "invalid case: oas3.0 missing maxLength", 147 | document: { 148 | openapi: "3.0.0", 149 | info: { version: "1.0" }, 150 | components: { 151 | schemas: { 152 | Foo: { 153 | type: "string", 154 | }, 155 | }, 156 | }, 157 | }, 158 | errors: [ 159 | { 160 | message: 161 | "Schema of type string must specify maxLength, enum, or const.", 162 | path: ["components", "schemas", "Foo"], 163 | severity: DiagnosticSeverity.Error, 164 | }, 165 | ], 166 | }, 167 | { 168 | name: "invalid case: oas3.1 missing maxLength", 169 | document: { 170 | openapi: "3.1.0", 171 | info: { version: "1.0" }, 172 | components: { 173 | schemas: { 174 | Foo: { 175 | type: ["null", "string"], 176 | }, 177 | }, 178 | }, 179 | }, 180 | errors: [ 181 | { 182 | message: 183 | "Schema of type string must specify maxLength, enum, or const.", 184 | path: ["components", "schemas", "Foo"], 185 | severity: DiagnosticSeverity.Error, 186 | }, 187 | ], 188 | }, 189 | { 190 | name: "valid case: format: date-time does not need maxLength", 191 | document: { 192 | openapi: "3.1.0", 193 | info: { version: "1.0" }, 194 | components: { 195 | schemas: { 196 | Foo: { 197 | type: ["null", "string"], 198 | }, 199 | }, 200 | }, 201 | }, 202 | errors: [ 203 | { 204 | message: 205 | "Schema of type string must specify maxLength, enum, or const.", 206 | path: ["components", "schemas", "Foo"], 207 | severity: DiagnosticSeverity.Error, 208 | }, 209 | ], 210 | }, 211 | ]); 212 | -------------------------------------------------------------------------------- /__tests__/owasp-api4-2023-string-restricted.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api4:2023-string-restricted", [ 5 | { 6 | name: "valid case: format (oas2)", 7 | document: { 8 | swagger: "2.0", 9 | info: { version: "1.0" }, 10 | definitions: { 11 | Foo: { 12 | type: "string", 13 | format: "email", 14 | }, 15 | }, 16 | }, 17 | errors: [], 18 | }, 19 | 20 | { 21 | name: "valid case: format (oas2)", 22 | document: { 23 | swagger: "2.0", 24 | info: { version: "1.0" }, 25 | definitions: { 26 | Foo: { 27 | type: "string", 28 | pattern: "/^foo/", 29 | }, 30 | }, 31 | }, 32 | errors: [], 33 | }, 34 | 35 | { 36 | name: "valid case: format (oas3)", 37 | document: { 38 | openapi: "3.0.0", 39 | info: { version: "1.0" }, 40 | components: { 41 | schemas: { 42 | Foo: { 43 | type: "string", 44 | format: "email", 45 | }, 46 | }, 47 | }, 48 | }, 49 | errors: [], 50 | }, 51 | 52 | { 53 | name: "valid case: pattern (oas3)", 54 | document: { 55 | openapi: "3.0.0", 56 | info: { version: "1.0" }, 57 | components: { 58 | schemas: { 59 | Foo: { 60 | type: "string", 61 | pattern: "/^foo/", 62 | }, 63 | }, 64 | }, 65 | }, 66 | errors: [], 67 | }, 68 | 69 | { 70 | name: "valid case: format (oas3.1)", 71 | document: { 72 | openapi: "3.1.0", 73 | info: { version: "1.0" }, 74 | components: { 75 | schemas: { 76 | Foo: { 77 | type: ["null", "string"], 78 | format: "email", 79 | }, 80 | }, 81 | }, 82 | }, 83 | errors: [], 84 | }, 85 | 86 | { 87 | name: "valid case: pattern (oas3.1)", 88 | document: { 89 | openapi: "3.1.0", 90 | info: { version: "1.0" }, 91 | components: { 92 | schemas: { 93 | Foo: { 94 | type: ["null", "string"], 95 | pattern: "/^foo/", 96 | }, 97 | }, 98 | }, 99 | }, 100 | errors: [], 101 | }, 102 | 103 | { 104 | name: "valid case: enum (oas3)", 105 | document: { 106 | openapi: "3.0.0", 107 | info: { version: "1.0" }, 108 | components: { 109 | schemas: { 110 | Foo: { 111 | type: "string", 112 | enum: ["a", "b", "c"], 113 | }, 114 | }, 115 | }, 116 | }, 117 | errors: [], 118 | }, 119 | 120 | { 121 | name: "valid case: format + pattern (oas3.1)", 122 | document: { 123 | openapi: "3.1.0", 124 | info: { version: "1.0" }, 125 | components: { 126 | schemas: { 127 | foo: { 128 | type: "string", 129 | format: "hex", 130 | pattern: "^[0-9a-fA-F]+$", 131 | maxLength: 16, 132 | }, 133 | }, 134 | }, 135 | }, 136 | errors: [], 137 | }, 138 | 139 | { 140 | name: "valid case: const (oas3.1)", 141 | document: { 142 | openapi: "3.1.0", 143 | info: { version: "1.0" }, 144 | components: { 145 | schemas: { 146 | Foo: { 147 | type: "string", 148 | const: "CONSTANT", 149 | }, 150 | }, 151 | }, 152 | }, 153 | errors: [], 154 | }, 155 | 156 | { 157 | name: "invalid case: neither format or pattern (oas2)", 158 | document: { 159 | swagger: "2.0", 160 | info: { version: "1.0" }, 161 | definitions: { 162 | Foo: { 163 | type: "string", 164 | }, 165 | }, 166 | }, 167 | errors: [ 168 | { 169 | message: 170 | "Schema of type string should specify a format, pattern, enum, or const.", 171 | path: ["definitions", "Foo"], 172 | severity: DiagnosticSeverity.Warning, 173 | }, 174 | ], 175 | }, 176 | 177 | { 178 | name: "invalid case: neither format or pattern (oas3)", 179 | document: { 180 | openapi: "3.1.0", 181 | info: { version: "1.0" }, 182 | components: { 183 | schemas: { 184 | Foo: { 185 | type: ["null", "string"], 186 | }, 187 | 188 | Bar: { 189 | type: "string", 190 | }, 191 | }, 192 | }, 193 | }, 194 | errors: [ 195 | { 196 | message: 197 | "Schema of type string should specify a format, pattern, enum, or const.", 198 | path: ["components", "schemas", "Foo"], 199 | severity: DiagnosticSeverity.Warning, 200 | }, 201 | { 202 | message: 203 | "Schema of type string should specify a format, pattern, enum, or const.", 204 | path: ["components", "schemas", "Bar"], 205 | severity: DiagnosticSeverity.Warning, 206 | }, 207 | ], 208 | }, 209 | ]); 210 | -------------------------------------------------------------------------------- /__tests__/owasp-api5-2023-admin-security-unique.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api5:2023-admin-security-unique", [ 5 | { 6 | name: "valid case: different security schemes", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/public/export": { 12 | get: { 13 | security: [ 14 | { 15 | ApiKey: [], 16 | }, 17 | ], 18 | }, 19 | }, 20 | "/admin/export": { 21 | get: { 22 | security: [ 23 | { 24 | Oauth2: ["admin_scope"], 25 | }, 26 | ], 27 | }, 28 | }, 29 | }, 30 | components: { 31 | securitySchemes: { 32 | ApiKey: {}, 33 | Oauth2: {}, 34 | }, 35 | }, 36 | }, 37 | errors: [], 38 | }, 39 | 40 | { 41 | name: "valid case", 42 | document: { 43 | openapi: "3.1.0", 44 | info: { version: "1.0" }, 45 | paths: { 46 | "/public/export": { 47 | get: { 48 | security: [ 49 | { 50 | oauth2: ["read_scope"], 51 | }, 52 | ], 53 | }, 54 | }, 55 | "/admin/export": { 56 | get: { 57 | security: [ 58 | { 59 | oauth2: ["admin_scope"], 60 | }, 61 | ], 62 | }, 63 | }, 64 | }, 65 | components: { 66 | securitySchemes: { 67 | oauth2: {}, 68 | }, 69 | }, 70 | }, 71 | errors: [], 72 | }, 73 | 74 | { 75 | name: "invalid case: oauth2 is used for both with no scopes", 76 | document: { 77 | openapi: "3.1.0", 78 | info: { version: "1.0" }, 79 | paths: { 80 | "/public/export": { 81 | get: { 82 | security: [{ oauth2: [] }], 83 | }, 84 | }, 85 | "/admin/export": { 86 | get: { 87 | security: [{ oauth2: [] }], 88 | }, 89 | }, 90 | }, 91 | components: { 92 | securitySchemes: { 93 | oauth2: {}, 94 | }, 95 | }, 96 | }, 97 | errors: [ 98 | { 99 | message: 100 | "Admin endpoint /admin/export has the same security requirement as a non-admin endpoint.", 101 | path: ["paths", "/admin/export", "get", "security", "0"], 102 | severity: DiagnosticSeverity.Error, 103 | }, 104 | ], 105 | }, 106 | 107 | { 108 | name: "invalid case: oauth2 is used for both with same scopes", 109 | document: { 110 | openapi: "3.1.0", 111 | info: { version: "1.0" }, 112 | paths: { 113 | "/public/export": { 114 | get: { 115 | security: [{ oauth2: ["foo"] }], 116 | }, 117 | }, 118 | "/admin/export": { 119 | get: { 120 | security: [{ oauth2: ["foo"] }], 121 | }, 122 | }, 123 | }, 124 | components: { 125 | securitySchemes: { 126 | oauth2: {}, 127 | }, 128 | }, 129 | }, 130 | errors: [ 131 | { 132 | message: 133 | "Admin endpoint /admin/export has the same security requirement as a non-admin endpoint.", 134 | path: ["paths", "/admin/export", "get", "security", "0"], 135 | severity: DiagnosticSeverity.Error, 136 | }, 137 | ], 138 | }, 139 | ]); 140 | -------------------------------------------------------------------------------- /__tests__/owasp-api7-2023-concerning-url-parameter.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api7:2023-concerning-url-parameter", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | "/foo": { 11 | get: { 12 | description: "get", 13 | parameters: { 14 | name: "not-a-redirect", 15 | in: "query", 16 | }, 17 | }, 18 | }, 19 | }, 20 | errors: [], 21 | }, 22 | 23 | { 24 | name: "invalid case", 25 | document: { 26 | openapi: "3.1.0", 27 | info: { version: "1.0" }, 28 | paths: { 29 | "/foo": { 30 | get: { 31 | description: "get", 32 | parameters: [ 33 | { 34 | name: "callback", 35 | in: "query", 36 | }, 37 | { 38 | name: "callbackUrl", 39 | in: "query", 40 | }, 41 | { 42 | name: "callback_url", 43 | in: "query", 44 | }, 45 | { 46 | name: "redirect", 47 | in: "query", 48 | }, 49 | { 50 | name: "redirectUrl", 51 | in: "query", 52 | }, 53 | { 54 | name: "redirect_url", 55 | in: "query", 56 | }, 57 | ], 58 | }, 59 | }, 60 | }, 61 | }, 62 | errors: [ 63 | { 64 | message: 65 | "Make sure to review the way this URL is handled to protect against Server Side Request Forgery.", 66 | severity: DiagnosticSeverity.Information, 67 | }, 68 | { 69 | message: 70 | "Make sure to review the way this URL is handled to protect against Server Side Request Forgery.", 71 | severity: DiagnosticSeverity.Information, 72 | }, 73 | { 74 | message: 75 | "Make sure to review the way this URL is handled to protect against Server Side Request Forgery.", 76 | severity: DiagnosticSeverity.Information, 77 | }, 78 | 79 | { 80 | message: 81 | "Make sure to review the way this URL is handled to protect against Server Side Request Forgery.", 82 | severity: DiagnosticSeverity.Information, 83 | }, 84 | { 85 | message: 86 | "Make sure to review the way this URL is handled to protect against Server Side Request Forgery.", 87 | severity: DiagnosticSeverity.Information, 88 | }, 89 | { 90 | message: 91 | "Make sure to review the way this URL is handled to protect against Server Side Request Forgery.", 92 | severity: DiagnosticSeverity.Information, 93 | }, 94 | ], 95 | }, 96 | ]); 97 | -------------------------------------------------------------------------------- /__tests__/owasp-api8-2023-define-cors-origin.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api8:2023-define-cors-origin", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0", contact: {} }, 10 | paths: { 11 | "/": { 12 | get: { 13 | responses: { 14 | "200": { 15 | description: "ok", 16 | headers: { 17 | "Access-Control-Allow-Origin": { 18 | schema: { 19 | type: "string", 20 | examples: ["*"], 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | errors: [], 31 | }, 32 | 33 | { 34 | name: "invalid case", 35 | document: { 36 | openapi: "3.1.0", 37 | info: { version: "1.0", contact: {} }, 38 | paths: { 39 | "/a": { 40 | get: { 41 | responses: { 42 | "200": { 43 | description: "ok", 44 | headers: { 45 | "Some-Other-Headers": { 46 | schema: { 47 | type: "string", 48 | examples: ["*"], 49 | }, 50 | }, 51 | }, 52 | }, 53 | }, 54 | }, 55 | }, 56 | "/b": { 57 | get: { 58 | responses: { 59 | "200": { 60 | description: "ok", 61 | headers: {}, 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | }, 68 | errors: [ 69 | { 70 | message: 71 | "Header `headers.Access-Control-Allow-Origin` should be defined on all responses.", 72 | path: ["paths", "/a", "get", "responses", "200", "headers"], 73 | severity: DiagnosticSeverity.Error, 74 | }, 75 | { 76 | message: 77 | "Header `headers.Access-Control-Allow-Origin` should be defined on all responses.", 78 | path: ["paths", "/b", "get", "responses", "200", "headers"], 79 | severity: DiagnosticSeverity.Error, 80 | }, 81 | ], 82 | }, 83 | ]); 84 | -------------------------------------------------------------------------------- /__tests__/owasp-api8-2023-define-error-responses-401.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api8:2023-define-error-responses-401", [ 5 | { 6 | name: "valid: defines a 401 response with content", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/": { 12 | get: { 13 | responses: { 14 | "401": { 15 | description: "ok", 16 | content: { 17 | "application/problem+json": {}, 18 | }, 19 | }, 20 | }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | errors: [], 26 | }, 27 | 28 | { 29 | name: "invalid: 401 is not defined at all", 30 | document: { 31 | openapi: "3.1.0", 32 | info: { version: "1.0" }, 33 | paths: { 34 | "/": { 35 | get: { 36 | responses: { 37 | "200": { 38 | description: "ok", 39 | content: { 40 | "application/json": {}, 41 | }, 42 | }, 43 | }, 44 | }, 45 | }, 46 | }, 47 | }, 48 | errors: [ 49 | { 50 | message: "Operation is missing responses[401].", 51 | path: ["paths", "/", "get", "responses"], 52 | severity: DiagnosticSeverity.Warning, 53 | }, 54 | { 55 | message: "Operation is missing responses[401].content.", 56 | path: ["paths", "/", "get", "responses"], 57 | severity: DiagnosticSeverity.Warning, 58 | }, 59 | ], 60 | }, 61 | 62 | { 63 | name: "invalid: 401 exists but content is missing", 64 | document: { 65 | openapi: "3.1.0", 66 | info: { version: "1.0" }, 67 | paths: { 68 | "/": { 69 | get: { 70 | responses: { 71 | "401": {}, 72 | }, 73 | }, 74 | }, 75 | }, 76 | }, 77 | errors: [ 78 | { 79 | message: "Operation is missing [401].content.", 80 | path: ["paths", "/", "get", "responses", "401"], 81 | severity: DiagnosticSeverity.Warning, 82 | }, 83 | ], 84 | }, 85 | ]); 86 | -------------------------------------------------------------------------------- /__tests__/owasp-api8-2023-define-error-responses-500.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api8:2023-define-error-responses-500", [ 5 | { 6 | name: "valid: defines a 500 response with content", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/": { 12 | get: { 13 | responses: { 14 | "500": { 15 | description: "ok", 16 | content: { 17 | "application/problem+json": {}, 18 | }, 19 | }, 20 | }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | errors: [], 26 | }, 27 | 28 | { 29 | name: "invalid: 500 is not defined at all", 30 | document: { 31 | openapi: "3.1.0", 32 | info: { version: "1.0" }, 33 | paths: { 34 | "/": { 35 | get: { 36 | responses: { 37 | "200": { 38 | description: "ok", 39 | content: { 40 | "application/json": {}, 41 | }, 42 | }, 43 | }, 44 | }, 45 | }, 46 | }, 47 | }, 48 | errors: [ 49 | { 50 | message: "Operation is missing responses[500].", 51 | path: ["paths", "/", "get", "responses"], 52 | severity: DiagnosticSeverity.Warning, 53 | }, 54 | { 55 | message: "Operation is missing responses[500].content.", 56 | path: ["paths", "/", "get", "responses"], 57 | severity: DiagnosticSeverity.Warning, 58 | }, 59 | ], 60 | }, 61 | 62 | { 63 | name: "invalid: 500 exists but content is missing", 64 | document: { 65 | openapi: "3.1.0", 66 | info: { version: "1.0" }, 67 | paths: { 68 | "/": { 69 | get: { 70 | responses: { 71 | "500": {}, 72 | }, 73 | }, 74 | }, 75 | }, 76 | }, 77 | errors: [ 78 | { 79 | message: "Operation is missing [500].content.", 80 | path: ["paths", "/", "get", "responses", "500"], 81 | severity: DiagnosticSeverity.Warning, 82 | }, 83 | ], 84 | }, 85 | ]); 86 | -------------------------------------------------------------------------------- /__tests__/owasp-api8-2023-define-error-validation.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api8:2023-define-error-validation", [ 5 | { 6 | name: "valid case: 400", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/": { 12 | get: { 13 | responses: { 14 | "400": { 15 | description: "classic validation fail", 16 | }, 17 | }, 18 | }, 19 | }, 20 | }, 21 | }, 22 | errors: [], 23 | }, 24 | 25 | { 26 | name: "valid case: 422", 27 | document: { 28 | openapi: "3.1.0", 29 | info: { version: "1.0" }, 30 | paths: { 31 | "/": { 32 | get: { 33 | responses: { 34 | "422": { 35 | description: "classic validation fail", 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | errors: [], 43 | }, 44 | 45 | { 46 | name: "valid case:400 and 422", 47 | document: { 48 | openapi: "3.1.0", 49 | info: { version: "1.0" }, 50 | paths: { 51 | "/": { 52 | get: { 53 | responses: { 54 | "400": { 55 | description: "classic validation fail", 56 | }, 57 | "422": { 58 | description: "classic validation fail", 59 | }, 60 | }, 61 | }, 62 | }, 63 | }, 64 | }, 65 | errors: [], 66 | }, 67 | 68 | { 69 | name: "valid case:4XX", 70 | document: { 71 | openapi: "3.1.0", 72 | info: { version: "1.0" }, 73 | paths: { 74 | "/": { 75 | get: { 76 | responses: { 77 | "4XX": { 78 | description: "classic validation fail", 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | }, 85 | errors: [], 86 | }, 87 | 88 | { 89 | name: "invalid case", 90 | document: { 91 | openapi: "3.1.0", 92 | info: { version: "1.0" }, 93 | paths: { 94 | "/": { 95 | get: { 96 | responses: { 97 | "200": { 98 | description: "ok", 99 | }, 100 | }, 101 | }, 102 | }, 103 | }, 104 | }, 105 | errors: [ 106 | { 107 | message: "Missing error response of either 400, 422 or 4XX.", 108 | path: ["paths", "/", "get", "responses"], 109 | severity: DiagnosticSeverity.Warning, 110 | }, 111 | ], 112 | }, 113 | ]); 114 | -------------------------------------------------------------------------------- /__tests__/owasp-api8-2023-no-scheme-http.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api8:2023-no-scheme-http", [ 5 | { 6 | name: "valid case: https", 7 | document: { 8 | swagger: "2.0", 9 | info: { version: "1.0" }, 10 | paths: { "/": {} }, 11 | host: "example.com", 12 | schemes: ["https"], 13 | }, 14 | errors: [], 15 | }, 16 | 17 | { 18 | name: "valid case: wss", 19 | document: { 20 | swagger: "2.0", 21 | info: { version: "1.0" }, 22 | paths: { "/": {} }, 23 | host: "example.com", 24 | schemes: ["wss"], 25 | }, 26 | errors: [], 27 | }, 28 | 29 | { 30 | name: "an invalid server.url using http", 31 | document: { 32 | swagger: "2.0", 33 | info: { version: "1.0" }, 34 | paths: { "/": {} }, 35 | host: "example.com", 36 | schemes: ["http"], 37 | }, 38 | errors: [ 39 | { 40 | message: "Server schemes must not use http. Use https or wss instead.", 41 | path: ["schemes", "0"], 42 | severity: DiagnosticSeverity.Error, 43 | }, 44 | ], 45 | }, 46 | 47 | { 48 | name: "an invalid server.url using http and https", 49 | document: { 50 | swagger: "2.0", 51 | info: { version: "1.0" }, 52 | paths: { "/": {} }, 53 | host: "example.com", 54 | schemes: ["https", "http"], 55 | }, 56 | errors: [ 57 | { 58 | message: "Server schemes must not use http. Use https or wss instead.", 59 | path: ["schemes", "1"], 60 | severity: DiagnosticSeverity.Error, 61 | }, 62 | ], 63 | }, 64 | 65 | { 66 | name: "an invalid server using ftp", 67 | document: { 68 | swagger: "2.0", 69 | info: { version: "1.0" }, 70 | paths: { "/": {} }, 71 | host: "example.com", 72 | schemes: ["ftp"], 73 | }, 74 | errors: [ 75 | { 76 | message: "Server schemes must not use http. Use https or wss instead.", 77 | path: ["schemes", "0"], 78 | severity: DiagnosticSeverity.Error, 79 | }, 80 | ], 81 | }, 82 | ]); 83 | -------------------------------------------------------------------------------- /__tests__/owasp-api8-2023-no-server-http.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api8:2023-no-server-http", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { "/": {} }, 11 | servers: [{ url: "https://api.example.com/" }], 12 | }, 13 | errors: [], 14 | }, 15 | 16 | { 17 | name: "an invalid server.url using http", 18 | document: { 19 | openapi: "3.1.0", 20 | info: { version: "1.0" }, 21 | paths: { "/": {} }, 22 | servers: [{ url: "http://api.example.com/" }], 23 | }, 24 | errors: [ 25 | { 26 | message: 27 | "Server URLs must not use http://. Use https:// or wss:// instead.", 28 | path: ["servers", "0", "url"], 29 | severity: DiagnosticSeverity.Error, 30 | }, 31 | ], 32 | }, 33 | 34 | { 35 | name: "valid case: using a relative path is permitted, deal with the HTTPS yourself", 36 | document: { 37 | openapi: "3.1.0", 38 | info: { version: "1.0" }, 39 | paths: { "/": {} }, 40 | servers: [{ url: "/" }], 41 | }, 42 | errors: [], 43 | }, 44 | ]); 45 | -------------------------------------------------------------------------------- /__tests__/owasp-api9-2023-inventory-access.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api9:2023-inventory-access", [ 5 | { 6 | name: "valid case: declares x-internal as either true or false", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { "/": {} }, 11 | servers: [ 12 | { url: "https://api.example.com/", "x-internal": false }, 13 | { url: "https://api-private.example.com/", "x-internal": true }, 14 | ], 15 | }, 16 | errors: [], 17 | }, 18 | 19 | { 20 | name: "invalid case: no x-internal declared", 21 | document: { 22 | openapi: "3.1.0", 23 | info: { version: "1.0" }, 24 | paths: { "/": {} }, 25 | servers: [{ url: "https://api.example.com/" }], 26 | }, 27 | errors: [ 28 | { 29 | message: 30 | "Declare intended audience of every server by defining servers[0].x-internal as true/false.", 31 | path: ["servers", "0"], 32 | severity: DiagnosticSeverity.Error, 33 | }, 34 | ], 35 | }, 36 | ]); 37 | -------------------------------------------------------------------------------- /__tests__/owasp-api9-2023-inventory-environment.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("owasp:api9:2023-inventory-environment", [ 5 | { 6 | name: "valid case: mentions one keyword in each server", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { "/": {} }, 11 | servers: [ 12 | { url: "https://api.example.com/", description: "Production" }, 13 | { url: "https://preprod.example.com/", description: "Preproduction" }, 14 | { url: "https://api-stag.example.com/", description: "Staging" }, 15 | { url: "https://api-test.example.com/", description: "test" }, 16 | ], 17 | }, 18 | errors: [], 19 | }, 20 | 21 | { 22 | name: "invalid case: no description declared", 23 | document: { 24 | openapi: "3.1.0", 25 | info: { version: "1.0" }, 26 | paths: { "/": {} }, 27 | servers: [ 28 | { url: "https://api.example.com/", description: "API" }, 29 | { url: "https://preprod.example.com/", description: "Trial" }, 30 | { url: "https://api-stag.example.com/", description: "Trial" }, 31 | { url: "https://api-test.example.com/", description: "muck about" }, 32 | ], 33 | }, 34 | errors: [ 35 | { 36 | message: 37 | "Declare intended environment in server descriptions using terms like local, staging, production.", 38 | path: ["servers", "0", "description"], 39 | severity: DiagnosticSeverity.Error, 40 | }, 41 | { 42 | message: 43 | "Declare intended environment in server descriptions using terms like local, staging, production.", 44 | path: ["servers", "1", "description"], 45 | severity: DiagnosticSeverity.Error, 46 | }, 47 | { 48 | message: 49 | "Declare intended environment in server descriptions using terms like local, staging, production.", 50 | path: ["servers", "2", "description"], 51 | severity: DiagnosticSeverity.Error, 52 | }, 53 | { 54 | message: 55 | "Declare intended environment in server descriptions using terms like local, staging, production.", 56 | path: ["servers", "3", "description"], 57 | severity: DiagnosticSeverity.Error, 58 | }, 59 | ], 60 | }, 61 | ]); 62 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => { 2 | return { 3 | preset: "ts-jest", 4 | testPathIgnorePatterns: ["__helpers__"], 5 | testEnvironment: "node", 6 | globals: { 7 | "ts-jest": { 8 | useIsolatedModules: true, 9 | }, 10 | }, 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stoplight/spectral-owasp-ruleset", 3 | "version": "0.0.0", 4 | "description": "Probably don't want to beg hackers to come and take your stuff.", 5 | "main": "dist/ruleset.js", 6 | "module": "dist/ruleset.mjs", 7 | "type": "commonjs", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/ruleset.d.ts", 11 | "import": "./dist/ruleset.mjs", 12 | "require": "./dist/ruleset.js" 13 | } 14 | }, 15 | "directories": { 16 | "test": "test" 17 | }, 18 | "scripts": { 19 | "build": "tsup", 20 | "type-check": "tsc --noEmit --noErrorTruncation --pretty false --incremental false", 21 | "test": "jest" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/stoplightio/spectral-owasp-ruleset.git" 26 | }, 27 | "keywords": [ 28 | "openapi", 29 | "openapi3", 30 | "openapi31", 31 | "api-design" 32 | ], 33 | "author": "Phil Sturgeon ", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/stoplightio/spectral-owasp-ruleset/issues" 37 | }, 38 | "homepage": "https://github.com/stoplightio/spectral-owasp-ruleset#readme", 39 | "dependencies": { 40 | "@stoplight/spectral-formats": "^1.6.0", 41 | "@stoplight/spectral-functions": "^1.7.2" 42 | }, 43 | "devDependencies": { 44 | "@sindresorhus/tsconfig": "^3.0.1", 45 | "@stoplight/types": "^13.20.0", 46 | "@types/jest": "^28.1.8", 47 | "jest": "^28.1.3", 48 | "ts-jest": "^28.0.8", 49 | "tsup": "^6.7.0", 50 | "typescript": "^4.9.5" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/functions/checkSecurity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createRulesetFunction, 3 | IFunctionResult, 4 | } from "@stoplight/spectral-core"; 5 | 6 | /** 7 | * @author Roberto Polli 8 | */ 9 | const getAllOperations = function* (paths: any): any { 10 | if (typeof paths !== "object") { 11 | return; 12 | } 13 | const validMethods = [ 14 | "get", 15 | "head", 16 | "post", 17 | "put", 18 | "patch", 19 | "delete", 20 | "options", 21 | "trace", 22 | ]; 23 | const operation = { path: "", operation: "" }; 24 | for (const idx of Object.keys(paths)) { 25 | const path = paths[idx]; 26 | if (typeof path === "object") { 27 | operation.path = idx; 28 | for (const httpMethod of Object.keys(path)) { 29 | typeof path[httpMethod] === "object" && 30 | validMethods.includes(httpMethod) && 31 | ((operation.operation = httpMethod), yield operation); 32 | } 33 | } 34 | } 35 | }; 36 | 37 | export default createRulesetFunction( 38 | { 39 | input: null, 40 | options: { 41 | type: "object", 42 | additionalProperties: false, 43 | properties: { 44 | schemesPath: { 45 | type: "array", 46 | }, 47 | nullable: true, 48 | methods: { 49 | type: "array", 50 | }, 51 | }, 52 | required: [], 53 | }, 54 | }, 55 | function checkSecurity(input: any, options: any): IFunctionResult[] { 56 | const errorList = []; 57 | const { schemesPath: s, nullable, methods } = options; 58 | const { paths, security } = input; 59 | 60 | for (const { path, operation: httpMethod } of getAllOperations(paths)) { 61 | // Skip methods not configured in `methods`. 62 | if (methods && Array.isArray(methods) && !methods.includes(httpMethod)) { 63 | continue; 64 | } 65 | let { security: operationSecurity } = paths[path][httpMethod]; 66 | let securityRef = [path, httpMethod]; 67 | if (operationSecurity === undefined) { 68 | operationSecurity = security; 69 | securityRef = ["$.security"]; 70 | } 71 | if (!operationSecurity || operationSecurity.length === 0) { 72 | errorList.push({ 73 | message: `Operation has undefined security scheme in ${securityRef}.`, 74 | path: ["paths", path, httpMethod, "security", s], 75 | }); 76 | } 77 | if (Array.isArray(operationSecurity)) { 78 | for (const [idx, securityEntry] of operationSecurity.entries()) { 79 | if (typeof securityEntry !== "object") { 80 | continue; 81 | } 82 | const securitySchemeIds = Object.keys(securityEntry); 83 | securitySchemeIds.length === 0 && 84 | nullable === false && 85 | errorList.push({ 86 | message: `Operation referencing empty security scheme in ${securityRef}.`, 87 | path: ["paths", path, httpMethod, "security", idx], 88 | }); 89 | } 90 | } 91 | } 92 | return errorList; 93 | } 94 | ); 95 | -------------------------------------------------------------------------------- /src/functions/differentSecuritySchemes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createRulesetFunction, 3 | IFunctionResult, 4 | } from "@stoplight/spectral-core"; 5 | 6 | /** 7 | * @author Roberto Polli 8 | */ 9 | const getAllOperations = function* (paths: any): any { 10 | if (typeof paths !== "object") { 11 | return; 12 | } 13 | const validMethods = [ 14 | "get", 15 | "head", 16 | "post", 17 | "put", 18 | "patch", 19 | "delete", 20 | "options", 21 | "trace", 22 | ]; 23 | const operation = { path: "", operation: "" }; 24 | for (const idx of Object.keys(paths)) { 25 | const path = paths[idx]; 26 | if (typeof path === "object") { 27 | operation.path = idx; 28 | for (const httpMethod of Object.keys(path)) { 29 | typeof path[httpMethod] === "object" && 30 | validMethods.includes(httpMethod) && 31 | ((operation.operation = httpMethod), yield operation); 32 | } 33 | } 34 | } 35 | }; 36 | 37 | export default createRulesetFunction( 38 | { 39 | input: null, 40 | options: { 41 | type: "object", 42 | additionalProperties: false, 43 | properties: { 44 | adminUrl: { 45 | type: "string", 46 | }, 47 | }, 48 | required: [], 49 | }, 50 | }, 51 | function differentSecuritySchemes( 52 | input: any, 53 | options: any 54 | ): IFunctionResult[] { 55 | const errorList = []; 56 | const nonAdminSecurityHashes = []; 57 | const adminSecurityEntries = []; 58 | 59 | const { adminUrl = "/admin" } = options; 60 | const { paths } = input; 61 | 62 | for (const { path, operation: httpMethod } of getAllOperations(paths)) { 63 | let { security: operationSecurity } = paths[path][httpMethod]; 64 | 65 | // No security so skip this and leave it for other rules which check for security. 66 | if (operationSecurity === undefined) { 67 | continue; 68 | } 69 | if (Array.isArray(operationSecurity)) { 70 | for (const [idx, securityEntry] of operationSecurity.entries()) { 71 | if (typeof securityEntry !== "object") { 72 | continue; 73 | } 74 | 75 | // This creates a string for easier comparison to see if its used elsewhere, but 76 | // this might not be tough enough for some of the weirder edge cases. 77 | const securityHash = JSON.stringify(securityEntry); 78 | 79 | if (path.includes(adminUrl)) { 80 | adminSecurityEntries.push({ 81 | uri: path, 82 | hash: securityHash, 83 | path: ["paths", path, httpMethod, "security", idx], 84 | }); 85 | } else { 86 | nonAdminSecurityHashes.push(securityHash); 87 | } 88 | } 89 | } 90 | } 91 | 92 | // For every admin security entry, check if it is used in a non-admin path. 93 | for (const { uri, hash, path } of adminSecurityEntries) { 94 | if (nonAdminSecurityHashes.includes(hash)) { 95 | errorList.push({ 96 | message: `Admin endpoint ${uri} has the same security requirement as a non-admin endpoint.`, 97 | path, 98 | }); 99 | } 100 | } 101 | 102 | return errorList; 103 | } 104 | ); 105 | -------------------------------------------------------------------------------- /src/ruleset.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defined, 3 | truthy, 4 | pattern, 5 | schema, 6 | falsy, 7 | xor, 8 | } from "@stoplight/spectral-functions"; 9 | import { oas2, oas3, oas3_0, oas3_1 } from "@stoplight/spectral-formats"; 10 | import { DiagnosticSeverity } from "@stoplight/types"; 11 | import checkSecurity from "./functions/checkSecurity"; 12 | import differentSecuritySchemes from "./functions/differentSecuritySchemes"; 13 | 14 | export default { 15 | formats: [oas2, oas3], 16 | 17 | aliases: { 18 | ArrayProperties: { 19 | targets: [ 20 | { 21 | formats: [oas2, oas3_0], 22 | given: [ 23 | // Check for type: 'array' 24 | '$..[?(@ && @.type=="array")]', 25 | ], 26 | }, 27 | { 28 | formats: [oas3_1], 29 | given: [ 30 | // Still check for type: 'array' 31 | '$..[?(@ && @.type=="array")]', 32 | 33 | // also check for type: ['array', ...] 34 | '$..[?(@ && @.type && @.type.constructor.name === "Array" && @.type.includes("array"))]', 35 | ], 36 | }, 37 | ], 38 | }, 39 | IntegerProperties: { 40 | targets: [ 41 | { 42 | formats: [oas2, oas3_0], 43 | given: [ 44 | // Check for type: 'string' 45 | '$..[?(@ && @.type=="integer")]', 46 | ], 47 | }, 48 | { 49 | formats: [oas3_1], 50 | given: [ 51 | // Still check for type: 'integer' 52 | '$..[?(@ && @.type=="integer")]', 53 | 54 | // also check for type: ['integer', ...] 55 | '$..[?(@ && @.type && @.type.constructor.name === "Array" && @.type.includes("integer"))]', 56 | ], 57 | }, 58 | ], 59 | }, 60 | StringProperties: { 61 | targets: [ 62 | { 63 | formats: [oas2, oas3_0], 64 | given: [ 65 | // Check for type: 'string' 66 | '$..[?(@ && @.type=="string")]', 67 | ], 68 | }, 69 | { 70 | formats: [oas3_1], 71 | given: [ 72 | // Still check for type: 'string' 73 | '$..[?(@ && @.type=="string")]', 74 | 75 | // also check for type: ['string', ...] 76 | '$..[?(@ && @.type && @.type.constructor.name === "Array" && @.type.includes("string"))]', 77 | ], 78 | }, 79 | ], 80 | }, 81 | }, 82 | 83 | rules: { 84 | /** 85 | * API1:2023 - Broken Object Level Authorization 86 | * https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/ 87 | * 88 | * Use case 89 | * - ❌ API call parameters use the ID of the resource accessed through the API /api/shop1/financial_info. 90 | * - ❌ Attackers replace the IDs of their resources with a different one which they guessed through /api/shop2/financial_info. 91 | * - ❌ The API does not check permissions and lets the call through. 92 | * - ✅ Problem is aggravated if IDs can be enumerated /api/123/financial_info. 93 | * 94 | * How to prevent 95 | * - ❌ Implement authorization checks with user policies and hierarchy. 96 | * - ❌ Do not rely on IDs that the client sends. Use IDs stored in the session object instead. 97 | * - ❌ Check authorization for each client request to access database. 98 | * - ✅ Use random IDs that cannot be guessed (UUIDs). 99 | */ 100 | 101 | /** 102 | * @author: Phil Sturgeon 103 | */ 104 | "owasp:api1:2023-no-numeric-ids": { 105 | description: 106 | "Use random IDs that cannot be guessed. UUIDs are preferred but any other random string will do.", 107 | severity: DiagnosticSeverity.Error, 108 | given: 109 | '$.paths..parameters[*][?(@property === "name" && (@ === "id" || @.match(/(_id|Id|-id)$/)))]^.schema', 110 | then: { 111 | function: schema, 112 | functionOptions: { 113 | schema: { 114 | type: "object", 115 | not: { 116 | properties: { 117 | type: { 118 | const: "integer", 119 | }, 120 | }, 121 | }, 122 | }, 123 | }, 124 | }, 125 | }, 126 | 127 | /** 128 | * API2:2023 — Broken Authentication 129 | * https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/ 130 | * 131 | * Use case 132 | * - ✅ Unprotected APIs that are considered “internal” 133 | * - ✅ Weak authentication that does not follow industry best practices 134 | * - ✅ Weak API keys that are not rotated 135 | * - ❌ Passwords that are weak, plain text, encrypted, poorly hashed, shared, or default passwords 136 | * - 🤷 Authentication susceptible to brute force attacks and credential stuffing 137 | * - ✅ Credentials and keys included in URLs 138 | * - ✅ Lack of access token validation (including JWT validation) 139 | * - ✅ Unsigned or weakly signed non-expiring JWTs 140 | * 141 | * How to prevent 142 | * - ❌ APIs for password reset and one-time links also allow users to authenticate, and should be protected just as rigorously. 143 | * - ✅ Use standard authentication, token generation, password storage, and multi-factor authentication (MFA). 144 | * - ✅ Use short-lived access tokens. 145 | * - ✅ Authenticate your apps (so you know who is talking to you). 146 | * - ❌ Use stricter rate-limiting for authentication, and implement lockout policies and weak password checks. 147 | */ 148 | 149 | /** 150 | * @author: Phil Sturgeon 151 | */ 152 | "owasp:api2:2023-no-http-basic": { 153 | message: 154 | "Security scheme uses HTTP Basic. Use a more secure authentication method, like OAuth 2, or OpenID.", 155 | description: 156 | "Basic authentication credentials transported over network are more susceptible to interception than other forms of authentication, and as they are not encrypted it means passwords and tokens are more easily leaked.", 157 | severity: DiagnosticSeverity.Error, 158 | given: "$.components.securitySchemes[*]", 159 | then: { 160 | field: "scheme", 161 | function: pattern, 162 | functionOptions: { 163 | notMatch: "basic", 164 | }, 165 | }, 166 | }, 167 | 168 | /** 169 | * @author: Roberto Polli 170 | * @see: https://github.com/italia/api-oas-checker/blob/master/rules/secrets-parameters.yml 171 | */ 172 | "owasp:api2:2023-no-api-keys-in-url": { 173 | message: "API Key passed in URL: {{error}}.", 174 | description: 175 | "API Keys are are passed in headers, cookies or query parameters to access APIs Those keys can be eavesdropped, especially when they are passed in the URL as logging or history tools will keep track of them and potentially expose them.", 176 | severity: DiagnosticSeverity.Error, 177 | formats: [oas3], 178 | given: ['$..[securitySchemes][?(@ && @.type=="apiKey")].in'], 179 | then: [ 180 | { 181 | function: pattern, 182 | functionOptions: { 183 | notMatch: "^(path|query)$", 184 | }, 185 | }, 186 | ], 187 | }, 188 | 189 | /** 190 | * @author: Roberto Polli 191 | * @see: https://github.com/italia/api-oas-checker/blob/master/rules/secrets-parameters.yml 192 | */ 193 | "owasp:api2:2023-no-credentials-in-url": { 194 | message: "Security credentials detected in path parameter: {{value}}.", 195 | description: 196 | "URL parameters MUST NOT contain credentials such as API key, password, or secret. See [RAC_GEN_004](https://docs.italia.it/italia/piano-triennale-ict/lg-modellointeroperabilita-docs/it/bozza/doc/04_Raccomandazioni%20di%20implementazione/04_raccomandazioni-tecniche-generali/01_globali.html?highlight=credenziali#rac-gen-004-non-passare-credenziali-o-dati-riservati-nellurl)", 197 | severity: DiagnosticSeverity.Error, 198 | formats: [oas3], 199 | given: ["$..parameters[?(@ && @.in && @.in.match(/query|path/))].name"], 200 | then: [ 201 | { 202 | field: "name", 203 | function: pattern, 204 | functionOptions: { 205 | notMatch: 206 | "/^.*(client_?secret|token|access_?token|refresh_?token|id_?token|password|secret|api-?key).*$/i", 207 | }, 208 | }, 209 | ], 210 | }, 211 | 212 | /** 213 | * @author: Roberto Polli 214 | * @see: https://github.com/italia/api-oas-checker/blob/master/security/securitySchemes_insecure.yml#L38 215 | */ 216 | "owasp:api2:2023-auth-insecure-schemes": { 217 | message: 218 | "Authentication scheme is considered outdated or insecure: {{value}}.", 219 | description: 220 | "There are many [HTTP authorization schemes](https://www.iana.org/assignments/http-authschemes/) but some of them are now considered insecure, such as negotiating authentication using specifications like NTLM or OAuth v1.", 221 | severity: DiagnosticSeverity.Error, 222 | formats: [oas3], 223 | given: ['$..[securitySchemes][?(@.type=="http")].scheme'], 224 | then: [ 225 | { 226 | function: pattern, 227 | functionOptions: { 228 | notMatch: "^(negotiate|oauth)$", 229 | }, 230 | }, 231 | ], 232 | }, 233 | 234 | /** 235 | * @author: Roberto Polli 236 | * @see: https://github.com/italia/api-oas-checker/blob/master/security/securitySchemes.yml 237 | */ 238 | "owasp:api2:2023-jwt-best-practices": { 239 | message: 240 | "Security schemes using JWTs must explicitly declare support for RFC8725 in the description.", 241 | description: 242 | 'JSON Web Tokens RFC7519 is a compact, URL-safe, means of representing claims to be transferred between two parties. JWT can be enclosed in encrypted or signed tokens like JWS and JWE.\n\nThe [JOSE IANA registry](https://www.iana.org/assignments/jose/jose.xhtml) provides algorithms information.\n\nRFC8725 describes common pitfalls in the JWx specifications and in\ntheir implementations, such as:\n- the ability to ignore algorithms, eg. `{"alg": "none"}`;\n- using insecure algorithms like `RSASSA-PKCS1-v1_5` eg. `{"alg": "RS256"}`.\nAn API using JWT should explicit in the `description`\nthat the implementation conforms to RFC8725.\n```\ncomponents:\n securitySchemes:\n JWTBearer:\n type: http\n scheme: bearer\n bearerFormat: JWT\n description: |-\n A bearer token in the format of a JWS and conformato\n to the specifications included in RFC8725.\n```', 243 | severity: DiagnosticSeverity.Error, 244 | given: [ 245 | '$.components.securitySchemes[?(@ && @.type=="oauth2")]', 246 | '$.components.securitySchemes[?(@ && (@.bearerFormat=="jwt" || @.bearerFormat=="JWT"))]', 247 | ], 248 | then: [ 249 | { 250 | field: "description", 251 | function: truthy, 252 | }, 253 | { 254 | field: "description", 255 | function: pattern, 256 | functionOptions: { 257 | match: ".*RFC8725.*", 258 | }, 259 | }, 260 | ], 261 | }, 262 | 263 | /** 264 | * @author: Phil Sturgeon 265 | */ 266 | "owasp:api2:2023-short-lived-access-tokens": { 267 | message: 268 | "Authentication scheme does not appear to support refresh tokens, meaning access tokens likely do not expire.", 269 | description: 270 | "Using short-lived access tokens is a good practice, and when using OAuth 2 this is done by using refresh tokens. If a malicious actor is able to get hold of an access token then rotation means that token might not work by the time they try to use it, or it could at least reduce how long they are able to perform malicious requests.", 271 | severity: DiagnosticSeverity.Error, 272 | given: '$.components.securitySchemes[?(@ && @.type=="oauth2")].flows[?(@property != "clientCredentials")]', 273 | then: [ 274 | { 275 | field: "refreshUrl", 276 | function: truthy, 277 | }, 278 | ], 279 | }, 280 | 281 | /** 282 | * @author: Roberto Polli 283 | * @see: https://github.com/italia/api-oas-checker/blob/master/security/security.yml 284 | */ 285 | "owasp:api2:2023-write-restricted": { 286 | message: "This write operation is not protected by any security scheme.", 287 | description: 288 | "All write operations (POST, PUT, PATCH, DELETE) must be secured by at least one security scheme. Security schemes are defined in the `securityScheme` section then referenced in the `security` key at the global or operation levels.", 289 | severity: DiagnosticSeverity.Error, 290 | given: "$", 291 | then: [ 292 | { 293 | function: checkSecurity, 294 | functionOptions: { 295 | schemesPath: ["securitySchemes"], 296 | methods: ["post", "put", "patch", "delete"], 297 | }, 298 | }, 299 | ], 300 | }, 301 | 302 | "owasp:api2:2023-read-restricted": { 303 | message: "This read operation is not protected by any security scheme.", 304 | description: 305 | "Read operations (GET, HEAD) should be secured by at least one security scheme. Security schemes are defined in the `securityScheme` section then referenced in the `security` key at the global or operation levels.", 306 | severity: DiagnosticSeverity.Warning, 307 | given: "$", 308 | then: [ 309 | { 310 | function: checkSecurity, 311 | functionOptions: { 312 | schemesPath: ["securitySchemes"], 313 | nullable: true, 314 | methods: ["get", "head"], 315 | }, 316 | }, 317 | ], 318 | }, 319 | 320 | /** 321 | * API3:2023 Broken Object Property Level Authorization 322 | * https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/ 323 | * 324 | * Use case 325 | * - ❌ APIs expose endpoints that return all object’s properties. 326 | * - ❌ Unauthorized access to private/sensitive object properties may result in data disclosure, data loss, or data corruption. Under certain circumstances, unauthorized access to object properties can lead to privilege escalation or partial/full account takeover. 327 | * - 🟠 The API endpoint exposes properties of an object that are considered sensitive and should not be read by the user. 328 | * - ✅ The API endpoint allows a user to change, add/or delete the value of a sensitive object's property which the user should not be able to access 329 | * 330 | * How to prevent 331 | * - ✅ Carefully define schemas for all the API responses (restricting unknown properties) 332 | * - 🟠 Identify all the sensitive data or Personally Identifiable Information (PII), and justify its use. 333 | * https://github.com/stoplightio/spectral-owasp-ruleset/issues/11 334 | * - ❌ Enforce response checks to prevent accidental leaks of data or exceptions. 335 | */ 336 | 337 | /** 338 | * @author: Roberto Polli 339 | * @see: https://github.com/italia/api-oas-checker/blob/master/security/objects.yml 340 | */ 341 | "owasp:api3:2023-no-additionalProperties": { 342 | message: 343 | "If the additionalProperties keyword is used it must be set to false.", 344 | description: 345 | "By default JSON Schema allows additional properties, which can potentially lead to mass assignment issues, where unspecified fields are passed to the API without validation. Disable them with `additionalProperties: false` or add `maxProperties`.", 346 | severity: DiagnosticSeverity.Warning, 347 | formats: [oas3_0], 348 | given: '$..[?(@ && @.type=="object" && @.additionalProperties)]', 349 | then: [ 350 | { 351 | field: "additionalProperties", 352 | function: falsy, 353 | }, 354 | ], 355 | }, 356 | 357 | /** 358 | * @author: Roberto Polli 359 | * @see: https://github.com/italia/api-oas-checker/blob/master/security/objects.yml 360 | */ 361 | "owasp:api3:2023-constrained-additionalProperties": { 362 | message: "Objects should not allow unconstrained additionalProperties.", 363 | description: 364 | "By default JSON Schema allows additional properties, which can potentially lead to mass assignment issues, where unspecified fields are passed to the API without validation. Disable them with `additionalProperties: false` or add `maxProperties`", 365 | severity: DiagnosticSeverity.Warning, 366 | formats: [oas3_0], 367 | given: 368 | '$..[?(@ && @.type=="object" && @.additionalProperties && @.additionalProperties!=true && @.additionalProperties!=false )]', 369 | then: [ 370 | { 371 | field: "maxProperties", 372 | function: defined, 373 | }, 374 | ], 375 | }, 376 | 377 | /** 378 | * @author: Roberto Polli 379 | * @see: https://github.com/italia/api-oas-checker/blob/master/security/objects.yml 380 | */ 381 | "owasp:api3:2023-no-unevaluatedProperties": { 382 | message: 383 | "If the unevaluatedProperties keyword is used it must be set to false.", 384 | description: 385 | "By default JSON Schema allows unevaluated properties, which can potentially lead to mass assignment issues, where unspecified fields are passed to the API without validation. Disable them with `unevaluatedProperties: false` or add `maxProperties`.", 386 | severity: DiagnosticSeverity.Warning, 387 | formats: [oas3_1], 388 | given: '$..[?(@ && @.type=="object" && @.unevaluatedProperties)]', 389 | then: [ 390 | { 391 | field: "unevaluatedProperties", 392 | function: falsy, 393 | }, 394 | ], 395 | }, 396 | 397 | /** 398 | * @author: Roberto Polli 399 | * @see: https://github.com/italia/api-oas-checker/blob/master/security/objects.yml 400 | */ 401 | "owasp:api3:2023-constrained-unevaluatedProperties": { 402 | message: "Objects should not allow unconstrained unevaluatedProperties.", 403 | description: 404 | "By default JSON Schema allows unevaluated properties, which can potentially lead to mass assignment issues, where unspecified fields are passed to the API without validation. Disable them with `unevaluatedProperties: false` or add `maxProperties`", 405 | severity: DiagnosticSeverity.Warning, 406 | formats: [oas3_1], 407 | given: 408 | '$..[?(@ && @.type=="object" && @.unevaluatedProperties && @.unevaluatedProperties!=true && @.unevaluatedProperties!=false )]', 409 | then: [ 410 | { 411 | field: "maxProperties", 412 | function: defined, 413 | }, 414 | ], 415 | }, 416 | 417 | /** 418 | * API4:2023 - Unrestricted Resource Consumption 419 | * https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/ 420 | * 421 | * Use case 422 | * - ✅ Attackers overload the API by sending more requests than it can handle. 423 | * - ✅ Attackers send requests at a rate exceeding the API's processing speed, clogging it up. 424 | * - ✅ The size of the requests or some fields in them exceed what the API can process. 425 | * - 🟠 “Zip bombs”, archive files that have been designed so that unpacking them takes excessive amount of resources and overloads the API. 426 | * 427 | * How to prevent 428 | * - ✅ Define proper rate limiting. 429 | * - ✅ Limit maximums on request parameter sizes 430 | * - ❌ Tailor the rate limiting to be match what API methods, clients, or addresses need or should be allowed to get. 431 | * - ❌ Add checks on compression ratios. 432 | * - ❌ Define limits for container resources. 433 | * - 🟠 Look for Zip uploads and warn about setting max file size? how do we know if they did? Demand something in the description? 434 | */ 435 | 436 | /** 437 | * @author: Phil Sturgeon 438 | */ 439 | "owasp:api4:2023-rate-limit": { 440 | message: "All 2XX and 4XX responses should define rate limiting headers.", 441 | description: 442 | "Define proper rate limiting to avoid attackers overloading the API. There are many ways to implement rate-limiting, but most of them involve using HTTP headers, and there are two popular ways to do that:\n\nIETF Draft HTTP RateLimit Headers:. https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n\nCustomer headers like X-Rate-Limit-Limit (Twitter: https://developer.twitter.com/en/docs/twitter-api/rate-limits) or X-RateLimit-Limit (GitHub: https://docs.github.com/en/rest/overview/resources-in-the-rest-api)", 443 | severity: DiagnosticSeverity.Error, 444 | formats: [oas3], 445 | given: "$.paths[*]..responses[?(@property.match(/^(2|4)/))]", 446 | then: { 447 | field: "headers", 448 | function: schema, 449 | functionOptions: { 450 | schema: { 451 | type: "object", 452 | oneOf: [ 453 | { 454 | required: ["RateLimit"], 455 | }, 456 | { 457 | required: ["RateLimit-Limit", "RateLimit-Reset"], 458 | }, 459 | { 460 | required: ["X-RateLimit-Limit"], 461 | }, 462 | { 463 | required: ["X-Rate-Limit-Limit"], 464 | }, 465 | ], 466 | }, 467 | }, 468 | }, 469 | }, 470 | 471 | /** 472 | * @author: Phil Sturgeon 473 | */ 474 | "owasp:api4:2023-rate-limit-retry-after": { 475 | message: "A 429 response should define a Retry-After header.", 476 | description: 477 | "Define proper rate limiting to avoid attackers overloading the API. Part of that involves setting a Retry-After header so well meaning consumers are not polling and potentially exacerbating problems.", 478 | severity: DiagnosticSeverity.Error, 479 | formats: [oas3], 480 | given: "$..responses[429].headers", 481 | then: { 482 | field: "Retry-After", 483 | function: defined, 484 | }, 485 | }, 486 | 487 | /** 488 | * @author: Jason Harmon 489 | */ 490 | "owasp:api4:2023-rate-limit-responses-429": { 491 | message: "Operation is missing rate limiting response in {{property}}.", 492 | description: 493 | "OWASP API Security recommends defining schemas for all responses, even errors. A HTTP 429 response signals the API client is making too many requests, and will supply information about when to retry so that the client can back off calmly without everything breaking. Defining this response is important not just for documentation, but to empower contract testing to make sure the proper JSON structure is being returned instead of leaking implementation details in backtraces. It also ensures your API/framework/gateway actually has rate limiting set up.", 494 | severity: DiagnosticSeverity.Warning, 495 | given: "$.paths..responses", 496 | then: [ 497 | { 498 | field: "429", 499 | function: truthy, 500 | }, 501 | { 502 | field: "429.content", 503 | function: truthy, 504 | }, 505 | ], 506 | }, 507 | 508 | /** 509 | * @author: Roberto Polli 510 | * @see: https://github.com/italia/api-oas-checker/blob/master/security/array.yml 511 | */ 512 | "owasp:api4:2023-array-limit": { 513 | message: "Schema of type array must specify maxItems.", 514 | description: 515 | "Array size should be limited to mitigate resource exhaustion attacks. This can be done using `maxItems`. You should ensure that the subschema in `items` is constrained too.", 516 | severity: DiagnosticSeverity.Error, 517 | given: "#ArrayProperties", 518 | then: { 519 | field: "maxItems", 520 | function: defined, 521 | }, 522 | }, 523 | 524 | /** 525 | * @author: Phil Sturgeon 526 | */ 527 | "owasp:api4:2023-string-limit": { 528 | message: "Schema of type string must specify maxLength, enum, or const.", 529 | description: 530 | "String size should be limited to mitigate resource exhaustion attacks. This can be done using `maxLength`, `enum` or `const`.", 531 | severity: DiagnosticSeverity.Error, 532 | given: "#StringProperties", 533 | then: { 534 | function: schema, 535 | functionOptions: { 536 | schema: { 537 | type: "object", 538 | anyOf: [ 539 | { 540 | required: ["maxLength"], 541 | }, 542 | { 543 | required: ["enum"], 544 | }, 545 | { 546 | required: ["const"], 547 | }, 548 | ], 549 | }, 550 | }, 551 | }, 552 | }, 553 | 554 | /** 555 | * @author: Phil Sturgeon 556 | */ 557 | "owasp:api4:2023-string-restricted": { 558 | message: 559 | "Schema of type string should specify a format, pattern, enum, or const.", 560 | description: 561 | "To avoid unexpected values being sent or leaked, strings should have a `format`, RegEx `pattern`, `enum`, or `const`.", 562 | severity: DiagnosticSeverity.Warning, 563 | given: "#StringProperties", 564 | then: { 565 | function: schema, 566 | functionOptions: { 567 | schema: { 568 | type: "object", 569 | anyOf: [ 570 | { 571 | required: ["format"], 572 | }, 573 | { 574 | required: ["pattern"], 575 | }, 576 | { 577 | required: ["enum"], 578 | }, 579 | { 580 | required: ["const"], 581 | }, 582 | ], 583 | }, 584 | }, 585 | }, 586 | }, 587 | 588 | /** 589 | * @author: Phil Sturgeon 590 | */ 591 | "owasp:api4:2023-integer-limit": { 592 | message: "Schema of type integer must specify minimum and maximum.", 593 | description: 594 | "Integers should be limited to mitigate resource exhaustion attacks. This can be done using `minimum` and `maximum`, which can with e.g.: avoiding negative numbers when positive are expected, or reducing unreasonable iterations like doing something 1000 times when 10 is expected.", 595 | severity: DiagnosticSeverity.Error, 596 | formats: [oas3_1], 597 | given: "#IntegerProperties", 598 | then: [ 599 | { 600 | function: xor, 601 | functionOptions: { 602 | properties: ["minimum", "exclusiveMinimum"], 603 | }, 604 | }, 605 | { 606 | function: xor, 607 | functionOptions: { 608 | properties: ["maximum", "exclusiveMaximum"], 609 | }, 610 | }, 611 | ], 612 | }, 613 | 614 | /** 615 | * @author: Phil Sturgeon 616 | */ 617 | "owasp:api4:2023-integer-limit-legacy": { 618 | message: "Schema of type integer must specify minimum and maximum.", 619 | description: 620 | "Integers should be limited to mitigate resource exhaustion attacks. This can be done using `minimum` and `maximum`, which can with e.g.: avoiding negative numbers when positive are expected, or reducing unreasonable iterations like doing something 1000 times when 10 is expected.", 621 | severity: DiagnosticSeverity.Error, 622 | formats: [oas2, oas3_0], 623 | given: "#IntegerProperties", 624 | then: [ 625 | { 626 | field: "minimum", 627 | function: defined, 628 | }, 629 | { 630 | field: "maximum", 631 | function: defined, 632 | }, 633 | ], 634 | }, 635 | 636 | /** 637 | * @author: Phil Sturgeon 638 | */ 639 | "owasp:api4:2023-integer-format": { 640 | message: "Schema of type integer must specify format (int32 or int64).", 641 | description: 642 | "Integers should be limited to mitigate resource exhaustion attacks. Specifying whether int32 or int64 is expected via `format`.", 643 | severity: DiagnosticSeverity.Error, 644 | given: "#IntegerProperties", 645 | then: [ 646 | { 647 | field: "format", 648 | function: defined, 649 | }, 650 | ], 651 | }, 652 | 653 | /** 654 | * API5:2023 — Broken function level authorization 655 | * https://owasp.org/API-Security/editions/2023/en/0xa5-broken-function-level-authorization/ 656 | * 657 | * - ✅ Don’t assume that an API endpoint is regular or administrative only based on the URL path. 658 | * - ❌ Do not rely on the client to enforce admin access. 659 | * - ✅ Deny all access by default api2:2023-protection- 660 | */ 661 | 662 | "owasp:api5:2023-admin-security-unique": { 663 | message: "{{error}}", 664 | description: "", 665 | severity: DiagnosticSeverity.Error, 666 | given: "$", 667 | then: [ 668 | { 669 | function: differentSecuritySchemes, 670 | functionOptions: { 671 | adminUrl: "/admin", 672 | }, 673 | }, 674 | ], 675 | }, 676 | 677 | /** 678 | * API6:2023 - Unrestricted Access to Sensitive Business Flows 679 | * https://owasp.org/API-Security/editions/2023/en/0xa6-unrestricted-access-to-sensitive-business-flows/ 680 | * 681 | * Use case 682 | * 683 | * - ❌ Purchasing a product flow - an attacker can buy all the stock of a 684 | * high-demand item at once and resell for a higher price (scalping) 685 | * - ❌ Creating a comment/post flow - an attacker can spam the system 686 | * - ❌ Making a reservation - an attacker can reserve all the available time 687 | * slots and prevent other users from using the system 688 | * 689 | * How to prevent 690 | * 691 | * - Device fingerprinting: denying service to unexpected client devices 692 | * (e.g headless browsers) tends to make threat actors use more 693 | * sophisticated solutions, thus more costly for them 694 | * - Human detection: using either captcha or more advanced biometric 695 | * solutions (e.g. typing patterns) 696 | * - Non-human patterns: analyze the user flow to detect non-human patterns 697 | * (e.g. the user accessed the "add to cart" and "complete purchase" 698 | * functions in less than one second) 699 | * - Consider blocking IP addresses of Tor exit nodes and well-known proxies 700 | */ 701 | 702 | /** 703 | * API7:2023 — Server Side Request Forgery 704 | * https://owasp.org/API-Security/editions/2023/en/0xa7-server-side-request-forgery/ 705 | * 706 | * Modern concepts encourage developers to access an external resource based 707 | * on user input: Webhooks, file fetching from URLs, custom SSO, and URL 708 | * previews. 709 | * 710 | */ 711 | 712 | "owasp:api7:2023-concerning-url-parameter": { 713 | message: 714 | "Make sure to review the way this URL is handled to protect against Server Side Request Forgery.", 715 | description: 716 | "Using external resource based on user input for webhooks, file fetching from URLs, custom SSO, URL previews, or redirects, can lead to a wide variety of security issues.\n\nLearn more about Server Side Request Forgery here: https://owasp.org/API-Security/editions/2023/en/0xa7-server-side-request-forgery/", 717 | severity: DiagnosticSeverity.Information, 718 | given: [ 719 | '$.paths[*].parameters[*].name', 720 | '$.paths[*][get,put,post,delete,options,head,patch,trace].parameters[*].name', 721 | ], 722 | then: { 723 | function: pattern, 724 | functionOptions: { 725 | notMatch: /(^(callback|redirect)|(_url|Url|-url))$/, 726 | } 727 | }, 728 | }, 729 | 730 | /** 731 | * API8:2023 — Security Misconfiguration 732 | * https://owasp.org/API-Security/editions/2023/en/0xa8-security-misconfiguration/ 733 | * 734 | * Poor configuration of the API servers allows attackers to exploit them. 735 | * 736 | * Use case 737 | * - ❌ Unpatched systems 738 | * - ❌ Unprotected files and directories 739 | * - ❌ Unhardened images 740 | * - ✅ Missing, outdated, or misconfigured TLS 741 | * - ❌ Exposed storage or server management panels 742 | * - ✅ Missing CORS policy or security headers 743 | * - 🟠 Error messages with stack traces 744 | * https://github.com/stoplightio/spectral-owasp-ruleset/issues/12 745 | * - ❌ Unnecessary features enabled 746 | * 747 | */ 748 | 749 | /** 750 | * @author: Phil Sturgeon (https://github.com/philsturgeon) 751 | */ 752 | "owasp:api8:2023-define-cors-origin": { 753 | message: "Header `{{property}}` should be defined on all responses.", 754 | description: 755 | 'Setting up CORS headers will control which websites can make browser-based HTTP requests to your API, using either the wildcard "*" to allow any origin, or "null" to disable any origin. Alternatively you can use "Access-Control-Allow-Origin: https://example.com" to indicate that only requests originating from the specified domain (https://example.com) are allowed to access its resources.\n\nMore about CORS here: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS.', 756 | given: "$..[responses][*].headers", 757 | then: { 758 | field: "Access-Control-Allow-Origin", 759 | function: truthy, 760 | }, 761 | severity: DiagnosticSeverity.Error, 762 | }, 763 | 764 | /** 765 | * @author: Andrzej 766 | */ 767 | "owasp:api8:2023-no-scheme-http": { 768 | message: "Server schemes must not use http. Use https or wss instead.", 769 | description: 770 | "Server interactions must use the http protocol as it's inherently insecure and can lead to PII and other sensitive information being leaked through traffic sniffing or man-in-the-middle attacks. Use the https or wss schemes instead.\n\nLearn more about the importance of TLS (over SSL) here: https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Protection_Cheat_Sheet.html", 771 | severity: DiagnosticSeverity.Error, 772 | formats: [oas2], 773 | given: "$.schemes.*", 774 | then: { 775 | function: schema, 776 | functionOptions: { 777 | schema: { 778 | type: "string", 779 | enum: ["https", "wss"], 780 | }, 781 | }, 782 | }, 783 | }, 784 | 785 | /** 786 | * @author: Andrzej 787 | */ 788 | "owasp:api8:2023-no-server-http": { 789 | message: 790 | "Server URLs must not use http://. Use https:// or wss:// instead.", 791 | description: 792 | "Server interactions must not use the http:// as it's inherently insecure and can lead to PII and other sensitive information being leaked through traffic sniffing or man-in-the-middle attacks. Use https:// or wss:// protocols instead.\n\nLearn more about the importance of TLS (over SSL) here: https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Protection_Cheat_Sheet.html", 793 | severity: DiagnosticSeverity.Error, 794 | formats: [oas3], 795 | given: "$.servers..url", 796 | then: { 797 | function: pattern, 798 | functionOptions: { 799 | notMatch: "/^http:/", 800 | }, 801 | }, 802 | }, 803 | 804 | /** 805 | * @author: Jason Harmon 806 | */ 807 | "owasp:api8:2023-define-error-validation": { 808 | message: "Missing error response of either 400, 422 or 4XX.", 809 | description: 810 | "Carefully define schemas for all the API responses, including either 400, 422 or 4XX responses which describe errors caused by invalid requests.", 811 | severity: DiagnosticSeverity.Warning, 812 | given: "$.paths..responses", 813 | then: [ 814 | { 815 | function: schema, 816 | functionOptions: { 817 | schema: { 818 | type: "object", 819 | anyOf: [ 820 | { 821 | required: ["400"], 822 | }, 823 | { 824 | required: ["422"], 825 | }, 826 | { 827 | required: ["4XX"], 828 | }, 829 | ], 830 | }, 831 | }, 832 | }, 833 | ], 834 | }, 835 | 836 | /** 837 | * @author: Jason Harmon 838 | */ 839 | "owasp:api8:2023-define-error-responses-401": { 840 | message: "Operation is missing {{property}}.", 841 | description: 842 | "OWASP API Security recommends defining schemas for all responses, even errors. The 401 describes what happens when a request is unauthorized, so its important to define this not just for documentation, but to empower contract testing to make sure the proper JSON structure is being returned instead of leaking implementation details in backtraces.", 843 | severity: DiagnosticSeverity.Warning, 844 | given: "$.paths..responses", 845 | then: [ 846 | { 847 | field: "401", 848 | function: truthy, 849 | }, 850 | { 851 | field: "401.content", 852 | function: truthy, 853 | }, 854 | ], 855 | }, 856 | 857 | /** 858 | * @author: Jason Harmon 859 | */ 860 | "owasp:api8:2023-define-error-responses-500": { 861 | message: "Operation is missing {{property}}.", 862 | description: 863 | "OWASP API Security recommends defining schemas for all responses, even errors. The 500 describes what happens when a request fails with an internal server error, so its important to define this not just for documentation, but to empower contract testing to make sure the proper JSON structure is being returned instead of leaking implementation details in backtraces.", 864 | severity: DiagnosticSeverity.Warning, 865 | given: "$.paths..responses", 866 | then: [ 867 | { 868 | field: "500", 869 | function: truthy, 870 | }, 871 | { 872 | field: "500.content", 873 | function: truthy, 874 | }, 875 | ], 876 | }, 877 | 878 | /** 879 | * API9:2023 Improper Inventory Management 880 | * https://owasp.org/API-Security/editions/2023/en/0xa9-improper-inventory-management/ 881 | * 882 | * How to prevent 883 | * - 🟠 Servers, define which environment is the API running in (e.g. production, staging, test, development) 884 | * - ✅ Require servers use x-internal true/false to explicitly explain what is public or internal for documentation tools 885 | * - 🤷‍♂️ There is no retirement plan for each API version. 886 | */ 887 | 888 | /** 889 | * @author: Phil Sturgeon 890 | */ 891 | "owasp:api9:2023-inventory-access": { 892 | message: 893 | "Declare intended audience of every server by defining servers{{property}} as true/false.", 894 | description: 895 | "Servers are required to use vendor extension x-internal set to true or false to explicitly explain the audience for the API, which will be picked up by most documentation tools.", 896 | severity: DiagnosticSeverity.Error, 897 | formats: [oas3], 898 | given: "$.servers.*", 899 | then: { 900 | field: "x-internal", 901 | function: defined, 902 | }, 903 | }, 904 | 905 | /** 906 | * @author: Phil Sturgeon 907 | */ 908 | "owasp:api9:2023-inventory-environment": { 909 | message: 910 | "Declare intended environment in server descriptions using terms like local, staging, production.", 911 | description: 912 | "Make it clear which servers are expected to run as which environment to avoid unexpected problems, exposing test data to the public, or letting bad actors bypass security measures to get to production-like environments.", 913 | severity: DiagnosticSeverity.Error, 914 | formats: [oas3], 915 | given: "$.servers.*", 916 | then: { 917 | field: "description", 918 | function: pattern, 919 | functionOptions: { 920 | match: 921 | "/(local|sandbox|alpha|beta|test|testing|stag|staging|prod|production|next|preprod|preproduction)/i", 922 | }, 923 | }, 924 | }, 925 | 926 | /** 927 | * API10:2023 Unsafe Consumption of APIs 928 | * https://owasp.org/API-Security/editions/2023/en/0xaa-unsafe-consumption-of-apis/ 929 | * 930 | * Use case 931 | * - ❌ Interacts with other APIs over an unencrypted channel; 932 | * - ❌ Does not properly validate and sanitize data gathered from other APIs prior to processing it or passing it to downstream components; 933 | * - ✅ Blindly follows redirections; 934 | * - ❌ Does not limit the number of resources available to process third-party services responses; 935 | * - ❌ Does not implement timeouts for interactions with third-party services; 936 | */ 937 | }, 938 | }; 939 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from 'tsup'; 2 | export default { 3 | entry: ["src/ruleset.ts"], 4 | clean: true, 5 | dts: true, 6 | target: "es2018", 7 | format: ["cjs", "esm"], 8 | sourcemap: true, 9 | noExternal: ["@stoplight/types"], 10 | external: ["@stoplight/spectral-core"], 11 | footer({ format }) { 12 | if (format === "cjs") { 13 | return { 14 | js: "module.exports = module.exports.default;", 15 | }; 16 | } 17 | 18 | return {}; 19 | }, 20 | }; 21 | --------------------------------------------------------------------------------