api-quality-spectral-ruleset 1.6.1 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/apq-spectral.yaml +98 -39
- package/functions/apq-oauth-url-pattern.js +205 -0
- package/functions/apq-paged-response-check.js +66 -31
- package/functions/apq-security-flows-allowed.js +194 -0
- package/functions/apq-security-types-allowed.js +187 -0
- package/functions/apq-valid-response-schema.js +58 -7
- package/functions/paths-include-exclude.js +42 -0
- package/package.json +2 -2
- /package/functions/{apq-string-parameter-integrity.js → apq-string-parameter-schema-integrity.js} +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!-- README.md -->
|
|
2
2
|
|
|
3
|
-
[](https://github.com/apiaddicts/apiaddicts-style-guide-spectral/actions/workflows/node.js.yml) 
|
|
4
4
|
|
|
5
5
|
# APIAddicts Style Guide Spectral
|
|
6
6
|
|
|
@@ -26,19 +26,19 @@ The Spectral Ruleset requires Node version 14 or later.
|
|
|
26
26
|
|
|
27
27
|
You can specify the ruleset directly on the command line:
|
|
28
28
|
|
|
29
|
-
`spectral lint -r https://raw.githubusercontent.com/apiaddicts/
|
|
29
|
+
`spectral lint -r https://raw.githubusercontent.com/apiaddicts/apiaddicts-style-guide-spectral/refs/heads/main/apq-spectral.yaml <api definition file>`
|
|
30
30
|
|
|
31
31
|
Or you can create a Spectral configuration file (`.spectral.yaml`) that references the ruleset:
|
|
32
32
|
|
|
33
33
|
```yaml
|
|
34
34
|
extends:
|
|
35
|
-
- https://raw.githubusercontent.com/apiaddicts/
|
|
35
|
+
- https://raw.githubusercontent.com/apiaddicts/apiaddicts-style-guide-spectral/refs/heads/main/apq-spectral.yaml
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
### Example
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
-
spectral lint -r https://raw.githubusercontent.com/apiaddicts/
|
|
41
|
+
spectral lint -r https://raw.githubusercontent.com/apiaddicts/apiaddicts-style-guide-spectral/refs/heads/main/apq-spectral.yaml petstore.yaml
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
### Using the Spectral VSCode extension
|
package/apq-spectral.yaml
CHANGED
|
@@ -40,7 +40,7 @@ functions:
|
|
|
40
40
|
- apq-numeric-invalid-format
|
|
41
41
|
- apq-numeric-missing-format
|
|
42
42
|
- apq-numeric-well-defined-format
|
|
43
|
-
- apq-string-parameter-integrity
|
|
43
|
+
- apq-string-parameter-schema-integrity
|
|
44
44
|
- apq-example-schema-types
|
|
45
45
|
- apq-standard-response-schema
|
|
46
46
|
- apq-allowed-http-verbs
|
|
@@ -54,11 +54,14 @@ functions:
|
|
|
54
54
|
- apq-response-content-required
|
|
55
55
|
- apq-tags-consistency
|
|
56
56
|
- apq-forbidden-title-pattern
|
|
57
|
+
- apq-security-flows-allowed
|
|
58
|
+
- apq-oauth-url-pattern
|
|
59
|
+
- apq-security-types-allowed
|
|
57
60
|
rules:
|
|
58
61
|
apiq:OAR001:
|
|
59
62
|
description: "For security reasons and as a REST best practice, the HTTPS protocol is mandatory."
|
|
60
63
|
message: "OAR001: HTTPS protocol is mandatory."
|
|
61
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
64
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR001.md"
|
|
62
65
|
severity: "error"
|
|
63
66
|
given: "$.servers[*].url"
|
|
64
67
|
then:
|
|
@@ -68,7 +71,7 @@ rules:
|
|
|
68
71
|
apiq:OAR002:
|
|
69
72
|
description: "A wrong scope definition may cause problems to import the API definition into WSO2."
|
|
70
73
|
message: "{{error}}"
|
|
71
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
74
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR002.md"
|
|
72
75
|
severity: "error"
|
|
73
76
|
given: "$.x-wso2-security.apim.x-wso2-scopes"
|
|
74
77
|
then:
|
|
@@ -76,7 +79,7 @@ rules:
|
|
|
76
79
|
apiq:OAR003:
|
|
77
80
|
description: "A description can help other developers to understand the correct use of the scope."
|
|
78
81
|
message: "OAR003: Scope must define a 'description' attribute."
|
|
79
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
82
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR003.md"
|
|
80
83
|
severity: "error"
|
|
81
84
|
given: "$.x-wso2-security.apim.x-wso2-scopes[*]"
|
|
82
85
|
then:
|
|
@@ -86,7 +89,7 @@ rules:
|
|
|
86
89
|
apiq:OAR004:
|
|
87
90
|
description: "A role with forbidden characters may cause problems in some applications."
|
|
88
91
|
message: "{{error}}"
|
|
89
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
92
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR004.md"
|
|
90
93
|
severity: "error"
|
|
91
94
|
given: "$.x-wso2-security.apim.x-wso2-scopes[*].roles"
|
|
92
95
|
then:
|
|
@@ -96,7 +99,7 @@ rules:
|
|
|
96
99
|
apiq:OAR005:
|
|
97
100
|
description: "A wrong scope may cause problems to import the API definition into WSO2 or allow all users to call the endpoint."
|
|
98
101
|
message: "{{error}}"
|
|
99
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
102
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR005.md"
|
|
100
103
|
severity: "error"
|
|
101
104
|
resolved: false
|
|
102
105
|
given: "$"
|
|
@@ -106,7 +109,7 @@ rules:
|
|
|
106
109
|
description: "Routes must define request media types supported by the API."
|
|
107
110
|
message: "OAR006: Specify at least one Media Type in the content of the request body."
|
|
108
111
|
severity: "error"
|
|
109
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
112
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR006.md"
|
|
110
113
|
given: "$.paths[*][post,put,patch]"
|
|
111
114
|
then:
|
|
112
115
|
- field: "requestBody"
|
|
@@ -116,7 +119,7 @@ rules:
|
|
|
116
119
|
apiq:OAR007:
|
|
117
120
|
description: "Routes must define response media types supported by the API"
|
|
118
121
|
message: "OAR007: Specify at least one Media Type in the content of the response body."
|
|
119
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
122
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR007.md"
|
|
120
123
|
severity: "error"
|
|
121
124
|
given:
|
|
122
125
|
- "$.paths[*][post,put,patch]"
|
|
@@ -128,7 +131,7 @@ rules:
|
|
|
128
131
|
apiq:OAR008:
|
|
129
132
|
description: "HTTP verbs not encouraged."
|
|
130
133
|
message: "{{error}}"
|
|
131
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
134
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR008.md"
|
|
132
135
|
severity: error
|
|
133
136
|
given: "$.paths[*]"
|
|
134
137
|
then:
|
|
@@ -162,7 +165,7 @@ rules:
|
|
|
162
165
|
apiq:OAR011:
|
|
163
166
|
description: "URLs should follow the configured naming convention (default kebab-case): all literal path segments must comply."
|
|
164
167
|
message: "{{error}}"
|
|
165
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
168
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR011.md"
|
|
166
169
|
severity: error
|
|
167
170
|
given:
|
|
168
171
|
- "$.paths[*]~"
|
|
@@ -174,7 +177,7 @@ rules:
|
|
|
174
177
|
apiq:OAR012:
|
|
175
178
|
description: "Path params, query params, object names and property names should follow the configured naming convention. You can configure snake_case (default), kebab-case, camelCase or UpperCamelCase"
|
|
176
179
|
message: "OAR012: Path params, query params, object names and property names must follow the configured naming convention."
|
|
177
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
180
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR012.md"
|
|
178
181
|
severity: warn
|
|
179
182
|
given:
|
|
180
183
|
- "$.paths[*][*].parameters[?(@.in == 'path' || @.in == 'query')].name"
|
|
@@ -197,7 +200,7 @@ rules:
|
|
|
197
200
|
apiq:OAR014:
|
|
198
201
|
description: "Resources depth level should be below the non-suggested range."
|
|
199
202
|
message: "{{error}}"
|
|
200
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
203
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR014.md"
|
|
201
204
|
severity: warn
|
|
202
205
|
given: "$.paths.*~"
|
|
203
206
|
then:
|
|
@@ -221,7 +224,7 @@ rules:
|
|
|
221
224
|
apiq:OAR016:
|
|
222
225
|
description: "Numeric types requires a valid format."
|
|
223
226
|
message: "OAR016: Numeric types must use a valid format for their type."
|
|
224
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
227
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR016.md"
|
|
225
228
|
severity: error
|
|
226
229
|
resolved: false
|
|
227
230
|
given: "$..[?(@ && @.type)]"
|
|
@@ -230,7 +233,7 @@ rules:
|
|
|
230
233
|
apiq:OAR017:
|
|
231
234
|
description: "Resource path should alternate static and parametrized parts."
|
|
232
235
|
message: "OAR017: Resource path should alternate static and parametrized parts"
|
|
233
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
236
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR017.md"
|
|
234
237
|
severity: error
|
|
235
238
|
given: "$.paths.*~"
|
|
236
239
|
then:
|
|
@@ -240,7 +243,7 @@ rules:
|
|
|
240
243
|
apiq:OAR018:
|
|
241
244
|
description: Operation not recommended for resource path depending on HTTP verb
|
|
242
245
|
message: "OAR018: Operation not recommended for resource path: {{path}} ({{verb}})"
|
|
243
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
246
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR018.md"
|
|
244
247
|
severity: warn
|
|
245
248
|
given: "$.paths"
|
|
246
249
|
then:
|
|
@@ -322,7 +325,7 @@ rules:
|
|
|
322
325
|
apiq:OAR022:
|
|
323
326
|
description: "$orderby must be defined as a query parameter in the collection GET operations selected by the configured paths."
|
|
324
327
|
message: "{{error}}"
|
|
325
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
328
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR022.md"
|
|
326
329
|
severity: warn
|
|
327
330
|
given: "$.paths"
|
|
328
331
|
then:
|
|
@@ -350,7 +353,7 @@ rules:
|
|
|
350
353
|
apiq:OAR025:
|
|
351
354
|
description: "$limit must be defined as an integer query parameter in the collection GET operations selected by the configured paths."
|
|
352
355
|
message: "{{error}}"
|
|
353
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
356
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR025.md"
|
|
354
357
|
severity: error
|
|
355
358
|
given: "$.paths"
|
|
356
359
|
then:
|
|
@@ -439,7 +442,7 @@ rules:
|
|
|
439
442
|
functionOptions:
|
|
440
443
|
notMatch: "^(Accept|Content-Type|Authorization)$"
|
|
441
444
|
apiq:OAR034:
|
|
442
|
-
description: "GET
|
|
445
|
+
description: "GET success JSON responses on collection paths must define an object schema with a paging block that complies with paging-schema (required start, limit and links with self/previous/next; optional numPages and total as integers)."
|
|
443
446
|
message: "{{error}}"
|
|
444
447
|
severity: error
|
|
445
448
|
given: "$.paths[*].get.responses[*]"
|
|
@@ -447,6 +450,8 @@ rules:
|
|
|
447
450
|
function: apq-paged-response-check
|
|
448
451
|
functionOptions:
|
|
449
452
|
paging-schema: '{"type":"object","properties":{"numPages":{"type":"integer"},"total":{"type":"integer"},"start":{"type":"integer"},"limit":{"type":"integer"},"links":{"type":"object","properties":{"next":{"type":"object","properties":{"href":{"type":"string"}}},"previous":{"type":"object","properties":{"href":{"type":"string"}}},"last":{"type":"object","properties":{"href":{"type":"string"}}},"self":{"type":"object","properties":{"href":{"type":"string"}}},"first":{"type":"object","properties":{"href":{"type":"string"}}}},"required":["self","previous","next"]}},"required":["start","limit","links"],"pagingPropertyName":"paging"}'
|
|
453
|
+
paths: "/status,/health,/ping,**/me"
|
|
454
|
+
pathValidationStrategy: "/exclude"
|
|
450
455
|
apiq:OAR035:
|
|
451
456
|
description: "Response code 401 must be defined for operations with security schemes defined."
|
|
452
457
|
message: "{{error}}"
|
|
@@ -477,7 +482,7 @@ rules:
|
|
|
477
482
|
then:
|
|
478
483
|
function: apq-schema-format
|
|
479
484
|
apiq:OAR038:
|
|
480
|
-
description: "The 201 response schema of a POST operation must have properties
|
|
485
|
+
description: "The 201 response schema of a POST operation must only have the allowed top-level properties ('data' or 'error' by default), each with at least one sub-property, and every required one."
|
|
481
486
|
message: "{{error}}"
|
|
482
487
|
severity: error
|
|
483
488
|
given: "$.paths.*.post.responses['201']"
|
|
@@ -485,10 +490,14 @@ rules:
|
|
|
485
490
|
function: apq-valid-response-schema
|
|
486
491
|
functionOptions:
|
|
487
492
|
data-property: data
|
|
493
|
+
allowed-properties: ""
|
|
494
|
+
required-properties: ""
|
|
495
|
+
paths: "/me,/health,/ping,/status"
|
|
496
|
+
pathValidationStrategy: "/exclude"
|
|
488
497
|
apiq:OAR039:
|
|
489
498
|
description: "Response codes must be defined according to the standard depending on the HTTP verb and resource path."
|
|
490
499
|
message: "{{error}}"
|
|
491
|
-
documentationUrl: https://github.com/apiaddicts/
|
|
500
|
+
documentationUrl: https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR039.md
|
|
492
501
|
severity: error
|
|
493
502
|
given: "$.paths[*][*]"
|
|
494
503
|
then:
|
|
@@ -533,7 +542,7 @@ rules:
|
|
|
533
542
|
apiq:OAR041:
|
|
534
543
|
description: "A WSO2 x-scope on an operation always requires an x-auth-type definition."
|
|
535
544
|
message: "{{error}}"
|
|
536
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
545
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR041.md"
|
|
537
546
|
severity: "error"
|
|
538
547
|
resolved: false
|
|
539
548
|
given: "$"
|
|
@@ -553,7 +562,7 @@ rules:
|
|
|
553
562
|
apiq:OAR043:
|
|
554
563
|
description: "OpenAPI definition contains structural errors that would be detected by a strict parser or validator."
|
|
555
564
|
message: "{{error}}"
|
|
556
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
565
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR043.md"
|
|
557
566
|
severity: error
|
|
558
567
|
given:
|
|
559
568
|
- $.paths.*.*.parameters.*.in
|
|
@@ -602,7 +611,7 @@ rules:
|
|
|
602
611
|
- "$.paths[*].patch"
|
|
603
612
|
- "$.paths[*].delete"
|
|
604
613
|
then:
|
|
605
|
-
function: schema
|
|
614
|
+
function: schema
|
|
606
615
|
functionOptions:
|
|
607
616
|
schema:
|
|
608
617
|
type: object
|
|
@@ -621,7 +630,7 @@ rules:
|
|
|
621
630
|
apiq:OAR048:
|
|
622
631
|
description: APIs must define at most one body parameter.
|
|
623
632
|
message: "OAR048: An operation can have at most one body parameter"
|
|
624
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
633
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR048.md"
|
|
625
634
|
severity: error
|
|
626
635
|
given: "$.paths[*][*]"
|
|
627
636
|
then:
|
|
@@ -657,7 +666,7 @@ rules:
|
|
|
657
666
|
apiq:OAR052:
|
|
658
667
|
description: "Numeric schema types must define a format."
|
|
659
668
|
message: "OAR052: Numeric types requires a format"
|
|
660
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
669
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR052.md"
|
|
661
670
|
severity: warn
|
|
662
671
|
resolved: false
|
|
663
672
|
given: "$..[?(@ && @.type)]"
|
|
@@ -666,7 +675,7 @@ rules:
|
|
|
666
675
|
apiq:OAR053:
|
|
667
676
|
description: "Response headers for API observability and tracing must be defined (excluding 204 responses and health endpoints)."
|
|
668
677
|
message: "OAR053: Response must include mandatory headers and exclude forbidden headers."
|
|
669
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
678
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR053.md"
|
|
670
679
|
severity: error
|
|
671
680
|
given: "$.paths[*][*].responses[*]"
|
|
672
681
|
then:
|
|
@@ -843,8 +852,8 @@ rules:
|
|
|
843
852
|
then:
|
|
844
853
|
function: apq-numeric-parameter-integrity
|
|
845
854
|
apiq:OAR075:
|
|
846
|
-
description: "String
|
|
847
|
-
message: "OAR075: String
|
|
855
|
+
description: "String parameter schemas should have minLength, maxLength, pattern (regular expression), or enum restriction."
|
|
856
|
+
message: "OAR075: String parameter schemas should have minLength, maxLength, pattern, or enum restriction."
|
|
848
857
|
severity: error
|
|
849
858
|
resolved: false
|
|
850
859
|
given:
|
|
@@ -857,13 +866,13 @@ rules:
|
|
|
857
866
|
- "$.components.parameters[*]"
|
|
858
867
|
- "$.parameters[*]"
|
|
859
868
|
then:
|
|
860
|
-
function: apq-string-parameter-integrity
|
|
869
|
+
function: apq-string-parameter-schema-integrity
|
|
861
870
|
functionOptions:
|
|
862
871
|
parameter_integrity: "minLength,maxLength,pattern,enum"
|
|
863
872
|
apiq:OAR076:
|
|
864
873
|
description: "Schema should use well-defined type and format."
|
|
865
874
|
message: "OAR076: Schema should use well-defined type and format."
|
|
866
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
875
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR076.md"
|
|
867
876
|
severity: error
|
|
868
877
|
resolved: false
|
|
869
878
|
given: "$..[?(@ && @.type)]"
|
|
@@ -884,7 +893,7 @@ rules:
|
|
|
884
893
|
apiq:OAR078:
|
|
885
894
|
description: "All API methods must have security defined."
|
|
886
895
|
message: "OAR078: Operation must have security defined."
|
|
887
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
896
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR078.md"
|
|
888
897
|
severity: error
|
|
889
898
|
given: "$"
|
|
890
899
|
then:
|
|
@@ -892,7 +901,7 @@ rules:
|
|
|
892
901
|
apiq:OAR079:
|
|
893
902
|
description: "Operations with path parameters should include a 404 Not Found response."
|
|
894
903
|
message: "OAR079: Path parameter present, but missing 404 Not Found response."
|
|
895
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
904
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR079.md"
|
|
896
905
|
severity: "warn"
|
|
897
906
|
given: "$.paths[*][get,post,put,patch,delete]"
|
|
898
907
|
then:
|
|
@@ -1010,7 +1019,7 @@ rules:
|
|
|
1010
1019
|
apiq:OAR091:
|
|
1011
1020
|
description: "Parameters must contain only $ref references."
|
|
1012
1021
|
message: "OAR091: Parameters must contain only $ref references."
|
|
1013
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
1022
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR091.md"
|
|
1014
1023
|
severity: error
|
|
1015
1024
|
resolved: false
|
|
1016
1025
|
given: "$.paths[*][get,post,put,patch,delete].parameters[*]"
|
|
@@ -1176,7 +1185,7 @@ rules:
|
|
|
1176
1185
|
apiq:OAR113:
|
|
1177
1186
|
description: "Field or extension must be at the assigned location"
|
|
1178
1187
|
message: "OAR113: Field or extension x-custom-example must be at the assigned location"
|
|
1179
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
1188
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR113.md"
|
|
1180
1189
|
severity: warn
|
|
1181
1190
|
given: "$"
|
|
1182
1191
|
then:
|
|
@@ -1187,7 +1196,7 @@ rules:
|
|
|
1187
1196
|
apiq:OAR114:
|
|
1188
1197
|
description: "Response headers for API security and key management must be defined (excluding 204 responses)."
|
|
1189
1198
|
message: "OAR114: Response must include mandatory headers and exclude forbidden headers."
|
|
1190
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
1199
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR114.md"
|
|
1191
1200
|
severity: "error"
|
|
1192
1201
|
given: "$.paths[*][*].responses[*]"
|
|
1193
1202
|
then:
|
|
@@ -1200,7 +1209,7 @@ rules:
|
|
|
1200
1209
|
apiq:OAR115:
|
|
1201
1210
|
description: "All fields listed in the required array must be defined in the schema properties."
|
|
1202
1211
|
message: "OAR115: All fields in the required array must be defined in schema properties."
|
|
1203
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
1212
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR115.md"
|
|
1204
1213
|
severity: warn
|
|
1205
1214
|
resolved: false
|
|
1206
1215
|
given: "$..[?(@ && @.required)]"
|
|
@@ -1209,7 +1218,7 @@ rules:
|
|
|
1209
1218
|
apiq:OAR116:
|
|
1210
1219
|
description: "Every API path must match the configured regular expression."
|
|
1211
1220
|
message: "{{error}}"
|
|
1212
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
1221
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR116.md"
|
|
1213
1222
|
severity: error
|
|
1214
1223
|
given: "$.paths.*~"
|
|
1215
1224
|
then:
|
|
@@ -1219,10 +1228,60 @@ rules:
|
|
|
1219
1228
|
apiq:OAR117:
|
|
1220
1229
|
description: "The API title must not match the configured forbidden regular expression."
|
|
1221
1230
|
message: "{{error}}"
|
|
1222
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
1231
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR117.md"
|
|
1223
1232
|
severity: error
|
|
1224
1233
|
given: "$.info.title"
|
|
1225
1234
|
then:
|
|
1226
1235
|
function: apq-forbidden-title-pattern
|
|
1227
1236
|
functionOptions:
|
|
1228
|
-
forbidden-pattern: "(?!)"
|
|
1237
|
+
forbidden-pattern: "(?!)"
|
|
1238
|
+
apiq:OAR118:
|
|
1239
|
+
description: "OAuth2 security schemes must only declare the flows allowed by the organization (authorizationCode and clientCredentials by default)."
|
|
1240
|
+
message: "{{error}}"
|
|
1241
|
+
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR118.md"
|
|
1242
|
+
severity: error
|
|
1243
|
+
given: "$"
|
|
1244
|
+
then:
|
|
1245
|
+
function: apq-security-flows-allowed
|
|
1246
|
+
functionOptions:
|
|
1247
|
+
allowed-flows: "authorizationCode,clientCredentials"
|
|
1248
|
+
paths: "/me,/health,/ping,/status"
|
|
1249
|
+
pathValidationStrategy: "/exclude"
|
|
1250
|
+
apiq:OAR119:
|
|
1251
|
+
description: "The authorizationUrl of every OAuth2 flow must match the pattern allowed by the organization (an HTTPS URL on a domain ending in /authorize by default)."
|
|
1252
|
+
message: "{{error}}"
|
|
1253
|
+
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR119.md"
|
|
1254
|
+
severity: error
|
|
1255
|
+
given: "$"
|
|
1256
|
+
then:
|
|
1257
|
+
function: apq-oauth-url-pattern
|
|
1258
|
+
functionOptions:
|
|
1259
|
+
field: authorizationUrl
|
|
1260
|
+
pattern: '^https://[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+(:[0-9]+)?(/[^/?#]+)*/authorize$'
|
|
1261
|
+
paths: "/me,/health,/ping,/status"
|
|
1262
|
+
pathValidationStrategy: "/exclude"
|
|
1263
|
+
apiq:OAR120:
|
|
1264
|
+
description: "The tokenUrl of every OAuth2 flow must match the pattern allowed by the organization (an HTTPS URL on a domain ending in /token by default)."
|
|
1265
|
+
message: "{{error}}"
|
|
1266
|
+
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR120.md"
|
|
1267
|
+
severity: error
|
|
1268
|
+
given: "$"
|
|
1269
|
+
then:
|
|
1270
|
+
function: apq-oauth-url-pattern
|
|
1271
|
+
functionOptions:
|
|
1272
|
+
field: tokenUrl
|
|
1273
|
+
pattern: '^https://[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+(:[0-9]+)?(/[^/?#]+)*/token$'
|
|
1274
|
+
paths: "/me,/health,/ping,/status"
|
|
1275
|
+
pathValidationStrategy: "/exclude"
|
|
1276
|
+
apiq:OAR121:
|
|
1277
|
+
description: "Security schemes must only use the types allowed by the organization (oauth2 and HTTP bearer by default)."
|
|
1278
|
+
message: "{{error}}"
|
|
1279
|
+
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR121.md"
|
|
1280
|
+
severity: error
|
|
1281
|
+
given: "$"
|
|
1282
|
+
then:
|
|
1283
|
+
function: apq-security-types-allowed
|
|
1284
|
+
functionOptions:
|
|
1285
|
+
allowed-security-types: "oauth2,bearer"
|
|
1286
|
+
paths: "/me,/health,/ping,/status"
|
|
1287
|
+
pathValidationStrategy: "/exclude"
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
const { pathsIncludeExclude } = require('./paths-include-exclude.js');
|
|
2
|
+
|
|
3
|
+
const DEFAULT_PATTERNS = {
|
|
4
|
+
authorizationUrl: '^https://[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+(:[0-9]+)?(/[^/?#]+)*/authorize$',
|
|
5
|
+
tokenUrl: '^https://[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+(:[0-9]+)?(/[^/?#]+)*/token$',
|
|
6
|
+
};
|
|
7
|
+
const DEFAULT_PATHS = '/me,/health,/ping,/status';
|
|
8
|
+
const INLINE_FLAGS_REGEX = /^\(\?([a-zA-Z]+)\)/;
|
|
9
|
+
const SUPPORTED_INLINE_FLAGS = 'imsu';
|
|
10
|
+
|
|
11
|
+
const VERBS_V2 = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch'];
|
|
12
|
+
const VERBS_V3 = VERBS_V2.concat(['trace']);
|
|
13
|
+
const VERBS_V32 = VERBS_V3.concat(['query']);
|
|
14
|
+
|
|
15
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
16
|
+
const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
17
|
+
|
|
18
|
+
const documentVersion = (root) => {
|
|
19
|
+
if (has(root, 'swagger')) return 'v2';
|
|
20
|
+
if (!has(root, 'openapi')) return null;
|
|
21
|
+
const openapi = String(root.openapi).trim();
|
|
22
|
+
if (openapi.startsWith('3.2')) return 'v32';
|
|
23
|
+
if (openapi.startsWith('3.1')) return 'v31';
|
|
24
|
+
return 'v3';
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const buildRegex = (raw) => {
|
|
28
|
+
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
|
29
|
+
const text = raw.trim();
|
|
30
|
+
let source = text;
|
|
31
|
+
let flags = '';
|
|
32
|
+
const inline = INLINE_FLAGS_REGEX.exec(source);
|
|
33
|
+
if (inline) {
|
|
34
|
+
const requested = inline[1].toLowerCase();
|
|
35
|
+
if ([...requested].every((flag) => SUPPORTED_INLINE_FLAGS.includes(flag))) {
|
|
36
|
+
flags = [...new Set(requested)].join('');
|
|
37
|
+
source = source.slice(inline[0].length);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (source === '') return null;
|
|
41
|
+
try {
|
|
42
|
+
return { regex: new RegExp(source, flags), text };
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const pointerTarget = (root, ref) => {
|
|
49
|
+
let fragment;
|
|
50
|
+
try {
|
|
51
|
+
fragment = decodeURIComponent(ref.slice(1));
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
if (!fragment.startsWith('/')) return undefined;
|
|
56
|
+
const segments = fragment.slice(1).split('/').map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
57
|
+
let value = root;
|
|
58
|
+
for (const segment of segments) {
|
|
59
|
+
if (!isObject(value) || !has(value, segment)) return undefined;
|
|
60
|
+
value = value[segment];
|
|
61
|
+
}
|
|
62
|
+
return isObject(value) ? { value, segments } : undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const collectOperations = (root, version) => {
|
|
66
|
+
const verbs = version === 'v32' ? VERBS_V32 : (version === 'v2' ? VERBS_V2 : VERBS_V3);
|
|
67
|
+
const operations = [];
|
|
68
|
+
const visitPathItem = (pathItem, pathKey, ancestors) => {
|
|
69
|
+
if (!isObject(pathItem) || ancestors.has(pathItem)) return;
|
|
70
|
+
const chain = new Set(ancestors).add(pathItem);
|
|
71
|
+
const ops = verbs.filter((verb) => has(pathItem, verb)).map((verb) => pathItem[verb]);
|
|
72
|
+
if (version === 'v32' && isObject(pathItem.additionalOperations)) ops.push(...Object.values(pathItem.additionalOperations));
|
|
73
|
+
ops.filter(isObject).forEach((op) => {
|
|
74
|
+
operations.push({ op, pathKey });
|
|
75
|
+
if (version === 'v2' || !isObject(op.callbacks)) return;
|
|
76
|
+
Object.values(op.callbacks).filter(isObject).forEach((callback) => {
|
|
77
|
+
Object.keys(callback)
|
|
78
|
+
.filter((expression) => !expression.startsWith('x-'))
|
|
79
|
+
.forEach((expression) => visitPathItem(callback[expression], pathKey, chain));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
if (isObject(root.paths)) {
|
|
84
|
+
Object.keys(root.paths)
|
|
85
|
+
.filter((pathKey) => pathKey.startsWith('/'))
|
|
86
|
+
.forEach((pathKey) => visitPathItem(root.paths[pathKey], pathKey, new Set()));
|
|
87
|
+
}
|
|
88
|
+
if ((version === 'v31' || version === 'v32') && isObject(root.webhooks)) {
|
|
89
|
+
Object.values(root.webhooks).forEach((pathItem) => visitPathItem(pathItem, null, new Set()));
|
|
90
|
+
}
|
|
91
|
+
return operations;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const definitionsBase = (version) => (version === 'v2' ? ['securityDefinitions'] : ['components', 'securitySchemes']);
|
|
95
|
+
|
|
96
|
+
const lookup = (root, segments) => segments.reduce((value, segment) => (isObject(value) ? value[segment] : undefined), root);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {object} root - The resolved document (given: "$")
|
|
100
|
+
* @param {object} options
|
|
101
|
+
* @param {string} options.field - "authorizationUrl" or "tokenUrl", the URL of the OAuth2 flows that is checked
|
|
102
|
+
* @param {string} options.pattern - regular expression the URL must match (unanchored, an optional leading "(?i)" style
|
|
103
|
+
* group sets the i, m, s and u flags); blank or invalid falls back to "https://host.domain/.../authorize" for
|
|
104
|
+
* authorizationUrl and "https://host.domain/.../token" for tokenUrl
|
|
105
|
+
* @param {string|string[]} options.paths - comma-separated path patterns matched segment by segment; "*" matches one segment
|
|
106
|
+
* and "**" zero or more (default "/me,/health,/ping,/status")
|
|
107
|
+
* @param {string} options.pathValidationStrategy - "/exclude" (default) skips the security schemes applied only to matching
|
|
108
|
+
* paths, "/include" only evaluates the security schemes applied to them; webhooks have no path, so they are only evaluated
|
|
109
|
+
* with "/exclude", and callbacks follow the path of their operation
|
|
110
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
111
|
+
* @returns {Array<{message: string, path: Array<string>}>} one result per URL that does not match, on the URL itself
|
|
112
|
+
*/
|
|
113
|
+
module.exports = (root, options, context) => {
|
|
114
|
+
if (!isObject(root)) return [];
|
|
115
|
+
const version = documentVersion(root);
|
|
116
|
+
if (version === null) return [];
|
|
117
|
+
|
|
118
|
+
const opts = options || {};
|
|
119
|
+
const field = opts.field;
|
|
120
|
+
if (!has(DEFAULT_PATTERNS, field)) return [];
|
|
121
|
+
const matcher = buildRegex(opts.pattern) || buildRegex(DEFAULT_PATTERNS[field]);
|
|
122
|
+
const { include, isEvaluatedPath } = pathsIncludeExclude(opts, DEFAULT_PATHS);
|
|
123
|
+
|
|
124
|
+
const base = definitionsBase(version);
|
|
125
|
+
const definitions = lookup(root, base);
|
|
126
|
+
const rawDefinitions = lookup(context.document && context.document.data, base);
|
|
127
|
+
const schemes = new Map();
|
|
128
|
+
if (isObject(definitions)) {
|
|
129
|
+
Object.keys(definitions).forEach((name) => {
|
|
130
|
+
const scheme = definitions[name];
|
|
131
|
+
if (!isObject(scheme)) return;
|
|
132
|
+
const rawEntry = isObject(rawDefinitions) ? rawDefinitions[name] : undefined;
|
|
133
|
+
const direct = isObject(rawEntry) && !has(rawEntry, '$ref');
|
|
134
|
+
const known = schemes.get(scheme);
|
|
135
|
+
if (!known || (!known.direct && direct)) schemes.set(scheme, { name, path: [...base, name], direct });
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const schemeFor = (key) => {
|
|
140
|
+
if (isObject(definitions) && has(definitions, key)) return isObject(definitions[key]) ? definitions[key] : undefined;
|
|
141
|
+
if (version !== 'v32') return undefined;
|
|
142
|
+
const self = typeof root.$self === 'string' ? root.$self.trim() : '';
|
|
143
|
+
const fragment = self !== '' && key.startsWith(`${self}#`) ? key.slice(self.length) : key;
|
|
144
|
+
if (!fragment.startsWith('#')) return undefined;
|
|
145
|
+
const target = pointerTarget(root, fragment);
|
|
146
|
+
if (!target) return undefined;
|
|
147
|
+
if (!schemes.has(target.value)) {
|
|
148
|
+
schemes.set(target.value, { name: target.segments[target.segments.length - 1], path: target.segments, direct: true });
|
|
149
|
+
}
|
|
150
|
+
return target.value;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const usage = new Map();
|
|
154
|
+
const rootSecurity = Array.isArray(root.security) ? root.security : [];
|
|
155
|
+
collectOperations(root, version).forEach(({ op, pathKey }) => {
|
|
156
|
+
const security = Array.isArray(op.security) ? op.security : rootSecurity;
|
|
157
|
+
const inScope = pathKey === null ? !include : isEvaluatedPath(pathKey);
|
|
158
|
+
security.filter(isObject).forEach((requirement) => {
|
|
159
|
+
Object.keys(requirement).forEach((key) => {
|
|
160
|
+
const scheme = schemeFor(key);
|
|
161
|
+
if (scheme) usage.set(scheme, usage.get(scheme) === true || inScope);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const inventory = context.documentInventory;
|
|
167
|
+
const isLocatable = (path) => {
|
|
168
|
+
const item = inventory.findAssociatedItemForPath(path, true);
|
|
169
|
+
if (!item || !item.document) return true;
|
|
170
|
+
return item.document.getRangeForJsonPath(item.path, false) !== undefined;
|
|
171
|
+
};
|
|
172
|
+
const locatable = (path) => {
|
|
173
|
+
if (!inventory || typeof inventory.findAssociatedItemForPath !== 'function') return path;
|
|
174
|
+
for (let length = path.length; length > 0; length -= 1) {
|
|
175
|
+
if (isLocatable(path.slice(0, length))) return path.slice(0, length);
|
|
176
|
+
}
|
|
177
|
+
return path;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const ruleCode = context.rule && context.rule.name ? context.rule.name.split(':').pop() : (field === 'tokenUrl' ? 'OAR120' : 'OAR119');
|
|
181
|
+
const results = [];
|
|
182
|
+
const check = (url, flow, name, path) => {
|
|
183
|
+
if (typeof url !== 'string' || matcher.regex.test(url)) return;
|
|
184
|
+
const flowText = typeof flow === 'string' && flow.trim() !== '' ? ` of flow '${flow}'` : '';
|
|
185
|
+
results.push({
|
|
186
|
+
message: `${ruleCode}: ${field} '${url}'${flowText} in security scheme '${name}' does not match the allowed pattern: ${matcher.text}`,
|
|
187
|
+
path: locatable(path),
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
schemes.forEach((entry, scheme) => {
|
|
192
|
+
if (usage.has(scheme) ? !usage.get(scheme) : include) return;
|
|
193
|
+
if (typeof scheme.type !== 'string' || scheme.type.trim().toLowerCase() !== 'oauth2') return;
|
|
194
|
+
if (version === 'v2') {
|
|
195
|
+
if (has(scheme, field)) check(scheme[field], scheme.flow, entry.name, [...entry.path, field]);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!isObject(scheme.flows)) return;
|
|
199
|
+
Object.keys(scheme.flows)
|
|
200
|
+
.filter((flow) => !flow.startsWith('x-') && flow.trim() !== '' && isObject(scheme.flows[flow]) && has(scheme.flows[flow], field))
|
|
201
|
+
.forEach((flow) => check(scheme.flows[flow][field], flow, entry.name, [...entry.path, 'flows', flow, field]));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return results;
|
|
205
|
+
};
|